Caffeine 快到 1450 万 ops/s,为什么我还是不敢说数据一致

上一篇《Caffeine 源码详解:为什么它是最快的 Java 本地缓存------一次压测引发的源码考古》(csdn同稿)把 Caffeine 的读路径、时间轮和 W-TinyLFU 拆完了,结论很明确:它快,是因为把读路径上的共享写压到了极限。

但这篇我想反过来讲一件事:

Caffeine 只解决"读得快",不解决"删得准"。

一旦你的架构变成:

text 复制代码
Caffeine(L1) → Redis(L2) → DB

问题的重心就从"本地缓存有多快"变成了"多实例之间怎么失效"。这也是我做三级缓存组件 cache-kit-spring-boot-starter 时踩得最深的一批坑。

一、先摆三个数字

这三个数字来自我在自研 cache-kit 上的实测:

  1. L1 命中吞吐:~1450 万 ops/s 工作集全部装进 L1,p99 约 1µs。
  2. 应用内写脏读窗口:最大 505ms 10 实例、716 写/s、4.6 万次广播零丢失的场景下测出来的。
  3. 最坏脏读上界: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 未删"的间隙里,另一个读线程会:

  1. L1 miss
  2. 从 L2 读到旧值
  3. 把旧值回填 L1

这下 L1 就脏了。

所以删除顺序必须是:

text 复制代码
DEL L2 → DEL L1 → 广播 → 延迟双删

先把远端层干掉,再清本地层,至少能避免"L2 旧值回填 L1"这种确定性脏读。

四、延迟双删能救什么,救不了什么

延迟双删的思路是:

text 复制代码
写 DB
删 L2
删 L1
等 1s
再删一次 L2

它的目标是把回填竞态的窗口压缩掉。

但它不是一致性方案,只是窗口压缩方案。

它能救的是:

  • 正常速度的回填竞态
  • 大多数短窗口内的旧值复活

它救不了的是:

  1. 慢读 读线程查 DB 花了 2 秒,双删 1 秒早就执行完了。
  2. 长 GC 读线程在 GC 前拿到旧值,GC 后再回填。
  3. 双删任务丢失 如果延迟任务是异步的,进程重启或队列积压,就可能丢。
  4. Redis 宕机期间的失效丢失 删除动作没执行成功,旧值还在 L2。
  5. 多实例 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 期间的旧值问题。

七、真正的兜底,只有两个

做了这么多之后,我发现多级缓存真正能兜底的,只有两件事:

  1. TTL 上界
  2. 强一致旁路

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 很快,但它快的是读路径。

多级缓存难,难的是失效链路。

一个是性能问题,一个是正确性问题。

它们经常被混在一起聊,但其实完全不在一个层面上。

如果你也在做多级缓存,我建议你先把这三个问题分开:

  1. 读得有多快?
  2. 失效传得有多准?
  3. 最坏能脏多久?

这三个问题分别对应:

text 复制代码
Caffeine
失效广播
TTL + 旁路

想清楚这三层,你才敢说这套缓存"能上生产"。

完整代码和文档:👉 cache-kit-spring-boot-starter

相关推荐
蜗牛互联网1 小时前
Java 17调用Embeddings API:余弦相似度与FAQ拒答阈值
java·开发语言·人工智能
小小张说故事1 小时前
Python 装饰器把函数"搞丢"了?5 个必踩的坑与完整避坑对照表
后端·python
Q26433650232 小时前
【有源码】基于SpringBoot+Vue的博物馆展览与服务一体化平台-java博物馆数字化展品展示与交互平台设计
java·vue.js·spring boot·spring·毕业设计·javaweb·课程设计
guo_wen_qiang2 小时前
项目使用sentinel遇到的问题
java·sentinel
CarIise2 小时前
IDEA与Maven基础课堂笔记
java·intellij-idea·caffe
小溪学编程2 小时前
C语言篇:枚举类型
java·c语言·前端
云和数据.ChenGuang2 小时前
JAVAEE AI工程师路线图
java·人工智能·java-ee·fastapi·springai·langchain4j
2601_962885722 小时前
如何用 Python 统计 A 股连续涨停(几连板)?
java·linux·python
大圣编蚕2 小时前
Spring AOP 失效排查:从原理到实战的完整指南
java·后端·spring