Redis 缓存一致性
加了缓存后读请求变快,但缓存里的数据是数据库的“副本”,一旦数据库更新而缓存没跟上,业务就会读到旧值,这就是缓存一致性问题。本章讲清楚常见读写模式、为什么顺序很关键,以及如何把不一致窗口压到业务可接受的范围。
四种读写模式
- 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 订阅异步清理;极度敏感的数据干脆不走缓存。缓存穿透、击穿、雪崩这三种典型故障,见下一章。