挑战大型系统的缓存设计——应对一致性问题
大家好,我是顶级架构师。
缓存的意义
所谓缓存,实际上就是用空间换时间,准确地说是用更高速的空间来换时间,从而整体上提升读的性能。
何为更高速的空间呢?
更快的存储介质。通常情况下,如果说数据库的速度慢,就得用更快的存储介质去替代它,目前最常见的就是Redis。Redis 单实例的读 QPS 可以高达 10w/s,90% 的场景下只需要正确使用 Redis 就能应对。
就近使用本地内存。就像 CPU 也有高速缓存一样,缓存也可以分为一级缓存、二级缓存。即便 Redis 本身性能已经足够高了,但访问一次 Redis 毕竟也需要一次网络 IO,而使用本地内存无疑有更快的速度。不过单机的内存是十分有限的,所以这种一级缓存只能存储非常少量的数据,通常是最热点的那些 key 对应的数据。这就相当于额外消耗宝贵的服务内存去换取高速的读取性能。
用空间换时间,意味着数据同时存在于多个空间。最常见的场景就是数据同时存在于 Redis 与 MySQL 上(为了问题的普适性,后面举例中若没有特别说明,缓存均指 Redis 缓存)。
实际上,最权威最全的数据还是在 MySQL 里的,只要 Redis 数据没有得到及时的更新而导致最新数据没有同步到 Redis 中,就出现了数据不一致。
大部分情况下,只要使用了缓存,就必然会有不一致的情况出现,只是说这个不一致的时间窗口是否能做到足够的小。有些不合理的设计可能会导致数据持续不一致,这是我们需要改善设计去避免的。
不过虽然无法做到强一致,但是我们能做到的是缓存与数据库达到最终一致,而且不一致的时间窗口我们能做到尽可能短,按照经验来说,如果能将时间优化到 1ms 之内,这个一致性问题带来的影响我们就可以忽略不计。
更新缓存的手段
通常情况下,我们在处理查询请求的时候,使用缓存的逻辑如下:
1 2 3 4 5 6 7 | data = queryDataRedis(key); |
也就是说优先查询缓存,查询不到才查询数据库。如果这时候数据库查到数据了,就将缓存的数据进行更新。这样的逻辑是正确的,而一致性的问题一般不来源于此,而是出现在处理写请求的时候。所以我们简化成最简单的写请求的逻辑,此时你可能会面临多个选择,究竟是直接更新缓存,还是失效缓存?而无论是更新缓存还是失效缓存,都可以选择在更新数据库之前,还是之后操作。
这样就演变出 4 个策略:更新数据库后更新缓存、更新数据库前更新缓存、更新数据库后删除缓存、更新数据库前删除缓存。下面我们来分别讲述。另外,搜索公众号编程技术圈后台回复“Java”,获取一份惊喜礼包。
更新数据库后更新缓存的不一致问题
一种常见的操作是,设置一个过期时间,让写请求以数据库为准,过期后,读请求同步数据库中的最新数据给缓存。那么在加入了过期时间后,是否就不会有问题了呢?并不是这样。
大家设想一下这样的场景。
假如这里有一个计数器,把数据库自减 1,原始数据库数据是 100,同时有两个写请求申请计数减一,假设线程 A 先减数据库成功,线程 B 后减数据库成功。那么这时候数据库的值是 98,缓存里正确的值应该也要是 98。
但是特殊场景下,你可能会遇到这样的情况:
线程 A 和线程 B 同时更新这个数据
更新数据库的顺序是先 A 后 B
更新缓存时顺序是先 B 后 A 如果我们的代码逻辑还是更新数据库后立刻更新缓存的数据,那么——
1 2 | updateMySQL(); |
时间 | 线程A(写请求) | 线程B(写请求) | 问题 |
---|---|---|---|
T1 | 更新数据库为99 | ||
T2 | 更新数据库为98 | ||
T3 | 更新缓存数据为98 | ||
T4 | 更新缓存数据为99 | 此时缓存的值被显式更新为99,但是实际上数据库的值已经是98,数据不一致 |
更新数据库前更新缓存的不一致问题
那你可能会想,这是否表示,我应该先让缓存更新,之后再去更新数据库呢?类似这样:
1 2 | updateRedis(key, data);//先更新缓存 |
这样操作产生的问题更是显而易见的,因为我们无法保证数据库的更新成功,万一数据库更新失败了,你缓存的数据就不只是脏数据,而是错误数据了。你可能会想,是否我在更新数据库失败的时候做 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,数据不一致 |
所以通常情况下,更新缓存再更新数据库是我们应该避免使用的一种手段。
更新数据库前删除缓存的问题
那如果采取删除缓存的策略呢?也就是说我们在更新数据库的时候失效对应的缓存,让缓存在下次触发读请求时进行更新,是否会更好呢?同样地,针对在更新数据库前和数据库后这两个删除时机,我们来比较下其差异。
最直观的做法,我们可能会先让缓存失效,然后去更新数据库,代码逻辑如下:
1 2 | deleteRedis(key);//先删除缓存让缓存失效 |
这样的逻辑看似没有问题,毕竟删除缓存后即便数据库更新失败了,也只是缓存上没有数据而已。然后并发两个写请求过来,无论怎么样的执行顺序,缓存最后的值也都是会被删除的,也就是说在并发写写的请求下这样的处理是没问题的。然而,这种处理在读写并发的场景下却存在着隐患。
还是刚刚更新计数的例子。例如现在缓存的数据是 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 的时间,大概是 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 分钟。另外,搜索公众号顶级科技后台回复“API”,获取一份惊喜礼包。
这是我们最终一致性的兜底方案,万一出现任何情况的不一致问题,最后都能通过缓存失效后重新查询数据库,然后回写到缓存,来做到缓存与数据库的最终一致。
如何减少缓存删除/更新的失败?
万一删除缓存这一步因为服务重启没有执行,或者 Redis 临时不可用导致删除缓存失败了,就会有一个较长的时间(缓存的剩余过期时间)是数据不一致的。
那我们有没有什么手段来减少这种不一致的情况出现呢?这时候借助一个可靠的消息中间件就是一个不错的选择。
因为消息中间件有 ATLEAST-ONCE 的机制,如下图所示。
我们把删除 Redis 的请求以消费 MQ 消息的手段去失效对应的 Key 值,如果 Redis 真的存在异常导致无法删除成功,我们依旧可以依靠 MQ 的重试机制来让最终 Redis 对应的 Key 失效。
而你们或许会问,极端场景下,是否存在更新数据库后 MQ 消息没发送成功,或者没机会发送出去机器就重启的情况?
这个场景的确比较麻烦,如果 MQ 使用的是 RocketMQ,我们可以借助 RocketMQ 的事务消息,来让删除缓存的消息最终一定发送出去。而如果你没有使用 RocketMQ,或者你使用的消息中间件并没有事务消息的特性,则可以采取消息表的方式让更新数据库和发送消息一起成功。事实上这个话题比较大了,我们不在这里展开。
如何处理复杂的多缓存场景?
有些时候,真实的缓存场景并不是数据库中的一个记录对应一个 Key 这么简单,有可能一个数据库记录的更新会牵扯到多个 Key 的更新。还有另外一个场景是,更新不同的数据库的记录时可能需要更新同一个 Key 值,这常见于一些 App 首页数据的缓存。
我们以一个数据库记录对应多个 Key 的场景来举例。
1 2 3 4 | updateMySQL();//更新数据库一条记录 |
这就涉及多个 Redis 的操作,每一步都可能失败,影响到后面的更新。甚至从系统设计上,更新数据库可能是单独的一个服务,而这几个不同的 Key 的缓存维护却在不同的 3 个微服务中,这就大大增加了系统的复杂度和提高了缓存操作失败的可能性。最可怕的是,操作更新记录的地方很大概率不只在一个业务逻辑中,而是散发在系统各个零散的位置。针对这个场景,解决方案和上文提到的保证最终一致性的操作一样,就是把更新缓存的操作以 MQ 消息的方式发送出去,由不同的系统或者专门的一个系统进行订阅,而做聚合的操作。如下图:
通过订阅 MySQL binlog 的方式处理缓存
目前业界类似的产品有 Canal,具体的操作图如下:
到这里,针对大型系统缓存设计如何保证最终一致性,我们已经从策略、场景、操作方案等角度进行了细致的讲述,这些是我根据多年开发经验进行总结的,希望能对你起到帮助。
最后给读者整理了一份BAT大厂面试真题,需要的可扫码回复“面试题”即可获取。
「顶级架构师」建立了读者架构师交流群,大家可以添加小编微信进行加群。欢迎有想法、乐于分享的朋友们一起交流学习。
扫描添加好友邀你进架构师群,加我时注明【姓名+公司+职位】
版权申明:内容来源网络,版权归原作者所有。如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。
猜你还想看
面试官:Spring MVC 如何保证 Controller 的并发安全性?面试必问!