Skip to content

先看一个典型的缓存事故:用户改了头像,刷新好几遍还是旧头像。查缓存 TTL,60 秒早该过期了——最后发现是更新顺序出了问题:先删了缓存,数据库还没改完,另一个线程又把旧数据读回缓存。缓存和数据库的不一致,就是这么不经意间产生的。

这篇把缓存一致性从最基础的 Cache Aside 讲到 binlog 订阅:为什么会出现不一致、延迟双删在防什么、Canal 怎么把缓存操作彻底解耦。代码按 Spring Boot 落地。


一、先定基调:Cache Aside(旁路缓存)是基础

先理清读写姿势,绝大多数不一致都是姿势错了

:先查缓存,没有就查库,查完回填缓存。

java
public User getUser(Long id) {
    String key = "user:" + id;
    User user = stringRedisTemplate.opsForValue().get(key);
    if (user == null) {
        user = userMapper.selectById(id);       // 缓存未命中,查库
        stringRedisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
    }
    return user;
}

:先更新数据库,再删除缓存(不是更新缓存)。

java
@Transactional
public void updateUser(User user) {
    userMapper.updateById(user);                       // ① 先更新 DB
    stringRedisTemplate.delete("user:" + user.getId()); // ② 再删缓存
}

为什么写操作要"删缓存"而不是"改缓存":改缓存会引入"读旧写新竞争"——两个请求同时改,后写 DB 的可能先写缓存,缓存里就是脏数据。删缓存则让下次读自然回填,简单可靠。

二、问题来了:先删缓存再更新 DB 的坑

上面的顺序是"先 DB 后删缓存",但如果写成"先删缓存后更新 DB",就会踩这个坑:

先删缓存后更新 DB 的脏读时序

线程 B 在 t3 读到的旧数据在 t4 回填了缓存,缓存从此是旧的,直到 TTL 过期。这就是"更新顺序写反"导致的不一致。

三、方案 v2:延迟双删(先删→更新→再删,防"回填旧值")

既然问题出在"更新 DB 期间有人把旧值读回缓存",那就在更新完成后再删一次,把可能回填的旧值清掉:

java
@Transactional
public void updateUser(User user) {
    String key = "user:" + user.getId();
    stringRedisTemplate.delete(key);                      // ① 先删缓存
    userMapper.updateById(user);                          // ② 更新 DB
    // ③ 延迟再删一次:等 t4 时刻的回填窗口过去
    redisDelayService.delayDelete(key, 500);              // 延迟 500ms 再删
}

延迟双删能根治吗:不能根治,只能把不一致窗口压缩到几百毫秒。因为"读旧值回填"这个动作无法完全禁掉,延迟删除只是确保最后一次删除发生在回填之后。窗口期内的脏读依然存在(对一致性要求高的数据不适用)。

常见误用:用 @Transactional 包住"更新 DB + 延迟删除"——事务提交前删除任务可能先跑,等于没删。延迟删除要放在事务提交后执行。

四、方案 v3:binlog 订阅(Canal)——把删缓存从业务代码里彻底解耦

延迟双删的问题:业务代码里到处是"删缓存"的痕迹,漏一处就是一次线上事故;而且删缓存和更新 DB 在同一个事务里,耦合太紧。

更干净的思路:更新 DB 是事实来源,让 DB 的 binlog 驱动缓存删除——用 Canal 监听 MySQL binlog,解析出数据变更事件,异步去删缓存:

应用写 DB → MySQL binlog → Canal 监听解析 → 删除对应缓存 key
java
// Canal 消费者:收到 binlog 变更事件后删缓存,业务代码里不再有任何删缓存逻辑
@CanalListener(database = "app", table = "t_user")
public void onUserChange(UserRowChange change) {
    // 无论新增/更新/删除,都删掉对应缓存,下次读自动回填
    change.getRowDataList().forEach(row ->
        stringRedisTemplate.delete("user:" + row.getPrimaryKey("id")));
}

这个方案的核心价值

对比点延迟双删binlog 订阅
业务侵入每个写接口都要加删缓存代码零侵入(只监听 binlog)
时序保证靠"延迟"猜窗口,有概率漏binlog 顺序即真实写入顺序,可靠
实时性延迟几百 ms秒级(Canal 拉取间隔)
架构成本中(要部署 Canal,监听 binlog)
适用规模中小系统缓存操作多的中大型系统

注意:binlog 方案删的是缓存 key,不是同步数据到缓存——删掉让下次读回填,依然走 Cache Aside 的路径。它是把"何时删"从业务时序里解放出来。

五、兜底:缓存雪崩的最后一层保护

不管哪种方案,缓存都可能"删早了"(更新事务回滚、binlog 延迟)。所以一致性方案要配兜底:

  1. 缓存 TTL:所有缓存带过期时间,即使删漏了,TTL 后也会回填(一致性靠 TTL 兜底)
  2. 逻辑过期/版本号:缓存里存版本号,更新时版本 +1,读取时版本不符即重新加载
  3. 缓存预热:删缓存后不依赖"读时回填",由后台任务立即回填,缩短空窗

六、我的选型结论

业务类型推荐方案
读多写少、能接受秒级一致Cache Aside + TTL 兜底(最简单)
写频繁、要尽快一致延迟双删(几百 ms 窗口可接受)
缓存操作多、要彻底解耦binlog 订阅(Canal)
强一致要求(金额类)别用缓存做唯一数据源,直接查库

一个核心认知:缓存一致性没有"完美方案",本质是在一致性和可用性/复杂度之间做权衡。绝大多数业务(用户资料、商品信息)能接受秒级甚至分钟级一致,一个带 TTL 的 Cache Aside 就够;真到强一致场景,答案往往是"别缓存"。

缓存基础设施见《Redis 快速入门》和《Redis 持久化与淘汰策略》;高可用部署见《Redis 哨兵与集群》;Spring 侧缓存序列化问题见《Spring Cache 的 Redis 序列化》。


验证记录:"先删缓存后更新 DB"的不一致时序、延迟双删的窗口逻辑,是并发时序问题,文中用时序推演验证(t1-t5 时刻表);Redis DEL 语义(删 key 后读 miss 触发回填)已实跑验证。Canal binlog 订阅为框架标准行为,标注为静态核对。