缓存一致性:为什么是删缓存而不是更新缓存
先改库再删缓存能成立,靠的不是"顺序对了",而是删除这个动作的幂等性。这篇把四种更新顺序逐一拆开,说明延迟双删、重试队列、binlog 订阅分别补的是哪个洞。
缓存和数据库的一致性,最常被简化为一句"先改数据库,再删缓存"。这句话是对的,但如果不清楚它为什么对、在什么条件下才成立,换一个场景就会失效——比如有人会问"那先删缓存再改库行不行",或者"为什么不直接更新缓存,删了下次还要查一次库"。
这篇把四种更新顺序逐一拆开对比,然后说清延迟双删、重试补偿、binlog 订阅各自补的是哪个洞。
一、Cache Aside:最常用的那一种
分类树这类数据的更新路径很直白:
1) UPDATE category SET ... WHERE id = ? ← 先改数据库
2) DEL category:tree ← 再删缓存
3) 下次请求未命中 → 查库重建 → 写回缓存
它有两个关键决策,分别解释。
为什么是"删"而不是"更新"
直觉上,更新缓存更好:改完库顺手把新值写进缓存,下次请求直接命中,连一次回源都省了。但更新缓存在并发下很难写对。
假设改库之后是"把新值写进缓存",考虑这样一条时间线:
| 时刻 | 线程 A(把价格改成 200) | 线程 B(把价格改成 300) | 数据库 | 缓存 |
|---|---|---|---|---|
| T1 | UPDATE ... = 200 | 200 | 旧值 | |
| T2 | UPDATE ... = 300 | 300 | 旧值 | |
| T3 | 写缓存 = 300 | 300 | 300 | |
| T4 | 写缓存 = 200(网络延迟,姗姗来迟) | 300 | 200 ← 脏数据 |
数据库里是 300,缓存里是 200,而且这个脏数据会一直留到下次有人改它或者它过期。问题出在:两次写缓存的顺序和两次改库的顺序无关,缓存的最终值取决于谁的网络更快,而不是谁的更新更新。
换成删除就没这个问题:
| 时刻 | 线程 A | 线程 B | 数据库 | 缓存 |
|---|---|---|---|---|
| T1 | UPDATE = 200 | 200 | 旧值 | |
| T2 | DEL | 200 | 空 | |
| T3 | UPDATE = 300 | 300 | 空 | |
| T4 | DEL | 300 | 空 |
两个 DEL 无论谁先谁后,结果都是"缓存为空",下次请求回源拿到的一定是数据库里的最新值。删除操作是幂等的,执行一次和执行两次结果相同,也不关心顺序;而更新操作不是。
所以"删而不是更新"的理由可以概括为三条:
- 更新缓存要处理并发写的覆盖顺序,容易写入脏数据;删除不关心顺序
- 删除是幂等的,最坏的结果只是下一次多回源一次,而多回源一次的性能损失是一次查询,脏数据的一致性损失可能要持续很久
- 删除的实现更简单,出错面更小
为什么是先改库再删缓存
把顺序反过来(先删缓存,再改库),问题同样出在并发窗口上:
| 时刻 | 线程 A(先删缓存) | 线程 B(读请求) | 数据库 | 缓存 |
|---|---|---|---|---|
| T1 | DEL 缓存 | 旧值 | 空 | |
| T2 | 未命中 → 读库 → 读到旧值 | 旧值 | 空 | |
| T3 | 把旧值写回缓存 | 旧值 | 旧值 | |
| T4 | UPDATE = 新值 | 新值 | 旧值 ← 长期脏 |
T2 恰好发生在 A 改库之前,读线程把旧值又写回了缓存。而 A 的删除动作已经用掉了,不会再删第二次——这份旧值会一直留在缓存里,直到 TTL 到期。
改成"先改库再删缓存"后,这个特定的窗口就消失了:
| 时刻 | 线程 A | 线程 B | 数据库 | 缓存 |
|---|---|---|---|---|
| T1 | UPDATE = 新值 | 新值 | 旧值 | |
| T2 | 命中旧值(短暂不一致) | 新值 | 旧值 | |
| T3 | DEL | 新值 | 空 | |
| T4 | 未命中 → 读库 → 新值 → 写回 | 新值 | 新值 |
即使 T2 读到了旧值,它在 T3 之后也不会再被写回,因为缓存已经被删掉了。结论是:A 的删除一定发生在 B 的写回之后,或者 B 读到的就是新值。
四条路径逐一验证一遍,"先改库再删缓存"的表现确实最好:
| 策略 | 并发读的后果 | 并发写的后果 | 总体评价 |
|---|---|---|---|
| 先删缓存,再改库 | 读线程可能把旧值写回,长期脏 | 相对安全 | ❌ 有长期脏数据风险 |
| 先改库,再删缓存 | 最多读到一次旧值,之后恢复 | 安全(删是幂等的) | ✅ 推荐 |
| 先改库,再更新缓存 | 一致性尚可 | 写入顺序错乱会产生脏数据 | ⚠️ 需额外控制并发 |
| 先更新缓存,再改库 | 不安全 | 库改失败则缓存是脏的 | ❌ 不推荐 |
二、它仍有一个极小的窗口
"先改库再删缓存"不是完美无缺,它剩下的洞是这个:
| 时刻 | 线程 A(写) | 线程 B(读) | 数据库 | 缓存 |
|---|---|---|---|---|
| T1 | UPDATE = 新值 | 新值 | 旧值 | |
| T2 | DEL | 新值 | 空 | |
| T3 | 未命中 → 读库 | 新值 | 空 | |
| T4 | ...(因 GC / 网络延迟卡住) | 新值 | 空 | |
| T5 | 把 T3 读到的值写回缓存 | 新值 | 新值(正确) |
这个例子里恰好没问题,因为 T3 读到的是新值。真正有问题的情况需要读线程在 T2 之前就读完了库、但 T4 之后才写回——也就是说,读操作要跨越整个"改库 + 删缓存 + 再有人查询"的全过程。这要求读线程从"查完库"到"写回缓存"之间停顿的时间,超过写线程的整个更新耗时。
发生概率极低,但优化到极致时确实存在(典型的诱因是查询是慢 SQL、或者应用发生了长时间 STW)。针对它有两个补丁:延迟双删和异步重试。
补丁一:延迟双删
思路很直接——既然怕读线程"删完之后才把旧值写回来",那就过一会儿再删一次:
public void updateCategory(Category c) {
categoryMapper.update(c); // 1. 先改库
redis.del("category:tree"); // 2. 立即删缓存
// 3. 延迟一段时间后再删一次,把窗口期内可能写回的旧值清掉
delayQueue.offer(new DelayTask("category:tree", 500, TimeUnit.MILLISECONDS));
}
那第二次删除的延迟怎么定?它必须大于"读线程从查库到写回缓存"的最长耗时,否则第二次删除可能发生在旧值写回之前,等于白删。这个时间取决于慢 SQL 的耗时、GC 停顿,很难精确测量,实践中通常拍一个保守值(几百毫秒到一秒)。
延迟双删的代价也很明显:
- 延迟时间只能拍脑袋,没有一个可证明正确的取值
- 需要一个延迟队列(基于
RDelayedQueue或本地延时任务),增加复杂度 - 延迟删除意味着在这段时间里,缓存可能仍然是旧值
所以它更像是给已有方案加一层保险,而不是一个独立的一致性方案。
补丁二:删除失败怎么办
上面所有讨论都假设"删除缓存"这个动作成功了。但 DEL 是网络调用,可能超时、可能失败:
DEL category:tree
(error) Could not connect to Redis at 127.0.0.1:6379
如果删除失败就吞掉异常,数据库已经改了、缓存还是旧的,而且旧值会一直留到 TTL 到期——这种情况下的一致性窗口是以 TTL 计的,不是以毫秒计的。
处理方式是把"删除"从一次性的同步调用,变成可重试的任务:
public void updateCategory(Category c) {
categoryMapper.update(c);
try {
redis.del("category:tree");
} catch (Exception e) {
// 删除失败:写入重试队列,由后台任务补偿
retryQueue.add("category:tree");
log.warn("缓存删除失败,已加入重试队列", e);
}
}
后台任务定期重试,直到成功为止。更完整的做法是本地消息表:删除请求先落库(和业务更新在同一个本地事务里),再由定时任务扫描并投递,这样即使应用在删除前宕机,重启后任务依然在。
补丁三:binlog 订阅
再往前一步,可以让业务代码彻底不负责删缓存——由数据库的变更日志来驱动:
业务代码: UPDATE category ──→ MySQL
│
↓ binlog(Canal / Debezium 订阅)
消费者解析变更 → DEL 缓存
Canal 的原理是伪装成 MySQL 的从库,接收 binlog 事件流,解析出表名、主键、变更前后的值,再由消费端按需删除对应的缓存 key。
它相比业务代码删除的优势:
| 维度 | 业务代码删缓存 | binlog 订阅 |
|---|---|---|
| 耦合度 | 每个写入口都要记得写删除逻辑 | 与业务代码完全解耦 |
| 覆盖度 | 漏写一处就永远不一致 | 覆盖所有写入路径,包括人工改数据、其他服务写入 |
| 顺序 | 依赖应用内的调用顺序 | 天然按 binlog 顺序,顺序有保证 |
| 实时性 | 同步,最快 | 有毫秒到秒级的消费延迟 |
| 复杂度 | 低 | 需要维护 Canal/Debezium 集群,有运维成本 |
| 适用场景 | 缓存 key 与表结构关系简单 | 缓存逻辑复杂、写入方多、希望统一治理 |
它的代价是引入了新的组件和延迟,而且需要维护"表变更 → 缓存 key"的映射关系。对于只有一两个写入口、缓存 key 也很单一的模块,直接用业务代码删就够了——不要为了一致性上的边际收益引入一整套数据同步链路。
三、更彻底的方案,以及它们的代价
如果要求"缓存和数据库强一致",上面这些做法都不够。可以往两个方向走:
方向一:让缓存承担原子性
把「判断 + 更新」写成一个 Lua 脚本,由 Redis 原子执行。但要注意,缓存内部的原子操作无法覆盖数据库,它只能保证缓存状态的自洽。真正的双写一致性必须由数据库事务或者分布式事务来兜底。
如果确实需要强一致,常见做法是让读请求也走同一套加锁逻辑:更新时对 key 加写锁,读请求发现锁存在就等待。代价是吞吐量下降,读多写少的缓存本来就是为了绕开锁才存在的——这样等于把锁又请了回来。
方向二:改变问题的前提
一致性问题的根源是"同一份数据存在两个地方"。如果业务上无法接受不一致,更根本的解法是:
- 只把缓存当缓存:接受短暂不一致,靠短 TTL 收敛,配合上面的删除 + 重试把窗口压到可接受范围
- 读写分离 + 强一致读:对一致性敏感的操作直接读主库,不走缓存;对展示类数据才用缓存
- 用数据库原生能力:比如 MySQL 的查询缓存、物化视图
在实践中,缓存的定位通常是"允许短暂不一致、但不允许长期不一致"。上面的删除策略、重试队列、binlog 订阅,都是在把"长期不一致"的可能性压掉,而不是去消灭"短暂不一致"——后者是缓存的固有属性,消灭它的唯一办法是不要缓存。
四、推荐的落地组合
对"读极多、写极少"的数据类型,一套务实的方案是这样:
| 环节 | 做法 | 解决的问题 |
|---|---|---|
| 读路径 | 未命中 → 查库 → 写回缓存 | 标准 Cache Aside |
| 写路径 | 先改库,再删缓存 | 主流程,避免脏数据长期驻留 |
| 删除失败 | 本地消息表 + 定时重试 | 防止缓存长期滞留旧值 |
| 残留窗口 | 延迟双删(或不处理,接受极小概率) | 覆盖极端并发窗口 |
| 变更驱动 | 复杂模块改用 binlog 订阅 | 解耦,覆盖所有写入路径 |
| 最终兜底 | 缓存设 TTL | 无论前面出什么错,数据都会自愈 |
最后一行值得单独强调:TTL 是最后的兜底手段。只要设了 TTL,任何一段没被处理好的不一致,都会在 TTL 到期后自动回到正确状态。所以对绝大多数缓存,"把删除逻辑写得尽量对,再用 TTL 兜底"比"追求删除逻辑绝对正确"更实际。
反过来,如果某个 key 为了防击穿而不设 TTL(比如前面提到的分类树),那么它的一致性就完全依赖主动刷新逻辑的正确性,没有任何兜底。这种情况下更要保证刷新入口唯一、或者引入 binlog 订阅来覆盖所有变更路径——"永不过期"换来的击穿免疫,代价就是把一致性责任全部转移到自己身上。
小结
- Cache Aside 的推荐顺序是先改库、再删缓存,它能把一致性窗口压到"读线程跨越整个更新流程"这一极小概率场景
- 用删而不是更新,根本原因是删除操作幂等;更新缓存会出现"写缓存的顺序与改库的顺序不一致",产生长期脏数据
- "先删缓存再改库"有一个经典缺陷:并发读会把旧值写回,而且不会再被删掉
- 延迟双删补的是"读线程删完之后才写回旧值"的窗口,但延迟时间只能拍脑袋,属于保险而非方案
DEL失败必须走重试队列 / 本地消息表,否则一致性窗口会以 TTL 计- binlog 订阅(Canal / Debezium)适合写入方多、缓存逻辑复杂的场景,代价是新组件与消费延迟
- 缓存只承诺"允许短暂不一致、不允许长期不一致",TTL 是最后的自愈兜底;不设 TTL 的 key 要额外承担一致性责任
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。