引入缓存的那一刻,你就有了两份数据:缓存里一份、数据库里一份。只要它们可能不一致,就有了「缓存一致性」问题。这是 缓存系列 里最难、也最容易线上翻车的一块——它不像穿透击穿雪崩有标准答案,而是一连串「在一致性、性能、可用性、复杂度之间怎么取舍」的工程判断。
本篇把开篇一笔带过的 Cache-Aside 竞态、延迟双删、多级失效逐帧拆开,讲清每种方案的窗口、代价与适用边界。
缓存系列压轴:本篇是 缓存系列开篇 的一致性下钻,前四篇分别深挖了 本地缓存 与 Redis 的 数据结构、持久化与高可用、线程模型与性能。更一般的分布式一致性(幂等、事务、最终一致)见 幂等与一致性。
TL;DR
- 缓存层做不到强一致:缓存 + DB 是两个独立系统、无法原子更新,最多做到「写后最新 + 最终一致」。先明确业务能接受多大的不一致窗口,再选方案。
- 写时删缓存,别更新缓存:并发下「更新缓存」可能旧值覆盖新值;「删缓存」只需保证下次读回源,代价仅一次 miss。
- 两种写顺序都有窗口:「先更新库再删缓存」是主流(窗口小、概率低);「先删缓存再更新库」有并发读回填旧值的隐患。删缓存失败要靠重试/消息补偿。
- 延迟双删:删缓存 → 更新库 → 延迟 Δt → 再删一次,清掉窗口内被并发读回填的旧值。Δt 要 > 一次「读DB+写缓存」耗时,且要覆盖主从复制延迟——靠拍脑袋定值是它的软肋。
- Binlog / CDC 订阅(更可靠):MySQL binlog → Canal/Debezium → MQ → 消费端失效缓存。相比应用双写:解耦、不漏、可重放、有序(配合按 key 幂等)。
- 多级缓存失效:DB 变更要同时失效所有节点 L1 + L2。四种做法:Redis Pub/Sub(简单不可靠)、MQ 广播(可靠可重放)、极短 TTL(简单有窗口)、版本号/逻辑时钟比对。
- 强一致的特殊手段:关键链路直查主库绕过缓存、双检加锁、逻辑过期 vs 物理过期。
- 选型看窗口:能容忍秒级用极短 TTL;要更稳用 binlog 订阅;多级再叠 MQ 广播 + 版本号。
Table of contents
Open Table of contents
1. 缓存一致性到底能到多强
先破除幻想:只要数据存在缓存和 DB 两处,就不可能有『缓存层的强一致』。 因为更新缓存和更新 DB 是两次独立操作,没有跨系统的原子事务把它们绑在一起;任何一步失败、任何并发交错,都会让两者短暂背离。
所以缓存一致性的目标不是「永远一致」,而是「把不一致窗口压到业务可接受的范围,并保证最终收敛」。做方案前先回答三个问题:
- 能接受多长的不一致窗口?(毫秒?秒?分钟?)
- 读写比多少、是否有热点?(决定值不值得为一致性加复杂度)
- 是单层缓存还是多级(L1+L2)?(多级的失效联动难度陡增)
答案不同,选型天差地别。下面从最常用的 Cache-Aside 开始。
2. 为什么写时删缓存,而不是更新缓存
Cache-Aside 写路径有两个选择:更新 DB 后更新缓存,还是删除缓存?主流是删除,原因:
- 并发下更新缓存会旧值覆盖新值:请求 A、B 都更新同一 key,A 先更 DB、B 后更 DB,但由于线程调度,B 的「写缓存」可能早于 A 的「写缓存」执行 → 缓存最终是 A 的旧值。
- 删除更简单且自愈:删掉后下次读自然回源拉最新,只需保证「删了 → 下次 miss → 回源」,代价仅一次 miss。
- 避免写放大:写进缓存的值可能在 TTL 内根本没人读,白算白写。
所以标准写法是 Cache-Aside 写时删除缓存。但「删」也有顺序和失败问题,见下节。
3. Cache-Aside 竞态逐帧拆解
标准写法长这样——读时回填、写时「先更新库、再删缓存」:
// 读:miss 回源并回填
public Product get(long id) {
String key = "product:" + id;
Product p = cacheGet(key);
if (p != null) return p;
p = db.findById(id);
if (p != null) cacheSet(key, p, Duration.ofMinutes(10));
return p;
}
// 写:先更新 DB,再删缓存(不是更新缓存,见 §2)
public void update(Product p) {
db.update(p); // ① 先落库
cacheDelete("product:" + p.id()); // ② 再失效缓存
}
看似简单的两步,并发下仍有竞态。逐帧拆开。
3.1 先更新库,再删缓存(主流)
这是推荐顺序,但仍有一个低概率窗口:
读 miss 读到旧值、写请求随后更新库并删缓存、读请求最后把旧值回填污染缓存。概率低(读的两步通常快于写的两步),但一旦发生窗口很长。
拆解:① B 读缓存 miss;② B 读 DB 拿到旧值 v0;③ A 更新 DB v0→v1;④ A 删缓存(此刻缓存本就空,删了个寂寞);⑤ B 把 v0 写回缓存。结果缓存=v0(旧)、DB=v1(新),且会一直错到 key 下次被失效。
为什么概率低:步骤 ②→⑤(读 DB + 写缓存)通常远快于 ③→④(写 DB + 删缓存),B 一般在 A 删除前就回填完了。但「低概率」不等于「不会」——一旦命中,脏值会长期存在,所以一致性敏感的场景要叠加补偿(§4 双删或 §5 binlog)。
3.2 先删缓存,再更新库
这个顺序问题更大:删缓存后、更新库前,并发读会 miss → 读到旧 DB 值 → 回填缓存;然后写请求才更新库 → 缓存留着旧值。窗口比 3.1 更容易命中,一般不推荐单独使用。
3.3 删缓存失败怎么办
无论哪种顺序,「删缓存」这一步本身可能失败(Redis 抖动、网络超时)。删失败 = 脏值残留。补偿手段:
- 重试:删失败进重试队列(本地重试 + 落 MQ 兜底),直到删成功。
- 消息补偿:把「删缓存」做成一条 MQ 消息,消费端保证最终删成功(天然可重放)。
- 这其实已经在往 §5 的 binlog 订阅方向演化了。
public void update(Product p) {
db.update(p);
try {
cacheDelete("product:" + p.id());
} catch (Exception e) {
// 删失败别吞掉:发一条失效消息,消费端重试直到成功(可重放)
mq.send("cache-invalidate", "product:" + p.id());
}
}
一旦你开始为「删失败」兜底,本质上就是在自建一条「变更 → 失效消息 → 消费端保证删成功」的管道——这恰恰是 §5 binlog 订阅的雏形,只是那里用数据库日志替代了业务代码里手写的这条消息,更不容易漏。
4. 延迟双删
针对 §3 的竞态,工程上常用延迟双删做补偿:
第二次删除专门清除「第一次删除→更新DB」窗口内被并发读回填的旧值;延迟需 > 一次「读DB+写缓存」耗时,通常异步执行。
流程:删缓存 → 更新 DB → 延迟 Δt → 再删一次缓存。第二次删除的作用是清掉「第一次删除到更新 DB 之间」被并发读回填的旧值。
public void update(Product p) {
cacheDelete("product:" + p.id()); // ① 第一次删
db.update(p); // ② 更新 DB
// ③ 延迟 Δt 后再删一次,清掉窗口内被并发读回填的旧值
delayQueue.schedule(Duration.ofMillis(500), () -> {
try { cacheDelete("product:" + p.id()); }
catch (Exception e) { mq.send("cache-invalidate", "product:" + p.id()); }
});
}
第二次删务必异步(延迟队列 / MQ 定时消息)执行,别用 Thread.sleep 阻塞业务线程;且第二次删同样要有失败兜底,否则「延迟删」本身失败又回到 §3.3 的问题。
关键与局限:
- Δt 怎么定:要 > 一次「读 DB + 写缓存」的耗时,否则第二次删得太早、脏值还没回填进来。
- 主从复制延迟:如果读走从库、DB 有主从复制延迟,Δt 还得覆盖复制延迟,否则第二次删后从库仍返回旧值又被回填——这让 Δt 很难拍准。
- 仍有残余窗口:「更新 DB → 第二次删除」之间依然是不一致的。
- 异步执行:第二次删通常丢到延迟队列/MQ 异步做,避免阻塞业务线程。
延迟双删是「够用但不优雅」的补偿——Δt 靠经验、跨复制延迟难覆盖。更可靠的做法是让数据库变更本身来驱动失效,即 binlog 订阅。
5. Binlog / CDC 订阅驱动失效
与其让应用「双写」(既写 DB 又管缓存,容易漏、容易乱序),不如订阅数据库的变更日志,由变更事件驱动缓存失效:
MySQL → binlog → Canal/Debezium → Kafka → 失效消费者,再扇出失效 L2 Redis 与广播失效各节点 L1。相比应用双写:解耦、不漏、可重放、有序。
链路:MySQL 主库变更 → binlog → Canal/Debezium 订阅 → Kafka(变更事件,见 Kafka 核心原理)→ 缓存失效消费者 → 删/更新 L2 Redis + 广播失效各节点 L1。
相比应用层双写,它的四个优势:
- 解耦:业务代码只管写 DB,完全不感知缓存的存在,缓存失效逻辑集中在消费端。
- 不漏:只要变更进了 binlog,失效事件必达(DB 事务提交 = binlog 有记录)。
- 可重放:消费位点(offset)可回退,出问题重刷一段失效即可修复。
- 有序:单表变更按 binlog 顺序处理;配合按 key 幂等(如带版本号,见 §6 与 幂等与一致性)避免乱序覆盖。
消费端的关键是幂等 + 防乱序:同一行可能被多次投递(重放/重试),也可能因分区/重试导致旧事件晚到,所以用行里的版本(或 binlog 位点、updated_at)做「新值才写、旧值只删」:
// canal/Kafka 消费端:按 key 幂等失效
public void onChange(RowChange c) {
long id = c.after("id");
String key = "product:" + id;
long ver = c.after("version"); // DB 行自带的单调版本号
// 删缓存永远安全:下次读自然回填最新值
cacheDelete(key);
// 若选择「主动回写」而非单纯删除,必须比对版本防旧事件覆盖新值
// cacheSetIfNewer(key, c.after(), ver);
}
优先「删」而非「写」——删除天然幂等、不怕乱序;只有为扛回源尖峰才主动回写,此时务必带版本号做 setIfNewer。代价:多一套 Canal/Debezium + MQ 的基础设施与运维;失效有「DB 提交 → binlog → 消费」的传播延迟(通常几十毫秒到秒级)。对一致性要求较高、又不想污染业务代码的中大型系统,这是当前最主流的做法。
6. 多级缓存的失效联动
引入 L1 本地缓存(见 本地缓存深挖)后,一致性难度陡增:DB 一次更新,要同时失效 L2 Redis + 所有应用节点各自的 L1。L2 好办(一个 DEL),难的是广播失效散落在 N 个节点上的 L1。四种方案:
| 方案 | 可靠性 | 延迟 | 复杂度 | 说明 |
|---|---|---|---|---|
| Redis Pub/Sub | 低 | 低 | 低 | 发布失效消息各节点订阅;节点宕机/断连期间漏消息不可重放 |
| MQ 广播 | 高 | 中 | 中 | Kafka/RabbitMQ 广播失效事件,可重放、可靠;多一套 MQ |
| 极短 TTL | 中 | —(TTL 窗口) | 极低 | L1 TTL 设 1–5s,接受这段不一致;简单粗暴、最常用 |
| 版本号 / 逻辑时钟 | 高 | 低 | 高 | 读 L1 时带版本号,落后则主动失效重拉;需维护版本 |
实践中常组合使用:L1 用极短 TTL 兜底(保证最终收敛的上界),再叠加 MQ 广播 做主动失效(把窗口从「TTL 秒级」压到「广播毫秒级」),一致性极敏感的字段再加版本号校验。Pub/Sub 因不可重放,只适合能容忍偶发漏失效的场景。
版本号校验的读路径:L1 命中也不盲信,拿一个「很轻量的版本源」(如 Redis 里一个 ver: 计数)比一下,落后就丢弃 L1、回 L2 重拉:
public Product get(long id) {
String key = "product:" + id;
long latest = redis.get("ver:" + key); // 轻量版本号,写侧每次变更 INCR
Cached<Product> l1 = caffeine.getIfPresent(key);
if (l1 != null && l1.version() >= latest) { // L1 未落后才用
return l1.value();
}
Product p = redisGet(key); // 回 L2
if (p == null) { p = db.findById(id); redisSet(key, p); }
caffeine.put(key, new Cached<>(p, latest)); // 带版本回填 L1
return p;
}
代价是每次读多一次 ver: 查询(可再叠一层极短 TTL 的本地版本缓存降低开销),换来「即使 L1 广播失效漏了,也不会用超过一个版本的旧值」。只对最敏感字段开这条路径,别对全部缓存一刀切。
7. 强一致的特殊手段
当业务确实要更强的一致(金额、库存、审计),别硬在缓存层堆补偿,换思路:
- 关键链路直查主库、绕过缓存:核心交易读写不走缓存,直接读主库(一致性 > 命中率)。
- 双检加锁(double-check locking):回源前后各查一次缓存 + 分布式锁,收敛并发回源(也顺带防击穿,见 缓存开篇 §3.2)。
- 逻辑过期 vs 物理过期:物理 TTL 到期瞬间会有 miss 窗口;逻辑过期(value 里带过期时间、不设真 TTL)读到过期时异步刷新、其余请求先用旧值——一致性更弱但可用性更好,适合「宁可用略旧值也不能并发回源」的热点场景。
- 涉及跨服务的强一致,回到分布式事务/最终一致的工具箱(2PC/TCC/Saga/outbox,见 幂等与一致性)。
8. 选型决策与 AdTech 落地
一切选型的总纲只有一句:实时性要求越低,越适合缓存。能容忍的不一致窗口越大,方案就可以越简单(长 TTL 足矣)、命中率越高、成本越低;窗口要求越窄,越要往下面的复杂方案走,直到窗口要求归零时干脆绕过缓存。所以下面的选型路径本质就是按「能接受多大的不一致窗口」从松到紧排列:
选型路径(按能接受的不一致窗口):
- 能容忍秒级不一致 → L1/L2 加极短 TTL 即可,最省事。
- 要更稳、不想污染业务代码 → Binlog/CDC 订阅驱动失效(§5)。
- 多级缓存 + 更敏感 → 在 2 的基础上叠 MQ 广播失效 L1 + 版本号校验。
- 强一致 → 关键链路绕过缓存直查主库(§7)。
AdTech 落地:广告竞价对不同数据的一致性要求分层——
- 预算快照:一致性最敏感(读到旧快照会让花光预算的 campaign 继续参竞 → 超投)。做法:预算扣减到阈值时主动推送失效给各竞价节点(而非等 TTL),L1 极短 TTL 兜底。
- campaign / 创意元数据:用 canal 订阅 campaign 表变更 → MQ 广播失效各节点 L1 元数据缓存,兼顾解耦与可重放。
- 频控计数:本身就在 Redis 原子
INCR,无缓存一致性问题。
参考
- 美团技术团队 · 缓存那些事(缓存一致性):https://tech.meituan.com/2017/03/17/cache-about.html
- Alibaba Canal(MySQL binlog 增量订阅):https://github.com/alibaba/canal
- Debezium 官方文档(CDC):https://debezium.io/documentation/
- Redis 官方文档 · Keyspace notifications:https://redis.io/docs/latest/develop/use/keyspace-notifications/
- Martin Kleppmann · Designing Data-Intensive Applications(缓存失效与 CDC):https://dataintensive.net/