Skip to content

缓存一致性-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 的那行场景真实可构造),所以才有后面的兜底手段。

顺带回答另一半问题——为什么是"删缓存"而不是"更新缓存":

  1. 并发写乱序(上表 #3):更新操作不可交换,后发起的旧值可能后到达;
  2. 懒加载思想:删除之后,下一个读的人自然会回源重建最新值,不用猜;
  3. 避免无效写:缓存值常是多表聚合/计算的结果。写少读多的 key 更新 10 次可能 1 次都没人读,白算 10 次;
  4. 幂等与自愈:删除失败可以重试,删除多次无害。更新写错值则一直错到下次写入。删除让缓存永远可以"从 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 在,事件就在。