Redis 缓存一致性

加了缓存后读请求变快,但缓存里的数据是数据库的“副本”,一旦数据库更新而缓存没跟上,业务就会读到旧值,这就是缓存一致性问题。本章讲清楚常见读写模式、为什么顺序很关键,以及如何把不一致窗口压到业务可接受的范围。

四种读写模式

Cache Aside 读写流程

  • Cache Aside(旁路缓存,最常用):读时先查缓存,未命中则查库再回填;写时更新数据库并删除缓存。
  • Read Through:应用只面向缓存,缓存未命中时由缓存组件自动回源数据库。
  • Write Through:写缓存的同时同步写数据库,缓存始终与库一致,适合写少且要求高的场景。
  • Write Behind(写回):只写缓存,由后台异步批量落库,写性能最高,但宕机可能丢数据。

对比一下:

模式一致性特点
Cache Aside缓存→库回填更新库+删缓存有窗口应用自己控制,最常用
Read Through缓存组件回源更新库有窗口对应用透明
Write Through同读缓存同步双写写延迟高
Write Behind同读缓存异步落库吞吐高、有丢数据风险

实际项目大多用 Cache Aside,下面围绕它展开。

先更新库,还是先删缓存

写路径有两种顺序,各有隐患:

  • 先删缓存、再更新库:删缓存后、更新库前,若有读请求把库里的旧值回填缓存,旧值会长期存活,形成脏数据。
  • 先更新库、再删缓存(推荐):不一致窗口只有“库已更新、缓存还没删”的短短一瞬;更麻烦的是删除可能失败,导致缓存一直是旧值直到过期。

所以核心思路是“更新数据库 + 删除缓存”,并为“删除失败”兜底:短 TTL 让脏数据最终过期,是最后一道防线。

延迟双删

为消除“读请求回写旧值”的窗口,可在更新库后删一次缓存,sleep 一小段(大于一次读回写的耗时),再删第二次:

// 以下片段需放入对应工程执行,省略了类外壳
public void updateName(String id, String name) throws InterruptedException {
    updateDb(id, name);        // 1. 先更新数据库
    deleteCache(id);           // 2. 立即删缓存
    Thread.sleep(500);         // 3. 等可能回写旧值的读请求结束
    deleteCache(id);           // 4. 再删一次,双保险
}

延迟双删不能根除不一致,只是把窗口缩小;sleep 该多长依业务读取耗时而定,且删除仍可能失败,需要重试。

binlog 订阅 + 消息队列

更稳妥的“最终一致”做法是解耦:用 Canal 之类组件订阅数据库 binlog,把变更事件发到消息队列,消费者收到后删缓存或重建缓存。这样业务只写库,缓存更新由异步链路完成,不依赖代码里每个写点都记得删缓存:

应用更新 DB → binlog → Canal 解析 → MQ → 消费者删除对应缓存

该方案天然支持重试与补偿,代价是多引入一套中间件,适合写路径分散、更新频繁的中大型系统。

强一致 vs 最终一致的取舍

要“缓存和库永远一致”,只能对同一份数据的读写做全局串行化(分布式锁 + 版本校验等),性能损失大,多数业务承受不起。务实的选择是接受最终一致,并努力缩短窗口:

  • 缓存必须设合理 TTL,作为最终收敛的兜底。
  • 更新库后删缓存,删除失败要重试(可借助 MQ)。
  • 对一致性最敏感的数据(如余额)宁可不缓存,直接读库。
  • 需要“读己之写”时,可在写后短时间强制读库绕过缓存。

小结:缓存一致性没有银弹。主流组合是 Cache Aside 模式 + 更新库后删缓存 + 短 TTL 兜底,窗口敏感再上延迟双删或 binlog 订阅异步清理;极度敏感的数据干脆不走缓存。缓存穿透、击穿、雪崩这三种典型故障,见下一章。

笔记加载中…