缓存和数据库一致性探讨

引言

一致性就是数据保持一致,在分布式系统中,可以理解为多个组件节点中数据的值是一致的。
● 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
● 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
● 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型。

持久化层和缓存层的一致性问题也通常被称为双写一致性问题,“双写”意为数据既在数据库中保存一份,也在缓存中保存一份。对于应用缓存的大部分场景来说,追求的则是最终一致性,少部分对数据一致性要求极高的场景则会追求强一致性。

读写缓存策略

缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般我们是如何使用缓存呢?有三种经典的读写缓存策略:
● 旁路缓存策略(Cache-Aside Strategy):只有当有应用来请求时,才将对应的对象进行缓存;
● 读写穿透缓存策略(Read/Write-Through Caching Strategy):读写请求由缓存层统一封装处理,业务服务仅操作缓存;
● 异步写入缓存策略(Write-Behind Caching Strategy):数据读取与Read-Through类似,但是数据写入由独立线程异步批量处理更新数据库。

旁路缓存策略(Cache-Aside Strategy)

这种策略的核心思想是:只有当有应用来请求时,才将对应的对象进行缓存。适用于读取频繁写入及更新不频繁的场景。

缓存-数据库读写流程

缓存-数据库读流程

image

主要流程为:

  1. 用户发起查询请求
  2. 业务服务首先根据关键参数作为key查询缓存
  3. 如果数据在缓存中存在cache hit,则直接返回缓存中查询结果。
  4. 如果数据不在缓存中cache miss,则进行数据库查询操作,将结果缓存并返回查询结果。

缓存-数据库写流程

image

主要流程为:

  1. 用户发起请求,需要写数据。
  2. 业务服务在完成逻辑处理后,开始更新数据库。
  3. 数据库更新完成后根据key删除缓存数据

为什么建议删除,而不是更新?

在 Cache-Aside 中,对于读请求的处理比较容易理解,但在写请求中,可能会有读者提出疑问,为什么要删除缓存,而不是更新缓存?站在符合直觉的角度来看,更新缓存是一个容易被理解的方案,但站在性能和安全的角度,更新缓存则可能会导致一些不好的后果。

首先是安全,在并发场景下,在写请求中更新缓存可能会引发数据的不一致问题,比如如下实际多请求并发的情况下:
● step1: 时刻1,线程1更新数据库的值为value1;
● step2: 时刻2,线程2更新数据库的值为value2;
● step3: 时刻3,线程2更新缓存的值为value2(因为一些网络原因,或其他因素,线程2快于线程1,这种情况是存在的);
● step4: 时刻4,线程1更新数据库的值为value1;


image

可以看到,最终的结果数据库中的值为value2,缓存中的数据为value1,数据不一致情况出现。选择删除缓存可以避免出现类似问题,最多会出现cache miss,触发从数据库查询加载。

其次是性能,当该缓存对应的结果需要消耗大量的计算过程才能得到时,比如需要访问多张数据库表并联合计算,那么在写操作中更新缓存的动作将会是一笔不小的开销。同时,当写操作较多时,可能也会存在刚更新的缓存还没有被读取到,又再次被更新的情况(这常被称为缓存扰动),显然,这样的更新是白白消耗机器性能的,会导致缓存利用率不高。而等到读请求未命中缓存时再去更新,也符合懒加载的思路,需要时再进行计算。删除缓存的操作不仅是幂等的,可以在发生异常时重试,而且写-删除和读-更新在语义上更加对称。

因此,建议是淘汰,而不是更新。

为什么先更新数据库,而不是先删除缓存?

缓存操作上确定了,应该删除而不是更新,那么时序上了,为什么不先删除缓存,再更新数据库呢?

在单线程下,这种方案看似具有一定合理性,这种合理性体现在删除缓存成功,但更新数据库失败的场景下,尽管缓存被删除了,下次读操作时,仍能将正确的数据写回缓存,相对于 Cache-Aside 中更新数据库成功,删除缓存失败的场景来说,先删除缓存的方案似乎更合理一些。那么,先删除缓存有什么问题呢?

首先是安全,在并发场景下,先删除缓存可能会引发数据的不一致问题,比如如下实际多请求并发的情况下:


image

比如初始缓存和DB中值都为value1,存在这样的场景:
● step1: 线程1写请求删除缓存;
● step2: 线程2读请求先读缓存,未命中;
● step3: 线程2读请求再读DB拿到值为value1;
● step4: 线程2读请求将读到的值刷回缓存,缓存中值为value1;
● step5: 线程1写请求更新DB的值为value2;
结论:可以看到,最终的结果数据库中的值为value2,缓存中的数据为value1,数据不一致情况出现。

其次是性能,先删除缓存,由于缓存中数据缺失,加剧数据库的请求压力,可能会增大缓存穿透出现的概率。

先更新数据库再删除缓存一定没问题吗?

其实即便是选择删除缓存,也存在数据不一致的可能性。


image

在上面的读写并发场景下,首先来自线程 1 的读请求在未命中缓存的情况下查询数据库( step 1 ),接着来自线程 2 的写请求更新数据库( step 2 ),但由于一些极端原因,线程 1 中读请求的更新缓存操作晚于线程 2 中写请求的删除缓存的操作( step 4 晚于 step 3 ),那么这样便会导致最终写入缓存中的是来自线程 1 的旧值,而写入数据库中的是来自线程 2 的新值,即缓存落后于数据库,此时再有读请求命中缓存( step 5 ),读取到的便是旧值。

这种场景的出现,不仅需要缓存失效且读写并发执行,而且还需要读请求查询数据库的执行早于写请求更新数据库,同时读请求的执行完成晚于写请求。足以见得,这种不一致场景产生的条件非常严格,在实际的生产中出现的可能性较小。

这种场景是不是没有解法了,也不是,可以延时双删,该方案的本质在于在延迟一定时间后,再进行一次缓存的删除,来解决并发情况下缓存到老数据的问题,即使先操作缓存后操作数据库也可以保证最终数据的一致。

该方案的核心点在于延迟时间T,通常我们把T设置为相同业务中一次查询操作耗时+几百毫秒,这样保证了第二次的删除可以清除掉因并发导致的缓存脏数据。该方案的劣势在于:

  1. 需要针对也许评估延迟时间,并增加二次删除逻辑,代码强耦合,增加了复杂度。
  2. 二次删除也可能出现缓存失败。

除此之外,在并发环境下,Cache-Aside 中也存在读请求命中缓存的时间点在写请求更新数据库之后,删除缓存之前,这样也会导致读请求查询到的缓存落后于数据库的情况。


image

虽然在下一次读请求中,缓存会被更新,但如果业务层面对这种情况的容忍度较低,那么可以采用加锁在写请求中保证“更新数据库&删除缓存”的串行执行为原子性操作(同理也可对读请求中缓存的更新加锁)。加锁势必会导致吞吐量的下降,故采取加锁的方案应该对性能的损耗有所预期。

如果删除失败怎么办?

除上面考虑的问题外,我们没有考虑操作数据库或者操作缓存可能失败的情况,而这种情况也是客观存在的。那么在这里我们简单讨论下,首先是如果更新数据库失败了,其实没有太大关系,因为此时数据库和缓存中都还是老数据,不存在不一致的问题。假设删除缓存失败了呢?此时确实会存在数据不一致的情况。

最简单的,就是设置一个缓存过期时间,那么当缓存过期后,用户再请求,就是新的数据了。除此之外有没有其他好的办法了?

缓存失败时可以增加重试机制。可以直接在代码中增加重试,这样简单但有一些不足,在于如果是网络原因导致的失败,立刻进行重试操作很可能也是失败的,因此在每次重试之间可能需要等待一段时间,比如几百毫秒甚至是秒级等待。为了不影响主流程的正常运行,你可能会将这个事情交给一个异步线程或者线程池来执行,但是如果机器此时也宕机了,这个删除操作也就丢失了。那要怎么解决这个问题呢?首先可以考虑引入消息队列,写入消息队列一样可能会失败,但是这是建立在缓存跟消息队列都不可用的情况下,应该说这样的概率是不高的。引入消息队列之后,就由消费端负责删除缓存以及重试,可能会慢一些但是可以保证操作不会丢失。


image

上述方案需要在正常业务逻辑中加入删除失败处理代码,侵入性很强,是否还有其他方案了?可以考虑订阅数据库的BinLog,数据库的BinLog存储了对数据库的更改操作日志记录,可以通过订阅该日志,来进行缓存的更新,业务代码不再关心缓存更新操作。


image.png

读写穿透缓存策略(Read/Write-Through Caching Strategy)

Read Through

和旁路缓存模式类似,先查询缓存:1. 缓存中存在,直接返回;2. 缓存中不存在,缓存服务自动从数据库中读取数据写入缓存,然后返回。


image

和旁路缓存模式的区别就是,旁路缓存模式是我们手动写入缓存,而读写穿透模式是自动从数据库中读取数据并写入缓存。

这种模式的缺陷是很多缓存层都不支持,例如Redis无法直接从MySQL中获取数据保存到自身中(除非使用Redis插件)。

Write-Through

在写请求时,先查询缓存中存不存在: 不存在,直接写入数据库; 存在,先更新缓存,然后同步更新数据库。两个操作都在一个事务中完成,只有两次都写成功了才是最终写成功了。


image

程序只和缓存交互,编码会变得更加简单和整洁,且保证了数据的一致性,但是写入延迟较大。适合写操作较多,并且对一致性要求较高的场景。

异步写入缓存策略(Write-Behind Caching Strategy)

Write behind意为异步回写模式,Write behind 在处理写请求时,只更新缓存而不更新数据库,对于数据库的更新,则是通过批量异步更新的方式进行的。


image

这种模式写性能非常好,因为都是直接写缓存,减轻了数据库的压力,具有较好的吞吐性。但数据库和缓存的一致性较弱,比如当更新的数据还未被写入数据库时,直接从数据库中查询数据是落后于缓存的。同时,缓存的负载较大,如果缓存宕机会导致数据丢失,所以需要做好缓存的高可用。

显然,Write behind 模式下适合大量写操作的场景,常用于电商秒杀场景中库存的扣减。

策略总结

image

从下到上,一致性保障逐渐增强。

怎么做到强一致性?

绝大多数业务场景,保证缓存和数据库的最终一致性就可以了,但是一些场景真的就要保证强一致性,是否有方案了?

如果要实现这两者的强一致性,只能是在更新完数据库之前,所有的读请求都必须要被阻塞直到缓存最终被删除为止,这里是不是让你想起了什么?比如volatile,ReadWriteLock等等,是的原理类似,只是当前场景不是单机而是一个分布式场景,那么可以考虑引入分布式锁。

初次之外,如果更有想法,可以引入一致性协议,比如Paxos和Raft等等,当然引入一个缓存,搞得这么复杂,可能不太合适。

总结

如果遇到一致性要求非常高的场景,应该考虑是否有必要引入缓存;
设置缓存的过期时间、每隔一段时间自动刷新,能解决大部分问题;
加读写锁可能会导致系统变得沉重,系统变慢;

参考文献

聊聊数据库与缓存数据一致性问题
一文搞定缓存和数据库一致性

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

推荐阅读更多精彩内容

  • 如何保证缓存和数据库一致性,这是一个老生常谈的话题了。 但很多人对这个问题,依旧有很多疑惑: 到底是更新缓存还是删...
    优雅地小男子阅读 161评论 0 0
  • 如何保证缓存和数据库一致性,这是一个老生常谈的话题了。 但很多人对这个问题,依旧有很多疑惑: 到底是更新缓存还是删...
    life_niu阅读 417评论 0 0
  • 前言 缓存由于其高并发和高性能的特性,已经在项目中被广泛使用。在读取缓存方面,大家没啥疑问,都是按照下图的流程来进...
    小波同学阅读 171评论 0 1
  • 读(查询)操作一致性 当读操作请求打到缓存上时,如果缓存里面存在数据,那么直接返回即可。如果缓存里面没有数据,那么...
    sunpy阅读 490评论 0 4
  • 一、先更新数据库,后更新缓存场景1)线程A未命中缓存2)线程A查询数据库3)线程B写入数据库4)线程B更新缓存5)...
    UCCU_ebd1阅读 174评论 0 0