数据库缓存双写一致性

前言

在日常的开发当中,我们经常使用缓存来替代一部分数据库的读写操作,以此提升系统性能和减轻DB负载。因为数据存储在了两个位置,所以需要保证这两份数据是一致的,或者在某段可以接受的时间范围内存在短暂的不一致(即需要保证最终一致性)。

常见方案

不同的业务对于一致性有自己的处理方法,目前我了解到的保证一致性大致有五种方案:(以下所有方案只讨论实时的操作步骤,暂不引入后台程序定时监控和补偿的机制)

方案1

读Cache,miss则从DB中load数据进Cache;写时先写DB,后写Cache。

流程图如下:


这个方案应该很容易想到,是比较小白的做法。但是这个方案在请求并发时会导致数据不一致:



如上图所示,当两个进程/线程同时进行写操作,线程A先把 DB 中的 x 更新为1,然后线程B把 DB 和 Cache 中的 x 更新为2,最后线程A把 Cache 中的 x 更新为1。此时 DB 中 x 的值为2,Cache 中 x 的值为1,所以 DB 和 Cache 的数据就不一致了,Cache 中存在脏数据/旧数据,当进行读操作时就会从 Cache 中读取到旧数据。

方案2

读Cache,miss则从DB中load数据进Cache;写时先写DB,然后删Cache。

这个是经典的 Cache Aside Pattern,facebook也在论文《Scaling Memcache at Facebook》中提到,他们用的也是先更新数据库,再删缓存的策略。



读操作和方案1一样,二次读,先读 Cache,miss 则从 DB 中读数据再加载进 Cache 中。和方案1不同的是写操作的第二步,由原来的更新 Cache 改为删除 Cache,这个改造解决了方案1并发写时造成数据不一致的现象。为什么呢?通过模拟并发写的流程可以发现,因为第二步是删除,两个线程无论谁先删谁后删结果都是一样的,Cache 中 x 都为空(不存在),自然就不会出现和 DB 中 x 的值不一样的情况;Cache 里 x 的值待触发读操作时再从 DB 中去加载数据。这里解决问题的本质是因为 delete 操作是个幂等性操作,和 add 一样,无论执行多少次,结果都是一样的,而 update 就不是幂等性操作,结果和执行的先后顺序有关。

这个方案并不是完全没问题,并发写时是保证了数据一致性,但如果读写同时执行是会有问题的:



可以看到,当写线程的两步操作在读线程读 DB 和回填 Cache 之间时,就会出现不一致的问题。最终 DB 中 x 的值是 2,而 Cache 中 x 是旧值 1。不过实际上这种情况发生的概率并不高,因为它需要读线程在读 DB(T2时刻)和写 Cache(T5时刻)中停留较长一段时间,等待写线程的T3和T4时刻的操作完成才能发生,除非读线程在请求写 Cache 时发生网络拥堵或丢包,或者在请求 Cache 前因为GC或操作系统进程调度等出现停顿。

方案3

读操作和方案2相同;写时先删Cache,再写DB


假设不考虑读操作的并发,方案2还存在一个问题,就是写时因为是双写,有可能 update DB 成功,但 delete Cache 失败,那么 Cache 中就会存在脏数据了,读的时候就会从 Cache 中拿到旧的数据。改进的方案也有,把双写的次序交换一下即可,先 delete Cache 再 update DB,如上图所示。即使第二步的 update DB 失败,但因为 Cache 中的数据已经被 delete 了,读时会从 DB 中去加载数据,所以不会出现不一致。

不过读写并发时还是会出现和上图类似的不一致现象:

除此以外,方案3有个不足的地方,一种场景,当并发请求,读写交替执行,读的最后一步操作在写的前面时,方案2具有容错性,而3没有,如下图:



可以看到,先写 DB 再删 Cache 的方案2具有容错性,不会不一致(最终 DB 中 x=2,Cache 中 x 不存在,待触发读操作时重新加载);而先删 Cache 再写 DB 的方案3最终 DB 的 x=2,Cache 的 x=1,出现了不一致现象。

方案4

读Cache,miss读DB,不回填Cache;写时先删Cache,再更新DB,然后发送异步消息回填Cache。



方案4和前三个方案很大一个不同是,缓存的构建不再由读触发,而是由写来负责,先删除旧数据再异步更新,通过一个消息组件来处理(处理的逻辑不是简单地执行application传过来的update语句,而是先从DB中去读取源数据,再更新进缓存,这样就不会出现同一个变量/数据更新次序错乱的的问题。该consumer本质上是一个队列,串行顺序消费,因此可以优化下,引入多个队列,把不同数据的更新消息分发到不同的队列中。不过同一个数据必须在同一个队列中,否则和方案1的本质就是一样的了,后更新者会把先前读到的脏数据写到缓存中,若限定在同一个队列时,最后的消息读到的一定是最新的数据,所以没问题)。
假设不考虑各种操作的失败情况,也假设 consumer 消费得足够快,那么这个方案在读写并发时就不会出现数据不一致的情况。因为它把缓存的更新收敛到了一个地方,变成了串行更新,不会出现方案1中的交错更新现象,也不会像方案2和3那样回填了延迟到来的旧数据,所以比起上述三个方案是更优的。

但是也有个缺点,因为引入了多一个组件,写的步骤变多了,整个流程失败的概率会变高,比如 Application 在发送 message 前就宕机了:


或者 consumer 发送给 Cache 的消息丢了,或者 Cache 执行 rebuild 的消息时失败了:


还有 consumer 本身挂了,无法接收/消费消息等等一系列情况,都会导致 Cache 和 DB 中数据的不一致。

方案5

读Cache,miss读DB,不回填Cache;写时先删Cache,再更新DB,然后通过消费binlog回填Cache。



读时和方案4一样,不回填 Cache,写时前半部分逻辑也和方案4一样,不同的是重建缓存的策略。这里不再由 Application 来发送消息给 consumer,而是通过 DB 自带的 binlog 机制来通知 consumer。这个改造虽然没有解决方案4中所有可能存在的问题,但是解决了 Application 发送消息失败的问题,这个意义还是很大的,因为方案4其实已经不是双写了,而是三处写:(1) delete Cache; (2) update DB; (3) send message 。(1) 和 (2) 任意一个失败都不会导致数据不一致,但是如果 (1) 和 (2) 成功了,但 (3) 失败了,就会导致数据不一致。现在我们把步骤 (3) 挪到了 binlog 中,看起来还是三处写,不还是会出现这个问题吗?其实不会,由 DB 的事务机制可知,所有的写操作都会直接反映到 binlog 中,也就是说 binlog 和真实的数据是绑定在一起的,这两者不会存在不一致的情况。所以这里实际上是通过 DB 把三处写合并为了两处写:(1) delete Cache; (2) update DB && binlog send message。

   虽然 binlog 向 consumer send message 这一步可能也会失败,但是因为源数据没有丢失(存在 binlog 中),和方案4的 Application 发送消息的失败不一样,还有重试的机会。

   对于方案5中 consumer 的选取,业界有个很好用的工具,就是阿里开源的 [canal](https://github.com/alibaba/canal),它是一个 MySQL binlog 增量订阅&消费组件,主要用途是基于 MySQL 数据库日志解析,提供增量数据订阅和消费,Cache 数据的刷新重建是它的一个很广泛的用途。

一点理解

粗略分析五种方案,我认为,双写一致性难以保证的本质是因为存在\color{red}{两个生产者和两个消费者} </font>:


两个消费者很好理解, DB 和 Cache。至于两个生产者,方案1,2,3中都是 Application;方案4对于 DB 的生产者是 Application,Cache 的生产者是 consumer;而方案5 Cache 的生产者是 binlog。方案4, 5优于方案1, 2, 3的原因是解决了并发时由于时序导致的数据不一致,方案5优于方案4是因为它把消费者和第二个生产者结合了起来,减少了操作步骤,步骤越少,最终失败的可能性就越低。

补充

即使是看起来最优的方案5,其实也无法保证最终一致性,所以无论哪种方案,都需要一个保障措施,保障 DB 和 Cache 数据的最终一致,可以使用一个后台程序进行定时监控,对比 DB 和 Cache 中的数据,若出现不一致则自动修复或通知告警,然后人工介入处理;或者对 Cache 中的数据设置过期时间,选取合理的时间范围,使当出现不一致时脏数据不至于停留太久,能够及时过期掉,也不会因太快过期而导致大量的读 miss 进而影响系统性能。
最终一致性容易保证,但是强一致性却很难做到。因为 DB 和 Cache 是两个独立的系统/存储介质,要做到强一致性,就需要用到分布式事务,而分布式事务要求所有系统都有一个可靠的事务/回滚机制。我们常用的缓存组件如 redis 并没有实现真正的事务,当出错时不支持回滚。假设某个缓存组件像 DB 一样支持了事务,此时条件齐备了,能实现强一致性了,但是这时候的性能是很低下的。为什么呢?因为每个事务里肯定用到锁操作 -- 要保证并发时每个系统里执行的更新次序是一样的就必须用到锁,无论是乐观锁还是悲观锁。所以部分请求的失败是在所难免的,这时候系统吞吐量就下来了;如果不用锁,在分布式调度中心把所有请求串行发送至 DB 和 Cache 时,这时候虽然成功率提高了,但是性能已经不敢恭维了。综上,我认为,对于一致性要求很高的业务,就不要使用缓存,缓存只适合那些对实时性要求高、且能接受短时间数据不一致的业务。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,335评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,895评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,766评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,918评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,042评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,169评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,219评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,976评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,393评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,711评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,876评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,562评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,193评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,903评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,699评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,764评论 2 351

推荐阅读更多精彩内容