Guava Cache weakKeys() 引发缓存命中率骤降:原理、排查与正确姿势
发布时间:2026/10/16 5:14:10 锦皓数字建站
 引发缓存命中率骤降:原理、排查与正确姿势`)
前两天线上一个服务突然出了怪事缓存命中率从 95% 掉到 30% 以下但缓存大小看起来很正常过期时间也没到甚至重启之后短时间恢复、过一会儿又稀稀拉拉地丢数据。查来查去最后定位到问题出在 Guava Cache 的一个看似不起眼的功能上——weakKeys()。这个标题看起来是个“小坑”实际上踩进去的人不少。我今天就把这个问题的来龙去脉、现象特征、定位手段和正确用法一次性讲透帮你以后少走弯路。1. 问题现象命中率骤降缓存却“看起来”一切正常1.1 一次典型的“怪病”案例先还原一个现象。某日线上监控报警一个用 Guava Cache 缓存用户配置的服务hitRate掉到了 25% 左右而且波动非常剧烈。大家的直观反应是“缓存被逐出了”或者“key 没有对齐”但检查了所有配置maximumSize 20000实际缓存条数不到 5000没有达到驱逐上限。expireAfterWrite 10 分钟数据写入后最多 10 分钟不应该成片消失。缓存 key 是业务里统一定义的KeyWrapper对象重写了hashCode()和equals()看起来没问题。没有手动调用invalidateAll()。更恼人的是这个现象是间歇性的某一段时间命中率正常过了某个时间点又开始丢。监控 GC 日志以后发现每次 Full GC 之后命中率都会出现一波明显下降然后慢慢回升一些但始终恢复不到之前的水位。最后翻代码才发现缓存初始化时写了一句当时“随手加”的配置CacheBuilder.newBuilder() .weakKeys() .maximumSize(20000) .build();就是这行.weakKeys()把整个缓存搞成了“薛定谔的缓存”——看起来还在摸它一下就没了。1.2 weakKeys() 到底改了什么weakKeys()的意思是缓存内部不再强引用 key 对象而是用WeakReference包一层。底层原理很好理解当外界没有任何强引用指向这个 key 对象时垃圾回收器就可以把这个 key 对象回收掉。key 一旦被回收对应的缓存条目就失去了“钥匙”。即使 value 还老老实实躺在缓存里查询时也无法再被定位到表现就是get(key)返回null或者CacheLoader.load()被重新调用。很多第一次接触这个功能的人会想“这不正好吗key 不被引用了就自动清理缓存正好帮我省内存。”问题是业务缓存里根本没有这么“贴心”的引用生命周期。大多数 key 对象都是在某个方法里临时构造出来、用来调一次缓存的方法一结束key 就没有强引用了。结果不是“缓存自动清理了没用的数据”而是“缓存把正在使用的数据也一起清掉了”。2. 原理剖析weakKeys() 到底改了什么底层行为2.1 弱引用回收缓存条目的“突然死亡”先明确一个概念弱引用不影响对象的可回收性。只要对象只剩下弱引用GC 在下一次运行到合适时机时就可能把它回收掉具体回收时间完全不受缓存控制。Guava Cache 在weakKeys()开启后每个 entry 的 key 字段本质上是一个WeakReferenceT。当垃圾回收发生时如果这个 key 对象没有其他强引用链引用就会被清空。此时条目进入“key 已死亡”状态之后任何get操作都无法找到它只能返回null。更麻烦的是这个过程不像 TTL 过期那样有明确的“时间线”。TTL 过期你可以推算出数据什么时候失效弱引用回收则完全由 GC 的调度决定可能几分钟没有 GC 就一切正常一次 Full GC 过后大片缓存集体消失。线上表现就是命中率像心电图一样上下跳很难跟业务逻辑挂钩。另外即使你不调用System.gc()JVM 在堆内存压力达到阈值时一样会触发 GC。缓存里的值如果频繁丢失每次查不到都要回源回源又产生更多对象堆压力更大GC 更频繁形成恶性循环。我见过一个极端 case加了weakKeys()之后缓存命中率下降导致数据库压力翻倍最后把缓存整个关掉反而更稳定。2.2 比“被回收”更隐蔽的问题弱引用模式下比较的是对象身份weakKeys()还有一个极其容易被忽略的副作用Guava Cache 会把 key 的比较逻辑从equals()切换成“对象身份”即。这几乎是所有误用weakKeys()的第一大坑。默认情况下Guava Cache 对 key 使用普通equals()比较。也就是说你用new String(abc)放进去再用new String(abc)去查只要两个字符串内容一样就能命中。开了weakKeys()之后情况完全变了。内部每个 key 都是WeakReference无法安全调用业务对象各自的equals()方法所以采用了对象身份比较。这意味着两个内容完全相同的字符串对象只要不是同一个实例查询就会 miss。两个业务上相等的KeyWrapper对象只要不是同一个引用查询就会 miss。换句话说你每次查询都必须拿出当初放进去的那个对象本身而不是一个“内容等价”的对象。这在业务代码里几乎不可能满足。拿到的 key 大多是请求参数临时构建的每次构造都是新对象于是每次查询都 miss。这也能解释为什么“重启后短暂恢复”服务刚启动时有人手工发起请求用的是某几个固定对象恰好命中了等正常业务流量一上来每次构造新 key就全部 miss 了。2.3 官方文档其实写得很清楚只是很多人没细读Guava 的CacheBuilder.weakKeys()文档里明确提到两点当 entry 的 key 不再被强引用时该 entry 可能在 GC 时被整体清除。 使用弱引用 key 时缓存查找使用的是对象身份而不是equals()。这两句话每条都是致命的可惜很多人看到“weak”两个字就开始自由发挥以为这是某种自动清理内存的魔法。结果就是key 生命周期不稳定 相等性判断从“内容”变成“身份”双重打击之下缓存能命中才怪。3. 复现与定位一场完整的“破案”过程3.1 最小复现几行代码就能复现丢缓存先给一个最小复现你本地跑一下就能看到问题。CacheString, String cache CacheBuilder.newBuilder() .weakKeys() .build(); // 第一次写入key 是一个全新的 String 实例 String key1 new String(userId-10001); cache.put(key1, someValue); // 模拟业务侧后续请求用另一个内容相同的新 String 去查 String key2 new String(userId-10001); String value cache.getIfPresent(key2); System.out.println(value); // 输出 null结果说明什么第一次写入时key1和key2内容完全一致equals也相等但weakKeys()模式下看的是对象身份key1 ! key2所以直接 miss。就算你“聪明”一点直接用key1去查只要key1在方法结束后失去了强引用下一次 GC 过后再查同样返回null。这段代码完整复刻了线上两个致命问题。你用new String做 key一旦开弱引用命中率就是“生死局”。3.2 现场定位第一步开启 CacheStats 看命中率排查这类问题第一步不是翻代码是先把数字拿到手。Guava 提供了recordStats()功能可以直接看命中率、加载次数等指标。CacheString, String cache CacheBuilder.newBuilder() .weakKeys() .recordStats() .build(); // 业务高峰期之后打印或者接入日志平台 System.out.println(cache.stats());CacheStats里有几个关键指标requestCount()总请求次数。hitCount()/hitRate()命中次数和命中率。missCount()/loadSuccessCount()未命中次数、成功加载次数。evictionCount()驱逐条数。如果发现evictionCount()不高但missCount()很高说明数据不是被“大小上限”逐出去的而是“凭空消失”。这时候把RemovalListener加上看驱逐原因CacheString, String cache CacheBuilder.newBuilder() .weakKeys() .removalListener(notification - { System.out.println(key notification.getKey() , cause notification.getCause()); }) .build();RemovalCause.COLLECTED是一个非常重要的信号。只有垃圾回收器回收了 key 或 value才会出现这个枚举值。一旦日志里出现大量COLLECTED就基本可以确认是弱引用问题。3.3 现场定位第二步在 key 对象上打印身份指纹如果你自己写了个KeyWrapper排查时可以在 get 入口临时打日志输出两个东西System.identityHashCode(key)对象本身的内存身份标识。key的hashCode()/equals()结果内容层面的相等性。身份哈希码每次新建对象都会变。如果日志里显示同一个业务的 keyidentityHashCode在不断变化而内容相等那就说明问题出在弱引用导致的“对象身份比较”上。我也见过有人用 Guava Cache 的asMap()直接遍历 key 来排查但真实生产环境里 key 可能已经有一部分被回收了遍历看到的是“幸存者”不太靠谱。最好还是靠统计指标和 removal 日志。3.4 复现“GC 之后全没了”的实验有些人可能觉得上面那个例子太简单毕竟key1还在强引用中。我把“GC 之后消失”的版本也贴出来方便你理解为什么线上是间歇性的CacheString, String cache CacheBuilder.newBuilder() .weakKeys() .build(); String key new String(demo); cache.put(key, value); // 让 key 失去强引用 key null; // 主动触发 GC弱引用通常会被回收 System.gc(); Thread.sleep(1000); String value cache.getIfPresent(new String(demo)); System.out.println(value); // null并且 removalListener 里会出现 COLLECTED这里有两点要注意不要指望System.gc()一定立刻回收所有弱引用这是 JVM 实现相关行为但绝大多数场景下 Full GC 都会清掉无强引用的弱引用。就算 GC 没有立刻回收缓存的命中率也已经因为“身份比较”而掉光了。所以这个实验只是帮你理解回收机制实际线上第一次 miss 往往不是回收导致的而是新对象 ! 旧对象导致的。4. 解决方案你其实只有几个正确选择4.1 大多数人请直接删掉 weakKeys()如果你的 key 是普通的字符串、数字、DTO 对象或者任何“内容相等即可复用”的类型就别用weakKeys()。直接使用默认的强引用 key配合maximumSize控制数量配合expireAfterWrite/expireAfterAccess控制时间这是最稳妥、最好理解、也最好排查的一条路。CacheString, UserConfig cache CacheBuilder.newBuilder() .maximumSize(5000) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats() .removalListener(notification - { // 记录驱逐原因方便排查 }) .build(); public UserConfig getUserConfig(String userId) { return cache.get(userId, () - loadFromDB(userId)); }这个配置下不管userId是字符串字面量、还是每次请求new String出来的对象只要内容一致就能命中。因为 Guava 默认的 key 比较就是equals()它不关心你的 key 对象是否同一个实例。很多团队当初写.weakKeys()的真实动机是想“防止 key 对象占内存”但业务缓存的 key 大多是短小不可变的对象哪怕百万级 key内存消耗也是可控的。真正吃内存的是 value控制 value 大小和数量才是关键。4.2 如果你真的需要弱引用先确认 key 有“长久强引用”弱引用不是完全不能用但要分场景。它适合一类非常特殊的 key这个 key 对象自身本来就计划长期存活在整个 JVM 进程里。举个例子你有一个全局的注册表内部维护了一批元数据对象的单例public class MetadataRegistry { private static final MapString, Metadata INSTANCES new ConcurrentHashMap(); public static Metadata getOrCreate(String type, String name) { return INSTANCES.computeIfAbsent(type : name, Metadata::new); } }如果缓存 key 一直使用MetadataRegistry提供的同一个实例那么这个 key 始终有强引用GC 不会把它回收掉弱引用缓存也就能正常工作。这种情况下weakKeys()不会带来命中率损失只是帮你“如果哪天注册表释放了这个对象缓存也顺势清理”。多数业务场景并不满足这个前提所以我的建议是写weakKeys()之前先回答一个问题——“这个 key 对象在整个进程内是否存在一个全局强引用容器”如果答案是不确定那就别用。4.3 别指望软引用能解决 key 的问题有人可能会想“那 Guava 有没有softKeys()我想用软引用代替弱引用。”很遗憾Guava 里没有softKeys()只有softValues()。Caffeine 也没有提供 key 的软引用支持。所以如果目标是“key 对象也可以被 GC 回收但希望缓存活得久一点”在 Guava 生态里没有对应开箱即用的实现。与其自己发明一个 key 的软引用包装类不如把 key 设计成轻量对象把重量级字段从 key 中拆出去让 key 只保留一个不可变 ID。重量级信息放进 value 或者 value 的附属结构里而不是塞进 key。用maximumWeight()weigher()按内存占用控制缓存容量而不是指望 GC 帮你清理。weakValues()/softValues()是另一回事它针对的是 value。weakValues()的语义是“value 没有强引用时缓存条目标记清除”这种配置也会导致缓存值突然消失而且同样受 GC 影响。如果你业务里 value 是重量级对象并且想自动丢弃请慎重评估不要以为“弱引用免费内存管理器”。4.4 如果只是想控制内存正确姿势是大小和过期组合很多加.weakKeys()的人初衷只是不想让缓存无限膨胀。这个目标完全可以用更直白的手段达到maximumSize(最大条数)按条数控制。maximumWeight(最大权重)weigher()按内存权重控制。expireAfterWrite()写入后过期。expireAfterAccess()访问后过期。组合示例CacheString, byte[] cache CacheBuilder.newBuilder() .maximumWeight(64 * 1024 * 1024) // 约 64MB .weigher((key, value) - value.length) .expireAfterAccess(30, TimeUnit.MINUTES) .build();这样缓存自身就能按需淘汰不需要把命运交给 GC。内存控制明确、行为可预测、日志可审计线上出了问题也能根据驱逐原因快速判断是不是“容量不够”而不是“GC 把它带走了”。我之前还见过一个折中方案key 用强引用value 用softValues()同时配置expireAfterAccess。这种配置下 value 在内存压力大时会被优先回收同时在正常情况下还能保留一段时间比weakKeys()务实得多。不过 softValues 也一样有“缓存条目随时可能消失”的特点依赖回源兜底是必要前提。5. 什么时候才应该用 weakKeys避坑清单与个人体会5.1 真正合理的 weakKeys() 场景根据我个人经验真正适合weakKeys()的场景非常窄至少需要同时满足以下条件key 对象存在独立的生命周期管理进程内有全局强引用容器持有它。业务逻辑确实希望“当 key 对象本身不再被任何地方使用时缓存条目自动消失”。你能接受 key 查询必须基于对象身份匹配而不是内容匹配。最典型的是类似“类元数据”“系统级配置项”这类全局单例。正常情况下这些单例对象一直活在注册表里弱引用缓存不会丢如果哪天注册表清掉了这个对象缓存跟随清理反而符合预期。大多数业务缓存、接口缓存、用户级缓存都不满足这些条件直接默认关闭weakKeys()是最省心的决策。5.2 五连问自查清单写任何 Guava Cache 代码时我建议过一遍下面这五道题第一问你的 key 对象在进程里有没有稳定强引用来源没有 → 不要用 weakKeys()第二问你的 key 查询是新构造的对象还是复用一个共享实例每次都 new → 不要用 weakKeys()第三问如果 GC 在任意时刻把当前部分缓存清掉业务能否容忍不能 → 不要用 weakKeys()第四问你希望缓存数据有清晰的可预期有效期吗是 → 用 expireAfterWrite / expireAfterAccess第五问你真的遇到“缓存 key 对象太大导致内存溢出”的问题了吗极少 → 优化 key 结构而不是上弱引用这五问走完大部分weakKeys()的误用都能被拦下来。5.3 踩过坑之后的几条实在经验第一Guava Cache 的生命周期和引用关系比想象中要“脆弱”。弱引用不是一个帮你管理内存的工具它只是个“不阻止回收”的标记。把缓存这种需要稳定命中的基础设施押在 GC 的行为上风险极高。第二排查这类问题RemovalListener里看到COLLECTED是实锤比看命中率猜原因靠谱得多。我会建议所有 Guava Cache 初始化都加上一个带 cause 的 removal 日志平时可能用不上出事的时候能省几小时排查时间。第三团队内部约定缓存封装统一入口。不要每个业务各自 new 一个CacheBuilder最好封装一个工具类默认禁止弱引用需要时必须走 code review并写清楚为什么。人很容易在“性能优化”的驱动下顺手加一个看似无害的选项结果埋下大雷。第四如果你真的要在项目里沿用弱引用 key至少加一个单测模拟 GC 后的表现把行为固化下来Test void weakKeysCacheShouldWorkOnlyWithStrongGlobalRefs() { CacheMetadata, String cache CacheBuilder.newBuilder() .weakKeys() .build(); Metadata key MetadataRegistry.getOrCreate(type, name); cache.put(key, value); System.gc(); assertEquals(value, cache.getIfPresent(key)); // 依靠全局注册表保住 key }这种测试用例至少能保证你“用对了”的时候行为符合预期而不是在线上靠运气命中。最后再说一个很多人都忽略的细节哪怕你正确使用了weakKeys()只要 key 对象存在内容相等但不同实例的替换逻辑命中率照样会劣化。因为弱引用模式下缓存比较的是身份业务上任何“相等即可复用”的假设都是错的。这个认知比记任何 API 都重要。我自己后来在团队里定了一条规矩Guava Cache 项目里禁止出现.weakKeys()除非写出完整的生命周期分析和测试用例。多数人听完这个故事的第一个反应都是“原来 weakKeys 这么危险”然后默默把代码改成了maximumSizeexpireAfterWrite。这就是我写这篇文章最想达到的效果。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。