《Spring Boot 实战》8.3 缓存一致性

缓存最难的从来不是怎么放,而是数据变了以后怎么办:讲清 Cache-Aside 的读写顺序为何推荐先写库再删缓存、延迟双删与订阅 binlog 各自换来了什么、穿透击穿雪崩三种故障的对策、缓存与事务边界如何对齐,以及一致性要求高时「不用缓存」这个正当选项。

本节目标:回答「数据变了以后缓存怎么办」这个缓存里最难的问题——读写顺序怎么定、延迟双删和 binlog 订阅各自换来了什么、三种缓存故障怎么防,以及什么时候该干脆放弃缓存。
适用版本:Spring Boot 4.1.x(Java 21)

8.3 缓存一致性

前两节讲的是「怎么把数据放进缓存」。这一节讲「数据变了以后怎么办」——这是缓存工程里真正难的部分,也是绝大多数线上脏数据的来源。

先明确一个前提:只要数据库和缓存是两个独立副本,就不存在零成本强一致。你能做的是把不一致窗口压到业务可接受的范围,并让最终收敛。理解这一点,后面所有方案的选择才有依据。

8.3.1 一致性问题从哪来

图书借阅服务里,Book 的库存 availableCopies 会被借书、还书频繁修改。如果详情接口把 Book 缓存了,就出现了两个副本:数据库里的权威值,和缓存里的快照。问题出在写路径:更新数据库和更新缓存是两次独立操作,它们之间任何时刻出问题,副本就会分叉。

分叉有三种典型触发方式:两次操作之间进程崩溃、两次操作之间并发读写、以及主从复制延迟导致「写已提交但读还读到旧值」。方案的目标就是针对这三种情形设计。

8.3.2 Cache-Aside 的读写顺序

读路径没有争议:先读缓存,未命中读库并回填。这就是所谓 Cache-Aside(旁路缓存)的标准形态——缓存不是数据库的「代理」,而是应用在读写时自己绕过它、自己维护它:

public Book detail(Long id) {
    String key = "cache:book:" + id;
    Book cached = readFromCache(key);
    if (cached != null) {
        return cached;                 // 命中:直接返回,不打库
    }
    Book book = repository.findById(id).orElseThrow();   // 未命中:回源
    writeToCache(key, book, Duration.ofMinutes(10));     // 回填
    return book;
}

「旁路」的关键含义是:应用同时知道数据库和缓存的存在,并负责两者的同步。这与「读穿 / 写穿」等由缓存层自己管理回源的模式不同——那些模式把同步责任交给缓存组件,Spring Cache 属于前者。理解这一点,后面所有写路径的讨论才有落点:因为同步责任在应用手里,顺序就完全由你决定,也就完全由你负责。

争议全在写路径上,有两种主流顺序。

方案 A:先更新数据库,再删除缓存(推荐)。

@Transactional
public Book update(Book book) {
    Book saved = repository.save(book);        // 1. 写库
    cacheManager.getCache("book").evict(book.getId());  // 2. 删缓存
    return saved;
}

方案 B:先删除缓存,再更新数据库。

@Transactional
public Book update(Book book) {
    cacheManager.getCache("book").evict(book.getId());  // 1. 先删缓存
    return repository.save(book);                       // 2. 再写库
}

为什么 A 更稳?用「读在写中间插进来」这个并发场景对比:

场景方案 A(先写库再删缓存)方案 B(先删缓存再写库)
并发读时机读在「写库后、删缓存前」命中旧缓存读在「删缓存后、写库前」未命中,回源读到旧库值并回填
结果删除动作随后清掉了旧缓存,下次读回源到新值缓存里被写回旧值,且写库不会再去删它,长期脏
风险窗口极小(删缓存失败才会脏)较大(回填旧值的窗口可被反复命中)

结论:方案 A 的脏窗口更小且能自愈(下次删除或过期即可收敛),方案 B 可能长期脏。 所以 Cache-Aside 的标准写法是「先写库、后删缓存」。

但方案 A 也不是完美。它有两个残留问题:

  • 删缓存失败:写库成功、删缓存抛异常,缓存里是旧值。解决靠重试 + 兜底 TTL——即使删失败,TTL 到期也会自动收敛。这就是「缓存必须设 TTL」比「手动失效」更重要的原因。
  • 主从延迟:写库走主、删缓存后马上有读请求走了从库(还没同步),回填旧值。对策是写后短时间内的读强制走主库,或接受一个小的不一致窗口。

8.3.3 延迟双删

针对方案 B 那个「并发读回填旧值」的窗口,有一种补救叫延迟双删:删一次、写库、再延迟一段时间删第二次。

public Book update(Book book) {
    Cache cache = cacheManager.getCache("book");
    cache.evict(book.getId());               // 第一次删
    Book saved = repository.save(book);      // 写库
    scheduler.schedule(() -> {               // 第二次删(延迟执行)
        cache.evict(book.getId());
    }, Duration.ofMillis(500));
    return saved;
}

第二次删除是为了清掉「第一次删除后、写库前」那段窗口里被并发读回填的旧值。

它的问题是:延迟多久是拍脑袋的。太短盖不住窗口,太长又有很长一段时间缓存是空的(等于放弃缓存)。而且它依赖一个额外的定时任务,进程重启就丢。所以延迟双删是「没有更好办法时的启发式」,不是标准答案。能选方案 A(先写库后删缓存)就不要用双删。

8.3.4 订阅 binlog:把失效和业务解耦

更彻底的做法是把「失效缓存」从业务代码里拿出来,交给一个订阅数据库变更日志的独立组件:业务只管写库,组件读 MySQL 的 binlog(用 canal、Debezium 之类),解析出变更事件,再去删对应缓存。

维度延迟双删(业务内)订阅 binlog(独立组件)
一致性窗口靠猜测的延迟,不确定由 binlog 解析延迟决定,通常更短更可控
业务侵入高,每个写方法都要写删除逻辑低,业务只写库
可靠性删除失败易被忽略组件可重放 binlog,可靠
引入成本低,几十行代码高,多一个中间件与运维负担
适用规模中小系统多服务、写路径复杂、要求收敛快

选择标准很直接:只有一套应用、写路径不多,用方案 A 加 TTL 兜底就够了;多个服务写同一份数据、业务代码不好统一加删除逻辑,才值得上 binlog 订阅。不要为了「看起来更高级」而引入 canal——它带来的运维复杂度是实打实的。

8.3.5 三种故障:穿透、击穿、雪崩

缓存失效引发的故障有三种,名字像但成因和对策完全不同,必须分开记。

故障成因表现对策
穿透请求的数据库里也不存在,缓存永远不命中每次请求都打库,缓存形同虚设空值缓存、布隆过滤器、参数校验
击穿单个热点 key 恰好过期,大量并发同时回源瞬间把库打爆互斥重建、逻辑过期、热点永不过期
雪崩大量 key 同时过期,或 Redis 整体不可用全量请求涌向库TTL 抖动、多级缓存、限流降级、集群高可用

穿透的两种对策各有代价。空值缓存是把「查不到」也缓存起来(值存一个占位、TTL 设短些),代价是缓存里混入空值、且新增数据后可能短暂读到空;注意上一节的 unless = "#result == null" 会阻止空值缓存,防穿透时要去掉它,并把 spring.cache.redis.cache-null-values 设为允许。布隆过滤器是在缓存前放一层位数组,能确定「一定不存在」,代价是有误判率、且删除元素麻烦、需要与数据同步维护。

击穿的经典对策是互斥重建:只有一个请求去回源,其余等待。用 Redis 的 SET key value NX PX 实现分布式互斥:

public Book detailWithMutex(Long id) {
    String key = "cache:book:" + id;
    Book cached = readFromCache(key);
    if (cached != null) {
        return cached;
    }
    String lockKey = "lock:book:" + id;
    boolean locked = redis.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(10));   // SET NX PX
    if (!locked) {
        // 没抢到锁:短暂等待后重试读缓存,避免全部回源
        sleepQuietly(50);
        return detailWithMutex(id);
    }
    try {
        Book book = repository.findById(id).orElseThrow();
        writeToCache(key, book, Duration.ofMinutes(10));
        return book;
    } finally {
        redis.delete(lockKey);
    }
}

这段代码有两个必须补的点:递归重试要有次数上限(否则锁释放后仍可能形成风暴),锁必须设过期时间(否则持锁进程崩溃会死锁)。生产上更稳的做法是「逻辑过期」——缓存里存值和逻辑过期时间,过期后由一个线程异步重建、其余线程先返回旧值,避免请求等待。

record CachedBook(Book book, Instant expireAt) {}

public Book detailWithLogicExpiry(Long id) {
    String key = "cache:book:" + id;
    CachedBook cached = readFromCache(key);
    if (cached == null) {
        return loadAndCache(id);                 // 完全没缓存,同步回源
    }
    if (cached.expireAt().isAfter(Instant.now())) {
        return cached.book();                    // 逻辑上未过期,直接返回
    }
    // 逻辑已过期:只让一个线程异步重建,其余线程立刻返回旧值
    if (rebuildLock.tryLock()) {
        try {
            executor.submit(() -> loadAndCache(id));
        } finally {
            rebuildLock.unlock();
        }
    }
    return cached.book();
}

逻辑过期的代价是会返回旧值(直到异步重建完成),换来的是「重建期间没有请求等待、也不回源打库」。它适合热点 key——宁可短暂读旧,也不能让请求卡住。注意这里的重建锁是本地锁,多实例部署时每个实例各自重建一次,属于可接受的重复劳动;若重建代价极高,仍需换成 Redis 分布式锁。

雪崩的根因是「过期时间整齐划一」。最廉价的解法是给 TTL 加随机抖动:

Duration ttl = Duration.ofMinutes(10)
        .plusSeconds(ThreadLocalRandom.current().nextInt(60));   // 10m ~ 11m 随机

再叠加多级缓存(本地 Caffeine 挡一层)与入口限流,Redis 真挂掉时至少不会把库瞬间打穿。

8.3.6 缓存与事务的边界

@CacheEvict 和 @Transactional 都是 AOP 切面,两者的执行顺序取决于切面优先级,默认并不保证「事务提交后再删缓存」。这会导致一个隐蔽问题:事务还没提交,缓存就已被删;此时另一个请求读库读到的是未提交的旧值并回填,事务提交后缓存里就留了旧值。

Spring 提供了 TransactionAwareCacheDecorator:它把缓存的写操作延迟到事务提交后才真正执行。用它包装 CacheManager 即可:

@Bean
CacheManager cacheManager(RedisCacheManager delegate) {
    // 让 put/evict 在事务提交后才落地,避免提交前删缓存导致回填旧值
    return new TransactionAwareCacheManagerProxy(delegate);
}

另一个更简单的原则:别在事务方法内部直接操作缓存(无论用注解还是 RedisTemplate),把缓存失效挪到事务提交之后执行。用 @TransactionalEventListener(phase = AFTER_COMMIT) 监听领域事件,在提交后做删除,是比双删更干净的结构。

反过来,@CacheEvict(beforeInvocation = true) 表示先删缓存再执行方法。它适用于「即使方法失败,也希望缓存被清掉」的场景;但对「先写库后删缓存」的正统顺序来说,默认的「方法执行后删」才对。

8.3.7 把选择收敛成一张清单

上面几节的方案很多,实际决策时按下面的顺序问,就能落到一个具体组合,而不是全都要:

问题回答决定
这数据能容忍短暂旧值吗?否不加缓存,读直接打库
写路径复杂、多个服务都写吗?是订阅 binlog 做失效
只有本服务写、写点集中吗?是「先写库、再删缓存」+ 重试 + TTL 兜底
存在会被反复查的不存在数据吗?是空值缓存或布隆过滤器(防穿透)
有单个热点 key 吗?是互斥重建或逻辑过期(防击穿)
缓存量大、过期集中吗?是TTL 加随机抖动 + 多级缓存(防雪崩)
缓存失效和事务在同一个方法里吗?是TransactionAwareCacheManagerProxy 或 AFTER_COMMIT 事件

这张表的价值在于:穿透、击穿、雪崩是三个独立问题,方案不互斥但要分别判断。很多团队只做了 TTL 抖动(防雪崩),却漏了空值缓存(防穿透),线上照样被打库——因为它们不是同一件事。

8.3.8 一致性要求高时,「不用缓存」也是一种选择

前面所有方案都在「最终一致」的框架里做优化。但有些数据不能接受任何不一致窗口——比如账户余额、库存扣减的实时判定。对这些数据,正确决策往往是根本不给它加缓存:

  • 强一致读直接打库,靠索引和连接池扛量;真扛不住就做读写分离或分库分表,而不是加缓存。
  • 需要「近似实时」的统计类数据,可以缓存,但要明确它是允许延迟的,并在产品层说清(例如「库存显示可能有几秒延迟」)。
  • 只有「读远多于写、且能容忍短暂旧值」的数据才适合缓存。判断标准是业务能接受多大的陈旧度,而不是「这个接口 QPS 高所以该缓存」。

把「不用缓存」当作一张正经的选项牌,能避免很多「为了性能引入了缓存,结果花了几个月修一致性 bug」的返工。

小结

  • 只要库和缓存是两个副本,就不存在零成本强一致;目标是把不一致窗口压到可接受范围并最终收敛。
  • Cache-Aside 读路径是「先读缓存、未命中回源回填」;写路径推荐「先写库、再删缓存」,其脏窗口更小且能靠 TTL 自愈。
  • 「先删缓存、再写库」会在并发读回填旧值后长期脏;延迟双删是补救的启发式,延迟多久拍脑袋,能不用就不用。
  • 订阅 binlog 把失效从业务解耦、更可靠,但引入中间件与运维成本;只有多服务写同一份数据时才值得。
  • 三种故障要分开治:穿透用空值缓存或布隆过滤器,击穿用互斥重建或逻辑过期,雪崩用 TTL 抖动 + 多级缓存 + 限流降级。
  • 互斥重建的锁必须设过期时间、重试要有上限;TTL 抖动是抵御雪崩成本最低的一招。
  • 缓存失效要与事务边界对齐:用 TransactionAwareCacheManagerProxy 或 AFTER_COMMIT 事件,避免事务提交前删缓存被回填旧值。
  • 强一致数据不要加缓存;「不用缓存」是应对一致性要求高时的正当选项,而不是妥协。

至此缓存这一章收口:抽象、落地、一致性。下一章换到安全,从认证与授权的配置入口讲起。

阅读导航:上一节:8.2 Redis 缓存实践 · 下一节:9.1 安全配置 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计