☰
Caffeine热点Key识别:用TinyLFU自动判断并缓存高频访问
2026/10/5 7:19:47 网站建设 项目流程

说实话,我最初看到“热点 key 存入 Caffeine”这个需求时,第一反应不是去写计数器、定时任务,而是想:Caffeine 自己在做的事情,不就是“判断热点 key 并尽量把它留在内存里”吗?很多 Java 后端同学遇到热点 key 第一反应就是“我得先识别哪些 key 热,然后手动塞进缓存”。这个思路本身没错,但如果缓存组件本身就选了 Caffeine,那绝大多数场景下根本不需要我们亲自动手判断,更不用发明一套自己的频率统计方案。因为 Caffeine 内部维护了一套叫 TinyLFU 的近似频率统计机制,它天然地、持续地在给每个 key 的“热力值”打分,够热的 key 会被留在缓存里,不够热的会被淘汰出去。

这篇文章我会从一个实际项目的视角,把“最简便判断热点 key 存入 Caffeine”这件事讲透。先说结论:所谓最简便,就是用 Caffeine 自己提供的能力来充当热点判断器,顶多再通过stats()和policy().eviction().hottest()把结果读出来做监控或预热。真的不需要从零实现一套热点识别系统。适合正在做本地缓存优化、接口性能治理、或者想搞清楚 Caffeine 原理的同学参考。

1. 热点 key 的识别逻辑,为什么很多人一上来就搞复杂了

1.1 先定义清楚:到底什么样的 key 叫热点

我在团队里经常问同事这个问题,大家的答案五花八门。有人说 QPS 超过 100 就算热点,有人说单接口访问占比超过 20% 算热点,还有人说是某个 key 的访问次数占全缓存访问次数比例很高就算热点。这些定义都有道理,但都忽略了一件关键的事:热点是有时间窗口和上下文的。

同一个商品 ID,在大促开始前可能一个小时只有几十次访问,大促开始后一分钟就能有几千次访问。同一个用户 ID,可能只在某个活动页面存在的那两天变成热点,活动一结束马上冷下来。所以“热点 key”不是一个静态标签,而是一个动态状态。如果我们想要自己实现判断逻辑,至少要解决三个问题:统计窗口选多大?阈值定多少?冷热交替怎么处理?

别小看这三个问题。窗口选太短,突发流量识别出来了,但容易误报;窗口选太长,短期热点又抓不住。阈值定高了,热点都穿过去了;定低了,一堆普通 key 挤在缓存里。冷热交替处理更是麻烦,今天热的 key 明天可能就没人访问,你还得定期清理掉这些“历史热点”,否则统计表会越来越大。这些问题不是不能解决,但每一样都是成本。

1.2 常见手动方案:计数器加定时任务,成本都花在哪

很多团队会走这样一条路:用 Redis 的INCR命令给每个 key 维护一个访问计数,然后定时任务每分钟扫描一次计数,把超出阈值的 key 名单拉出来,再往 Caffeine 里塞。这套方案在流量不大的时候是能跑的,但有几个非常现实的坑。

第一,计数本身会占用资源。每个 key 都要INCR一次,热点高的时候这个操作本身会变成新热点。有人会优化成“本地计数 + 异步刷到 Redis”,但这就引入了批次合并、内存占用、刷盘时机等问题。第二,扫描 Redis 所有 key 的计数也是一个成本。你要么用SCAN全表扫,要么维护一个带时间桶的 key 集合,复杂度会上来。第三,手动把热点 key 塞进 Caffeine 之后,还得处理这个 key 什么时候从缓存里降级、什么时候移除,否则这个名单会越长越大。

我见过最夸张的例子,业务同学为了判断热点 key,专门起了个线程池,每 10 秒对 Redis 里的 key 访问计数做一次大排序,然后把 top 100 塞进本地缓存。结果排序和塞缓存的代码比业务代码还复杂,最后命中率却没有明显提升。原因很简单:热点 key 从“被识别出来”到“被塞进 Caffeine”之间是有延迟的,这个延迟内热点流量已经穿过去了。而且他还不知道 Caffeine 自己就有类似的能力,白白重复造了轮子。

2. 最简单方案:直接让 Caffeine 成为热点判断器

2.1 Caffeine 自动缓存加载,天然只保留热点

先看一个最普通的用法。我们构建一个 Caffeine 缓存,配置最大容量和过期时间,然后在业务代码里用get(key, loader)去读。这个方式有什么特别的?它会把每一个真实访问过的 key 自动写入缓存,后面的重复访问直接命中,不会再走到下游。

Cache<String, Object> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); Object value = cache.get("product:123", k -> loadFromDb(k));

这段代码看起来很简单,但它背后做的事情比“缓存一个 key”多得多。每次get命中时,Caffeine 都会更新这个 key 的访问频率;每次写入新 key 时,Caffeine 也会把它插入到频率统计结构里。当缓存容量达到maximumSize的时候,新来的 key 并不是简单地“挤掉最老的一个”,而是会和目前的“最低频 key”比一比,谁的访问热度低,谁就出局。

所以你会发现,一个有 10 万条容量的 Caffeine 缓存,最终留下的往往不是“最近进入的 10 万个 key”,而是“最近一段时间里访问频率最高的那一批 key”。这是 LRU 做不到的,LRU 只关心“最近用过没有”,不关心“用过几次”。而热点 key 往往是高频访问的那一类,所以 Caffeine 天然就把热点 key 留住了。

2.2 recordStats:用现成统计观察热度变化

有些朋友会问:如果不自己写计数,我怎么知道当前缓存里有多少命中和失效?这个 Caffeine 也想到了,只要构建缓存时加一个.recordStats(),就可以通过cache.stats()拿到命中和未命中的统计数据。

Cache<String, Object> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats() .build(); CacheStats stats = cache.stats(); long hitCount = stats.hitCount(); long missCount = stats.missCount(); double hitRate = stats.hitRate();

这里的hitRate是一个很值得关注的指标。如果一段时间内命中率持续很低,说明大部分请求都在访问不存在的 key,缓存基本没起作用,这时候就要考虑是不是容量太小、过期时间太短,或者热点 key 压根没有进入缓存的路径。如果命中率突然剧烈波动,说明流量结构在变化,可能是新的热点出现了。

有了recordStats,我们不需要自己写计数器,就能知道缓存的整体健康状况。但这里要注意,stats()返回的是全局命中统计,不能直接告诉我们是哪一个 key 特别热。想看到具体的热点 key 名字,就需要用到policy().eviction().hottest()。

2.3 policy().eviction().hottest():一行拿到当前热点 key

hottest(int limit)是 Caffeine 提供的一个非常方便的方法,它可以直接返回当前缓存中访问频率最高的前 N 个 key。注意,它返回的是 Map.Entry 的列表,而且顺序是从热到冷排序的。

List<Map.Entry<String, Object>> hotEntries = cache.policy() .flatMap(policy -> policy.eviction()) .map(eviction -> eviction.hottest(20)) .orElse(Collections.emptyList()); for (Map.Entry<String, Object> entry : hotEntries) { System.out.println("hot key = " + entry.getKey()); }

这是我见过最直接的“判断热点 key 并存进 Caffeine”的姿势了。为什么这么说?因为热点判断完全由 Caffeine 内部完成,我们只是把结果取出来看,不需要自己定义窗口、阈值、排序逻辑。拿到这份名单后,你可以用来做三件事:第一,打印到日志里,方便排查问题;第二,推送到监控平台做热点可视化;第三,作为预热名单,在重启应用或新节点上线时把这些 key 提前加载到 Caffeine 中。

不过这里有一个使用前提:hottest()通常要求缓存启用了容量限制,也就是设置了maximumSize或maximumWeight,因为热点排序依赖与容量淘汰相关的频率结构。如果没有容量限制,policy().eviction()返回的 Optional 可能为空,自然拿不到hottest()。

3. TinyLFU 干了什么:为什么它能“自动判断”热点

3.1 从 LFU 的问题说起:传统计数器的两个痛点

想真正理解 Caffeine 为什么能“自动判断热点”,得先理解它底层的 TinyLFU 算法。在 Caffeine 出现之前,实现 LFU(Least Frequently Used,最不经常使用)最简单粗暴的办法是给每个 key 维护一个精确的访问计数。但这样做有两个非常大的痛点。

第一个痛点是内存。如果系统有十万个 key 在候选进入缓存,你就要为所有候选 key 维护计数。每个 key 用 long 类型计数就是 8 字节,十万个就是 80 万字节,看起来不多,但缓存本身可能也就几十 MB,结果统计结构吃掉的内存比实际缓存内容还多,这就很不划算。第二个痛点是衰减。一个 key 可能昨天很热,今天完全没人访问,但它的计数还是很高,会一直占着缓存名额。如果没有衰减机制,LFU 就会变成“历史热点排行榜”,而不是“当前热点排行榜”。

3.2 Count-Min Sketch 频率草图

TinyLFU 解决内存问题的核心是一个叫 Count-Min Sketch 的数据结构。你可以把它理解成一个固定宽度的二维计数器数组,每个 key 会被映射到多个计数器上。怎么映射?通过多个不同的哈希函数,把 key 散列到数组的不同位置,然后对这几个位置的计数分别加 1。查询的时候,同样算出这几个位置,取这几个计数的最小值作为这个 key 的近似访问频率。

为什么取最小值?因为哈希碰撞会“误伤”,也就是不同 key 可能映射到同一个计数器,导致某个位置计数偏大。取最小值可以在一定程度上抵消这种虚假增加。这种结构的好处是内存占用非常固定,管你有十万个 key 还是一百万个 key,计数数组的大小一开始就定死了,不会随着 key 数量增长而膨胀。代价也很明显:它是个近似统计,不是精确的。但 Caffeine 并不需要精确知道“product:123 被访问了 521 次”,它只需要知道“product:123 比 product:456 更热”,相对排序够用就行了。

3.3 4-bit 计数器与衰减机制

Caffeine 还做了个更细的设计:频率草图里每个计数器不是用 long 或 int,而是用 4-bit 来存。4-bit 能表示的最大值是 15,也就是说每个计数器的计数最多记到 15,再往上加会怎样?Caffeine 会在全局计数达到一定阈值时,把所有计数器整体减半,相当于做了一次全局的指数衰减。这么做的好处是:旧的计数会逐渐被削弱,新出现的热点很快就能够“冒头”,不会被老热点的历史计数压制。

这里可以做一个类比。假设你开了一家店,每个常客都拿着一张积分卡,每来一次盖一个章。但是为了不让老顾客永远躺赢,老板会定期把所有人的积分卡盖章数减掉一半。如果一个顾客以前很常来,但最近不来了,他的积分很快就会掉到和新来的顾客差不多;而最近天天来的顾客,积分很快就会超过所有人。这个“积分”就是 key 的频率估计,定期减半就是 Caffeine 的计数器衰减机制。

3.4 为什么说这是“最简便”的判断方式

看到这里你应该明白了,Caffeine 并不是一个“普通缓存框架”,它内部有一套专门为热点识别设计的算法。你使用时只需要配置好maximumSize和过期策略,剩下的事情框架全包了。它不要求你告诉它“访问超过 100 次算热点”,因为它会用频率草图和衰减机制算出每个 key 的相对热度;它也不要求你手动处理冷热交替,因为旧热点的计数会随着全局衰减慢慢变小,最终给新热点让位。

这其实就是标题里“最简便”三个字的底气所在:你不需要自己判断,引擎本身就在判断。你唯一要做的,是学会读取判断结果,并且相信这个结果足够支撑缓存淘汰决策。

4. 实操:配置、预热与验证

4.1 关键参数怎么选

虽然 Caffeine 自动判断热点很省事,但参数配置不对,再好的算法也白搭。我这里列几个直接影响热点判断效果的配置项,每个都含我自己踩过的坑。

maximumSize决定缓存最多能放多少个 key。这个值如果太小,热点 key 再多也装不下,频繁淘汰会导致抖动;如果太大,占用的堆内存又会挤占其他业务。通常可以先给一个偏保守的值,比如10000,然后根据stats().hitRate()来调整。如果你发现命中率长期低于 60%,在确定业务访问有局部性特征的前提下,可以把maximumSize调大,直到命中率稳定在可接受范围。

expireAfterWrite和expireAfterAccess是两个容易搞混的过期策略。expireAfterWrite是“写入后固定时间过期”,适合数据本身有时效性的场景,比如配置项、验证码。expireAfterAccess是“最后一次访问后固定时间过期”,适合希望热门数据更容易被保留的场景。但它也有风险:一个永远被访问的 key 永远不会过期,除非被内存淘汰策略挤出去。所以不要盲目用expireAfterAccess,很多线上问题都是“设置了一个很长的 expireAfterAccess,结果某些 key 永久占用内存”。建议大多数业务优先用expireAfterWrite,让过期时间更可控。热点判断本身主要靠频率,不依赖过期策略,所以不必靠它来“识别热点”。

initialCapacity是初始化容量。它影响的是底层哈希表初始桶数量,设置合理可以减少扩容带来的性能损耗。但注意,它不是容量上限,也不会决定热点判断。有些人把它调得特别大想“提升性能”,这反而会浪费内存,因为初始化桶越多,占用的内存起步就越高。一般范围在maximumSize的十分之一到一半之间比较合理,不必追求精确匹配。

4.2 怎么知道当前热点 key 是哪些

想看到当前热点 key,我推荐两种方式。第一种非常简单,直接在代码里调用hottest(),定时拉取并打印。适合开发环境和测试环境快速验证,也适合临时排查线上问题。

ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { List<Map.Entry<String, Object>> hot = cache.policy() .flatMap(policy -> policy.eviction()) .map(eviction -> eviction.hottest(20)) .orElse(Collections.emptyList()); hot.forEach(entry -> System.out.println(System.currentTimeMillis() + " hot=" + entry.getKey())); }, 0, 30, TimeUnit.SECONDS);

第二种方式是通过recordStats()的统计数据做宏观判断。如果整体命中率高,说明热点基本都被缓存接住了;如果整体 miss 高,再去看hottest()列表,往往能发现热点 key 是新出现的、还没被缓存覆盖。这两个手段配合起来,基本可以覆盖日常排查需求。

另外要提醒一点:hottest()返回的是当前缓存中的 key,而不是“曾经请求过但不在缓存中的失败热点”。如果某个 key 特别热但容量不够,它可能每次进入后很快又被淘汰,这样hottest()里能看到它,但不代表它被稳定缓存住了。遇到这种情况,要么提升容量,要么对这个 key 做特殊处理,比如单独建一个更小的、永不淘汰的缓存层。

4.3 热点 key 预热:从历史统计导入 Caffeine

有些场景下我们希望在流量到来之前就准备好热点 key,比如大促前把预测的热门商品 ID 预热到本地缓存。这本质上是把“历史热点名单”转成“未来热点缓存”。

实现方式不复杂,就是把名单里的 key 逐个调cache.get(key, loader)或者直接cache.put(key, value)。但有一个点值得注意:Caffeine 的put能把数据放进缓存,但它不会像真实访问那样显著增加这个 key 的频率。所以预热的数据可能会在缓存容量压力下被淘汰,除非随后真的有大量访问把它“加热”。换句话说,预热只是给一个初始座位,能不能坐稳,还得看接下来的真实流量。

如果想让预热数据也被当成“有热度”,可以在预热阶段用cache.get(key, k -> value)并配合一些模拟访问。不过我不建议把这事做得太复杂,因为 Caffeine 的频率衰减很快,真实流量起来后热点自然会被抬起来,提前模拟访问只是白白增加代码复杂度。

4.4 一个完整的“判断 + 监控” Demo

为了让你直接抄作业,我写一个完整的可运行示例,用最简单的方式把热点判断和监控串起来。这里模拟一个商品查询接口,访问不同的商品 ID,然后定时输出当前热点 key 和命中率。

import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.stats.CacheStats; import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Random; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class HotKeyDetector { private static final int HOT_LIMIT = 10; public static void main(String[] args) { Cache<String, Object> cache = Caffeine.newBuilder() .maximumSize(5_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(); // 模拟热点流量:大量访问 product:1 到 product:5,少量访问其他 ScheduledExecutorService traffic = Executors.newSingleThreadScheduledExecutor(); traffic.scheduleAtFixedRate(() -> { Random random = new Random(); String key = random.nextInt(100) < 80 ? "product:" + (1 + random.nextInt(5)) : "product:" + (100 + random.nextInt(20)); cache.get(key, k -> "value-of-" + k); }, 0, 10, TimeUnit.MILLISECONDS); // 定时打印缓存统计和热点 key ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(); monitor.scheduleAtFixedRate(() -> { CacheStats stats = cache.stats(); System.out.printf("hitRate=%.2f%% hits=%d misses=%d%n", stats.hitRate() * 100, stats.hitCount(), stats.missCount()); List<Map.Entry<String, Object>> hotKeys = cache.policy() .flatMap(policy -> policy.eviction()) .map(eviction -> eviction.hottest(HOT_LIMIT)) .orElse(Collections.emptyList()); System.out.println("hot keys: " + hotKeys.stream().map(Map.Entry::getKey).toList()); }, 1, 1, TimeUnit.SECONDS); } }

这个 Demo 没有引入 Spring、没有引入 Redis,核心逻辑就是用get(key, loader)模拟访问,再通过hottest()和stats()观察结果。你跑起来之后会看到product:1到product:5稳定出现在热点 key 列表里,而其他访问量小的 key 偶尔出现但不稳定。这就是 Caffeine 自动判断热点的一个直观验证。

5. 常见问题与排查经验

5.1 热点 key 频繁被淘汰,但命中率却不高

这个问题的典型表现是:日志里能看到同一个 key 经常被加载,但 Caffeine 的hitRate一直上不去,hottest()里也能看到这个 key。为什么会出现这种情况?大概率是maximumSize设置太小了,缓存里同时只能放少量 key,热点 key 和一堆冷 key 打架,谁赢谁留下完全看频率排位。

排查思路:先看device之外的指标,比如stats().evictionCount()。如果淘汰次数非常多,说明容量压力很大。接着看hottest()里前几名占了多大比例,如果前几个 key 的访问量占比极高,可以考虑给它们单独加一层“永不淘汰”的缓存,或者干脆把maximumSize调大。另外,如果你的业务存在低频但很重要的数据,建议不要让它们和超级热点抢同一个缓存池,而是拆分出两个不同规格的 Caffeine 实例。

5.2 过期时间设置不当的坑

Caffeine 的过期判断不是精确到毫秒的,而是惰性删除加定期清理的组合。如果你设置expireAfterWrite(Duration.ofMinutes(5)),其实这个 key 不会刚好在第 5 分钟被物理删除,它可能在下一次访问时才发现过期了,也可能在 Caffeine 的内部维护线程清理时被移除。这个延迟大多数场景下无感,但如果你对强一致性敏感,比如优惠券状态、库存数字,就得注意。

另外,expireAfterAccess会让热点 key 更容易长期存活,但也会带来“冷数据永远不冷”的问题。我之前负责过一个报表服务,所有 key 都设置了 24 小时的expireAfterAccess,结果访问频率不高的旧数据一直占着容量,新的热点反而挤不进来。后来改成expireAfterWrite之后,内存占用直接降了 30%,热点命中率反而稳定了。所以过期时间不是越长越好,要结合“数据多久会变”和“热点会不会自然变冷”来选。

5.3 Caffeine 的近似性:为什么 hottest 结果看起来“不够精确”

要接受一个事实:hottest()的结果是近似频率排名,不是精确访问次数排名。由于 Count-Min Sketch 存在哈希碰撞,两个不同的 key 可能共享同一个计数器的“增量”,极端情况下会出现“访问了 10 次的 key 排在访问了 100 次的 key 前面”的情况。碰撞无法完全避免,但 Caffeine 通过多个哈希取最小值、4-bit 计数器、衰减机制把误差控制到了工程可接受的范围。

所以你在用hottest()做业务决策时,不要把它当成精确统计。比如“前 10 个热点 key 必须给它们分配更大的权重”,这个可以做,但不要拿这个名单去和财务对账,也不要因为某个 key 没出现在 top 10 里就断定它不热。它适合作为分析方向和告警依据,不适合作为唯一事实来源。如果业务真的需要精确到个位数的访问次数排行,那还是得在应用层自己做埋点统计,Caffeine 的近似统计解决不了这个需求。

5.4 如果非要自己控制热点 key 名单怎么办

如果你是因为特殊原因必须维护一份“业务自定义热点名单”,比如某些 key 即使访问频率不高也想强行保留,那我的建议是不要和 Caffeine 的默认淘汰策略对抗。可以单独构建一个容量很小、基于expireAfterWrite的长生命周期缓存,专门放这些“人工热点”。这样既不干扰 Caffeine 的自动热点判断,也满足业务强约束。这也是我自己经常用的一种玩法:一个自动热点缓存池 + 一个手动白名单缓存池,查询时先手动白名单,再查自动池。

如果你已经看完了前面的内容,你应该能感受到:判断热点 key 这件事,绝大多数情况下是 Caffeine 框架内部工作的“副产品”,我们要做的更多是“观察”和“确认”,而不是“实现”。我个人的经验是,先把maximumSize和过期时间调好,再用recordStats()跑几天看命中率,最后用hottest()做定期巡检,这样的组合拳已经能覆盖九成场景。真正需要自己写热点识别逻辑的场景,往往是数据源到缓存之间还存在明显的成本差异,或者需要非常精确的排名,这时候再考虑叠加外部统计也不迟。希望这些踩坑经验能让你少走一点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询