先更库还是先删缓存?数据库与 Redis 双写一致性全对比

先更库还是先删缓存?数据库与 Redis 双写一致性全对比

这个问题几乎每个后端都踩过坑。

答案看似简单,实则藏着极端场景下的致命 bug。


核心矛盾:为什么需要"双写"?

因为数据库和 Redis 的角色不同:

角色 职责
MySQL 最终数据源,保证持久化和事务
Redis 热点缓存,加速查询

读多写少时,数据同步路径是:写 DB → 删/更缓存 → 下次读命中缓存

问题就出在这个箭头上:顺序反了,数据就脏了。


方案一:先更库,后删缓存(主流推荐 ✅)

这是大多数公司的默认选择。

流程

复制代码
复制代码
`① 更新 MySQL
② 删除 Redis 缓存
③ 下次读 → 缓存未命中 → 回源查 DB → 写入缓存
`

为什么推荐?

因为删缓存比更新缓存安全

  • 删缓存:最坏结果是缓存短暂不存在,读请求回源一次,数据最终一致
  • 更新缓存:如果更新失败,缓存里存的是旧数据,用户永远拿不到新值

但有一个致命场景:延时双删都救不了

复制代码
复制代码
`时间线:
T1: 线程A 更新 DB(新值 = 100)
T2: 线程B 读缓存 → 命中旧值(值 = 50)
T3: 线程A 删缓存
T4: 线程B 旧值已读走,返回 50 ❌
`

问题本质:更新 DB 和删缓存之间存在时间差,这段窗口内,旧读请求可能恰好命中缓存。

这不是概率问题,高并发下一定会发生

怎么解决?三种手段

手段 原理 效果
延时双删 更新 DB 后,延迟 N ms 再删一次缓存 兜底,但 N 难设定
串行化 同一 key 的读写加分布式锁 强一致,但牺牲性能
消息队列 更新 DB 后发 MQ,异步确保删缓存 解耦,但引入最终一致性

其中消息队列方案是大厂最常用的:

复制代码
复制代码
`更新 DB → 写 Binlog → Canal 订阅 → 发送 MQ → 消费删缓存
`

Canal 把"删缓存"这个动作从业务代码中剥离,即使删失败,MQ 会重试,保证最终一定删掉。


方案二:先删缓存,后更库(绝对不推荐 ❌)

流程

复制代码
复制代码
`① 删除 Redis 缓存
② 更新 MySQL
③ 下次读 → 缓存未命中 → 回源查 DB → 写入缓存(新值)
`

看起来也能保证最终一致?

看这个场景

复制代码
复制代码
`时间线:
T1: 线程A 删缓存
T2: 线程B 读缓存 → 未命中 → 查 DB(此时 DB 还是旧值)
T3: 线程B 把旧值写入缓存
T4: 线程A 更新 DB(新值 = 100)
结果:缓存 = 旧值,DB = 新值,数据永久不一致 ❌❌❌
`

这个 bug 比方案一严重得多

对比项 先更库后删缓存 先删缓存后更库
脏数据持续时间 短暂(下一次读就修复) 永久(直到缓存过期或手动清理)
发生概率 高并发下必现 较低,但一旦发生就是脏数据
修复成本 自动修复 需要人工介入或等待过期

先删缓存的最大风险是:在 DB 更新完成前,旧值已经被写回缓存了。

一旦发生,缓存里的旧值会一直存在,直到 TTL 过期。如果 TTL 设得很长(比如 1 小时),这 1 小时内所有读请求都拿到脏数据。


两种方案终极对比

维度 先更库后删缓存 ✅ 先删缓存后更库 ❌
脏数据窗口 极短(μs~ms 级) 可能很长(直到 TTL 过期)
脏数据能否自愈 ✅ 能(下次读自动修复) ❌ 不能(旧值已写入缓存)
实现复杂度 中等(需处理延时双删或 MQ) 简单但风险极高
大厂实践 ✅ 主流方案 ❌ 基本不用
推荐指数 ⭐⭐⭐⭐⭐

真正的最优解:不要自己写双写逻辑

最高效的做法是 让基础设施替你完成

方案 工具 原理
Binlog 异步删除 Canal + MQ 监听 DB 变更,异步删缓存,失败重试
订阅 Binlog 直写 Otter / Maxwell 变更直接同步到 Redis,不经过业务代码
缓存中间件 JetCache / Cache Aside 框架 封装双写逻辑,内置重试和补偿

核心思想一致:把"删缓存"从业务主流程中剥离,用异步 + 重试保证最终一致性。


一句话总结

先更库,后删缓存。 不是因为它完美,而是因为它的最坏情况只是"短暂不一致",而反过来的最坏情况是"永久脏数据"。

能用 MQ 异步删,就别在主链路上同步删。能让 Canal 干的活,就别让业务代码扛。

双写一致性的本质不是选顺序,而是承认一定会不一致,然后设计一个能自愈的机制