先更库还是先删缓存?数据库与 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 干的活,就别让业务代码扛。
双写一致性的本质不是选顺序,而是承认一定会不一致,然后设计一个能自愈的机制。