ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Caffeine热点Key识别:用TinyLFU自动判断并缓存高频访问

Caffeine热点Key识别:用TinyLFU自动判断并缓存高频访问 说实话我最初看到“热点 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 自动写入缓存后面的重复访问直接命中不会再走到下游。CacheString, 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()拿到命中和未命中的统计数据。CacheString, 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()一行拿到当前热点 keyhottest(int limit)是 Caffeine 提供的一个非常方便的方法它可以直接返回当前缓存中访问频率最高的前 N 个 key。注意它返回的是 Map.Entry 的列表而且顺序是从热到冷排序的。ListMap.EntryString, Object hotEntries cache.policy() .flatMap(policy - policy.eviction()) .map(eviction - eviction.hottest(20)) .orElse(Collections.emptyList()); for (Map.EntryString, 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 出现之前实现 LFULeast 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(() - { ListMap.EntryString, 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) { CacheString, 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()); ListMap.EntryString, 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()做定期巡检这样的组合拳已经能覆盖九成场景。真正需要自己写热点识别逻辑的场景往往是数据源到缓存之间还存在明显的成本差异或者需要非常精确的排名这时候再考虑叠加外部统计也不迟。希望这些踩坑经验能让你少走一点弯路。
返回列表