缓存为什么会命中:从局部性、LRU/LFU 到 Caffeine

从重用距离和访问序列理解缓存命中,复现 LRU 的扫描污染与 LFU 的热点滞后,再拆解 Caffeine 的 W-TinyLFU、频率草图、并发维护和业务接入边界。

一个服务把十万个商品放进缓存,命中率未必高;另一个服务只缓存一万个商品,却可能覆盖大部分访问。差别首先来自请求分布:用户是否反复访问同一批数据,这批数据是否在缓存容量里,以及热点多久变化一次。

淘汰策略把过去的访问记录转成对下一次请求的猜测。LRU 相信最近访问的对象更可能再被访问,LFU 相信访问频繁的对象更值得保留。两种猜测都有适用条件,也都有很短的反例。

这里先用可运行的访问序列说明局部性与策略的关系,再进入 Caffeine。实现细节限定在 Caffeine 3.1.8,使用官方设计资料解释背景,用对应 tag 的源码核对具体行为。仓库里附带的模拟器只实现教学用 LRU、驻留期 LFU 和精确历史准入模型;它没有实现 Caffeine,也没有测量吞吐或延迟。

一、局部性描述的是访问行为

时间局部性指同一数据在较短时间内被重复使用。用户打开商品页,随后查询价格、库存和详情,同一个商品 ID 会再次出现。空间局部性指访问位置相近的数据容易一起被使用,例如顺序遍历数组、扫描相邻索引记录。前者有利于保留近期对象,后者有利于一次载入附近内容。

“附近”必须说明尺度。CPU 按缓存行搬数据,InnoDB 按页缓存数据,应用缓存通常按业务 Key 保存对象。商品 100 和商品 101 的编号相邻,不保证它们在堆内相邻,也不保证用户会同时查看。应用给 Map 增加一个条目,通常不会自动把邻近商品都带进来。硬件局部性与业务访问局部性可以同时存在,但解释的现象不同。

上一文讨论的紧凑 Listpack、排序数组和页式 B+ 树,主要利用布局上的连续性来减少访问成本。这一篇讨论有限容量里应该保留哪些业务对象。一个缓存实现可以有很好的节点布局,却保留了一批永远不再被请求的数据;也可以命中率很高,却在每次命中时争抢一把锁。两种问题要分开测。

工作集和重用距离

工作集可以理解为某个观察窗口内仍在被使用的一组数据。如果请求长期集中在一千个商品上,缓存容量足以容纳它们,预热后就有机会获得高命中;若每次请求都是新商品,扩大容量只能延迟填满,无法制造不存在的重复访问。

重用距离提供了更精确的视角。一个 Key 两次访问之间出现了多少个不同 Key?对于等大小对象、完全相联、每次访问都按规则更新的精确 LRU,容量为 C 时,重用距离小于 C 的再次访问会命中;达到或超过 C 时,原对象已经被挤出去。这个判断依赖模型条件,不能直接照搬给分组缓存、带 TTL 或加权缓存。

例如 A B C A,第二次 A 前面出现两个不同 Key,容量 3 的 LRU 可以保留 A。再看 A B C D A,距离为 3,容量 3 已经放不下全部最近对象,第二次 A 会 Miss。请求间隔的毫秒数并未参与这个模型;“过去一分钟访问过”与“还在容量里”也不是同一件事。

周期访问尤其容易误判。容量 3,反复访问 A B C D A B C D,对象只有四个,看上去很集中,但 LRU 稳态仍可能每次都 Miss。每次再次访问的对象恰好是刚被移出的那个。把缓存从 3 扩到 4 会产生很大的变化,说明命中率与容量之间可能有明显拐点,不总是平滑增长。

我会先看真实 trace 中的重复率、热门 Key 分布和热点存续时间,再判断缓存是否值得做。只有请求次数,没有 Key 粒度和时间顺序,无法区分“同一个对象访问百万次”与“百万个对象各访问一次”。

二、淘汰、准入、过期和刷新是不同决策

缓存 Miss 后,应用从数据库加载对象,把它交给缓存。容量够用时,这个动作很简单;容量不足时,需要确定是否让新对象留下,以及为了它让谁离开。这两个判断分别是准入和淘汰。

淘汰 eviction 从当前驻留对象里选牺牲者。准入 admission 判断一个候选对象是否值得占用长期空间。一个未通过准入的对象,仍可以成功从数据库加载并返回给这次请求,只是不继续留在某个缓存区域。不能把“拒绝缓存”理解为“拒绝服务请求”。

过期 expiration 根据时间或业务期限决定对象是否仍然有效。再热门的旧价格,到截止时间也不应被读取。刷新 refresh 则尝试为仍在使用的条目加载新版本;是否继续返回旧值、何时触发、失败怎样处理,依赖缓存与业务协议。Caffeine Eviction 文档分别列出容量、权重和时间约束,Refresh 文档说明刷新与过期并不等价。

缓存加载后的准入、淘汰、过期与刷新职责

图里容量决策和有效期判断是两条约束。一个对象可以尚未到期却因容量不足离开,也可以仍然很热却因有效期结束不可再读。设置一个小时 TTL,不等于保证对象驻留一个小时;没有 TTL,也不等于允许对象永久有效。

这一区分影响事故排查。命中率下降时,先确认 Miss 是第一次访问、容量淘汰、到期、主动失效,还是节点重启后空缓存。只把 TTL 调长,解决不了工作集超出容量的问题,却可能增加旧值风险。只扩大容量,也无法改变一个错误的失效协议。

Redis 有自己的近似 LRU/LFU 内存淘汰实现,不能因为名称一致就把它与进程内精确 LRU 当作相同算法。关于 Key 消失的现场取证,已有 Redis 过期与淘汰文章;这里关注访问策略怎样做预测,以及 Java 本地缓存怎样把策略放进高并发路径。

三、LRU 为什么简单,又为什么怕扫描

LRU 是 Least Recently Used,容量不足时删除最久没有访问的对象。常见实现是哈希表配双向链表:哈希表定位节点,命中后把节点移到最近端,新节点也放在最近端,淘汰则从最旧端移除。定位和链表调整都可以做到平均或常数级操作,但实际访问还包含锁、指针和分配成本。

链表维护的是访问顺序,不是插入顺序。第一次放 A,再放 B,再命中 A,最近端应该是 A;如果命中不调整位置,这就是另一种策略。Java LinkedHashMap 可以启用访问顺序模式,但围绕它实现完整缓存还需要处理并发、自动加载、过期和统计,不能只补一个 removeEldestEntry 就视为完整替代品。

看一个容量 3 的序列:

A B C A B C X Y Z A B C

先访问 A、B、C,再各访问一次,热集合已经被识别。此时从最旧到最新是 A、B、C。接着顺序读取只用一次的 X、Y、Z,这三次加载依次挤掉 A、B、C。最后再访问热集合,三次都 Miss。十二次请求中,LRU 只在第二轮 A、B、C 命中,共 3 次。

扫描污染的原因很具体:所有新加载对象都被放到最近端,LRU 把“一次最新访问”解释成保留信号,无法识别这是低重用的大范围扫描。批量导出、管理后台遍历、搜索长尾和健康检查,都可能把偶发读取混入正常热点流量。

也不能把所有扫描都绕过缓存。有些扫描结果随后会被频繁使用,例如用户打开列表后逐项查看;如果一律拒绝,反而错过短期局部性。可以按请求类型隔离缓存、只缓存特定投影或使用更适合混合负载的准入机制,但都应拿实际请求轨迹验证。

LRU 的另一个优点是反应快。容量 2,旧热点 A、B 各访问三次后,流量完全切到 C、D。前两次 C、D 会替换旧对象,随后立即命中。对频繁换热点的负载,忘掉很久以前的历史常常有利。扫描反例说明 LRU 的局限,不意味着近期信息没有价值。

并发实现里,每次命中都移动链表也会形成竞争。即使 get 只读业务 Value,它仍写策略元数据。吞吐瓶颈可能出现在访问顺序维护,而不是哈希查询。优化缓存既要看预测质量,也要看记录一次访问花了多少成本。

四、LFU 能保住旧热点,也可能保得太久

LFU 是 Least Frequently Used,优先淘汰访问频率低的对象。但“频率”至少要约定三个条件:统计多长的历史,统计驻留期间还是包括 Miss,以及相同频率怎样打破平局。不说明这些,两个叫 LFU 的实现可以对同一序列给出不同结果。

下面的模拟器采用驻留期计数:加载时计数为 1,命中加一,离开后计数丢弃;相同频率按最久未访问淘汰。它故意不做老化,方便看出问题。前面的扫描序列中,A、B、C 都有频率 2,X 刚进来频率 1。Y 到来时先替换 X,Z 又替换 Y,因此能留下部分旧热点。

注意它没有保住全部旧热点。第一次 X 到来时,缓存中三个对象的频率相同,必须先牺牲一个,按约定删掉 A。扫描后 B、C 仍在,最后访问 A 会 Miss,B、C 命中。总计 5 次命中,比 LRU 的 3 次多,但仍比“拒绝所有低价值扫描对象”的理想保留结果少一次。

这是淘汰策略和准入策略的区别:LFU 可以尽量选低频驻留对象,却仍然先接纳了 X,挤掉一个旧热点。只有知道 X 相比 A 不值得长留,才有机会避免这次损失。

热点切换暴露长期计数的问题

再换容量 2、十二次请求:

A B A B A B C D C D C D

A、B 各有三次访问,随后不再使用。驻留期 LFU 接纳 C 时先牺牲 A,剩下 B 的计数为 3;D 到来会替换频率 1 的 C,再次 C 又替换 D。新的两个热点反复竞争一个位置,B 占着另一个位置却完全没有后续访问。LFU 只在前半段得到 4 次命中,后半段全部 Miss;LRU 则获得 8 次命中。

这个问题需要“过去的热度会逐渐失效”这样的机制。可以统计滑动窗口,也可以周期衰减计数,或者用近期和频率混合策略。每种方式都引入参数与维护成本:窗口太短会错过低频但稳定对象,太长会保留已经消失的热点。

LFU 计数也不必是无限增长的精确整数。概率计数、饱和计数和近似草图都能压缩元数据,代价是分辨率和误差。对缓存来说,目标是比较候选的相对价值,未必需要知道它过去准确访问了 12,345 次。它也不适合承担计费或审计计数。

五、用可运行序列分开观察两种信息

仓库脚本 scripts/experiments/cache-policy-traces.mjs 加入第三种教学模型:为所有被请求的 Key 记录精确累计次数,包含不在缓存里的 Key;驻留对象按 LRU 选择候选牺牲者,只有新对象的历史次数严格更大,才允许替换。频率相同则拒绝。这个模型叫 Admission,便于区分准入和驻留期 LFU。

它没有频率衰减,没有 Window 和 Protected 区,也没有有限内存的草图;历史 Map 还会无限增长,所以不能作为生产实现。使用它只为了验证一个局部判断:先问“新对象是否值得留下”,对扫描结果会发生什么。

node scripts/experiments/cache-policy-traces.mjs

当前脚本使用 Node 运行并通过断言,输出如下。两个实验每次都从空缓存开始,不含预热排除,也不含过期或加载失败。

scan: capacity=3, trace=A B C A B C X Y Z A B C
LRU: hits=3, misses=9
LFU: hits=5, misses=7
Admission: hits=6, misses=6
shift: capacity=2, trace=A B A B A B C D C D C D
LRU: hits=8, misses=4
LFU: hits=4, misses=8
Admission: hits=4, misses=8

扫描时,X、Y、Z 各只有一次历史请求,无法胜过已有两次的 A、B、C,因此三者都不长留。最后 A、B、C 全部命中,共 6 次。相同的准入模型面对热点切换,C、D 到第三次请求才追平旧对象;因为相同频率拒绝,实验结束时仍然没进缓存。历史信息越精确,并不自动意味着预测越好。

实验 LRU 命中 驻留期 LFU 命中 无衰减历史准入命中 揭示的问题
热点夹一次性扫描,容量 3 3/12 5/12 6/12 新对象不一定值得替换旧热点
热点从 A/B 切到 C/D,容量 2 8/12 4/12 4/12 累计频率会让旧历史滞留

这些数据只证明脚本约定下的行为,不证明 Caffeine 在同样序列中会得到第三列结果,也不能据此宣布某种算法全局最好。真实缓存有权重、并发、草图碰撞和不同维护时机;短 trace 的偶然顺序也可能明显影响结论。

读者可以把扫描长度拉长,把旧热点的访问次数提高,再让新热点持续更久。修改时保留断言,手动重新推导预期值。观察策略何时开始适应,比只看一个最终命中率更有用。用业务 trace 测试时,则必须保持原始时间顺序,不能随机打乱后再声称保留了热点切换特征。

六、Caffeine 的 W-TinyLFU 怎样组合近期与频率

TinyLFU 的主要角色是准入:给出候选与牺牲者的近期频率估计,判断替换是否划算。它并不需要把全部驻留对象按频率排序,也不是一个完整的过期或加载框架。原始 TinyLFU 论文讨论了频率摘要、历史老化及其与淘汰策略的组合;论文中的实验结论有具体负载和基线,不能理解为对所有应用的最优保证。

Caffeine 的容量策略采用 Window TinyLFU,即 W-TinyLFU。新对象先获得一个小的近期窗口,窗口用 LRU 管理;主缓存分为 Probation 与 Protected 两个区域,形成分段 LRU;窗口出来的候选,在缓存需要腾出空间时,与主区域的牺牲者比较频率。主区域中的复用对象有机会得到保护。Caffeine Efficiency解释了这些区域承担的不同任务。

Caffeine 的窗口、主缓存分段与 TinyLFU 准入路径

图中的 Window 给新对象一个短期机会。热点刚切换到 C、D 时,它们不需要一开始就在历史频率上胜过 A、B,先在窗口里承接短时间重复访问;持续复用的对象再竞争长期空间。一次性扫描也会经过窗口,但大多难以战胜主缓存里反复使用的对象。

Probation 是主缓存中的试用区域。命中后可提升到 Protected;Protected 超过预算时,把较旧对象降回 Probation,而不是立即删除。这样,主缓存可以把“至少被再次使用过”和“刚进入主区”分开,选择牺牲者时优先考虑后者。区域名字描述的是策略状态,不是业务重要性或永久保留承诺。

候选比较发生在维护路径

以 3.1.8 源码为准,evictFromWindow 把超出窗口预算的节点移到主缓存 Probation 尾部,evictFromMain 在总容量超限时选择候选与牺牲者,admit 比较频率。实现会先移动节点再解决超限,因此示意图的“准入门”是逻辑判断,不能误读成所有节点都先在一个独立物理队列外等待。BoundedLocalCache.java是这条路径的证据。

通常候选频率更高则保留候选。不过 3.1.8 的 admit 还包含针对哈希碰撞攻击的少量随机放行逻辑,所以教学里的“严格大于才进”也不能冒充完整源码。这个细节并不改变频率准入的主要目的,却会让短序列的逐次结果不完全由一个比较式决定。

窗口大小同样不是永久固定常数。3.1.8 初始把约 1% 的容量给 Window,主区域的 Protected 预算是主区约 80%,随后存在 hill-climbing 调整。整数舍入、很小容量和权重缓存也会改变实际边界。运行时调整的含义是:观察一段命中表现,改变窗口与主区的划分,再根据效果继续探测。不能拿初始比例画成业务可以依赖的永久配置。

这种组合仍然会失败。工作集远大于容量、对象只请求一次、热点变化比统计适应更快,都可能得到较低命中率。大窗口有利于短时爆发,但留给长期热点的空间变少;小窗口更抗扫描,却可能错过很新的热点。自适应是在负载中寻找折中,没有消除有限容量的约束。

七、频率草图怎样用很少空间记住很多请求

如果为每个曾经请求的 Key 保存精确计数,元数据也会随历史 Key 数量增长。缓存已经拒绝一批一次性对象,旁边的频率 Map 却永久留下它们,内存限制就被绕过了。Caffeine 用 FrequencySketch 压缩历史,只保留足以做相对比较的估计。

3.1.8 的草图把 4-bit 饱和计数器打包进 long 数组;一个 Key 映射到四个计数器位置,估计频率取四者最小值,上限为 15。不同 Key 可能撞在同一计数器上,最小值可以降低单个碰撞造成的高估,却无法完全排除碰撞。达到采样阈值后,对计数器做衰减,让旧访问逐渐减弱。FrequencySketch.java可以核对 frequency、increment 和 reset。

假设 A 映射的四个计数值为 5、8、5、6,估计取 5。另一个 Key 碰撞其中一处,把第二个值加到 9,估计仍然是 5;如果四个位置都受碰撞影响,估计就可能偏高。这个例子只是草图原理,不是实际哈希函数生成的位置,也没有把误差当成精确概率。

饱和意味着访问 15 次和更多次可能暂时无法区分。缓存不需要区分两个都很热的对象每一次额外访问,省下来的位数更有价值;但如果业务确实需要准确排行榜,草图就不合适。这里的频率属于缓存策略,不能拿给业务展示成“用户访问次数”。

衰减也不是精确的“过去十分钟”。3.1.8 由采样计数触发 reset,且饱和计数的增量行为会影响采样推进。请求很密集时,策略时间推进更快;流量少时,旧历史可能保留更久。时间 TTL 仍然由过期机制处理,不能拿草图衰减替代业务有效期。

源码还把相关计数器安排在一个较局部的块里,减少更新草图时分散的内存访问。这是一个很好的连接点:策略层利用请求的时间局部性,实现层又利用内存布局的空间局部性。一个决定保留谁,另一个决定记录这次访问需要触碰多少内存。

草图容量取整、对象节点、桶数组、引用和监听任务都会消耗内存,不能用“每个频率计数只有四位”推算整个缓存的对象开销。要量内存,必须把 Value、Key、缓存元数据和加载期间的在途对象一起计算。

八、高并发下,命中与策略维护不用同步完成

如果每次读取都立刻在同一把锁下调整多个访问队列,热点越集中,锁竞争越明显。Caffeine 将数据访问和策略维护分开:主数据结构完成 Key 定位,访问事件通过读缓冲记录,再由维护路径批量更新队列与频率;写事件也有对应维护任务。

官方 Design 文档描述了分条带读缓冲和批量维护。读事件用于优化预测,在竞争下允许丢失部分;写事件关系到结构状态,不能用同样方式随意丢弃。“允许丢读事件”指丢失策略记录,不等于 get 可以把存在的 Value 随机丢掉。

这种解耦会带来最终维护时机上的差异。写入后容量统计和队列状态不一定立刻呈现理想化的串行快照,容量限制也不应被当作 JVM 堆内存的硬实时上界。测试容量状态时可显式调用 cleanUp() 触发待处理维护,但业务不能把每次请求都做清理当成常规流程,那会重新付出同步维护成本。

同样,过期条目不可见与对应内存已经回收不是一回事。逻辑读取会判断有效性,后台或访问驱动的维护负责清理;需要更及时的定时维护时,应该了解 Scheduler 的配置和版本行为。用 Thread.sleep 后看一次 estimatedSize(),不能准确证明到期那一刻还可以读取旧值。

本地缓存还有进程边界。十个实例各有自己的缓存,某个 Key 在一个实例上很热,另一个实例上可能刚预热;实例重启、流量迁移和扩容都会改变工作集。多实例总容量约等于各自预算的叠加,但其中可能存着大量重复对象,不能直接把它视为一个更大的共享缓存。

并发加载也要分层看。LoadingCache.get 能协同同一个缓存实例里同一个 Key 的加载,但十个应用实例仍可能同时回源。多个不同 Key 同时过期,加载合并也无济于事。回源限流、批量接口和负载保护依然需要在服务层设计。

九、把商品缓存接进真实请求

假设商品简介允许短时间陈旧,商品价格展示需要定期更新,结算价格必须由交易服务确认。先按业务有效性拆开这些读模型:缓存商品简介与列表展示数据,不把本地缓存里的价格直接当作扣款依据。TTL、刷新和失效通知都无法替代交易侧的最终校验。

一个最小 LoadingCache 接入示例如下。需要依赖 com.github.ben-manes.caffeine:caffeine:3.1.8,Java 17 可运行;ProductRepository 和 ProductView 是业务自己的接口与对象。这是接入示意,当前仓库没有引入 Java 构建项目,也没有声称运行了真实 Caffeine 压测。

LoadingCache<Long, ProductView> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(10))
    .refreshAfterWrite(Duration.ofMinutes(1))
    .recordStats()
    .build(id -> productRepository.loadView(id));

ProductView view = cache.get(productId);
CacheStats stats = cache.stats();

首次 get Miss,加载器取得数据并返回;成功的条目进入缓存。后续命中会读取值并留下策略访问事件。达到刷新资格后的一次访问可以触发异步刷新,在刷新完成前仍可能返回旧值;refreshAfterWrite 不等于每分钟主动扫描数据库。刷新抛出异常时,旧值会保留,异常被记录;旧条目仍受过期约束,超过 expireAfterWrite 后不可继续作为普通命中返回。官方 Refresh 说明是这几种语义的依据。

一分和十分都只是演示参数。选值应来自业务允许的陈旧窗口、回源成本和失败预算。若促销状态必须秒级生效,不能照抄十分钟有效期;若简介变化极少,过于频繁刷新又会增加数据库负担。刷新 executor 也要规划,阻塞数据库调用不应在共享线程池里无限排队。

容量与权重如何选

maximumSize 限制条目数量,适合对象大小比较稳定的情况;若商品 Value 大小相差数十倍,数量预算无法代表内存预算。可以用 maximumWeight 和 Weigher 定义应用层权重,例如序列化字节数或经过测量的估算值。权重用于容量核算,不代表按 Value 大小直接给淘汰优先级。

Weigher 也不是 JVM 对象大小测量器。Key、节点、数组和其他元数据仍需预算;Value 更新后权重需要按缓存支持的更新路径重新计算,因此缓存不可变快照通常更清晰。把一个可变 List 放进缓存,再不断追加内容,条目数量和初始权重都没有变化,堆使用却能持续上升。

失效与加载存在竞态

假设线程 T1 正在从数据库加载旧版本,T2 完成商品修改并执行失效,随后 T1 的旧结果回来。具体缓存操作的原子性可以约束部分竞争,但数据库事务、跨实例通知和所有手工 getIfPresent → load → put 路径不属于同一个原子操作。应根据业务要求采用版本化 Key、检查数据版本、提交后失效与补偿通知等协议,并测试时序。

通知丢失时,TTL 可以作为最终回退,无法保证即时一致;通知先于数据库事务提交时,重新加载又可能拿到旧值。失效应与提交顺序对齐,重要数据要允许版本检测或重试。退款、权限和库存扣减等敏感操作,应从缓存展示与权威写路径之间明确分工。

查不到的商品也要有约定。直接缓存一个“空对象”可以减少重复无效查询,但 TTL 过长会挡住刚创建的商品。把可恢复的后端异常伪装成“商品不存在”更危险,会把暂时故障变成错误的负缓存。加载失败、确定不存在、权限不允许,是三种不同状态。

十、命中率怎样衡量才不会误导

基础命中率是 hits / (hits + misses),但一次低成本字典查询和一次昂贵推荐计算对系统的影响不同。对象大小不一时,请求命中率与字节命中率也会分叉。可以结合回源次数、回源耗时和下游限流情况,衡量缓存是否真的减少了压力。

Caffeine 需要启用 recordStats() 才能获得相应统计;CacheStats 提供命中、加载与淘汰等信息。Statistics 文档介绍了这些指标。累计值应取时间窗增量,发布前后还要区分冷启动期与稳定期。直接拿两个运行时长不同实例的累计命中率比较,容易把预热差异误当成策略改进。

评估时至少保留 Key 的时间顺序、对象大小或权重,以及加载代价信息。Key 可以用稳定脱敏映射保护业务数据,但同一 Key 的重复关系必须保留。跨实例流量要按实际路由分开重放,否则把本地缓存合成一个共享缓存,会高估可用历史与容量。

同一份 trace 可以扫一组容量,画命中率随预算变化的曲线,找到工作集拐点;再单独观察扫描期间和热点切换后的表现。不要只挑一段固定热点日志,它会天然偏向保留长期历史的策略。真实环境还要加入版本发布、扩容和缓存失效造成的冷区间。

离线 trace 也有局限。缓存加速后,请求时序、并发度和业务行为可能变化;回放忽略了过期与版本,就只能评价容量策略;没有负载与加载时间,则无法测并发回源和尾延迟。离线结果适合筛选候选,在线灰度才检验完整服务。

如果命中率提高而 p99 变差,检查策略维护、Value 体积、GC、加载线程池和回源合并是否发生竞争。如果命中率很高却仍持续压垮数据库,可能是少量低命中高成本 Key,或全量缓存重建时的集中 Miss。聚合平均值会隐藏这类差异。

我会把缓存的上线验收写成业务可检查的条件:允许的陈旧时间有没有被遵守,回源量是否下降,加载失败有没有被正确表达,堆占用和尾延迟是否在预算内,节点重启时能否承受冷启动。某个算法的漂亮命中率,只回答了其中一个问题。

十一、什么情况下不需要更复杂的策略

缓存容量足以容纳整个稳定工作集,策略之间的差别可能很小。对于非常短生命周期、请求内使用的小 Map,引入 Caffeine 的自动淘汰和后台维护也未必值得。批量查询和 SQL 索引如果已经满足延迟需求,可以先避免复制一份新的数据生命周期。

请求几乎没有重用时,再复杂的准入也只能少留无用数据,不能创造命中。对于强一致数据,缓存命中可能带来更大的陈旧风险;需要全局共享的有限状态,本地缓存也不能承担跨节点权威存储。高命中率无法弥补职责选错。

相反,当服务有明显热点、夹杂长尾扫描、又会发生热点切换时,W-TinyLFU 的组合更值得测试。采用成熟库通常比自行拼一个同步 LRU 更能覆盖并发和维护细节,但仍需要业务失效协议与回源保护。选择理由应来自负载特征与验证结果,而不是“它用了更先进的算法”。

观察到的负载 应关注的机制 仍需验证的边界
最近访问很快再次复用 近期窗口、LRU 工作集是否超过容量
一次性扫描夹在稳定热点里 准入、频率信息、流量隔离 扫描结果是否也有短期复用
热点频繁切换 窗口、频率衰减、自适应 新热点进入所需的时间
Value 大小差异明显 权重预算与不可变对象 权重是否反映真实堆使用
数据频繁变化 有效期、刷新和版本化失效 跨实例竞态与陈旧值风险

局部性告诉我们过去访问为什么有预测价值。LRU 和 LFU 各自保留一部分历史,TinyLFU 把频率用于准入,Caffeine 再把窗口、分段 LRU、衰减和并发维护组合起来。理解这些职责后,就能从请求轨迹推导策略,而不必把缓存当作一个只要“开了就快”的黑盒。

参考与复现

模拟器完整源码位于仓库 scripts/experiments/cache-policy-traces.mjs,运行 node scripts/experiments/cache-policy-traces.mjs 即可复现正文两组结果。它的断言覆盖每组命中数、容量上界与双精度示例;没有验证真实 Caffeine 的并发时序、内存占用或性能,这些需要单独的 Java 项目和业务负载。