首页 / 资讯中心 / 文章详情

Guava Cache weakKeys引发缓存穿透的根因解析与排查实践

Guava Cache weakKeys引发缓存穿透的根因解析与排查实践 ★ FEATURED ARTICLE
1. 问题现场一次诡异的缓存穿透上个月排查一个订单查询服务的性能问题时遇到了一件挺邪门的事。服务本身架构不复杂上层接Nginx业务层用本地缓存挡流量再往下有分布式缓存兜底数据库扛最终数据。本地缓存用的是 Guava Cache配置很简单key 是用户维度的某个业务标识value 是订单摘要对象缓存 5 分钟过期容量上限 10 万条另外加了一个.weakKeys()。这个服务平时跑得很稳P99 基本稳定在 20ms 上下。突然有一天监控报警P99 直接飙到 200ms数据库慢查询数量也在涨。我第一反应是下游分布式缓存出问题了结果一查分布式缓存的命中率完全正常Redis 本身没有慢请求网络也健康。再往下追发现业务日志里大量订单摘要都在走“完整查询”逻辑也就是说本地缓存几乎全在 miss。当时我盯着监控面板想了半天本地缓存怎么会突然全部失效缓存没有设置主动失效业务代码也没有发布变更唯一能联想到的变数就是服务在那段时间发生过几次 Full GC。后来把本地缓存的统计信息打出来一看发现命中率从接近 95% 直接掉到 10% 上下remove/eviction 计数在 GC 之后明显上涨。这才开始怀疑是不是.weakKeys()的问题。于是临时把.weakKeys()去掉换成普通强引用 key 的方式服务很快恢复正常缓存命中率也回来了。这篇文章就把这个案子的完整过程拆开讲讲重点说一下 Guava Cache 的弱引用 key 机制到底是什么、为什么会导致缓存无法命中、什么样的场景容易踩坑以及如果你也遇到类似问题应该从哪些方向排查。这个问题的背景其实并不冷门它是 Guava Cache 使用中最容易被忽略的一个点引用类型的选择会直接影响缓存的可见性。很多人可能知道 Guava Cache 提供了weakKeys()、weakValues()、softValues()这些方法但未必清楚这些方法背后改变了什么语义。这篇文章面向的主要是使用 Guava Cache 做本地缓存的 Java 开发同学特别是那些在缓存上加了weakKeys()之后发现缓存命中率不稳定的情况。2. 弱引用机制到底改了什么2.1 先从引用类型说起Java 里有几种引用强度强引用、软引用、弱引用、虚引用。平时我们写的Object obj new Object()就是强引用只要强引用还在GC 就不会回收这个对象。软引用只有在内存不足时才会被回收弱引用更弱一些只要 GC 扫描到它不管内存够不够都会回收。Guava Cache 的weakKeys()做的事情说白了就是把缓存中的 key 用WeakReference包一层。正常情况下缓存会把 key 和 value 都强引用住这样只要缓存没被清理key 和 value 就一直在。但一旦启用weakKeys()缓存里存的就不再是 key 的强引用而是弱引用。引用类型的核心区别用一个生活化的例子理解强引用就像你手里攥着一张电影票根只要不主动丢票根一直在弱引用就像你把票根放在一个开放式的托盘里保洁阿姨只要路过觉得这票根没用了直接就会收走。你查缓存的时候如果托盘里已经空了那自然就命中不了了。Guava Cache 的CacheBuilder提供了几种与引用相关的配置它们的语义差异非常明显配置引用类型行为特征典型用途默认不设置强引用 key value只要缓存不清理key 和 value 都不会被回收绝大多数场景下的自省缓存weakKeys()弱引用 key强引用 valuekey 无外部强引用时会被 GC 回收对应条目会被清理或无法命中key 的生命周期完全由外部控制weakValues()强引用 key弱引用 valuevalue 无外部强引用时会被 GC 回收value 是大对象希望不阻止其回收softValues()强引用 key软引用 value内存紧张时 value 会被回收value 重建成本高但可以容忍偶尔丢失这里要特别注意weakKeys()和weakValues()是可以叠加的但叠加之后key 和 value 都可能随时被 GC 回收缓存命中率会变得非常不稳定。除非你非常清楚自己在做什么否则不建议叠加。2.2 内部存储结构与 GC 的交互Guava Cache 的内部实现是分段缓存类似ConcurrentHashMap的 segment 机制。每个分段内部维护了一个哈希表哈希表里存的是ReferenceEntry对象。当启用weakKeys()时这个ReferenceEntry内部会用WeakReference包装 key。有个细节值得注意在创建ReferenceEntry时key 的hashCode()结果已经固化在 entry 里面了。也就是说查找的时候先用当前传入 key 的hashCode()定位到某个桶然后在桶的链表上逐个比较 entry 里的 key 是否 equals。这里就埋了一个大坑如果你的hashCode()结果不稳定或者equals()依赖可变字段那么即使缓存没有被 GC 干扰查找也会出问题。关于这一点后面我专门讲。key 被 GC 回收之后entry 并不会立刻从哈希表里消失但 entry 里弱引用指向的对象已经是 null 了。这个时候如果执行get()操作Guava 会判断 key 已经被回收于是把这条 entry 清理掉然后继续查找下一个。如果整条链表上都没有匹配的 entry此次查询就会 miss。所以你可以这样理解weakKeys()的完整运行链路外部代码把 key 对象放进缓存缓存只持有这个 key 的弱引用某个时刻 GC 运行发现外部没有强引用指向这个 key 对象于是把这个 key 回收之后任何线程用一个新的等价对象来查询即使hashCode()和equals()都正确也查不到结果因为原来的 entry 已经失去了有效的 key。这就是标题里说的“缓存无法命中”的底层原因。2.3 为什么与其他缓存策略的表现不同普通ConcurrentHashMap做缓存时key 和 value 都被 Map 强引用只要你不主动 removekey 永远不会被 GC 回收。Guava Cache 默认也是这样。所以大家默认“放进缓存就一定会待到过期或被容量淘汰”这种心智模型在默认配置下是成立的。但weakKeys()改变了这个心智模型缓存不再保证 key 能存活下来。很多人在配置缓存的时候看到 API 上有weakKeys()方法想当然地认为这是“防止缓存导致的内存泄漏”的银弹于是随手加上。这恰恰是出问题的根源。真实场景里大多数 key 对象都是查询时即时构造的。比如把用户的某个 ID 和业务标识拼成一个自定义对象然后从接口参数里取出来作为缓存 key。这个对象除了传给缓存之外没有被别的变量持有。在强引用模式下缓存本身持有这个 key所以下次查询时即使构造了一个新的等价对象也能通过 equals 命中。但在弱引用模式下缓存不持有 key 的强引用GC 一来这个 key 就没了下次怎么查都 miss。3. 复现与定位三步锁死问题根源3.1 一个最小复现 Demo为了验证问题我自己写了一个最小复现程序。这个程序的核心逻辑很简单自定义一个不可变的 key 类正确实现equals()和hashCode()然后使用weakKeys()缓存存完之后把外部强引用置空再模拟几次 GC然后重新构造一个等价对象去查询观察命中情况。下面是复现代码import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import java.util.Objects; import java.util.concurrent.TimeUnit; public class WeakKeyCacheMisuseDemo { public static void main(String[] args) throws Exception { CacheUserBizKey, String cache CacheBuilder.newBuilder() .weakKeys() .maximumSize(10000) .recordStats() .build(); // 第一次写入构造 key 并放入缓存 UserBizKey putKey new UserBizKey(1001L, order_20240601); cache.put(putKey, 订单摘要数据); // 模拟外部不再持有 key 的强引用 putKey null; // 让 GC 有机会运行回收弱引用的 key for (int i 0; i 3; i) { System.gc(); Thread.sleep(100); } // 每次查询都构造一个新的等价 key UserBizKey queryKey new UserBizKey(1001L, order_20240601); String value cache.getIfPresent(queryKey); System.out.println(查询结果: value); System.out.println(命中率: cache.stats().hitRate()); } static class UserBizKey { private final Long userId; private final String orderNo; UserBizKey(Long userId, String orderNo) { this.userId userId; this.orderNo orderNo; } Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; UserBizKey that (UserBizKey) o; return Objects.equals(userId, that.userId) Objects.equals(orderNo, that.orderNo); } Override public int hashCode() { return Objects.hash(userId, orderNo); } } }第一次跑这个程序的时候查询结果打印出null命中率是 0.0。这已经验证了一个不可变、正确实现了equals()和hashCode()的 key在weakKeys()模式下只要外部没有强引用就一定会被 GC 回收后续查询就不可能命中。我把weakKeys()去掉再跑一次同样的逻辑查询结果正常打印出“订单摘要数据”命中率回到 1.0。对比实验说明问题出在弱引用配置上而不是 key 类的实现上。3.2 为什么明明 equals 正确还是查不到很多人会在这里犯迷糊我明明实现了equals()和hashCode()而且 key 类是不可变的为什么还会查不到关键在于“查找”这个动作的前提是“对象还存在于缓存中”。equals()只是判断两个对象是否逻辑相等它不能帮你把已经被 GC 回收的对象找回来。weakKeys()模式下缓存里存的是弱引用弱引用的对象被 GC 回收后entry 就失去了有效的 key后续任何查找都只能扑空。你可以把整个过程拆成几个阶段来理解放入阶段cache.put(key, value)时缓存把 key 包装成弱引用同时将 key 的hashCode()固化到 entry 中。外部释放阶段业务代码不再持有 key 的强引用此时 key 对象唯一存活的地方是缓存里的弱引用。GC 回收阶段GC 扫描到弱引用对象发现没有强引用路径直接回收 key 对象。查询阶段新的等价 key 对象被构造出来hashCode()计算后定位到同一个桶但原来的 entry 里 key 已经为 null无法命中。所以weakKeys()出现缓存 miss 的本质原因不是 equals 写错了而是“缓存根本没有能力保留住 key 对象”。如果你只在缓存里放了一次 key之后外部就再也不持有这个 key那么weakKeys()基本等于宣告这条缓存是“命不久矣”的。3.3 如果项目里查不到明显回来的引用怎么办有的人会说我的 key 是复用对象不是每次 new 的为什么还是 miss这种情况大概率是 key 对象不可变但它在整个系统里确实没有长生命周期或者 key 的equals()/hashCode()依赖了可变字段。这四个问题本质上都能从“key 对象的存活状态”和“查找判定”两个维度去排查。最简单的办法是临时写一个统计代码把cache.stats()的数值打出来观察一段时间。如果 miss 率与 GC 时间点强相关那基本可以锁死是这个因素。我当时就是通过对比 GC 日志和缓存命中率的变化曲线才确认了病因。4. 最容易踩坑的四种场景4.1 每次 new 对象当 key这是最常见的一种。比如你有一个方法入参是一个业务 ID里面直接new UserBizKey(id, bizType)作为缓存 key 查缓存。如果你用的是普通强引用 key这个写法完全没问题因为缓存会强引用住第一次放入的 key 对象。但一旦启用weakKeys()每次 new 出来的 key 对象如果没有其他地方持有它GC 一跑就没了。这种情况下缓存表现出的特征是刚 put 进去的缓存可能几秒钟之后就查不到了命中率极低。缓存本身没有坏数据没写错但就是查不到。4.2 byte[] 作为 key还有一个隐蔽的坑是byte[]作为 key。数组类型没有真正覆盖equals()和hashCode()它们使用的是Object的默认实现也就是基于对象内存地址的 identity hash。这意味着即使两个byte[]的内容完全一样只要它们是两个不同的数组对象equals()就返回 falsehashCode()也不一样。这个问题其实在强引用 key 下也存在。但因为byte[]作为 key 时通常不是每次新建的比如从某个地方解析出来一个字节数组做查询可能你以为“内容一样就是同一个 key”实际上 Java 不这么认为。这会导致即使没有 weakKeys也可能出现查不到的问题。而一旦叠加weakKeys()上面讲的弱引用生命周期问题又加了一层问题就更严重了。如果你确实需要用字节内容作为缓存 key建议用ByteBuffer.wrap(byte[])包装ByteBuffer实现了内容相等的equals()和hashCode()。或者干脆自己封装一个不可变的BytesKey类把字节数组复制一份并实现基于内容的比较方法。4.3 hashCode 依赖可变字段有些自定义 key 对象不走不可变路线包含了一个可变的 status 字段。你放入缓存时 status 是 1查询时把 status 改成 2再去查缓存虽然整体业务含义可能是同一个 key但hashCode()已经变了Guava Cache 根据新的 hashCode 定位到的桶和原来存 entry 的桶不是同一个自然找不到。这里有一个容易忽略的点即使对象没有发生 GC可变字段导致的hashCode()变化也会让查找失效。不要以为只有weakKeys()才会导致 miss。如果你项目里已经把缓存 key 设计成了可变对象无论强引用还是弱引用都应该尽快改成不可变对象。这个是缓存设计的基础素养。4.4 生命周期设计不一致还有一种情况比较隐蔽put 的时候 key 对象被某个长生命周期的对象持有比如 Spring 容器的单例 bean 里有一个成员变量引用着 key这种情况下 GC 不会回收 keyweakKeys()也不会出问题。但是如果这个长生命周期引用的位置放错了或者代码重构后引用被移除了key 就顿时变成可回收状态缓存瞬间失效。这种场景排查起来很费劲因为代码里看起来明明有地方持有着 key但实际运行时引用路径已经断了。我建议在设计时就明确 key 的生命周期边界要么确定由缓存外部的某个对象负责持有 key 的强引用要么就不要用weakKeys()。5. 合理使用 weakKeys 的正确姿势5.1 什么时候才应该用 weakKeys聊了那么多坑那weakKeys()是不是一无是处也不是。它存在的意义在于当你希望一条缓存条目的生命周期严格跟随外部 key 的生命周期时弱引用是一个合适的手段。举个例子某个系统内部维护了一组动态配置对象这些对象本身在内存中由业务模块管理业务模块会在一定条件下释放它们。你想建立一个“配置对象 - 附加数据”的本地缓存又不想让这个缓存阻止配置对象被回收。这种情况下weakKeys()是合理的缓存条目的存在依赖 key 对象的存活key 被回收了缓存条目也就没有存在意义了。但使用之前必须确认两个前置条件key 对象在缓存之外确实有可靠的强引用来源负责维持其生命周期。key 类的equals()和hashCode()完全基于不可变字段不存在状态变化影响判断的情况。如果不满足这两个条件weakKeys()大概率会给你带来一次线上事故。5.2 如果只想防止缓存导致内存泄漏应该怎么做很多人用weakKeys()的动机是“防止大对象被缓存长期占用内存”。这个诉求没错但方案通常不应该选weakKeys()。如果你担心的是 value 太大长期占内存更自然的思路是限制容量 时间过期用maximumSize()配合expireAfterAccess()或者expireAfterWrite()让缓存主动淘汰旧数据。使用weakValues()或softValues()value 以弱引用或软引用方式持有外部没有强引用时value 可以被 GC 回收而 key 仍由缓存持有不会出现 key 丢失导致完全无法命中的问题。对比一下几种配置的效果配置key 存活保障value 存活保障适合场景默认缓存持有缓存持有常规本地缓存maximumSize TTL缓存持有缓存持有但可被容量淘汰业务数据缓存weakValues()缓存持有弱引用GC 可回收value 大且可重建softValues()缓存持有软引用内存紧张时回收value 大、重建成本高weakKeys()弱引用外部无强引用即回收缓存持有key 生命周期由外部控制从这个表可以看出大部分担心缓存内存占用的人想要的其实是 value 的回收能力而不是 key 的回收能力。选weakValues()或softValues()更符合直觉也能避免缓存命中率的剧烈波动。5.3 改造成强引用 key 的落地示例我当时的改造方案是去掉weakKeys()保留maximumSize和 TTL同时用recordStats()观察命中率。这个方案的风险是如果 key 对象本身非常大且数量非常多强引用 key 会让内存占用上升。但这个服务的 key 只是两个 long/int 组合内存占用很小完全不是问题。CacheUserBizKey, OrderSummary cache CacheBuilder.newBuilder() .maximumSize(100_000) .expireAfterWrite(5, TimeUnit.MINUTES) .recordStats() .build();如果你的业务场景确实需要 key 跟随外部生命周期那么一定要确保 put 方和 get 方都持有同一个 key 对象或者至少有一个公共的强引用源。这里有个经验之谈尽量用不可变对象作为 key并且不要对同一个逻辑 key 重复 new 不同对象否则在weakKeys()模式下即使外部有持有也可能因为 hash 定位不一致而多走一次查询。6. 缓存命中率排查的通用套路6.1 先让缓存说实话排查任何缓存问题第一步永远是打开统计信息。Guava Cache 默认不记录命中率需要在CacheBuilder上显式加recordStats()。加了之后通过cache.stats()能拿到请求数、命中数、未命中数、加载耗时、淘汰数量等指标。没有这些数据你只能凭感觉猜问题。我当时就是加了一行recordStats()然后写了一个定时任务每隔 10 秒把本地缓存的统计信息和 JVM GC 次数、GC 耗时一起打印出来才能明确看到缓存命中率随 GC 变化的曲线。这里要注意一点CacheStats记录的是所有操作包括getIfPresent和get两种。如果是get(K, Callable)方式命中率统计也包含自动加载的 count分析时不要混在一起看。6.2 从三个方向排查 miss如果命中率异常下降我总结了三个排查方向按优先级排列第一确认缓存配置。打开CacheBuilder的配置代码看看是否使用了weakKeys()、weakValues()或softValues()中的任意一个。如果有直接对照业务代码检查 key 对象的生命周期和 equals/hashCode 实现。这一步通常能找到 80% 的问题。第二查看 key 对象的 GC 情况。通过jmap -histo:live观察 key 类实例数量或者在代码里临时打印key对象的hashCode()和某个外部弱引用的存活状态。如果 key 对象在 GC 后实例数骤降说明弱引用已经把它们带走了。第三写一个对照组。临时把weakKeys()去掉改成默认强引用跑一段时间对比命中率。如果命中率明显回升问题就锁死了。我当时的线上恢复操作就是这么做的先去掉配置服务恢复正常再回过头来梳理根因。6.3 自查清单速查检查项检查方式弱键模式下异常表现key 类的 hashCode 稳定性同一对象多次调用 hashCodehashCode 变化定位桶不一致key 类的 equals 一致性构造等价对象比较 equals返回 false链上无法匹配key 对象外部强引用jmap 观察实例数 / 代码审查GC 后实例数骤降缓存 miss缓存配置项审查 CacheBuilder 配置出现 weakKeys/weakValues/softValues命中率趋势recordStats 打印miss 率与 GC 时间点强相关这张表我用过很多次不只是针对 Guava Cache排查 Caffeine、自研 LocalCache 时也能套用大半套路。最后再分享一个小技巧排查这类问题的时候别只看均值要看时间线。缓存命中率可能平均下来是 80%看起来很健康但如果把命中率按分钟绘制成曲线你可能会发现每次 GC 之后命中率都会断崖式下跌然后在后续的访问中逐步回升因为新的缓存又慢慢建立起来了。这种周期性波动比整体偏低的命中率更能说明问题。另外System.gc()只是建议 JVM 执行 GC实际是否执行取决于垃圾收集器和 JVM 参数。我在复现 demo 时为了让效果更明显加了-XX:DisableExplicitGC的反向参数并配合Thread.sleep但线上排查不需要这么精确看曲线趋势就足够了。这个案例给我的最大教训是缓存配置的每一个选项背后都有完整的语义模型不要看到一个 API 名字里有“weak”就觉得是“内存优化神器”。引用类型的配置改的是整个缓存的可见性与生命周期语义而不是简单的“省内存开关”。以后我写缓存配置都会先问自己一个问题这个 key 值如果 GC 把它回收了业务还能不能正确运行如果答案是不能那就别用weakKeys()。
阅读完成 · 觉得有帮助?
咨询建站