Appearance
缓存一致性-CacheAside与延迟双删
看在前面
缓存方案里贯穿始终的假设——「更新库、删缓存」——就是一致性问题的主战场。缓存穿透-击穿-雪崩把三大问题讲完后,这一篇正面回答「缓存和 DB 怎么才能不打架」:Cache Aside 的时序推演、延迟双删、Canal 兜底,以及那个诚实的收尾——为什么只能做到最终一致。姊妹篇:热key治理-发现与处置三板斧。
讲到这里,面试官会自然把话题引向贯穿始终的那个假设——"更新库、删缓存"。这是一致性问题的主战场,追问通常长这样。
Cache Aside:为什么是先更库再删缓存
面试官:你为什么是"先更新数据库,再删除缓存"?(Cache Aside)
先给出 Cache Aside 的完整读写时序:
- 读:查缓存 → 命中返回;miss → 查 DB → 回填缓存 → 返回;
- 写:更新 DB → 删除缓存(不是更新缓存)。
把四种组合摆开推演(设写线程要把 X 从 A 改成 B,同时有读线程并发):
| # | 写侧策略 | 不一致是怎么发生的 | 发生概率 |
|---|---|---|---|
| 1 | 先删缓存,再更库 | 删完缓存、库还没更完的窗口里,读线程 miss → 从库读到旧值 A → 把 A 回填进缓存 → 写线程把库更成 B → 缓存里的 A 要活到 TTL 结束 | 高。窗口 = 写库耗时(毫秒~百毫秒),期间每个并发读都会中招 |
| 2 | 先更库,再删缓存 | 读线程 miss 后在写库提交前从库读到 A,又在删缓存之后才把 A 写进缓存 | 极低。要求一次读的"写缓存"步骤比整个写事务还慢,且两个时间点完全错开 |
| 3 | 更新缓存(而非删除) | 两个写并发时网络乱序:写 A 先发起后到达,用旧值 A 覆盖了新值 B | 高 |
| 4 | 先删缓存 + 延迟双删 | 见下文,窗口被压到延迟参数级别 | 低 |
结论:方案 2(先更库再删缓存)的不一致窗口最小,这是业界默认姿势。但它依然不是零——概率低不等于不存在(方案 2 的那行场景真实可构造),所以才有后面的兜底手段。
顺带回答另一半问题——为什么是"删缓存"而不是"更新缓存":
- 并发写乱序(上表 #3):更新操作不可交换,后发起的旧值可能后到达;
- 懒加载思想:删除之后,下一个读的人自然会回源重建最新值,不用猜;
- 避免无效写:缓存值常是多表聚合/计算的结果。写少读多的 key 更新 10 次可能 1 次都没人读,白算 10 次;
- 幂等与自愈:删除失败可以重试,删除多次无害。更新写错值则一直错到下次写入。删除让缓存永远可以"从 DB 重建"这个事实来源自愈。
延迟双删:给「先删缓存」打补丁
面试官:先删缓存那个方案就没救了吗?延迟双删说说。
延迟双删是给"先删缓存"(或读写分离场景)打补丁的标准姿势:
java
public void update(Product np) {
redis.del(key(np)); // ① 第一删:拦住大部分回填旧值的读
db.update(np); // ② 更库
CompletableFuture.runAsync(() -> { // ③ 第二删:异步延迟执行,不拖慢响应
sleep(500); // 等"读旧值 + 回填缓存"的窗口过去
redis.del(key(np)); // 把①②期间漏进缓存的旧值清掉
}, DELAY_DELETER_POOL);
}核心是第二次删除的延迟时间怎么定:必须覆盖"一个读请求从查库到写缓存的全过程"。如果读走从库,还要加上主从复制延迟。经验值 500 ms ~ 1 s,再按 binlog 的时间戳做监控校准。太短删了个寂寞(回填还没发生),太长则不一致窗口变大、且删除堆积。
两个诚实的缺点(主动说出来反而加分):一是异步删除失败(要配重试队列或下面的 binlog 兜底)。二是延迟值是拍的经验值,本质是用"大概率正确"换实现简单。
删除失败兜底:Canal 与 binlog 语义
面试官:删除缓存这一步失败了怎么办? Canal 了解吗?
单机重试解决不了"删除请求本身丢失"(网络分区、进程宕在提交前)。Canal 的思路是把"删缓存"从业务代码里剥离,挂到数据库的变更日志上:
MySQL(binlog-format=ROW)
│ Canal Server 伪装成一台 MySQL slave,
│ 向 master 发送 dump 协议请求 binlog 流
▼
解析 INSERT/UPDATE/DELETE 事件(拿到表、主键、变更前后镜像)
▼
投递到 RocketMQ / Kafka
▼
缓存失效服务消费:删除 Redis key + 广播失效本地缓存java
// 消费 binlog 消息 → 删缓存(业务代码零侵入,删除动作与业务事务解耦)
@RocketMQMessageListener(topic = "binlog-product", consumerGroup = "cache-invalidate")
public class BinlogCacheInvalidator implements RocketMQListener<BinlogEvent> {
public void onMessage(BinlogEvent e) {
if (!"product".equals(e.getTable())) return;
for (Row row : e.getAfterRows()) {
String key = "product:" + row.get("id");
redis.del(key);
localInvalidateTopic.publish(key); // 顺带广播失效各实例本地缓存
}
}
}Canal 方案的分量在于可靠性语义:只要 DB 事务提交了,binlog 就在,删除事件就不会丢。Canal 记录消费位点,宕机重启从位点续传;MQ 侧还有 ack/重试。业务代码写库成功后哪怕立刻宕机,缓存也终将被刷新。代价是:架构多了三个组件(Canal、MQ、消费者)。失效是异步的(毫秒到秒级延迟,窗口内可能读到旧值)。还需要处理全量初始化和顺序性(同一行并发变更的 binlog 顺序天然有序,消费端要保序)。
强一致的诚实答案:做不到,就分层
面试官:那缓存和数据库到底能做到强一致吗?
给一个干净的收尾:不加分布式锁/串行化,做不到强一致,只能做到最终一致。缓存层存在的意义就是并发和性能,而一致性窗口恰恰来自并发。工程上的分层决策是:
| 一致性要求 | 方案 | 代价 |
|---|---|---|
| 允许秒级不一致(绝大多数页面数据) | Cache Aside + TTL 兜底 | 几乎为零 |
| 尽量短的最终一致 | 先更库再删缓存 + 延迟双删 / Canal 异步删 | 复杂度上升 |
| 不一致不可接受(余额、库存扣减) | 不走这套——DB 为主 + 分布式锁串行化,或直接放弃缓存 | 性能大幅下降 |
追问链
- 延迟双删的延迟怎么定?→ 覆盖一个读请求查库到写缓存的全过程;读走从库要加主从复制延迟;经验值 500ms~1s,按 binlog 时间戳校准;
- 为什么删缓存而不是更新缓存?→ 并发写乱序不可交换 + 懒加载自愈 + 避免无效写 + 删除幂等可重试;
- Canal 挂了怎么办?→ 位点续传 + MQ ack 重试;业务代码宕机删除也不会丢——binlog 在,事件就在。