上一篇缓存机制总览里,本地缓存只用一节匆匆带过:HashMap、静态变量、Ehcache、Guava Cache 各点一句。可真正把本地缓存用到极致的场景——比如广告竞价里每台机器几万 QPS、每次请求都要在亚毫秒内读一堆元数据——光知道”有个 Guava Cache”是远远不够的。
本文是缓存系列的第 2 篇(本地缓存下钻)。 开篇讲了本地 vs 分布式的取舍、更新策略、穿透击穿雪崩、多级缓存;这一篇只钻一件事:进程内那层缓存,怎么把命中率、延迟和 GC 都榨到极致。
这里的主角是 Caffeine——Guava Cache 作者另起炉灶重写的继任者,也是 Spring Boot 2.x 之后官方推荐的本地缓存实现。我们会把它最值钱的两块拆开看:W-TinyLFU 淘汰算法(为什么命中率能压过 LRU)和堆内 vs 堆外的内存模型(为什么缓存配大了反而拖垮竞价 P99)。
TL;DR
- 本地缓存的价值是”零网络”:进程内直接读引用,亚毫秒甚至纳秒级、零 RTT、零序列化;代价是多节点不共享(各存各的)、受堆内存与 GC 约束(缓存越大 GC 越痛)。
- Caffeine 是 Guava Cache 的继任者:同一作者重写,API 近乎兼容,但淘汰算法、并发读写、异步加载全面升级——新项目不该再选 Guava Cache。
- W-TinyLFU 是 Caffeine 的命中率杀招:
Window(LRU) + Main(SLRU: probation/protected)分区,配一张 Count-Min Sketch 频率草图做准入判定——只有”历史更热”的候选才准入 main,把一次性访问挡在 window。 - 为什么比 LRU 强:纯 LRU 对”扫描/突发流量”毫无抵抗力(一波冷数据就冲垮热点);W-TinyLFU 用频率信息 + 老化机制,兼顾”长期热点”与”近期热点”,多数负载命中率明显更高。
- 过期看清四件套:
expireAfterWrite(最多陈旧多久)、expireAfterAccess(多久没人用就清)、expireAfter(可变 TTL)、refreshAfterWrite(到点异步刷新、先返回旧值)——前者管失效,后者管平滑。 - LoadingCache 天然防击穿:同一 key 的并发加载天生互斥(相当于单机
singleflight),“并发回源 N 次”收敛为”1 次”——正是开篇 §3.2 击穿解法的本地缓存版;AsyncLoadingCache再把加载做成非阻塞。 - 引用类型是把双刃剑:
weakKeys改用==身份比较(易踩坑)、softValues交给 GC 在内存吃紧时回收(时机不可控、GC 压力大),能救 OOM 但别当常规调优手段。 - 调优抓两个旋钮:
maximumSize(按条数)vsmaximumWeight(按权重,value 大小不均时用);打开recordStats()盯命中率、驱逐数、加载耗时,用数据定容量而不是拍脑袋。 - 堆内 vs 堆外:Caffeine 是堆内,快但受 GC 与堆大小双重约束(缓存一大,GC 停顿跟着涨);堆外(OHC / MapDB / Ehcache3 offheap)容量能上几十上百 GB、对 GC 友好,代价是序列化开销与实现复杂度。
- 从 Guava 迁移几乎是平替:
CacheBuilder → Caffeine、expireAfterWrite等 API 基本同名;注意默认不再统计、弱引用语义、refreshAfterWrite行为等细节。 - L1 与分布式的一致性是老大难:本地缓存没有统一失效通道,L1 失效要靠广播/极短 TTL/版本号——细节见缓存一致性深挖。
- AdTech 实践:竞价节点用 Caffeine 做 L1 缓存 campaign 元数据 / 预算快照,命中 ~90%、TTL 极短;但缓存配太大会触发 Full GC、把竞价 P99 抖出去——本地缓存的容量本身就是一个 GC 预算问题。
Table of contents
Open Table of contents
1. 为什么要本地缓存:它的价值与代价
开篇 §2 已经对比过本地缓存与分布式缓存,这里只强调一点最本质的差别:本地缓存的全部价值,来自”它就在你进程里”。
分布式缓存(Redis)再快,一次读也要走:序列化 key → 网络出栈 → 跨机 RTT → Redis 处理 → 回包 → 反序列化 value。即使同机房,这一整套也在 0.5–3ms 量级。而本地缓存命中时,你拿到的就是堆里那个对象的引用——没有网络、没有序列化、没有拷贝,延迟是纳秒到亚毫秒级。
这个差别在两类场景里是决定性的:
- 超高频、超低延迟:广告竞价
tmax只有 80–150ms,一次请求要读预算、频控、创意、画像好几份数据,任何一份走网络都是奢侈——热点数据必须在进程内。 - 给分布式缓存”挡枪”:本地缓存作为 L1 吸掉绝大多数读,Redis 只承接 L1 未命中的那一小部分,天然缓解开篇 §2.7 的热 Key 问题。
但零网络不是免费的,本地缓存有两个绕不开的代价:
- 多节点不共享:每个进程各存一份。10 台机器就有 10 份副本,内存被放大 10 倍;更麻烦的是一致性——某条数据更新了,怎么让 10 台机器的本地缓存都失效?这是 §9 和一致性深挖的主题。
- 受堆内存与 GC 约束:本地缓存的数据是活生生的 Java 对象,占的是 JVM 堆。缓存越大,堆里存活对象越多,GC 扫描与停顿就越重——这也是 §7 与那次生产事故的核心。
一句话定位:本地缓存是”用内存换网络、用一致性复杂度换延迟”的交易。 它最适合”读极频繁、能容忍极短不一致、单条不大”的热点数据——这正是 Caffeine 被设计出来伺候的场景。
2. Caffeine:为什么它是 Guava Cache 的继任者
Guava Cache 用了很多年,API 优雅、功能够用。但它的作者 Ben Manes 后来另起炉灶写了 Caffeine——不是修补,而是重写,目标是把本地缓存的命中率与吞吐都推到理论上限。今天新项目里,Caffeine 几乎是 Java 本地缓存的默认答案(Spring Cache 也内置了它的适配)。
它相对 Guava Cache 的关键升级有三块:
| 维度 | Guava Cache | Caffeine |
|---|---|---|
| 淘汰算法 | 分段 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(最不经常使用) 只看”历史累计次数”,硬伤是对变化迟钝:早期攒了高频次的老数据会长期赖着不走,即使它早已不热;而且精确 LFU 要给每个 key 维护一个计数器,内存开销大。
现实负载往往既有”长期稳定热点”又有”近期突发热点”,还夹着扫描噪声——LRU 和 LFU 单独都应付不好。
3.2 W-TinyLFU 的结构:Window + Main + 频率草图
Caffeine 用的 Window-TinyLFU 把两者的优点缝在一起。它的核心是一个准入策略(admission policy):不是所有新条目都配进主缓存,得先过”频率”这一关。
W-TinyLFU:新条目先进 Window(LRU) 吸收突发;被挤出的候选与 Main(SLRU) 的受害者比”历史频率”,赢家才准入——用一张小小的频率草图,让缓存记住”谁真的热”。
拆开看三个部件:
- Window 区(约 1%,LRU):所有新写入先进 window。它是个小小的 LRU,专门吸收”刚出现、还看不出热不热”的突发流量。这样即便一波扫描进来,也只在 window 里翻搅,不会立刻污染主缓存。
- Main 区(约 99%,SLRU):主缓存用分段 LRU(SLRU),再切成 Probation 试用段(约 20%) 和 Protected 保护段(约 80%)。新晋入的先落 probation;在 probation 里再次命中就晋升到 protected;protected 满了则把冷条目降级回 probation。等于给”证明过自己是常客”的数据一个受保护的家。
- TinyLFU 准入门(Count-Min Sketch):window 淘汰出的候选,并不能直接进 main。它要和 main 里”即将被淘汰的受害者”比一比历史访问频率——频率高者进,低者出。这个”比频率”就靠一张 Count-Min Sketch。
3.3 Count-Min Sketch:用几 KB 记住”谁热”
精确统计每个 key 的频率太贵。TinyLFU 用 Count-Min Sketch——一种概率型频率估计结构:
- 用多个哈希函数把 key 映射到一张二维计数表的不同格子,每个计数器只用 4 bit(省内存到极致);
- 查询某 key 的频率时,取它命中的那几个格子里的最小值(min),以此压低哈希冲突带来的高估;
- 它会高估、绝不低估(冲突只会让计数偏大),对”准入判定”这种只需相对比较的场景足够用。
一张能覆盖百万级 key 的草图也就几 KB——这正是 TinyLFU 敢用”频率”做决策却不炸内存的关键。
3.4 频率老化(aging):让热点能漂移
只累加频率会退化成 LFU 的老毛病(老热点赖着不走)。TinyLFU 的解法是周期性老化:当全局计数达到某个采样阈值时,把草图里所有计数器整体减半。这样:
- 老热点的高频会随时间”衰减”,若不再被访问就慢慢让位;
- 近期变热的数据能较快积累出竞争力。
结果就是:W-TinyLFU 同时吃到了 LFU 的”频率视角”和 LRU 的”近期视角”,还用 window 挡住扫描污染——在数据库、Web、分析类等大多数真实负载上,命中率都稳定高于同容量的 LRU(作者论文里的曲线尤其在中小容量区间领先明显)。你什么都不用配,Caffeine.newBuilder() 默认就是它。
4. 过期与刷新:四种策略的区别与组合
命中率之外,本地缓存第二重要的是过期语义——数据能陈旧多久、怎么刷新。Caffeine 提供四种,别混用。
过期四件套:前三个管”何时失效”,refreshAfterWrite 管”到点怎么平滑续上”;生产里最常见的组合是 expireAfterWrite + refreshAfterWrite。
4.1 expire 三兄弟:管”失效”
expireAfterWrite(d):条目写入后d到期失效。语义是”这份数据最多允许陈旧 d”——最常用,也最好推理。expireAfterAccess(d):最后一次读或写后d到期。适合会话、连接这类”一段时间没人碰就该清掉”的缓存;但热点会一直续命、永不过期,用它兜”最大陈旧”要小心。expireAfter(Expiry):按 key/value 动态计算每个条目的过期时间。比如预算快照可以”剩余预算多就给长 TTL、快花光就给极短 TTL”,把一致性风险和缓存收益按数据本身来权衡。
4.2 refreshAfterWrite:管”平滑续上”
refreshAfterWrite 和 expire 不是一回事,很多人踩这个坑:
- expire 是”硬失效”:到期后该条目作废,下一个读者必须同步等待回源——这正是开篇 §3.2 说的击穿风险来源。
- refresh 是”软刷新”:到点后下一次访问触发异步刷新,刷新还没完成时,读者先拿到旧值,不阻塞。刷新在后台完成后再替换。
所以经典组合是两者叠加: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 存成弱引用或软引用,用来让缓存”在内存吃紧时自动瘦身”。听起来很美,坑也不少。
weakKeys():key 用弱引用。关键坑:一旦开启,key 的相等判定从equals()变成==(身份比较)——你用一个内容相同但不是同一个对象的 key 去查,会 miss。除非你确实用规范化的、可被 GC 回收的对象当 key,否则别开。weakValues():value 用弱引用,只要没有别处强引用它,下次 GC 就可能被回收。同样把 value 的等值语义变成==。softValues():value 用软引用——只有在 JVM 内存即将 OOM 时才被回收。像个”内存兜底阀门”,但代价很重:
软引用的三宗罪:① 回收时机不可控,完全由 GC 决定,你无法预测缓存何时被清空;② 加剧 GC 压力,软引用的处理本身就是 GC 的额外负担,大量软引用会拉长停顿;③ 命中率不稳定,一次大 GC 可能把缓存清掉一大片,导致回源尖刺。
实践建议:优先用
maximumSize/maximumWeight显式控制容量(见 §6),把内存占用变成一个你算得清、控得住的数字。softValues只在”宁可缓存被清空也绝不能 OOM”的兜底场景才考虑,且必须配合监控——它不是常规调优手段,而是最后的安全阀。weakKeys更要慎用,==语义的坑很隐蔽。
6. 容量与命中率调优
6.1 maximumSize vs maximumWeight
maximumSize(n):按条数限制。简单直接,适合每个 value 大小相近的场景(如定长的元数据对象)。maximumWeight(w)+weigher:按权重限制,你自己定义每个条目的”重量”。适合 value 大小差异大的场景——比如缓存的是列表/字符串,长度悬殊时,按条数根本控不住实际内存。
Cache<String, byte[]> cache = Caffeine.newBuilder()
.maximumWeight(64L * 1024 * 1024) // 总重量上限 64MB
.weigher((String k, byte[] v) -> v.length) // 每条按字节数计重
.build();
注意:
maximumSize和maximumWeight互斥,只能选一个。而且它们限的是”逻辑容量”,不等于精确的堆占用——真要控堆内存,还得结合对象实际大小估算(这直接关系到 §7 的 GC)。
6.2 recordStats:用数据定容量
不要拍脑袋定容量。 打开 recordStats(),Caffeine 会记录一组关键指标,通过 cache.stats() 读出:
hitRate():命中率——最核心的指标;evictionCount():因容量淘汰的条目数——如果居高不下,说明容量偏小、热点装不下;loadFailureRate()/averageLoadPenalty():回源失败率与平均回源耗时。
定容量的经验流程是:从一个估计值起步 → 观察命中率与驱逐数 → 逐步加大容量直到命中率收益变平(边际递减)。命中率从 85% 提到 95% 往往值得,从 98% 提到 99% 可能就是在浪费内存——而这些内存是要还给 GC 的(§7)。把 stats() 接进 Grafana/监控 是本地缓存上生产的标配。
7. 堆内 vs 堆外本地缓存
这是本地缓存最容易被忽视、却最容易翻车的一维。
7.1 Caffeine 是堆内:快,但 GC 要还债
Caffeine 把数据存成普通 Java 对象,放在 JVM 堆里。好处是极致的快——命中就是拿引用,没有任何序列化。但堆里的东西都归 GC 管:
- 缓存里的对象大多长期存活,会被晋升到老年代;
- 缓存越大,老年代里存活对象越多,每次 GC 要扫描/标记的存活集就越大,停顿越长;
- 极端情况下,一个几 GB 的堆内大缓存能把 Full GC 的停顿从几十毫秒拖到秒级——对延迟敏感服务是灾难。
所以本地缓存的容量,本质上是一笔 GC 预算。 这和开篇”缓存越大越好”的朴素直觉相反:堆内缓存有一个”再大就得不偿失”的拐点,越过它,GC 停顿吃掉的延迟会超过缓存命中省下的延迟。
7.2 堆外方案:容量换序列化成本
当你确实需要很大的本地缓存(几十上百 GB),又不想被 GC 拖垮,就轮到**堆外缓存(off-heap)**登场:
堆内 vs 堆外的取舍:堆内快到纳秒但受 GC 与堆大小约束,堆外把数据挪到 GC 管不着的直接内存、容量能上百 GB,代价是每次读写都要序列化。
常见堆外方案:
- OHC(Off-Heap Cache):Cassandra 用的堆外缓存库,key/value 存
DirectByteBuffer,纯堆外、GC 无感。 - Ehcache 3 的 offheap 层:Ehcache 3 支持分层存储(heap → offheap → disk → clustered),可以配”小堆内 + 大堆外”两级。
- MapDB:基于内存映射文件,可堆外可落盘,适合缓存量大到接近本地磁盘规模的场景。
它们的共同代价是序列化:数据进堆外要序列化成字节、读出来要反序列化——每次读写都多一道 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 的近似 LRU 变成了 W-TinyLFU,命中率通常直接涨一截。
- 统计默认关闭:Guava 默认记部分统计,Caffeine 需显式
recordStats()才统计(省掉不必要的开销)。 refreshAfterWrite语义:两者都支持,但要确认你依赖的是”异步刷新、先返回旧值”这个行为(§4.2),别当成硬过期。- 弱/软引用语义一致:
weakKeys/softValues仍会把等值判定变==,坑不变(§5)。 - Spring 集成:Spring Boot 直接支持
spring.cache.type=caffeine,@Cacheable底层可无缝换成 Caffeine。
结论很简单:没有理由在新代码里继续用 Guava Cache;老代码迁移成本也低,收益(命中率 + 并发吞吐)却实打实。
9. 本地缓存与分布式缓存的一致性联动
本地缓存最难的部分不是性能,而是一致性。分布式缓存(Redis)好歹是”一份数据、一个失效点”;本地缓存是”N 个节点、N 份副本,没有统一的失效通道”。当数据库里某条数据变了,你要让所有节点的 L1 都失效——这就是L1 失效难题。
常见做法(开篇 §2.8 提过,这里点到为止):
- 极短 TTL:本地 TTL 设 1–5s,接受极短的不一致窗口。简单粗暴,竞价这类场景最常用。
- 广播失效:更新后通过 Redis Pub/Sub 或 Kafka/MQ 广播失效事件,各节点订阅后清本地缓存。Pub/Sub 简单但漏消息不可重放,MQ 可靠但重。
- 版本号比对:读本地缓存时带版本号,落后就主动失效重取。
这块的竞态边界、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 的元数据(读多写少,走配置中心广播失效)、预算快照(读极频繁,TTL 极短)。
- 一组典型量级:L1 命中率 ~90%、
<1ms、TTL 仅 1–5s(预算快照宁可短也不敢读旧值导致超投)。 - 为什么用 Caffeine 而不是
HashMap:要淘汰(W-TinyLFU 保命中率)、要过期(expireAfterWrite控陈旧)、要防击穿(LoadingCache自带 singleflight)、要统计(recordStats盯命中率)——这些开篇 §2.1 的裸HashMap一样都给不了。
预算快照的一致性尤其敏感:读到旧快照会让花光预算的 campaign 继续参竞、造成超投。所以 L1 的 TTL 必须极短,且预算扣减到阈值时要主动推送失效,而不是被动等 TTL——这正是 §9 说的 L1 失效难题在竞价场景的落地。
11. 速查表
- 本地缓存:零网络、纳秒/亚毫秒、零序列化;代价是多节点不共享 + 受堆/GC 约束。
- Caffeine > Guava Cache:W-TinyLFU 命中率更高、并发几乎无锁、支持异步加载;新项目默认选它。
- W-TinyLFU:
Window(LRU 1%) + Main(SLRU: probation 20% / protected 80%)+ Count-Min Sketch 频率准入 + 老化;兼顾长期与近期热点、抗扫描污染。 - 过期:
expireAfterWrite(最多陈旧多久)、expireAfterAccess(多久没用就清)、expireAfter(可变 TTL)、refreshAfterWrite(异步刷新、先返旧值);常用 write + refresh 组合。 - 防击穿:
LoadingCache自带 per-key 加载互斥(singleflight),本地缓存层天然防击穿;AsyncLoadingCache非阻塞。 - 引用类型:
weakKeys变==语义(慎用)、softValuesGC 兜底但不可控 + 加剧 GC;优先显式限容量。 - 调优:
maximumSize(按条)vsmaximumWeight(按权重);recordStats()看命中率/驱逐/回源,按边际收益定容量。 - 堆内 vs 堆外:堆内 Caffeine 快但吃 GC;堆外(OHC/MapDB/Ehcache3)容量大、GC 友好、代价是序列化;大缓存用两级。
- 一致性:本地缓存跨多节点后失效变成分布式问题(极短 TTL / 广播 / 版本号),详见一致性篇。
- AdTech:竞价 L1 用 Caffeine 缓存元数据/预算快照,命中 ~90%、TTL 极短;缓存配太大会触发 Full GC、抖 P99——容量即 GC 预算。
至此,本地缓存这层被彻底拆开:为什么用(零网络)→ 用什么(Caffeine)→ 命中率靠什么(W-TinyLFU)→ 怎么过期/刷新 → 怎么控内存与 GC(堆内/堆外)→ 怎么在竞价路径落地。下一步,缓存系列会转向分布式那半边——Redis 的内部机制与缓存一致性。
参考
- Ben Manes. Caffeine Wiki — Efficiency(含 W-TinyLFU 与命中率对比):Caffeine 淘汰算法与各类负载命中率曲线的一手资料。
- Gil Einziger, Roy Friedman, Ben Manes. TinyLFU: A Highly Efficient Cache Admission Policy:W-TinyLFU 与 Count-Min Sketch 频率准入的原始论文。
- Google Guava. CachesExplained:Guava Cache 的官方说明,理解迁移到 Caffeine 的起点。
- Caffeine. API 文档与 Population/Eviction/Refresh 章节:
expireAfter*、refreshAfterWrite、AsyncLoadingCache、weigher的权威用法。 - 美团技术团队. 缓存那些事:本地缓存与多级缓存的工程实践综述。