怎么解决 Redis 缓存和数据库的一致性问题?
结论:先接受一个前提——在"先写库、再删缓存"的旁路模式下,只能做到最终一致,做不到强一致。工程上的目标不是消灭不一致窗口,而是把它压到业务可接受(通常百毫秒级)并保证"最终一定收敛"。手段依次是:更新库后删缓存 → 失败重试补偿 → binlog 订阅兜底 → 极短 TTL 兜底。
一、四种更新策略的取舍
| 策略 | 一致性 | 并发风险 | 结论 |
|---|---|---|---|
| 先更新缓存,再更新库 | 差 | 双写都失败时缓存脏数据长期存在 | 不推荐 |
| 先更新库,再更新缓存 | 中 | 并发写导致旧值覆盖新值 | 不推荐 |
| 先更新库,再删缓存 | 较好 | 删除失败则脏到过期 | 主流方案 |
| 先删缓存,再更新库 | 差 | 读请求回填旧值 | 需配合延迟双删 |
二、为什么"先删缓存再更新库"会翻车
时间线(错误顺序)
T1 写请求 A:DEL cache
T2 读请求 B:cache miss → 读 DB 得到旧值 v1
T3 写请求 A:UPDATE db = v2
T4 读请求 B:SET cache = v1 ← 旧值回填,长期不一致
时间线(正确顺序:先库后删)
T1 写请求 A:UPDATE db = v2
T2 读请求 B:cache miss → 读 DB 得到 v2(已提交)→ SET cache = v2
T3 写请求 A:DEL cache ← 无论 B 何时回填,最后一步都会清掉
但"先库后删"仍有窗口:A 删除缓存后、读请求 B 在 T2 之前读到旧值并回填(因为 B 的读发生在 A 的 UPDATE 之前,提交后才写缓存)。这种概率窗口极小(要求 B 的"读库-写缓存"恰好跨过 A 的更新与删除),但高并发下依然可能发生,所以需要后续兜底。
三、四级兜底方案
第 1 级:更新 DB 后立即 DEL cache
└─ 失败?→ 第 2 级
第 2 级:删除失败进重试队列(MQ / 本地重试表),指数退避重删
└─ 仍失败?→ 第 3 级
第 3 级:订阅 binlog(Canal / Debezium),由独立服务异步删缓存
└─ 消费延迟?→ 第 4 级
第 4 级:给缓存设 TTL(如 5 分钟 + 抖动),保证最终收敛
// 第 1+2 级:先库后删,删除失败抛给 MQ 重试
@Transactional
public void updateOrder(Order o) {
orderMapper.update(o); // 1. 写库
afterCommit(() -> { // 2. 事务提交后再删
try {
redis.delete("order:" + o.getId());
} catch (Exception e) {
mq.send("cache:evict", "order:" + o.getId()); // 3. 失败转异步重试
}
});
}
注意 afterCommit:如果在事务内删缓存,事务回滚后缓存已被删,虽不致命(下次读会回填),但事务未提交时其他读可能加载旧值再回填,反而扩大窗口。
# Canal 订阅示例:把 binlog 变更投递到删缓存任务
canal:
destination: example
filter:
regex: "trade\\.order_.*"
mq:
topic: canal-order-change
四、特殊场景的处理
- 读多写少、强一致要求高:加分布式读写锁,读也加读锁、写加写锁,牺牲吞吐换一致;
- 主从延迟:写后立即读可能打到从库的旧数据,可用"写后短时间强制读主"或
WAIT命令等待同步; - 缓存与库都要更新同一份数据:尽量避免"双写",让缓存只做读加速,写路径只写库;
- 删除 vs 更新缓存:删除更安全(避免并发写覆盖),更新只在"缓存结构复杂、重建成本极高"时才考虑。
五、常见追问
- 问:延迟双删的第二删要延迟多久?答:大于"一次读请求从读库到回填缓存"的耗时,通常 500 ms~1 s;本质是用时间换窗口,不如 binlog 方案确定。
- 问:为什么不用分布式事务保证强一致?答:缓存不是事务资源,2PC 会把 Redis 绑进事务链路,性能与可用性都不可接受;缓存的定位就是"允许短暂不一致"。
- 问:TTL 设短一点不就好了?答:TTL 越短,穿透越高、DB 压力越大;TTL 只是最后兜底,不能替代主动删除。
- 问:Canal 方案有什么代价?答:需要维护 binlog 订阅组件与位点,消费有秒级延迟;好处是与业务解耦、不会漏删,适合核心数据。
- 问:如何验证一致性做得到不到位?答:对账任务定期比对 DB 与缓存的关键字段,统计不一致率与修复耗时;没有对账就等于没有一致性保障。
小结
- 唯一推荐顺序:先更新库、事务提交后删缓存、删失败进重试、binlog 兜底、TTL 收尾。
- 强一致要换模型(读写锁、读主库、不用缓存),而不是在旁路模式上打补丁。
- 一致性的验收标准是"最终收敛时间 + 对账机制",不是"设计上看起来没有窗口"。