数据库和缓存的一致性如何保证?

为了缓解数据库的访问压力,我们通常会引入 Redis 作为缓存层 ------ 读请求优先命中缓存,不用每次都查数据库,性能直接拉满。但这种方式有一个问题:数据更新的时候,既要操作数据库,也要操作缓存,这两个步骤不是原子性的,在并发场景下,很容易出现数据库和缓存数据不一致的问题。

针对这个问题,我把我学习到的技术方案,整个学习过程和知识点整理成这篇博客,既是给自己做学习沉淀,也希望能帮到和我一样正在学习的同学。

一、为什么会有一致性问题?

一致性的核心矛盾,本质上是缓存和数据库是两个独立的存储组件,我们无法用一个原子操作同时完成两者的更新。

举个最通俗的例子:我们要把用户的年龄从 20 更新到 21,既要改数据库里的数据,也要处理缓存里的数据。这两个操作必然有先后顺序,一旦中间出现并发请求,或者其中一个操作执行失败,就会出现 "数据库里是新值,缓存里是旧值" 的不一致情况。

接下来我会一步步拆解,哪些方案是坑,哪些方案是业界主流的最优解。

二、那些行不通的更新方案

最开始我想当然地觉得,数据更新了,缓存肯定也要跟着更新啊,不然不就白加缓存了?但深入分析后才发现,无论是先更数据库还是先更缓存,"双更新" 的方案在并发场景下都有严重的一致性问题。

2.1 先更新数据库,再更新缓存

这个方案是我最开始觉得 "最稳妥" 的,毕竟数据库是数据的源头,先把源头改对,再更新缓存,怎么会出问题?

直到我模拟了并发更新的场景,才发现问题所在:假设请求 A 和请求 B 同时更新同一条用户年龄数据:

  1. 请求 A 先把数据库里的年龄更新为 1
  2. 在请求 A 更新缓存前,请求 B 把数据库里的年龄更新为 2,并且立刻把缓存更新为 2
  3. 这时请求 A 才姗姗来迟,把缓存更新为 1

最终结果:数据库里是最新的 2,缓存里却是旧的 1,数据彻底不一致了。

2.2 先更新缓存,再更新数据库

既然先更数据库有问题,那反过来先更缓存呢?很遗憾,还是会出现一样的问题,只是反过来了而已。

同样是并发更新的场景:

  1. 请求 A 先把缓存里的年龄更新为 1
  2. 在请求 A 更新数据库前,请求 B 把缓存里的年龄更新为 2,并且立刻把数据库更新为 2
  3. 这时请求 A 才把数据库更新为 1

最终结果:缓存里是 2,数据库里是 1,还是不一致。

结论很清晰:只要是 "更新数据库 + 更新缓存" 的双更新方案,在并发写场景下,必然会出现数据不一致的问题,完全不推荐使用。

三、Cache Aside 旁路缓存策略

踩完双更新的坑,我学到了业界最主流的方案 ------Cache Aside 旁路缓存策略。这个策略的核心思路彻底颠覆了我之前的想法:写请求不更新缓存,而是直接删除缓存;读请求发现缓存缺失时,再从数据库读取数据并回写缓存。

3.1 策略的基础规则

它分为读策略和写策略两部分,非常好记:

  • 读策略:先读缓存,命中就直接返回;没命中就读数据库,把读到的数据写入缓存,再返回结果。
  • 写策略:先更新数据库,数据库更新成功后,再删除缓存。

这里又出现了一个新问题:写操作里,到底是 "先删缓存,再更数据库",还是 "先更数据库,再删缓存"?我也做了详细的对比分析。

3.2 方案 1:先删除缓存,再更新数据库

这个方案看似能避免问题,但在 "读 + 写" 并发的场景下,还是会出现不一致。

举个例子,用户年龄当前是 20:

  1. 请求 A 要把年龄更新为 21,第一步先删除了缓存
  2. 这时请求 B 来读取这个用户的年龄,缓存没命中,就从数据库读到了旧值 20,并且把 20 回写到了缓存里
  3. 最后请求 A 才把数据库里的年龄更新为 21

最终结果:缓存里是 20,数据库里是 21,数据不一致,而且如果没有缓存过期时间兜底,这个不一致会一直存在。

针对这个问题,业界有一个 "延迟双删" 的补救方案,伪代码如下:

plaintext

复制代码
# 第一步:先删除缓存
redis.delKey(X)
# 第二步:更新数据库
db.update(X)
# 第三步:睡眠一段时间,确保读请求完成缓存回写
Thread.sleep(N)
# 第四步:再次删除缓存,把脏数据删掉
redis.delKey(X)

但这个方案有个很致命的问题:睡眠时间 N 很难评估,要大于读请求 "读数据库 + 回写缓存" 的时间,实际业务里根本没法精准预估,只能尽可能降低不一致的概率,极端场景还是会出问题,所以并不是最优解。

3.3 方案 2:先更新数据库,再删除缓存

这也是业界最推荐的基础方案,我们先看它在并发场景下的表现。

同样是 "读 + 写" 并发的场景,用户年龄当前是 20,缓存中没有这条数据:

  1. 请求 A 来读取数据,缓存没命中,从数据库读到了 20,还没来得及回写缓存
  2. 这时请求 B 来更新数据,把数据库里的年龄更新为 21,并且成功删除了缓存
  3. 最后请求 A 才把读到的旧值 20 回写到了缓存里

理论上,这个场景确实会出现不一致,但为什么还说它是最优解?因为这个场景出现的概率极低:缓存的写入速度远远快于数据库的写入,正常情况下,请求 A 的缓存回写,一定会比请求 B 的 "更新数据库 + 删缓存" 更快完成,几乎不会出现上面的时序。

而且我们还可以给缓存加上过期时间做兜底,就算真的出现了极端的不一致情况,等缓存过期后,读请求会重新从数据库读取最新值回写缓存,实现最终一致性。

四、如何保证两个操作都能执行成功?

学到这里,我以为已经掌握了终极方案,结果又发现了一个新的问题:"先更新数据库,再删除缓存" 是两个独立的操作,如果数据库更新成功了,但是删除缓存失败了怎么办?

这就会出现数据库里是新值,缓存里还是旧值的情况,而且如果没有过期时间,这个不一致会一直存在,这也是实际业务里最容易踩的坑。

针对这个问题,我学习了两种成熟的解决方案,核心思路都是保证删除缓存的操作最终一定能执行成功。

4.1 解决方案 1:消息队列重试机制

这个方案的核心是:把删除缓存的操作交给消息队列做兜底,失败了就自动重试,直到删除成功。

具体流程:

  1. 先更新数据库,更新成功
  2. 把要删除的缓存 key 发送到消息队列
  3. 消费者监听消息队列,拿到 key 后执行删除缓存操作
  4. 如果删除成功,就给消息队列返回 ACK 确认,消息被移除;如果删除失败,消息队列会把消息重新投递给消费者,再次重试,直到成功。

这个方案的优点是实现简单,能保证删除操作最终成功;缺点是对业务代码有入侵,需要在原有业务逻辑里耦合消息队列的代码。

4.2 解决方案 2:订阅 MySQL binlog + Canal 异步删缓存

这个方案是大厂里更常用的,完全和业务代码解耦,核心是基于 MySQL 的 binlog 日志来做缓存删除。

我们都知道,MySQL 的数据只要发生更新,就会生成一条 binlog 变更日志。我们可以用阿里开源的 Canal 中间件,把自己伪装成 MySQL 的从节点,订阅主库的 binlog 日志。

具体流程:

  1. 业务代码只需要更新数据库,不用关心缓存操作
  2. 数据库更新成功后,生成 binlog 变更日志
  3. Canal 拿到 binlog 日志,解析出更新的数据,发送到消息队列
  4. 消费者监听消息,根据变更的数据执行缓存删除操作,同样通过 ACK 机制保证删除成功才确认消息。

这个方案的优点是完全和业务代码解耦,业务开发不用再关心缓存的一致性问题,所有缓存操作都由统一的服务处理;缺点是引入了 Canal、消息队列等组件,对运维能力有更高的要求。

五、为什么是删除缓存,而不是更新缓存?

学到这里,我还有一个疑问:为什么所有的最优方案都是删除缓存,而不是更新缓存呢?更新缓存的话,下次读请求不就直接命中了吗,缓存命中率不是更高?

认真梳理后,我找到了答案,主要有这 3 个核心原因:

  1. 操作更轻量级,出错概率更低:删除一个 key,比更新一个复杂的缓存数据简单太多。实际业务里,缓存的数据往往不是单表数据,可能是多张表关联聚合的结果,比如商品详情,要关联商品表、价格表、库存表,更新缓存需要重新查询聚合所有数据,耗时久、逻辑复杂,很容易出问题。
  2. 懒加载,节省计算资源:不是所有的缓存数据都会被频繁访问,如果我们每次更新数据库都更新缓存,但是这个缓存之后很久都没人访问,就白白浪费了计算资源。而删除缓存,只有等下次读请求来的时候,才会重新加载缓存,这就是经典的 Lazy Loading 懒加载思想。
  3. 避免并发更新的脏数据:就像最开始分析的,更新缓存会在并发写场景下出现严重的脏数据问题,而删除缓存能最大程度规避这个问题。

当然,如果你的业务对缓存命中率有极高的要求,也可以用更新缓存的方案,但必须配合分布式锁,保证同一时间只有一个请求能更新缓存,避免并发问题,同时也要接受锁带来的性能损耗。

六、总结与心得

这次的学习,让我彻底搞懂了数据库和缓存一致性的核心问题和解决方案,也明白了分布式系统里的一个核心道理:没有绝对的强一致性,只有最终一致性,我们要做的是在性能和一致性之间找到平衡,同时做好兜底方案。

最后给和我一样的初学者总结一下核心结论:

  1. 绝对不要用 "双更新" 的方案,并发场景下必然会出现数据不一致。
  2. 基础场景优先使用Cache Aside 旁路缓存策略,写操作遵循「先更新数据库,再删除缓存」,同时给缓存加上过期时间做兜底。
  3. 要保证删除缓存的操作最终成功,中小项目可以用消息队列重试,大型项目推荐用 Canal 订阅 binlog 的方案,和业务解耦。
  4. 优先选择删除缓存,而不是更新缓存,除非你的业务对缓存命中率有极致要求。
相关推荐
skiy2 小时前
redis 使用
数据库·redis·缓存
mygljx2 小时前
Redis 下载与安装 教程 windows版
数据库·windows·redis
奕成则成2 小时前
Redis 大 Key 问题排查与治理:原因、危害、实战方案
数据库·redis·缓存
551只玄猫3 小时前
【数据库原理 实验报告5】数据查询的应用(连接)
数据库·sql·mysql·课程设计·实验报告
不吃香菜学java3 小时前
苍穹外卖-新增菜品代码开发
spring boot·spring·servlet·log4j·maven·mybatis
aisifang004 小时前
MySQL官网驱动下载(jar包驱动和ODBC驱动)【详细教程】
数据库·mysql
551只玄猫4 小时前
【数据库原理 实验报告2】创建和管理数据表
数据库·sql·mysql·课程设计·实验报告
qq_334060214 小时前
spring_springmvc_mybatis权限控制+boostrap实现UI
java·spring·mybatis
wuyikeer4 小时前
windows同时安装两个不同版本的Mysql
windows·mysql·adb