本节目标:回答「数据变了以后缓存怎么办」这个缓存里最难的问题——读写顺序怎么定、延迟双删和 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 安全配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。