为了缓解数据库的访问压力,我们通常会引入 Redis 作为缓存层 ------ 读请求优先命中缓存,不用每次都查数据库,性能直接拉满。但这种方式有一个问题:数据更新的时候,既要操作数据库,也要操作缓存,这两个步骤不是原子性的,在并发场景下,很容易出现数据库和缓存数据不一致的问题。
针对这个问题,我把我学习到的技术方案,整个学习过程和知识点整理成这篇博客,既是给自己做学习沉淀,也希望能帮到和我一样正在学习的同学。
一、为什么会有一致性问题?
一致性的核心矛盾,本质上是缓存和数据库是两个独立的存储组件,我们无法用一个原子操作同时完成两者的更新。
举个最通俗的例子:我们要把用户的年龄从 20 更新到 21,既要改数据库里的数据,也要处理缓存里的数据。这两个操作必然有先后顺序,一旦中间出现并发请求,或者其中一个操作执行失败,就会出现 "数据库里是新值,缓存里是旧值" 的不一致情况。
接下来我会一步步拆解,哪些方案是坑,哪些方案是业界主流的最优解。
二、那些行不通的更新方案
最开始我想当然地觉得,数据更新了,缓存肯定也要跟着更新啊,不然不就白加缓存了?但深入分析后才发现,无论是先更数据库还是先更缓存,"双更新" 的方案在并发场景下都有严重的一致性问题。
2.1 先更新数据库,再更新缓存
这个方案是我最开始觉得 "最稳妥" 的,毕竟数据库是数据的源头,先把源头改对,再更新缓存,怎么会出问题?
直到我模拟了并发更新的场景,才发现问题所在:假设请求 A 和请求 B 同时更新同一条用户年龄数据:
- 请求 A 先把数据库里的年龄更新为 1
- 在请求 A 更新缓存前,请求 B 把数据库里的年龄更新为 2,并且立刻把缓存更新为 2
- 这时请求 A 才姗姗来迟,把缓存更新为 1
最终结果:数据库里是最新的 2,缓存里却是旧的 1,数据彻底不一致了。
2.2 先更新缓存,再更新数据库
既然先更数据库有问题,那反过来先更缓存呢?很遗憾,还是会出现一样的问题,只是反过来了而已。
同样是并发更新的场景:
- 请求 A 先把缓存里的年龄更新为 1
- 在请求 A 更新数据库前,请求 B 把缓存里的年龄更新为 2,并且立刻把数据库更新为 2
- 这时请求 A 才把数据库更新为 1
最终结果:缓存里是 2,数据库里是 1,还是不一致。
结论很清晰:只要是 "更新数据库 + 更新缓存" 的双更新方案,在并发写场景下,必然会出现数据不一致的问题,完全不推荐使用。
三、Cache Aside 旁路缓存策略
踩完双更新的坑,我学到了业界最主流的方案 ------Cache Aside 旁路缓存策略。这个策略的核心思路彻底颠覆了我之前的想法:写请求不更新缓存,而是直接删除缓存;读请求发现缓存缺失时,再从数据库读取数据并回写缓存。
3.1 策略的基础规则
它分为读策略和写策略两部分,非常好记:
- 读策略:先读缓存,命中就直接返回;没命中就读数据库,把读到的数据写入缓存,再返回结果。
- 写策略:先更新数据库,数据库更新成功后,再删除缓存。
这里又出现了一个新问题:写操作里,到底是 "先删缓存,再更数据库",还是 "先更数据库,再删缓存"?我也做了详细的对比分析。
3.2 方案 1:先删除缓存,再更新数据库
这个方案看似能避免问题,但在 "读 + 写" 并发的场景下,还是会出现不一致。
举个例子,用户年龄当前是 20:
- 请求 A 要把年龄更新为 21,第一步先删除了缓存
- 这时请求 B 来读取这个用户的年龄,缓存没命中,就从数据库读到了旧值 20,并且把 20 回写到了缓存里
- 最后请求 A 才把数据库里的年龄更新为 21
最终结果:缓存里是 20,数据库里是 21,数据不一致,而且如果没有缓存过期时间兜底,这个不一致会一直存在。
针对这个问题,业界有一个 "延迟双删" 的补救方案,伪代码如下:
plaintext
# 第一步:先删除缓存
redis.delKey(X)
# 第二步:更新数据库
db.update(X)
# 第三步:睡眠一段时间,确保读请求完成缓存回写
Thread.sleep(N)
# 第四步:再次删除缓存,把脏数据删掉
redis.delKey(X)
但这个方案有个很致命的问题:睡眠时间 N 很难评估,要大于读请求 "读数据库 + 回写缓存" 的时间,实际业务里根本没法精准预估,只能尽可能降低不一致的概率,极端场景还是会出问题,所以并不是最优解。
3.3 方案 2:先更新数据库,再删除缓存
这也是业界最推荐的基础方案,我们先看它在并发场景下的表现。
同样是 "读 + 写" 并发的场景,用户年龄当前是 20,缓存中没有这条数据:
- 请求 A 来读取数据,缓存没命中,从数据库读到了 20,还没来得及回写缓存
- 这时请求 B 来更新数据,把数据库里的年龄更新为 21,并且成功删除了缓存
- 最后请求 A 才把读到的旧值 20 回写到了缓存里
理论上,这个场景确实会出现不一致,但为什么还说它是最优解?因为这个场景出现的概率极低:缓存的写入速度远远快于数据库的写入,正常情况下,请求 A 的缓存回写,一定会比请求 B 的 "更新数据库 + 删缓存" 更快完成,几乎不会出现上面的时序。
而且我们还可以给缓存加上过期时间做兜底,就算真的出现了极端的不一致情况,等缓存过期后,读请求会重新从数据库读取最新值回写缓存,实现最终一致性。
四、如何保证两个操作都能执行成功?
学到这里,我以为已经掌握了终极方案,结果又发现了一个新的问题:"先更新数据库,再删除缓存" 是两个独立的操作,如果数据库更新成功了,但是删除缓存失败了怎么办?
这就会出现数据库里是新值,缓存里还是旧值的情况,而且如果没有过期时间,这个不一致会一直存在,这也是实际业务里最容易踩的坑。
针对这个问题,我学习了两种成熟的解决方案,核心思路都是保证删除缓存的操作最终一定能执行成功。
4.1 解决方案 1:消息队列重试机制
这个方案的核心是:把删除缓存的操作交给消息队列做兜底,失败了就自动重试,直到删除成功。
具体流程:
- 先更新数据库,更新成功
- 把要删除的缓存 key 发送到消息队列
- 消费者监听消息队列,拿到 key 后执行删除缓存操作
- 如果删除成功,就给消息队列返回 ACK 确认,消息被移除;如果删除失败,消息队列会把消息重新投递给消费者,再次重试,直到成功。
这个方案的优点是实现简单,能保证删除操作最终成功;缺点是对业务代码有入侵,需要在原有业务逻辑里耦合消息队列的代码。
4.2 解决方案 2:订阅 MySQL binlog + Canal 异步删缓存
这个方案是大厂里更常用的,完全和业务代码解耦,核心是基于 MySQL 的 binlog 日志来做缓存删除。
我们都知道,MySQL 的数据只要发生更新,就会生成一条 binlog 变更日志。我们可以用阿里开源的 Canal 中间件,把自己伪装成 MySQL 的从节点,订阅主库的 binlog 日志。
具体流程:
- 业务代码只需要更新数据库,不用关心缓存操作
- 数据库更新成功后,生成 binlog 变更日志
- Canal 拿到 binlog 日志,解析出更新的数据,发送到消息队列
- 消费者监听消息,根据变更的数据执行缓存删除操作,同样通过 ACK 机制保证删除成功才确认消息。
这个方案的优点是完全和业务代码解耦,业务开发不用再关心缓存的一致性问题,所有缓存操作都由统一的服务处理;缺点是引入了 Canal、消息队列等组件,对运维能力有更高的要求。
五、为什么是删除缓存,而不是更新缓存?
学到这里,我还有一个疑问:为什么所有的最优方案都是删除缓存,而不是更新缓存呢?更新缓存的话,下次读请求不就直接命中了吗,缓存命中率不是更高?
认真梳理后,我找到了答案,主要有这 3 个核心原因:
- 操作更轻量级,出错概率更低:删除一个 key,比更新一个复杂的缓存数据简单太多。实际业务里,缓存的数据往往不是单表数据,可能是多张表关联聚合的结果,比如商品详情,要关联商品表、价格表、库存表,更新缓存需要重新查询聚合所有数据,耗时久、逻辑复杂,很容易出问题。
- 懒加载,节省计算资源:不是所有的缓存数据都会被频繁访问,如果我们每次更新数据库都更新缓存,但是这个缓存之后很久都没人访问,就白白浪费了计算资源。而删除缓存,只有等下次读请求来的时候,才会重新加载缓存,这就是经典的 Lazy Loading 懒加载思想。
- 避免并发更新的脏数据:就像最开始分析的,更新缓存会在并发写场景下出现严重的脏数据问题,而删除缓存能最大程度规避这个问题。
当然,如果你的业务对缓存命中率有极高的要求,也可以用更新缓存的方案,但必须配合分布式锁,保证同一时间只有一个请求能更新缓存,避免并发问题,同时也要接受锁带来的性能损耗。
六、总结与心得
这次的学习,让我彻底搞懂了数据库和缓存一致性的核心问题和解决方案,也明白了分布式系统里的一个核心道理:没有绝对的强一致性,只有最终一致性,我们要做的是在性能和一致性之间找到平衡,同时做好兜底方案。
最后给和我一样的初学者总结一下核心结论:
- 绝对不要用 "双更新" 的方案,并发场景下必然会出现数据不一致。
- 基础场景优先使用Cache Aside 旁路缓存策略,写操作遵循「先更新数据库,再删除缓存」,同时给缓存加上过期时间做兜底。
- 要保证删除缓存的操作最终成功,中小项目可以用消息队列重试,大型项目推荐用 Canal 订阅 binlog 的方案,和业务解耦。
- 优先选择删除缓存,而不是更新缓存,除非你的业务对缓存命中率有极致要求。