Redis

缓存一致性:为什么是删缓存而不是更新缓存

先改库再删缓存能成立,靠的不是"顺序对了",而是删除这个动作的幂等性。这篇把四种更新顺序逐一拆开,说明延迟双删、重试队列、binlog 订阅分别补的是哪个洞。

2026-03-08 约 13 分钟 5149 字 Redis 缓存 一致性 Cache Aside

缓存和数据库的一致性,最常被简化为一句"先改数据库,再删缓存"。这句话是对的,但如果不清楚它为什么对、在什么条件下才成立,换一个场景就会失效——比如有人会问"那先删缓存再改库行不行",或者"为什么不直接更新缓存,删了下次还要查一次库"。

这篇把四种更新顺序逐一拆开对比,然后说清延迟双删、重试补偿、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_* 四个变量后自动开启。