关于数据库:万字图文讲透数据库缓存一致性问题

48次阅读

共计 7244 个字符,预计需要花费 19 分钟才能阅读完成。

导语 | 缓存正当应用确晋升了零碎的吞吐量和稳定性,然而这是有代价的。这个代价便是缓存和数据库的一致性带来了挑战,本文将针对最常见的 cache-aside 策略下如何保护缓存一致性彻底讲透。

然而主观上,咱们的业务规模很可能要求着更高的 QPS,有些业务的规模自身就十分大,也有些业务会遇到一些流量顶峰,比方电商会遇到大促的状况。

而这时候大部分的流量实际上都是读申请,而且大部分数据也是没有那么多变动的,如热门商品信息、微博的内容等常见数据就是如此。此时,缓存就是咱们应答此类场景的利器。

缓存的意义

所谓缓存,实际上就是用空间换工夫,精确地说是用更高速的空间来换工夫,从而整体上晋升读的性能。

何为更高速的空间呢?

更快的存储介质。通常状况下,如果说数据库的速度慢,就得用更快的存储组件去代替它,目前最常见的就是 Redis(内存存储)。Redis 单实例的读 QPS 能够高达 10w/s,90% 的场景下只须要正确应用 Redis 就能应答。

就近应用本地内存。就像 CPU 也有高速缓存一样,缓存也能够分为一级缓存、二级缓存。即使 Redis 自身性能曾经足够高了,但拜访一次 Redis 毕竟也须要一次网络 IO,而应用本地内存无疑有更快的速度。不过单机的内存是非常无限的,所以这种一级缓存只能存储十分大量的数据,通常是最热点的那些 key 对应的数据。这就相当于额定耗费贵重的服务内存去换取高速的读取性能。

引入缓存后的一致性挑战

用空间换工夫,意味着数据同时存在于多个空间。最常见的场景就是数据同时存在于 Redis 与 MySQL 上(为了问题的普适性,前面举例中若没有特地阐明,缓存均指 Redis 缓存)。

实际上,最权威最全的数据还是在 MySQL 里的。而万一 Redis 数据没有失去及时的更新(例如数据库更新了没更新到 Redis),就呈现了数据不统一。

大部分状况下,只有应用了缓存,就必然会有不统一的状况呈现,只是说这个不统一的工夫窗口是否能做到足够的小。有些不合理的设计可能会导致数据继续不统一,这是咱们须要改善设计去防止的。

这里的一致性实际上对于本地缓存也是同理的,例如数据库更新后没有及时更新本地缓存,也是有一致性问题的,下文对立以 Redis 缓存作为引子讲述,实际上解决本地缓存原理基本一致。

(一)缓存不一致性无奈主观地齐全毁灭

为什么咱们简直没方法做到缓存和数据库之间的强统一呢?

现实状况下,咱们须要在数据库更新完后把对应的最新数据同步到缓存中,以便在读申请的时候能读到新的数据而不是旧的数据(脏数据)。然而很惋惜,因为数据库和 Redis 之间是没有事务保障的,所以咱们无奈确保写入数据库胜利后,写入 Redis 也是肯定胜利的;即使 Redis 写入能胜利,在数据库写入胜利后到 Redis 写入胜利前的这段时间里,Redis 数据也必定是和 MySQL 不统一的。如下两图所示:

无奈事务保持一致

所以说这个工夫窗口是没方法齐全毁灭的,除非咱们付出极大的代价,应用分布式事务等各种伎俩去维持强统一,然而这样会使得零碎的整体性能大幅度降落,甚至比不必缓存还慢,这样不就与咱们应用缓存的指标南辕北辙了吗?

不过尽管无奈做到强统一,然而咱们能做到的是缓存与数据库达到最终统一,而且不统一的工夫窗口咱们能做到尽可能短,依照教训来说,如果能将工夫优化到 1ms 之内,这个一致性问题带来的影响咱们就能够忽略不计。

更新缓存的伎俩

通常状况下,咱们在解决查问申请的时候,应用缓存的逻辑如下:

data = queryDataRedis(key);

if (data ==null) {data = queryDataMySQL(key); // 缓存查问不到,从 MySQL 做查问

     if (data!=null) {updateRedis(key, data);// 查问完数据后更新 MySQL 最新数据到 Redis

     }

}

也就是说优先查问缓存,查问不到才查询数据库。如果这时候数据库查到数据了,就将缓存的数据进行更新。这是咱们常说的 cache aside 的策略,也是最罕用的策略。

这样的逻辑是正确的,而一致性的问题个别不来源于此,而是呈现在解决写申请的时候。所以咱们简化成最简略的写申请的逻辑,此时你可能会面临多个抉择,到底是间接更新缓存,还是生效缓存?而无论是更新缓存还是生效缓存,都能够抉择在更新数据库之前,还是之后操作。

这样就演变出 4 个策略:更新数据库后更新缓存、更新数据库前更新缓存、更新数据库后删除缓存、更新数据库前删除缓存。上面咱们来别离讲述。

(一)更新数据库后更新缓存的不统一问题

一种常见的操作是,设置一个过期工夫,让写申请以数据库为准,过期后,读申请同步数据库中的最新数据给缓存。那么在退出了过期工夫后,是否就不会有问题了呢?并不是这样。

大家构想一下这样的场景。

如果这里有一个计数器,把数据库自减 1,原始数据库数据是 100,同时有两个写申请申请计数减一,假如线程 A 先减数据库胜利,线程 B 后减数据库胜利。那么这时候数据库的值是 98,缓存里正确的值应该也要是 98。

然而非凡场景下,你可能会遇到这样的状况:

线程 A 和线程 B 同时更新这个数据

更新数据库的程序是先 A 后 B

更新缓存时程序是先 B 后 A
如果咱们的代码逻辑还是更新数据库后立即更新缓存的数据,那么——

updateMySQL();
updateRedis(key, data);

就可能呈现:数据库的值是 100->99->98,然而缓存的数据却是 100->98->99,也就是数据库与缓存的不统一。而且这个不统一只能等到下一次数据库更新或者缓存生效才可能修复。

工夫 线程 A(写申请) 线程 B(写申请) 问题
T1 更新数据库为 99
T2 更新数据库为 98
T3 更新缓存数据为 98
T4 更新缓存数据为 99 此时缓存的值被显式更新为 99,然而实际上数据库的值曾经是 98,数据不统一

当然,如果更新 Redis 自身是失败的话,两边的值诚然也是不统一的,这个前文也论述过,简直无奈铲除。

(二)更新数据库前更新缓存的不统一问题

那你可能会想,这是否示意,我应该先让缓存更新,之后再去更新数据库呢?相似这样:

updateRedis(key, data);// 先更新缓存
updateMySQL();// 再更新数据库

这样操作产生的问题更是不言而喻的,因为咱们无奈保障数据库的更新胜利,万一数据库更新失败了,你缓存的数据就不只是脏数据,而是谬误数据了。

你可能会想,是否我在更新数据库失败的时候做 Redis 回滚的操作可能解决呢?这其实也是不靠谱的,因为咱们也不能保障这个回滚的操作 100% 被胜利执行。
同时,在写写并发的场景下,同样有相似的一致性问题,请看以下状况:

  • 线程 A 和线程 B 同时更新同这个数据
  • 更新缓存的程序是先 A 后 B
  • 更新数据库的程序是先 B 后 A

举个例子。线程 A 心愿把计数器置为 0,线程 B 心愿置为 1。而依照以上场景,缓存的确被设置为 1,但数据库却被设置为 0。

工夫 线程 A(写请 求) 线程 B(写申请) 问题
T1 更新缓存为 0
T2 更新缓存为 1
T3 更新数据库为 1
T4 更新数据库数据为 0 此时缓存的值被显式更新为 1,然而实际上数据库的值是 0,数据不统一

所以通常状况下,更新缓存再更新数据库是咱们应该防止应用的一种伎俩。

(三)更新数据库前删除缓存的问题

那如果采取删除缓存的策略呢?也就是说咱们在更新数据库的时候生效对应的缓存,让缓存在下次触发读申请时进行更新,是否会更好呢?同样地,针对在更新数据库前和数据库后这两个删除机会,咱们来比拟下其差别。

最直观的做法,咱们可能会先让缓存生效,而后去更新数据库,代码逻辑如下:

deleteRedis(key);// 先删除缓存让缓存生效
updateMySQL();// 再更新数据库

这样的逻辑看似没有问题,毕竟删除缓存后即使数据库更新失败了,也只是缓存上没有数据而已。而后并发两个写申请过去,无论怎么样的执行程序,缓存最初的值也都是会被删除的,也就是说在并发写写的申请下这样的解决是没问题的。

然而,这种解决在读写并发的场景下却存在着隐患。

还是刚刚更新计数的例子。例如当初缓存的数据是 100,数据库也是 100,这时候须要对此计数减 1,减胜利后,数据库应该是 99。如果这之后触发读申请,缓存如果无效的话,外面应该也要被更新为 99 才是正确的。

那么思考下这样的申请状况:

线程 A 更新这个数据的同时,线程 B 读取这个数据

线程 A 胜利删除了缓存里的老数据,这时候线程 B 查问数据发现缓存生效

线程 A 更新数据库胜利

工夫 线程 A(写申请) 线程 B(读申请) 问题
T1 删除缓存值
T2 1. 读取缓存数据,缓存缺失,从数据库读取数据 100
T3 更新数据库中的数据 X 的值为 99
T4 将数据 100 的值写入缓存 此时缓存的值被显式更新为 100,然而实际上数据库的值曾经是 99 了

能够看到,在读写并发的场景下,一样会有不统一的问题。

针对这种场景,有个做法是所谓的“提早双删策略”,就是说,既然可能因为读申请把一个旧的值又写回去,那么我在写申请解决完之后,等到差不多的时间延迟再从新删除这个缓存值。

工夫 线程 A(写申请 线程 C(新的读申请) 线程 D(新的读申请) 问题
T5 sleep(N) 缓存存在,读取到缓存旧值 100 其余线程可能在双删胜利前读到脏数据
T6 删除缓存值
T7 缓存缺失,从数据库读取数据的最新值(99)

这种解决思路的关键在于对 N 的工夫的判断,如果 N 工夫太短,线程 A 第二次删除缓存的工夫仍旧早于线程 B 把脏数据写回缓存的工夫,那么相当于做了无用功。而 N 如果设置得太长,那么在触发双删之前,新申请看到的都是脏数据。

(四)更新数据库后删除缓存

那如果咱们把更新数据库放在删除缓存之前呢,问题是否解决?咱们持续从读写并发的场景看上来,有没有相似的问题。

工夫 线程 A(写申请) 线程 B(读申请) 线程 C(读申请) 潜在问题
T1 更新主库 X = 99(原值 X = 100)
T2 读取数据,查问到缓存还有数据,返回 100 线程 C 实际上读取到了和数据库不统一的数据
T3 删除缓存
T4 查问缓存,缓存缺失,查询数据库失去以后值 99
T5 将 99 写入缓存

能够看到,大体上,采取先更新数据库再删除缓存的策略是没有问题的,仅在更新数据库胜利到缓存删除之间的时间差内——[T2,T3)的窗口,可能会被别的线程读取到老值。

而在开篇的时候咱们说过,缓存不一致性的问题无奈在主观上齐全毁灭,因为咱们无奈保障数据库和缓存的操作是一个事务里的,而咱们能做到的只是尽量缩短不统一的工夫窗口。

在更新数据库后删除缓存这个场景下,不统一窗口仅仅是 T2 到 T3 的工夫,内网状态下通常不过 1ms,在大部分业务场景下咱们都能够忽略不计。因为大部分状况下一个用户的申请很难能再 1ms 内疾速发动第二次。

然而实在场景下,还是会有一个状况存在不统一的可能性,这个场景是读线程发现缓存不存在,于是读写并发时,读线程回写进去老值。并发状况如下:

工夫 线程 A(写申请) 线程 B(读申请 – 缓存不存在场景) 潜在问题
T1 查问缓存,缓存缺失,查询数据库失去以后值 100
T2 更新主库 X = 99(原值 X = 100)
T3 删除缓存
T4 将 100 写入缓存 此时缓存的值被显式更新为 100,然而实际上数据库的值曾经是 99 了

总的来说,这个不统一场景呈现条件十分严格,因为并发量很大时,缓存不太可能不存在;如果并发很大,而缓存真的不存在,那么很可能是这时的写场景很多,因为写场景会删除缓存。

所以待会咱们会提到,写场景很多时候实际上并不适宜采取删除策略。

(五)总结四种更新策略

终上所述,咱们比照了四个更新缓存的伎俩,做一个总结比照,其中应答计划也提供参考,具体不做开展,如下表:

策略 并发场景 潜在问题 应答计划
更新数据库 + 更新缓存 写 + 读 线程 A 未更新完缓存之前,线程 B 的读申请会短暂读到旧值 能够疏忽
写 + 写 更新数据库的程序是先 A 后 B,但更新缓存时程序是先 B 后 A,数据库和缓存数据不统一 分布式锁(操作重)
更新缓存 + 更新数据库 无并发 线程 A 还未更新完缓存然而更新数据库可能失败 利用 MQ 确认数据库更新胜利(较简单)
写 + 写 更新缓存的程序是先 A 后 B,但更新数据库时程序是先 B 后 A 分布式锁(操作很重)
删除缓存值 + 更新数据库 写 + 读 写申请的线程 A 删除了缓存在更新数据库之前,这时候读申请线程 B 到来,因为缓存缺失,则把以后数据读取进去放到缓存,而后线程 A 更新胜利了数据库 提早双删(然而提早的工夫不好预计,且提早的过程中仍旧有不统一的工夫窗口)
更新数据库 + 删除缓存值 写 + 读(缓存命中) 线程 A 实现数据库更新胜利后,尚未删除缓存,线程 B 有并发读申请会读到旧的脏数据 能够疏忽
写 + 读(缓存不命中) 读申请不命中缓存,写申请解决完之后读申请才回写缓存,此时缓存不统一 分布式锁(操作重)

从一致性的角度来看,采取更新数据库后删除缓存值,是更为适宜的策略。因为呈现不统一的场景的条件更为刻薄,概率相比其余计划更低。

那么是否更新缓存这个策略就一无是处呢?不是的!

删除缓存值意味着对应的 key 会生效,那么这时候读申请都会打到数据库。如果这个数据的写操作十分频繁,就会导致缓存的作用变得十分小。而如果这时候某些 Key 还是十分大的热 key,就可能因为扛不住数据量而导致系统不可用。

如下图所示:

删除策略频繁的缓存生效导致读申请无奈利用缓存

所以做个简略总结,足以适应绝大部分的互联网开发场景的决策:

针对大部分读多写少场景,倡议抉择更新数据库后删除缓存的策略。

针对读写相当或者写多读少的场景,倡议抉择更新数据库后更新缓存的策略。

最终一致性如何保障?

缓存设置过期工夫

第一个办法便是咱们下面提到的,当咱们无奈确定 MySQL 更新实现后,缓存的更新 / 删除肯定能胜利,例如 Redis 挂了导致写入失败了,或者过后网络呈现故障,更常见的是服务过后刚好产生重启了,没有执行这一步的代码。
这些时候 MySQL 的数据就无奈刷到 Redis 了。为了防止这种不一致性永恒存在,应用缓存的时候,咱们必须要给缓存设置一个过期工夫,例如 1 分钟,这样即便呈现了更新 Redis 失败的极其场景,不统一的工夫窗口最多也只是 1 分钟。
这是咱们最终一致性的兜底计划,万一呈现任何状况的不统一问题,最初都能通过缓存生效后从新查询数据库,而后回写到缓存,来做到缓存与数据库的最终统一。

如何缩小缓存删除 / 更新的失败?

万一删除缓存这一步因为服务重启没有执行,或者 Redis 长期不可用导致删除缓存失败了,就会有一个较长的工夫(缓存的残余过期工夫)是数据不统一的。

那咱们有没有什么伎俩来缩小这种不统一的状况呈现呢?这时候借助一个牢靠的消息中间件就是一个不错的抉择。

因为消息中间件有 ATLEAST-ONCE 的机制,如下图所示。

咱们把删除 Redis 的申请以生产 MQ 音讯的伎俩去生效对应的 Key 值,如果 Redis 真的存在异样导致无奈删除胜利,咱们仍旧能够依附 MQ 的重试机制来让最终 Redis 对应的 Key 生效。

而你们或者会问,极其场景下,是否存在更新数据库后 MQ 音讯没发送胜利,或者没机会发送进来机器就重启的状况?

这个场景确实比拟麻烦,如果 MQ 应用的是 RocketMQ,咱们能够借助 RocketMQ 的事务音讯,来让删除缓存的音讯最终肯定发送进来。而如果你没有应用 RocketMQ,或者你应用的消息中间件并没有事务音讯的个性,则能够采取音讯表的形式让更新数据库和发送音讯一起胜利。事实上这个话题比拟大了,咱们不在这里开展。

如何解决简单的多缓存场景?

有些时候,实在的缓存场景并不是数据库中的一个记录对应一个 Key 这么简略,有可能一个数据库记录的更新会牵扯到多个 Key 的更新。还有另外一个场景是,更新不同的数据库的记录时可能须要更新同一个 Key 值,这常见于一些 App 首页数据的缓存。

咱们以一个数据库记录对应多个 Key 的场景来举例。

如果零碎设计上咱们缓存了一个粉丝的主页信息、主播打赏榜 TOP10 的粉丝、单日 TOP 100 的粉丝等多个信息。如果这个粉丝登记了,或者这个粉丝触发了打赏的行为,下面多个 Key 可能都须要更新。只是一个打赏的记录,你可能就要做:

updateMySQL();// 更新数据库一条记录
deleteRedisKey1();// 生效主页信息的缓存
updateRedisKey2();// 更新打赏榜 TOP10
deleteRedisKey3();// 更新单日打赏榜 TOP100

这就波及多个 Redis 的操作,每一步都可能失败,影响到前面的更新。甚至从零碎设计上,更新数据库可能是独自的一个服务,而这几个不同的 Key 的缓存保护却在不同的 3 个微服务中,这就大大增加了零碎的复杂度和进步了缓存操作失败的可能性。最可怕的是,操作更新记录的中央很大概率不只在一个业务逻辑中,而是散发在零碎各个零散的地位。

针对这个场景,解决方案和上文提到的保障最终一致性的操作一样,就是把更新缓存的操作以 MQ 音讯的形式发送进来,由不同的零碎或者专门的一个零碎进行订阅,而做聚合的操作。如下图:

不同业务零碎订阅 MQ 音讯独自保护各自的缓存 Key

专门更新缓存的服务订阅 MQ 音讯保护所有相干 Key 的缓存操作

通过订阅 MySQL binlog 的形式解决缓存

下面讲到的 MQ 解决形式须要业务代码外面显式地发送 MQ 音讯。还有一种优雅的形式便是订阅 MySQL 的 binlog,监听数据的实在变动状况以解决相干的缓存。

例如刚刚提到的例子中,如果粉丝又触发打赏了,这时候咱们利用 binlog 表监听是能及时发现的,发现后就能集中处理了,而且无论是在什么零碎什么地位去更新数据,都能做到集中处理。

目前业界相似的产品有 Canal,具体的操作图如下:

利用 Canel 订阅数据库 binlog 变更从而收回 MQ 音讯,让一个专门消费者服务保护所有相干 Key 的缓存操作

到这里,针对大型零碎缓存设计如何保障最终一致性,咱们曾经从策略、场景、操作计划等角度进行了粗疏的讲述,心愿能对你起到帮忙。

注:本文基于自己博客 https://jaskey.github.io/blog…

作者集体邮箱 jaskeylin@apache.org,微信:JaskeyLam

正文完
 0