上一篇《Caffeine 源码详解:为什么它是最快的 Java 本地缓存------一次压测引发的源码考古》(csdn同稿)把 Caffeine 的读路径、时间轮和 W-TinyLFU 拆完了,结论很明确:它快,是因为把读路径上的共享写压到了极限。
但这篇我想反过来讲一件事:
Caffeine 只解决"读得快",不解决"删得准"。
一旦你的架构变成:
text
Caffeine(L1) → Redis(L2) → DB
问题的重心就从"本地缓存有多快"变成了"多实例之间怎么失效"。这也是我做三级缓存组件 cache-kit-spring-boot-starter 时踩得最深的一批坑。
一、先摆三个数字
这三个数字来自我在自研 cache-kit 上的实测:
- L1 命中吞吐:~1450 万 ops/s 工作集全部装进 L1,p99 约 1µs。
- 应用内写脏读窗口:最大 505ms 10 实例、716 写/s、4.6 万次广播零丢失的场景下测出来的。
- 最坏脏读上界:L2 TTL 默认 10 分钟加抖动。
第一个数字看起来很爽,后两个才是生产里真正要面对的。
也就是说,性能问题和一致性问题,根本不在同一个层面上。
二、脏读不是"删得慢",是"回填竞态"
很多人以为多级缓存脏读的原因是:
删缓存太慢了,所以读到旧值。
其实不是。真正的问题是回填竞态。
看这个时间线:
text
读线程 写线程
────── ──────
1. L1 miss
2. L2 miss
3. 查 DB,拿到旧值
4. 写 DB
5. 删 L2
6. 删 L1
7. 延迟双删
8. 把旧值回填 L1
9. 把旧值回填 L2
问题出在第 3 步和第 8 步之间。
读线程在第 3 步拿到的确实是"当时的正确值",但它不知道写线程马上要改数据。等它把旧值写回 L1/L2 时,删除动作已经结束了。
这就是回填竞态。
它不是"删慢了",而是读和写的时序天然可能交错。
三、删除顺序为什么必须是 L2 先、L1 后
我一开始想当然地写成了:
text
先删 L1,再删 L2
结果踩坑了。
正确顺序应该是:
text
先删 L2,再删 L1
原因很简单:
如果先删 L1,在"L1 已删、L2 未删"的间隙里,另一个读线程会:
- L1 miss
- 从 L2 读到旧值
- 把旧值回填 L1
这下 L1 就脏了。
所以删除顺序必须是:
text
DEL L2 → DEL L1 → 广播 → 延迟双删
先把远端层干掉,再清本地层,至少能避免"L2 旧值回填 L1"这种确定性脏读。
四、延迟双删能救什么,救不了什么
延迟双删的思路是:
text
写 DB
删 L2
删 L1
等 1s
再删一次 L2
它的目标是把回填竞态的窗口压缩掉。
但它不是一致性方案,只是窗口压缩方案。
它能救的是:
- 正常速度的回填竞态
- 大多数短窗口内的旧值复活
它救不了的是:
- 慢读 读线程查 DB 花了 2 秒,双删 1 秒早就执行完了。
- 长 GC 读线程在 GC 前拿到旧值,GC 后再回填。
- 双删任务丢失 如果延迟任务是异步的,进程重启或队列积压,就可能丢。
- Redis 宕机期间的失效丢失 删除动作没执行成功,旧值还在 L2。
- 多实例 L1 的广播丢失 L2 清了,但别的实例 L1 里还留着旧值。
所以我最后的结论是:
延迟双删是必要的,但不是充分的。
它能把脏读窗口从"秒级"压到"几十到几百毫秒",但它不能把最终一致变成强一致。
五、binlog 补的是另一个洞
后来我又加了一层 binlog 直连失效。
它的价值很明确:
DBA 改库、别的服务直写 DB、脚本刷数据,这些"绕过应用"的写,也能触发缓存失效。
实测数据:
- 端到端失效延迟:avg 10.3ms,p99 14.2ms
- 300 次直写,零漏失效
这个能力很关键,因为它补上了应用层感知不到的写入。
但 binlog 依然不是万能的。
它解决的是:
text
有人绕过应用改了 DB
它不解决的是:
text
读线程已经拿到旧值,准备回填
也就是说:
- binlog 解决"谁来通知我删"
- 延迟双删解决"删完之后旧值会不会回来"
- TTL 解决"所有手段都失效后,脏数据最多活多久"
这三个问题不在一个层面上。
六、pub/sub 和 Streams 的边界
多实例之间还有一层:本地 L1 怎么互相通知?
最直觉的做法是 Redis pub/sub。
它的优点:
- 快
- 零存储
- 实现简单
缺点也很明显:
- fire-and-forget
- 订阅方掉线就丢
- 网络抖动就丢
实测里,pub/sub 的跨实例失效传播大概在几十毫秒级:
- 2 实例 × 200 键:202ms 全送达
- 3 实例 × 500 键:Streams 模式 157ms 零丢失
后来我加了一个可选的 Streams 模式:
- 每个实例一个消费组
- ACK 确认
- 短暂掉线后可以补投
它的语义更好,但代价是:
- Redis 里有存储
- 消费组有生命周期
- Stream 有长度上限
所以我的结论是:
pub/sub 适合能接受 L1 TTL 兜底的场景,Streams 适合对"重启窗口丢失效"更敏感的场景。
它们都解决不了 L2 TTL 期间的旧值问题。
七、真正的兜底,只有两个
做了这么多之后,我发现多级缓存真正能兜底的,只有两件事:
- TTL 上界
- 强一致旁路
TTL 是最后的安全网。
我的设计里:
- L1 TTL 默认 30s
- L2 TTL 默认 10m + 抖动
为什么 L1 必须短?
因为 pub/sub 丢了、Streams 也可能落后,最后总得有一个"最多脏多久"的上界。
L1 短 TTL 的意义就在这里:
就算广播全丢了,本地脏值最多也活 30 秒。
第二个是强一致旁路。
比如库存扣减、余额判断、权限校验,这种字段根本不该进缓存。
我的组件里提供了一个显式旁路:
java
CacheKit.withDb(() -> mapper.selectById(id));
这不是性能优化,是正确性声明。
八、一张表总结
| 手段 | 解决什么 | 不解决什么 | 兜底 |
|---|---|---|---|
| 先删 L2 再删 L1 | 避免 L2 旧值回填 L1 | 回填竞态 | 无 |
| 延迟双删 | 压缩回填竞态窗口 | 慢读、长 GC、任务丢失 | 无 |
| binlog | 感知绕过应用的写 | 回填竞态 | 无 |
| pub/sub | 多实例 L1 快速失效 | 掉线丢消息 | L1 TTL |
| Streams | 掉线不丢失效 | TTL 期间旧值 | L1 TTL |
| L1 TTL | 本地脏值上界 | L2 脏值 | 自身 |
| L2 TTL | 远端脏值上界 | 窗口内脏读 | 自身 |
withDb 旁路 |
强一致读 | 无 | 无 |
这张表是我踩完坑以后才画出来的。
如果只让我留一句话,我会留这句:
多级缓存的一致性,不是靠某一个机制解决的,而是靠一层一层兜底堆出来的。
九、写在最后
Caffeine 很快,但它快的是读路径。
多级缓存难,难的是失效链路。
一个是性能问题,一个是正确性问题。
它们经常被混在一起聊,但其实完全不在一个层面上。
如果你也在做多级缓存,我建议你先把这三个问题分开:
- 读得有多快?
- 失效传得有多准?
- 最坏能脏多久?
这三个问题分别对应:
text
Caffeine
失效广播
TTL + 旁路
想清楚这三层,你才敢说这套缓存"能上生产"。
完整代码和文档:👉 cache-kit-spring-boot-starter