
背景我在给自己的三级缓存组件做压测时发现一个反直觉现象——每次命中都多做一次反序列化的实现在高并发下反而比直接返回缓存对象的流行框架更稳聚合吞吐 ~1450 万 vs ~690 万 ops/sp99 1µs vs 427µs。数据不合直觉于是把两边基于 Caffeine 的本地缓存源码翻了个底朝天最后在 Caffeine 的源码里找到了答案。这篇文章就是这个考古过程的整理——组件本身不是主角Caffeine 才是。组件链接放在文末正文只谈 Caffeine。一、先摆数据反直觉在哪先解释两个指标不熟悉压测术语的读者ops/s是每秒完成的读一次缓存次数多线程聚合p99是把所有请求耗时排序后第 99 百分位的值——99% 的请求比它快它代表最倒霉的那批用户的体验。对比的两个实现本地缓存层都用 Caffeine区别只有一点实现 ACaffeine 里存JSON 字符串每次命中反序列化出新对象Jackson实测 ~250ns实现 BCaffeine 里存Java 对象引用命中直接返回不反序列化理论上更快单线程实测B 的单次命中 ~150nsmean 166nsA ~430nsmean 494ns——B 快 3 倍符合直觉。但 16 线程以上画风突变A 聚合吞吐持续爬到 1450 万 ops/s、p99 稳在 1µsB 在 8 线程就到顶~650 万线程翻倍吞吐不动p99 从 35µs 一路恶化到 427µs。单次更贵的赢了单次更便宜的输了。差距不在反序列化在于它们给 Caffeine 喂了不同的过期策略。要理解这个差异得先弄清 Caffeine 的读路径到底做了什么。二、Caffeine 读路径为什么它是无锁的Caffeine 的读操作getIfPresent全程不加锁这是它快的根本原因。拆开看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 WheelCaffeine 不用优先队列管过期堆的插入/删除是 O(log n)还有锁而是用层级时间轮条目按到期时间挂到轮子的槽位上插入/重挂 O(1)维护任务顺时针扫槽就能批量清理。当expireAfterRead回调返回的新到期时间与当前挂载位置不同节点必须从旧槽摘下、挂到新槽——这是一次对共享数据结构的写操作。固定 TTL每次回调返回值相同 → 无需重挂 → 读路径保持纯读。动态策略返回值随时间漂移 → 每次读都可能触发重挂 →热键的条目变成几十个线程争抢的共享写点在多核间来回弹缓存行争用。我的隔离实验验证了这一点16 线程、裸 Caffeine、1000 个键唯一变量是过期策略过期策略吞吐尾部max固定expireAfterWrite(300s)~5200 万 ops/s无异常尖刺自定义 Expiry每次读重算剩余时间~4000 万 ops/s-25%300ms 级尖刺还有一个反直觉的对照实现 A 每次命中分配 ~880 BJackson 反序列化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主缓存分 protected80%被再次访问晋升和 probation20%待审两段前面还有一个 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 → DBMyBatis-Plus 零注解接入已发布 Maven Central压测对比的完整数据在仓库docs/CONSISTENCY.md。