背景:我在给自己的三级缓存组件做压测时发现一个反直觉现象------每次命中都多做一次反序列化的实现,在高并发下反而比"直接返回缓存对象"的流行框架更稳(聚合吞吐 ~1450 万 vs ~690 万 ops/s,p99 1µs vs 427µs)。数据不合直觉,于是把两边基于 Caffeine 的本地缓存源码翻了个底朝天,最后在 Caffeine 的源码里找到了答案。这篇文章就是这个考古过程的整理------组件本身不是主角,Caffeine 才是。组件链接放在文末,正文只谈 Caffeine。
一、先摆数据:反直觉在哪
先解释两个指标(不熟悉压测术语的读者):ops/s 是每秒完成的"读一次缓存"次数(多线程聚合);p99 是把所有请求耗时排序后第 99 百分位的值------99% 的请求比它快,它代表"最倒霉的那批用户"的体验。
对比的两个实现,本地缓存层都用 Caffeine,区别只有一点:
- 实现 A:Caffeine 里存 JSON 字符串,每次命中反序列化出新对象(Jackson,实测 ~250ns)
- 实现 B:Caffeine 里存 Java 对象引用,命中直接返回(不反序列化,理论上更快)
单线程实测:B 的单次命中 ~150ns(mean 166ns),A ~430ns(mean 494ns)------B 快 3 倍,符合直觉。
但 16 线程以上画风突变:A 聚合吞吐持续爬到 1450 万 ops/s、p99 稳在 1µs;B 在 8 线程就到顶(~650 万),线程翻倍吞吐不动,p99 从 35µs 一路恶化到 427µs。
单次更贵的赢了,单次更便宜的输了。 差距不在反序列化,在于它们给 Caffeine 喂了不同的"过期策略"。要理解这个差异,得先弄清 Caffeine 的读路径到底做了什么。
二、Caffeine 读路径:为什么它是无锁的
Caffeine 的读操作(getIfPresent)全程不加锁,这是它快的根本原因。拆开看:
scss
getIfPresent(key)
└─ ConcurrentHashMap.get(key) // 分段数组定位 bin,无锁读
└─ node.getValue() // 取缓存条目
└─ hasExpired(node) // 过期检查(读 node 上的时间字段)
└─ afterRead(node) // 唯一的"写"动作,见下文
三个值得注意的设计:
1. 读缓冲是分条的环形数组(StripedBuffer)
每次读都要"记录这次访问"(供 LRU/LFU 统计),如果多个线程往同一个队列塞,就退化成锁竞争。Caffeine 的解法是开 N 条环形数组(N ≈ CPU 核数),线程按哈希分散到不同条带,条带内部用 CAS 入队------绝大多数读操作互不干扰。条带间用填充字段隔离,避免伪共享(几个热字段挤在同一缓存行上互相打架)。
2. 访问记录是异步消费的
读缓冲只负责"记账",记账本身不做任何统计计算。等缓冲攒够了(或写操作触发),Caffeine 提交一个 maintenance 任务(跑在 ForkJoinPool 公共池)批量消化------用户线程从不为统计买单。
3. 淘汰决策也是异步的
条目满了不会当场找受害者淘汰(那要拿锁、扫队列),而是标记"需要维护",由 maintenance 任务统一处理。用户线程的最坏情况只是"多塞了一次队列"。
这套设计下,读路径上唯一的共享写,就是往读缓冲的 CAS------按理说 64 线程也不该劣化。但压测里实现 B 恰恰劣化了。缺口在过期策略。
三、过期机制:固定 TTL 与自定义 Expiry 的本质区别
Caffeine 支持三种过期:expireAfterWrite(写入后固定时长)、expireAfterAccess(访问后刷新)、自定义 Expiry(按 key/value 动态计算)。
源码层面,前两者在内部被包装成常量 Expiry ------expireAfterRead 回调返回的剩余时间是恒定值。而自定义 Expiry(以及任何按时间动态变化的策略)每次读回调返回的剩余时间都在变。
区别在 Caffeine 的时间轮(Timer Wheel):
- Caffeine 不用优先队列管过期(堆的插入/删除是 O(log n),还有锁),而是用层级时间轮:条目按"到期时间"挂到轮子的槽位上,插入/重挂 O(1),维护任务顺时针扫槽就能批量清理。
- 当
expireAfterRead回调返回的新到期时间与当前挂载位置不同 ,节点必须从旧槽摘下、挂到新槽------这是一次对共享数据结构的写操作。 - 固定 TTL:每次回调返回值相同 → 无需重挂 → 读路径保持纯读。
- 动态策略:返回值随时间漂移 → 每次读都可能触发重挂 → 热键的条目变成几十个线程争抢的共享写点,在多核间来回弹(缓存行争用)。
我的隔离实验验证了这一点(16 线程、裸 Caffeine、1000 个键,唯一变量是过期策略):
| 过期策略 | 吞吐 | 尾部(max) |
|---|---|---|
固定 expireAfterWrite(300s) |
~5200 万 ops/s | 无异常尖刺 |
| 自定义 Expiry(每次读重算剩余时间) | ~4000 万 ops/s(-25%) | 300ms 级尖刺 |
还有一个反直觉的对照:实现 A 每次命中分配 ~880 B(Jackson 反序列化),B 只分配 ~300 B------A 的 GC 压力是 B 的 6 倍,但 A 的 p99 纹丝不动。这说明在这个量级上,"每读一次共享写"的争用才是尾延迟的主导因素,对象分配/GC 不是。
顺带修正一个常见误区:很多文章说"Caffeine 快是因为用了 W-TinyLFU"------W-TinyLFU 决定的是淘汰谁 (容量压力下的命中率),而命中路径上根本没有淘汰逻辑。读写路径快靠的是无锁读 + 异步维护,W-TinyLFU 决定的是"留下来的东西对不对"。
四、W-TinyLFU:比 LRU 强在哪
淘汰策略解决的问题是:容量满了留谁。经典方案的缺陷:
- LRU:一次批量拉取/扫表就能把真正的热点全部冲出缓存(突发污染)。
- LFU:老热点僵死------曾经高频、如今冷清的条目赖着不走(频次没有老化)。
W-TinyLFU(论文 TinyLFU 的窗口增强版)的组合拳:
1. 频率估计用 Count-Min Sketch,且只有 4 bit
不存完整计数,而是用紧凑的 sketch:几个独立哈希函数,每个哈希定位到一段 4 bit 计数器,读频率时取所有候选中的最小值。4 bit 溢出怎么办?Caffeine 在计数到达上限时按概率重置一半的计数器(对数老化)------老热点的频次随时间衰减,解决 LFU 僵死。
2. 准入(Admission)而非全量比较
新条目不直接进主缓存,而是和主缓存 LRU 队尾的"受害者"比 sketch 频率:新来的频率不如受害者,新来的直接淘汰。一次扫表污染的热数据,因为 sketch 频率是长期累计的,根本赢不了老热点------突发污染被挡在门外。
3. Window + 分段 LRU
主缓存分 protected(80%,被再次访问晋升)和 probation(20%,待审)两段;前面还有一个 window 队列(比例自适应调整)吸收刚写入的突发流------突发先在 window 里消化,不冲击主缓存的频率统计。
实际效果:对"热点集中"的 skewed 访问,W-TinyLFU 命中率显著高于 LRU;对"突发扫表"场景,LRU 命中率崩到个位数时它还能守住。代价是 sketch 的空间开销和淘汰决策的计算------这些全在异步 maintenance 里,不碰读路径。
五、维护任务:所有脏活的集中地
过期清理、容量淘汰、读缓冲消化、sketch 衰减、时间轮扫描------全部集中在 maintenance 任务里。触发时机:写缓冲满、读缓冲满、或有"该清理"的标记。它跑在 ForkJoinPool 公共池上,一次 run 会循环处理直到干净,然后睡觉。
这个设计换来两个特性:
- 用户线程最坏只付一次 CAS(读)或一次入队(写);
- 清理永远批量进行,摊薄成本。
但也意味着:maintenance 线程是 Caffeine 的心脏。如果你给 Caffeine 配了极小的容量(条目频繁淘汰)或自定义 Expiry(频繁重排),maintenance 的负载会放大------压测里的尾延迟尖刺(300ms 级 max)正是重排压力传递到维护循环的表现。
六、给使用者的四条建议
- 能用固定 TTL 就别用自定义 Expiry 。
expireAfterWrite是读路径最快形态。确实需要按条目动态过期(比如按 value 大小、按租户差异化)再上 Expiry,并意识到"读触发的重算"在热键高并发下有争用代价。 expireAfterAccess慎用于热键。它的语义要求每次访问刷新时间------热键的刷新本身就是共享写。和上面同源。- 容量设够,让热点进得了主缓存。W-TinyLFU 的准入机制会拒绝频率低的新条目,容量不足时不是"随机丢",而是"新东西进不来"------表现上像缓存"僵死",先查容量再怀疑策略。
- 统计开销可以接受 。
recordStats的计数器也是分条的(LongAdder 风格),打开它做容量规划的成本远小于收益------命中率才是缓存容量的第一变量。
结语
回到开头那个反直觉的压测结果,现在可以说清楚了:"每次命中多做一次反序列化"的代价是可预算的 CPU 时间(~430ns);而"每读一次重算过期"的代价是不可预算的并发争用(尾延迟放大百倍)。缓存组件选型时,比起单次操作的纳秒级差异,"读路径上有没有共享写"才是高并发场景真正该看的。
这些实验全部可复现(Testcontainers + 裸 Caffeine 隔离实验都在仓库的测试里)。背景组件:cache-kit-spring-boot-starter------一个实体元数据驱动的三级缓存(Caffeine → Redis → DB,MyBatis-Plus 零注解接入,已发布 Maven Central),压测对比的完整数据在仓库 docs/CONSISTENCY.md。