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

# 核心策略
保证缓存(如 Redis)和数据库(如 MySQL)的数据一致性是一个非常经典、但也挺让人头疼的问题。因为两者的更新不是同一个原子操作,只要有时间差,或者高并发时执行顺序错乱,就会出现一致性问题
先说最基本的策略,一定要给缓存设置一个过期时间,避免异常情况下数据库和缓存长时间不一致
| 方案 | 问题 | 结论 |
|---|---|---|
| 先更新数据库,再更新缓存 | 会出现脏数据 | 不推荐 |
| 先更新缓存,再更新数据库 | 会出现脏数据 | 不推荐 |
| 先删除缓存再更新数据库 | 会有并发读写问题,虽然可以用延迟双删解决,但还是有风险 | 可用,但控制不好再次删除的时间,依然有风险 |
| 先更新数据库,再删除缓存 | 出现问题的概率极低 | 推荐 |
| 场景 | 方案 | 一致性强度 |
|---|---|---|
| 中小型项目 / 允许微小延迟 | 先更新DB + 再删缓存 | 最终一致 |
| 高并发 / 业务解耦 | 先更新DB + Canal监听Binlog + 异步删缓存 | 最终一致 |
| 强一致性 | 读写请求使用 分布式锁(Redisson) 或 读写锁 | 强一致 |
# 先更新数据库,再更新缓存
问题:如果两个线程同时更新同一条数据,可能会因为网络延迟导致并发乱序
现象:线程A先更新DB,线程B后更新DB;但缓存中,线程B的缓存先写进去,线程A的缓存后写进去。结果导致数据库是B的值,缓存是A的值,出现脏数据
结论:不推荐

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

如图所示,不同颜色的线代表不同的线程。无论是先更新数据库还是先更新缓存都会造成不一致的情况
除了并发的情况,我们还需要考虑异常的情况。比如更新数据库成功了,更新缓存失败了,或者更新缓存成功了,更新数据库失败了
基于内存缓存利用率的角度,我们一般不会采用同步更新的操作,因为有可能更新完的缓存并不一定会马上读取,导致缓存中缓存了大量无用的数据,降低缓存命中率。所以我们可以考虑删除缓存
# 先删除缓存,再更新数据库
线程A去删除缓存,然后准备写DB。此时线程B来读数据,发现缓存中没有,就去读了旧的DB数据,并把旧数据回写进了缓存。接着线程A才把新数据写入DB。结果缓存里永远是旧数据,还是会有并发读写问题

用延时双删解决,主要思路为删除数据库中的值后等待一段时间再删除一次缓存。这个等待的时间需要保证删除的时间点在写入旧缓存的时间点后面,所以这个等待的时间是难以估计的,极端情况下,仍然会出现缓存不一致的情况

# 先更新数据库,再删除缓存

从上图看,还是会存在数据不一致的情况,但是在实际中这个问题出现的概率并不高,因为要满足如下3个条件
- 缓存刚好失效
- 读写请求并发
- 更新数据库+删除缓存的时间,要比读数据库+写缓存时间短
条件3发生的概率是很低的,因为写数据库要加锁,耗费的时间都比较长。所以我们一般情况下会采用先更新数据库+再删除缓存的方案
# 解决删除缓存失败的方案
为了确保“先更新数据库,再删除缓存”在异常情况下依然可靠,我们需要引入重试与异步机制
# 消息队列重试机制
当应用层删除缓存失败时,将失败的那个 Key 丢进消息队列
流程如下
- 更新数据库
- 删除缓存失败
- 将失败信息发给 MQ
- 消费服务异步读取 MQ,尝试再次删除缓存,直到成功
# 订阅数据库Binlog

- 业务代码只管更新数据库。
- 数据库(如 MySQL)生成 Binlog 日志。
- 利用中间件(如 Canal)伪装成数据库从节点,实时异步订阅这个 Binlog。
- Canal 提取出变更的数据,投递给消息队列,最终由一个专用的消费服务去删除对应的缓存
业务代码完全不需要关心缓存更新,彻底解耦,且自带重试机制,一致性保障最高