Redis 中如何保证缓存与数据库的数据一致性?
- 标签:后端、Redis
🏛 业务架构师
缓存一致性先讲结论:没有强一致方案,只有"不一致窗口"长短和代价的权衡。业务架构的第一步是和业务方确认容忍度——商品详情晚 5 秒可接受,库存与余额不行。
主流方案按一致性递增:
- Cache Aside(旁路缓存,默认选择):读— miss— 回源回填;写—先更新 DB 再删缓存。选"删"不选"改":改缓存有并发覆写问题,删让下次读自然回源。理论上存在"读线程回填旧值"的极小概率竞态,业务上可接受;
- 延迟双删:写前删一次、写库后延迟几百毫秒再删一次,缓解"读旧值回填"。延迟时间拍脑袋是它的软肋,适合读并发高但精度要求中等的场景;
- 订阅 binlog(Canal)异步失效:业务只写库,CDC 感知变更删缓存——业务代码零侵入、天然兜住所有写路径(包括定时任务、DBA 手工改数),是我做平台级治理的首选,代价是引入 CDC 链路的运维。
三条实操铁律:
- 缓存要设 TTL:任何方案都有漏网时刻,TTL 是最终自愈的保险丝;
- 删除失败要补偿:删缓存失败进重试队列或依赖 binlog 二次对账,不能静默吞掉;
- 强一致需求别走缓存:直接 DB 当前读。用"接受秒级不一致"换 90% 的读流量,是缓存架构的商业本质——想两头全要,成本会指数上升。
🏗 系统架构师
缓存一致性没有完美方案,只有按业务选"可接受的窗口"。我按方案演进讲:
- 先更新 DB 再删缓存(Cache Aside,推荐):读时回填,写时删缓存。为什么删而不是更新:更新缓存在并发写下容易乱序覆盖;删除天然幂等。缺点:删与下一次回填之间有窗口;极端时序(读旧值→写库→回填旧值)概率极低但存在;
- 延迟双删:写库前删一次,写库后延迟几百毫秒再删一次,清理主从延迟窗口内的脏读回填。延迟时间拍脑袋是它的硬伤;
- 订阅 binlog 异步删(Canal/订阅机制):删除动作由数据变更事件驱动,与业务代码解耦、可靠(有位点可重放),是我做大型系统的首选;
- 强一致场景:加分布式锁串行化读写(性能差),或干脆别用缓存——缓存适用前提就是接受最终一致。
我的标准组合:Cache Aside + binlog 订阅删 + 过期时间兜底(key 设 TTL,即便删除失败也能自愈)+ 关键业务读时校验版本。目标是把不一致窗口压到毫秒级并保证最终一致,而不是追求理论上的强一致。
💻 全栈开发专家
先说结论:无法强一致,只能最终一致。我标准方案是 Cache Aside + 先更新 DB 再删缓存:
java
@Transactional
public void updateProduct(Product p) {
productMapper.update(p); // 1. 更新数据库
redis.delete(key(p.getId())); // 2. 删除缓存(不是更新缓存)
}
public Product get(Long id) {
Product p = redis.get(key(id));
if (p == null) {
p = productMapper.selectById(id);
if (p != null) redis.setex(key(id), 300, p); // 过期时间兜底
// 极端并发下"旧值回填"仍有小概率,靠 TTL 兜底
}
return p;
}为什么"删缓存"而不是"更新缓存":并发写时两次更新缓存的先后序可能颠倒,缓存留下旧值;删除让下次读触发回源,天然收敛。为什么先 DB 后删:反过来会出现"删缓存后、DB 提交前"的读把旧值又填回去。
更稳的两层加固(我在线上交易数据的做法):
- 延迟双删:更新后删一次,延迟 500ms~1s 再删一次(清掉主从延迟窗口内的脏读回填)。
- 订阅 binlog 删缓存(Canal):代码里不用管缓存失效,由 binlog 异步驱动删除,和业务解耦、不怕漏删——这是我用得最多的方案。
兜底:所有缓存必须带 TTL,即使逻辑漏删最多脏一个 TTL 周期。