Skip to content
Charles Shao
Go back

本地缓存深挖:Caffeine、W-TinyLFU 与堆外缓存

views

上一篇缓存机制总览里,本地缓存只用一节匆匆带过:HashMap、静态变量、Ehcache、Guava Cache 各点一句。可真正把本地缓存用到极致的场景——比如广告竞价里每台机器几万 QPS、每次请求都要在亚毫秒内读一堆元数据——光知道”有个 Guava Cache”是远远不够的。

本文是缓存系列的第 2 篇(本地缓存下钻)。 开篇讲了本地 vs 分布式的取舍、更新策略、穿透击穿雪崩、多级缓存;这一篇只钻一件事:进程内那层缓存,怎么把命中率、延迟和 GC 都榨到极致。

这里的主角是 Caffeine——Guava Cache 作者另起炉灶重写的继任者,也是 Spring Boot 2.x 之后官方推荐的本地缓存实现。我们会把它最值钱的两块拆开看:W-TinyLFU 淘汰算法(为什么命中率能压过 LRU)和堆内 vs 堆外的内存模型(为什么缓存配大了反而拖垮竞价 P99)。

TL;DR

Table of contents

Open Table of contents

1. 为什么要本地缓存:它的价值与代价

开篇 §2 已经对比过本地缓存与分布式缓存,这里只强调一点最本质的差别:本地缓存的全部价值,来自”它就在你进程里”。

分布式缓存(Redis)再快,一次读也要走:序列化 key → 网络出栈 → 跨机 RTT → Redis 处理 → 回包 → 反序列化 value。即使同机房,这一整套也在 0.5–3ms 量级。而本地缓存命中时,你拿到的就是堆里那个对象的引用——没有网络、没有序列化、没有拷贝,延迟是纳秒到亚毫秒级。

这个差别在两类场景里是决定性的:

但零网络不是免费的,本地缓存有两个绕不开的代价:

  1. 多节点不共享:每个进程各存一份。10 台机器就有 10 份副本,内存被放大 10 倍;更麻烦的是一致性——某条数据更新了,怎么让 10 台机器的本地缓存都失效?这是 §9 和一致性深挖的主题。
  2. 受堆内存与 GC 约束:本地缓存的数据是活生生的 Java 对象,占的是 JVM 堆。缓存越大,堆里存活对象越多,GC 扫描与停顿就越重——这也是 §7 与那次生产事故的核心。

一句话定位:本地缓存是”用内存换网络、用一致性复杂度换延迟”的交易。 它最适合”读极频繁、能容忍极短不一致、单条不大”的热点数据——这正是 Caffeine 被设计出来伺候的场景。

2. Caffeine:为什么它是 Guava Cache 的继任者

Guava Cache 用了很多年,API 优雅、功能够用。但它的作者 Ben Manes 后来另起炉灶写了 Caffeine——不是修补,而是重写,目标是把本地缓存的命中率与吞吐都推到理论上限。今天新项目里,Caffeine 几乎是 Java 本地缓存的默认答案(Spring Cache 也内置了它的适配)。

它相对 Guava Cache 的关键升级有三块:

维度Guava CacheCaffeine
淘汰算法分段 LRU(近似)W-TinyLFU(频率 + 近期,命中率更高,见 §3)
并发分段锁(segment),写竞争明显借鉴 ConcurrentHashMap,读写用环形缓冲异步记账,几乎无锁
异步加载AsyncLoadingCache(返回 CompletableFuture,见 §4)

Caffeine 的一个精妙设计是:把”记录访问”和”淘汰决策”从读写主路径上摘出来。 每次 get 不再当场去改动 LRU 链表(那需要加锁),而是把这次访问先塞进一个 per-thread 的环形缓冲(ring buffer),由后台批量、异步地回放去更新频率草图与淘汰结构。这样读路径几乎零竞争——高并发下吞吐远超 Guava 的分段锁。

一个最小可用的 Caffeine:

Cache<String, Campaign> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofSeconds(5))
    .recordStats()                       // 打开统计,见 §6
    .build();

Campaign c = cache.get("cmp:123", id -> loadFromDB(id));  // miss 时回源

之所以要专门开一篇讲本地缓存,很大程度上就是为了讲清 Caffeine 里这两样别处看不到的东西:W-TinyLFU 的准入逻辑它对内存/GC 的态度。下面逐一拆。

3. W-TinyLFU:Caffeine 命中率的杀招

这是本篇最值得深挖的一节。要理解 W-TinyLFU,先看它想解决 LRU / LFU 各自的什么痛点。

3.1 LRU 与 LFU 各有硬伤

现实负载往往既有”长期稳定热点”又有”近期突发热点”,还夹着扫描噪声——LRU 和 LFU 单独都应付不好。

3.2 W-TinyLFU 的结构:Window + Main + 频率草图

Caffeine 用的 Window-TinyLFU 把两者的优点缝在一起。它的核心是一个准入策略(admission policy):不是所有新条目都配进主缓存,得先过”频率”这一关。

W-TinyLFU 结构与准入流程图。左侧一个新条目 New entry 先进入 Window 区(一个约占总容量 1% 的 LRU,用来吸收突发/新访问)。当 Window 满时淘汰出一个候选,送到中间的 TinyLFU 准入门;准入门内是一张 Count-Min Sketch 频率草图(4-bit 计数器、多个哈希函数取最小值)。准入门把这个候选的历史访问频率,与右侧 Main 区(SLRU 结构,分为约 80% 的 Protected 保护段和约 20% 的 Probation 试用段)中即将被淘汰的受害者做频率比较:频率更高者准入 Main 区的 Probation 段,频率更低者被拒绝、直接淘汰出局。Main 区内部,Probation 里的条目再次命中会晋升到 Protected,Protected 满时会把冷条目降级回 Probation。底部说明 Count-Min Sketch 用极省内存的方式记录历史热度,只让历史更热的候选挤进 main,把偶发的一次性访问挡在 window,且频率计数会周期性老化(整体减半)让热点集合随时间漂移。

W-TinyLFU:新条目先进 Window(LRU) 吸收突发;被挤出的候选与 Main(SLRU) 的受害者比”历史频率”,赢家才准入——用一张小小的频率草图,让缓存记住”谁真的热”。

拆开看三个部件:

3.3 Count-Min Sketch:用几 KB 记住”谁热”

精确统计每个 key 的频率太贵。TinyLFU 用 Count-Min Sketch——一种概率型频率估计结构:

一张能覆盖百万级 key 的草图也就几 KB——这正是 TinyLFU 敢用”频率”做决策却不炸内存的关键。

3.4 频率老化(aging):让热点能漂移

只累加频率会退化成 LFU 的老毛病(老热点赖着不走)。TinyLFU 的解法是周期性老化:当全局计数达到某个采样阈值时,把草图里所有计数器整体减半。这样:

结果就是:W-TinyLFU 同时吃到了 LFU 的”频率视角”和 LRU 的”近期视角”,还用 window 挡住扫描污染——在数据库、Web、分析类等大多数真实负载上,命中率都稳定高于同容量的 LRU(作者论文里的曲线尤其在中小容量区间领先明显)。你什么都不用配,Caffeine.newBuilder() 默认就是它。

4. 过期与刷新:四种策略的区别与组合

命中率之外,本地缓存第二重要的是过期语义——数据能陈旧多久、怎么刷新。Caffeine 提供四种,别混用。

Caffeine 过期与刷新四种策略对比图。四行分别是:expireAfterWrite——写入后固定时间过期、到期条目失效,控制数据最多陈旧多久,最常用;expireAfterAccess——最后一次读或写后计时过期,适合一段时间没人用就清掉的会话类缓存;expireAfter 可变——按 key/value 动态计算过期时间,不同条目可有不同 TTL,例如按剩余预算定 TTL;refreshAfterWrite——到点异步刷新、刷新期间先返回旧值,配合 loading cache 不阻塞读、避免 TTL 到期时的并发回源。底部说明组合心法:expireAfterWrite 兜最多陈旧多久、refreshAfterWrite 兜热点不被 TTL 击穿,两者常一起配;LoadingCache 天生对同一 key 的并发加载做互斥(singleflight),因此本地缓存层天然防击穿,AsyncLoadingCache 再把加载做成非阻塞。

过期四件套:前三个管”何时失效”,refreshAfterWrite 管”到点怎么平滑续上”;生产里最常见的组合是 expireAfterWrite + refreshAfterWrite

4.1 expire 三兄弟:管”失效”

4.2 refreshAfterWrite:管”平滑续上”

refreshAfterWrite 和 expire 不是一回事,很多人踩这个坑:

所以经典组合是两者叠加refreshAfterWrite(1m) + expireAfterWrite(5m)——1 分钟后开始异步续新(多数读永远拿的是”稍旧但立即可用”的值),只有当数据 5 分钟都没人访问、彻底过期后,才回到”同步回源”。既躲开了 TTL 到点的并发回源冲击,又给了一个陈旧上限。

4.3 AsyncLoadingCache 与”天然防击穿”

需要回源的缓存用 LoadingCache(同步)或 AsyncLoadingCache(异步,返回 CompletableFuture,适合回源本身就是异步 IO 的场景):

LoadingCache<Long, Campaign> cache = Caffeine.newBuilder()
    .maximumSize(50_000)
    .refreshAfterWrite(Duration.ofSeconds(2))
    .expireAfterWrite(Duration.ofSeconds(10))
    .build(id -> loadCampaignFromDB(id));     // CacheLoader

Campaign c = cache.get(123L);   // miss/过期时由 loader 回源

这里有个常被忽略、却极重要的性质:LoadingCache 对同一 key 的并发加载天生互斥。当 100 个线程同时 miss 同一个 key,Caffeine 只让一个线程真正执行 loader 回源,其余线程阻塞等待并复用它的结果——这就是单机版的 singleflight,把”并发回源 100 次”收敛为”1 次”。

换句话说:用了 LoadingCache,本地缓存这一层就自带了开篇 §3.2 讲的击穿防护,不需要你再手写互斥锁。这也是”能用 loading cache 就别裸用 getIfPresent + 手动回源”的原因之一。

5. 引用类型:weak/soft 与 GC 的博弈

Caffeine(和 Guava)支持把 key/value 存成弱引用或软引用,用来让缓存”在内存吃紧时自动瘦身”。听起来很美,坑也不少。

软引用的三宗罪:① 回收时机不可控,完全由 GC 决定,你无法预测缓存何时被清空;② 加剧 GC 压力,软引用的处理本身就是 GC 的额外负担,大量软引用会拉长停顿;③ 命中率不稳定,一次大 GC 可能把缓存清掉一大片,导致回源尖刺。

实践建议:优先用 maximumSize / maximumWeight 显式控制容量(见 §6),把内存占用变成一个你算得清、控得住的数字。softValues 只在”宁可缓存被清空也绝不能 OOM”的兜底场景才考虑,且必须配合监控——它不是常规调优手段,而是最后的安全阀。weakKeys 更要慎用,== 语义的坑很隐蔽。

6. 容量与命中率调优

6.1 maximumSize vs maximumWeight

Cache<String, byte[]> cache = Caffeine.newBuilder()
    .maximumWeight(64L * 1024 * 1024)              // 总重量上限 64MB
    .weigher((String k, byte[] v) -> v.length)     // 每条按字节数计重
    .build();

注意:maximumSizemaximumWeight 互斥,只能选一个。而且它们限的是”逻辑容量”,不等于精确的堆占用——真要控堆内存,还得结合对象实际大小估算(这直接关系到 §7 的 GC)。

6.2 recordStats:用数据定容量

不要拍脑袋定容量。 打开 recordStats(),Caffeine 会记录一组关键指标,通过 cache.stats() 读出:

定容量的经验流程是:从一个估计值起步 → 观察命中率与驱逐数 → 逐步加大容量直到命中率收益变平(边际递减)。命中率从 85% 提到 95% 往往值得,从 98% 提到 99% 可能就是在浪费内存——而这些内存是要还给 GC 的(§7)。把 stats() 接进 Grafana/监控 是本地缓存上生产的标配。

7. 堆内 vs 堆外本地缓存

这是本地缓存最容易被忽视、却最容易翻车的一维。

7.1 Caffeine 是堆内:快,但 GC 要还债

Caffeine 把数据存成普通 Java 对象,放在 JVM 堆里。好处是极致的快——命中就是拿引用,没有任何序列化。但堆里的东西都归 GC 管

所以本地缓存的容量,本质上是一笔 GC 预算。 这和开篇”缓存越大越好”的朴素直觉相反:堆内缓存有一个”再大就得不偿失”的拐点,越过它,GC 停顿吃掉的延迟会超过缓存命中省下的延迟。

7.2 堆外方案:容量换序列化成本

当你确实需要很大的本地缓存(几十上百 GB),又不想被 GC 拖垮,就轮到**堆外缓存(off-heap)**登场:

堆内 vs 堆外本地缓存取舍对比表。左栏堆内 On-Heap(Caffeine / Guava):存储位置是 JVM 堆内的普通 Java 对象;访问方式是直接引用、无序列化、纳秒级;GC 影响是大缓存拉长 GC 扫描与停顿、对象越多老年代压力越大;容量上限受堆大小约束、GB 量级;适用中小容量、超低延迟、命中率优先的场景。右栏堆外 Off-Heap(OHC / MapDB / Ehcache3):存储位置是堆外直接内存 DirectByteBuffer;访问方式需序列化/反序列化、微秒级;GC 影响是对象在堆外、不被 GC 扫描、大缓存也不加剧 GC;容量上限可达数十到上百 GB;适用超大容量、GC 敏感、可接受序列化成本的场景。底部取舍主线:堆内最快但受 GC 与堆大小双重约束(缓存一大 GC 停顿就涨),堆外容量大、对 GC 友好、代价是每次读写的序列化开销与更复杂的内存管理,选型前先算清容量、延迟、GC 预算三者。

堆内 vs 堆外的取舍:堆内快到纳秒但受 GC 与堆大小约束,堆外把数据挪到 GC 管不着的直接内存、容量能上百 GB,代价是每次读写都要序列化。

常见堆外方案:

它们的共同代价是序列化:数据进堆外要序列化成字节、读出来要反序列化——每次读写都多一道 CPU 开销,延迟从纳秒级掉到微秒级。所以选型是一道明确的算术题:

先算清”容量 × 延迟 × GC 预算”三者的关系:容量小、要极致低延迟 → 堆内 Caffeine;容量大到会把 GC 拖垮、又能接受微秒级 + 序列化成本 → 堆外。很多系统的最优解是两级:Caffeine 存最热的一小撮(堆内、纳秒),堆外存次热的一大批(GC 友好、容量大)。

8. 从 Guava Cache 迁移到 Caffeine

迁移几乎是平替,API 有意做成高度相似,主要就是把构建器换掉:

// Guava
Cache<K, V> c = CacheBuilder.newBuilder()
    .maximumSize(10_000).expireAfterWrite(5, TimeUnit.MINUTES).build();

// Caffeine
Cache<K, V> c = Caffeine.newBuilder()
    .maximumSize(10_000).expireAfterWrite(Duration.ofMinutes(5)).build();

迁移时留意几个差异点:

结论很简单:没有理由在新代码里继续用 Guava Cache;老代码迁移成本也低,收益(命中率 + 并发吞吐)却实打实。

9. 本地缓存与分布式缓存的一致性联动

本地缓存最难的部分不是性能,而是一致性。分布式缓存(Redis)好歹是”一份数据、一个失效点”;本地缓存是”N 个节点、N 份副本,没有统一的失效通道”。当数据库里某条数据变了,你要让所有节点的 L1 都失效——这就是L1 失效难题

常见做法(开篇 §2.8 提过,这里点到为止):

这块的竞态边界、Binlog/Canal 订阅驱动失效、多级广播失效的可靠性对比,是缓存一致性深挖:双删、Binlog 订阅与多级失效的主题。这里只需记住:本地缓存一旦跨了多节点,“失效”就从一个操作变成了一个分布式问题——这也是为什么很多团队宁愿给 L1 配极短 TTL,也不愿背上广播失效的复杂度。

10. 落到 AdTech:竞价节点的 L1 缓存

开篇 §4 讲过竞价路径的”L1 Caffeine + L2 Redis”多级缓存。现在能把 L1 这一层讲得更具体了。

竞价引擎每台机器几万 QPS,每次 bid 请求(tmax 80–150ms)要读:campaign / 创意元数据预算快照频控计数……如果每份都走 Redis,光网络 RTT 累加就可能吃掉大半个 tmax。所以竞价节点在进程内用 Caffeine 做 L1

预算快照的一致性尤其敏感:读到旧快照会让花光预算的 campaign 继续参竞、造成超投。所以 L1 的 TTL 必须极短,且预算扣减到阈值时要主动推送失效,而不是被动等 TTL——这正是 §9 说的 L1 失效难题在竞价场景的落地。

11. 速查表

至此,本地缓存这层被彻底拆开:为什么用(零网络)→ 用什么(Caffeine)→ 命中率靠什么(W-TinyLFU)→ 怎么过期/刷新 → 怎么控内存与 GC(堆内/堆外)→ 怎么在竞价路径落地。下一步,缓存系列会转向分布式那半边——Redis 的内部机制与缓存一致性。

参考


views
Share this post on:

Previous Post
Redis 深挖(一)· 数据结构与内存模型
Next Post
缓存机制:命中率、更新策略与穿透击穿雪崩