十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Caffeine本地缓存未设上限引发OOM:JVM内存排查与修复实战

Caffeine本地缓存未设上限引发OOM:JVM内存排查与修复实战 1. 事故现场告警里的GC overhead和突然变慢的接口凌晨1点47分手机钉钉告警群连续弹了十几条消息。某某服务GC耗时超过90%接口平均耗时从80ms直接跳到8秒紧接着是一轮又一轮的Full GC。值班同事在群里说了一句服务卡死了重启过了又卡了我就知道这不是一个普通的偶发问题。先说下当时的部署背景。这是一个活动运营平台的后端服务部署了4个节点每台机器8G堆用的JDK11。接口主要面向运营后台和内部管理端平时并发不算高但大促前会有运营批量配置活动的操作。出事那天的流量高峰比平时高了三倍左右然后GC就开始失控了。1.1 一开始只是CPU曲线抬高了事故的第一现象不是OOM而是CPU飙升。监控面板上GC线程的CPU占用持续飙高接口却越来越慢。这是典型的垃圾回收器在疯狂干活但活干不完的状态。为什么GC频繁会导致接口慢因为无论CMS还是G1在做垃圾回收时都会发生Stop The World尤其是Full GC所有业务线程都要停下等垃圾回收线程跑完。当老年代里塞满了长期存活对象回收器反复扫描、标记、整理又每次都只能回收很少的空间整个服务就陷入回收-卡顿-再回收-再卡顿的恶性循环。当时第一反应是是不是有人写了慢SQL没加索引或者哪个接口发版引入了死循环。但慢SQL查询和数据库负载都正常这就把问题推回到了应用自身的JVM内存上。1.2 Full GC频率爆炸后的连锁反应我登上机器先跑了一条命令jstat -gcutil pid 1000 10输出的数据非常难看老年代O区使用率显示100%Full GC次数一直在涨而且每次Full GC耗时都在好几秒。关键点在于老年代使用率在Full GC之后几乎没有下降说明堆里存的是大量存活对象而不是可以随手丢弃的垃圾对象。看到这个数据我的第一判断是内存泄漏或者内存里攒了不该攒的大对象。紧接着监控就报了OOM——java.lang.OutOfMemoryError: GC overhead limit exceeded然后实例开始自动重启。1.3 先止血重启 临时加大堆但不解决根因团队首先把服务重新拉起同时把某台机器的堆从8G临时调到了12G想用扩容换时间。服务确实恢复了一段时间缓存重建后一切看似正常但到了第二波流量高峰GC又开始恶化。这里有个教训如果你的OOM是增长型的重启和大堆只是把爆炸时间往后推。只要代码里那个疯狂占用堆的逻辑没改数据量再涨一截OOM就会再炸一次。临时扩容只是给了我们排查问题的窗口而不是解决事故的手段。2. 定位链路复盘从GC日志到堆转储锁到本地缓存这个真凶这起事故真正花时间的不是重启而是从GC异常一路定位到Caffeine这个本地缓存组件。我复盘一下完整的排查链路包含用的工具、看到的现象和每一步判断的依据。2.1 第一步jstat确认GC状态判断是泄漏还是大对象堆积前面提到的jstat -gcutil只是第一层。为了看更细的JVM堆行为我接着看了GC日志jstat -gc pid 1000 10这里重点观察几个增长率Eden区每秒分配多少、经过几次Young GC后晋升到老年代的对象有多少、老年代空间占用曲线。当天观察到的现象是每次Young GC之后进入老年代的对象体积非常大说明有一部分对象从出生开始就是大对象直接绕过Eden进入老年代或者Eden区容量不足以容纳它们。同时老年代使用率下降不明显说明这些对象不是短命对象而是长期停留在堆里。这时候我心里已经有了嫌疑对象ConcurrentHashMap承载的缓存结构。因为本地缓存的特性就是长期存活、持续增长、不主动清理和GC日志里的表现完全吻合。2.2 第二步jmap导出堆转储别在高峰期乱来为了看清堆里到底是什么对象必须拉一份堆转储文件。我用的是jmap -dump:live,formatb,file/tmp/app.hprof pid这里一定要提醒大家线上导出堆转储要谨慎。虽然-dump:live只保留从GC Roots可达的对象但触发执行时也可能因为堆过大、机器IO压力大而卡顿甚至会导致更严重的Full GC。所以我的习惯是如果服务已经濒临OOM先确认还有多少堆余量再执行dump如果堆已经满到临界点优先重启恢复服务挂上-XX:HeapDumpOnOutOfMemoryError等待下次OOM时自动生成快照而不是继续在濒死实例上操作。当时dump出来的文件大小是7.2G。下载到本地之前我先用du -h确认了文件大小然后在本地用6G堆的MAT去打开它。这里顺便说一句如果你用的是8G以内的堆建议MAT的MemoryAnalyzer.ini里的-Xmx也调到至少4G否则打开大dump文件时会直接卡死或爆内存。2.3 第三步MAT里看Dominator TreeCaffeine直接吃掉将近一半堆用MAT打开堆转储后主要看三个地方Leak Suspects报告MAT会直接列出它怀疑的内存泄漏入口和保留大小。Dominator Tree支配树按对象及其持有对象的总保留大小从大到小排序。Top Consumers看哪个包、哪个类占用的堆百分比最高。我刚打开Leak Suspects报告第一个嫌疑就是com.github.benmanes.caffeine.cache.LocalCacheFactory相关的对象保留大小显示占整个堆的70%左右也就是说堆里超过一半的对象都是被一个Caffeine本地缓存给拖着。再点进Dominator Tree看了具体节点的字段这个Caffeine的缓存结构下面挂着几十万个缓存条目每个条目value是一个聚合统计对象一个对象占几十KB。合在一起就是灾难。看到这里嫌疑已经非常明确了这个本地缓存正在无上限地吸收所有请求查询出来的汇总结果把堆当成了无限大的存储池。2.4 补充确认代码里搜一下缓存定义果然没有maximumSize定位到具体组件后我立刻在代码库里搜索Caffeine的实例化代码。找到的代码大概是这样的private final CacheString, AggregatedData dashboardCache Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(30)) .build();全项目里搜了一遍这个缓存的newBuilder()后面只设置了expireAfterWrite没有设置maximumSize或maximumWeight。而业务代码里每次生成大屏聚合数据后都会执行dashboardCache.put(key, data)key是租户ID 大屏ID 时间片的组合value是一个几十KB的聚合对象。流量高峰时不同的租户、不同的大屏、不同的时间片在不断产生新的key旧key的过期时间是30分钟这就意味着30分钟内活跃请求产生的全部聚合对象都会堆在内存里。老年代被打满只是时间问题。3. 根因拆解为什么Caffeine不设上限就会变成堆内存黑洞如果只把问题归为忘了加maximumSize那这篇博文的价值就少了一半。更关键的是要理解Caffeine在没有容量上限时到底是怎么工作的以及为什么很多人会惯性写错。3.1 maximumSize不设的时候Caffeine到底怎么工作Caffeine的Cache接口设计里maximumSize(long)是用来控制条目数的。但Caffeine这个实现在很多场景下都很智能它的核心驱逐算法是TinyLFU会记录每个key的访问频率并在容量达到上限后通过频率信息来决定淘汰哪些条目。问题是当你不设置maximumSize时Caffeine根本不会启用基于大小的驱逐逻辑。从行为上讲它就像一张无限大的Hash Tablekey可以不断增加value可以不断堆积直到把JVM堆耗尽。源码层面Caffeine构建器里的maximumSize默认值是Long.MAX_VALUE也就是无限大。所以Caffeine.newBuilder().build()创建的缓存和new ConcurrentHashMap()在能不能无限增长这个维度上是一致的——只要没有其他约束它会一直吃堆。3.2 被忽略的驱逐机制Caffeine淘汰不是满了就立刻扔就算你设置了maximumSize还有一个容易踩的机制Caffeine的大小驱逐不是精确同步触发的。Caffeine为了减少写锁与竞争把驱逐动作设计成了惰性维护模式。它不会在put()的瞬间就立刻检查有没有超过容量上限、超额条目是否已经被移除而是在后续读写操作触发维护动作时再批量处理需要驱逐的条目。如果某个缓存条目在超出上限后被写入了但没有后续读写操作去触发维护这些条目依然会留在内存里占用空间。这一点和Guava Cache有所区别。Guava的驱逐会在写操作时尽量同步移除过期或超限条目但Caffeine为了高并发性能更倾向于在cleanUp()或维护阶段处理驱逐。这也是为什么在测试Caffeine缓存时你可能会发现超过上限了怎么还没淘汰的现象。理解了这一点修代码的时候就会知道不能只依赖容量驱逐做实时精确控制还要接受延迟淘汰这个现实并在年轻代GC时配合expireAfterWrite等过期策略一起使用。3.3 你以为只是多存几个对象但Caffeine节点本身就很贵还有一个很多人不会主动算的账Caffeine里一个缓存条目并不是一个Key 一个Value引用那么轻量。Caffeine底层存储使用的是扩展的ConcurrentHashMap节点每个节点除了key和value外还要存储访问频率信息、过期时间戳、状态标记等元数据。在JDK11下一个简单的Caffeine节点可能比HashMap中的一个普通Entry多占用几十甚至上百字节。当你缓存的是几十万条数据时单是这些元数据就会占据几百MB堆内存。如果你缓存的是大对象——比如一个聚合统计后的JSON串、一个大ArrayList或一个包含嵌套结构的POJO——那每个条目的内存占用就非常可观了。我们这次事故里的value平均50KB几十万条就是几十个GB的堆用量8G堆怎么可能扛得住。很多开发对本地缓存有误解觉得它就是往Map里塞对象有个引用而已实际上缓存框架为了支持功能特性付出的额外内存开销比想象中大得多。当你决定做一个大容量缓存时先估算这条单位内存成本比事后优化重要得多。3.4 为什么当时会写出这个裸缓存三个常见的开发直觉误区我在排查时复盘了当时写这段代码的思考过程基本逃不过这三个误区本地缓存不是会自动淘汰吗如果只设了过期时间没设容量上限那么它只会按时间淘汰。只要key不断新增堆就会持续增长。自动淘汰不等于自动限制堆内存。缓存的对象都很小几万个也没问题吧单看条目数可能觉得几万不就算什么但一旦value是几十KB的聚合对象几万条目乘以几十KB就是GB级别。规模估算是缓存设计的必要环节不是可有可无的优化。反正是本地缓存最多影响一个实例。这句话在单机时没有大问题但线上服务往往多个实例同时堆积一个实例OOM崩溃后流量会转移到其他实例连锁反应之下整个集群都容易雪崩。我们这次就是一台机器OOM后负载均衡转发到其他节点其他节点也陆续开始高GC。这些直觉不是完全没有道理但它们的前提是缓存有明确的容量上限或淘汰策略作为兜底。没有兜底的缓存就像没有刹车的车平时没事一旦路况复杂就失控。4. 修复实操把Caffeine关进笼子这套配置我一直在用定位到根因后修复方案其实很清晰给所有Caffeine缓存加上完整的容量、过期、监控策略。但加一个maximumSize只是及格线我更建议按下面这套思路来配置。4.1 最基础的配置maximumSize expireAfterWrite组合修复后我们使用的第一版配置是这样private final CacheString, AggregatedData dashboardCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .removalListener((key, value, cause) - log.warn(Dashboard cache eviction. key{}, cause{}, key, cause)) .build();为什么maximumSize和expireAfterWrite要组合使用maximumSize负责兜底容量不管数据有多热、key有多多缓存条目数超过上限就会触发驱逐这是防止OOM的第一道防线。expireAfterWrite负责数据时效如果只有容量上限而没有过期时间缓存里存的数据可能长期不更新业务看到的还是旧数据。对运营平台的大屏聚合数据来说10分钟内的新鲜度是可接受的。两者的配合还能减少冲掉热点的问题。如果只设容量上限每次流量高峰可能会把高价值的热点数据挤出缓存命中率剧烈波动有了过期时间旧数据会定期让位给新数据容量驱逐的压力也小一些。这里给的10分钟是一个例子实际设置要根据业务容忍的滞后时间来决定。如果数据要求严格实时就不该用本地缓存而应该直接查库或走分布式缓存。关于maximumSize具体设置多少我提供一个快速估算方法先测出一个缓存value大致的堆占用。可以在本地写个小程序构造一条数据后通过jol或直接压测估算。我们这条value大概50KB。设定目标最大占用。我们当时希望这个缓存最多用500MB堆那理论条目数就是500MB / 50KB ≈ 10000条。为了给GC和临时对象留空间建议再打个五折也就是5000条左右。如果你不知道单条value多大最稳的办法是先用小值上线再通过recordStats()的监控数据看实际内存表现慢慢调大。切忌上来就设一个看起来够用的百万级上限。4.2 更进一步用weigher按业务权重限制内存占用有些场景下maximumSize按条目数控制并不精确因为每一条缓存对象的size差异可能非常大。比如一个key对应小配置对象几KB另一个key对应大报表对象几百KB用条目数控制内存时实际内存占用会很不均匀。Caffeine提供了maximumWeight和weigher配合的机制可以按对象的权重来限制总容量private final CacheString, AggregatedData dashboardCache Caffeine.newBuilder() .maximumWeight(512L * 1024 * 1024) // 缓存总权重上限512MB .weigher((String key, AggregatedData value) - value.getJsonBytes().length) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build();weigher返回的权重可以理解为这个对象大约占多少字节maximumWeight是总权重的上限。Caffeine在写入时会累加权重超过上限后触发驱逐。这里有几个注意点weigher的计算要轻量不能在里面做复杂的序列化或数据库查询否则每次写入缓存都会产生额外开销。weigher不能返回负数不然Caffeine会直接抛异常。权重值不要设得太大或太小最好和实际内存单位对齐比如用字节数或KB数来估算方便后续换算。一旦启用了maximumWeight就不应该再同时设置maximumSize两者是二选一的关系。4.3 别忘了监听eviction淘汰了多少要心里有数很多团队配置完maximumSize就完事了但我建议一定要加removalListener和recordStats。原因很简单你不监控缓存就不知道缓存有没有在正常工作也不知道它有没有被频繁驱逐导致命中率崩掉。removalListener可以在缓存条目被移除时收到回调输出移除原因。Caffeine移除原因主要有这几类移除原因含义是否需要关注EXPLICIT手动invalidate删除通常不需要REPLACEDkey对应的value被覆盖通常不需要EXPIRED过期时间到了正常但量级异常时需关注SIZE容量超限被驱逐正常但大量SIZE驱逐说明容量设置过小或数据增长过快我们这次上线后日志里就出现过一段时间的SIZE驱逐告警。通过eviction原因分析我们确认新配置的5000条上限在大促期间不够用然后才逐步调整到10000条。recordStats()打开后可以定时获取CacheStats并上报到监控系统。比较关键的指标是hitRate和evictionCounthitRate如果过低说明缓存命中率差大量请求都在穿透到数据库此时要么数据过期时间太短要么缓存容量太小。evictionCount如果持续快速增长说明缓存处于高频换入换出状态可能把热点数据也淘汰掉了需要增大容量或优化key设计。4.4 复盘验证上线后的指标对比和压测结果配置修复后我们分两步做了验证。第一步是压测。用JMeter构造了和线上高峰相当的请求QPS分别跑旧的裸缓存配置和新配置观察JVM的堆占用曲线和GC频率。旧配置在半小时内老年代使用率就涨到了70%以上Full GC频率持续升高新配置下老年代使用率稳定在2G以内Full GC基本消失只有正常的Young GC。第二步是灰度上线。先改一台机器的配置观察三个指标接口平均RT是否回落到正常水平GC每秒耗时和Full GC频率是否稳定业务侧的大屏数据是否还在正常更新灰度观察了一天后才同步到所有节点。上线后老年代使用率曲线变得相当平缓再也没有出现爬坡式增长。顺带说一句我们还把堆从临时的12G调回了8G说明修复不是靠堆容量硬扛而是真的把内存占用控制住了。5. 类似的OOM陷阱这些堆内存杀手也在线上等着你这次事故之后我在处理其他线上OOM时总结出了一些常见的堆内存失控场景和Caffeine缓存有相似的触发逻辑。这里选两个最常见的说。5.1 大文件分片上传时为什么也会堆溢出有一个经典场景前端大文件分片上传后端接口接收分片后把每个分片的二进制内容临时存在内存里等所有分片都传完之后再合并。分片数量的上限文件总大小/单分片大小。如果单个文件是5G后端又把每个分片的byte[]封在对象里放进List等着合并那服务端堆很快就会被打满。这种问题的本质和Caffeine缓存一样内存中累积了大量生命周期较长的对象且数量没有上限约束。常见的解决思路分片数据落盘到临时目录而不是留在内存里最后合并时再读文件。用Part接口配合DiskFileItemFactory设置内存阈值超过阈值自动写入临时文件。对同一文件的分片数量做限制比如最多上传有限个分片超出后拒绝合并请求。如果你在处理类似场景ByteArrayOutputStream攒着所有分片然后一次性flush这个写法在文件小的时候很香在文件大的时候就是在埋雷。5.2 用POI大批量写Excel把整表数据全攒在内存里的坑另一个高频OOM场景是Excel导入导出。用Apache POI的XSSFWorkbook写大量数据时所有行和单元格对象都会被保留在内存中直到workbook.write()之后才算释放。一个几十万行的Sheet单元格数量可能是几百万个对象开销极大。处理这种场景的核心思路有两个导出时使用SXSSFWorkbook它内部会维护一个滑动窗口只保留最近N行在内存中超过窗口的行自动刷入临时文件。这是POI官方支持的流式写方案。导入时尽量用XSSFReader流式读取或分批读取一批处理一批不要把整个工作簿都加载进来再处理。这类问题的共性和Caffeine缓存事故完全一致短期看数据量不多放大到几十万、几百万条就突破了堆的临界点。5.3 通用排查OOM的最后检查清单我处理过的线上OOM事故无论表象是什么最后基本都能归到下面这几类静态集合/单例Map持有大量对象没有清理本地缓存没有设置容量上限或过期策略ThreadLocal值对象在当前线程池里长期存活请求处理中把大文件/大对象整体加载到内存连接池或并发队列堆积了海量待处理任务排查时我的固定顺序是先看监控确认是堆还是非堆Memory哪个区满了。jstat看GC情况判断是泄漏还是分配过多。让OOM自动dump或者手动jmap dump别凭感觉猜。用MAT分析Dominator Tree找到保留内存最大的对象链。在代码里重点搜static、Cache、newBuilder()、ThreadLocal、Listbyte[]这些关键词。最后再分享一个我自己现在写代码的习惯每次new一个缓存对象必须同时回答三个问题——容量上限是什么过期策略是什么淘汰原因会不会有人看到只要这三个问题都能说清楚类似本地缓存把堆吃穿的事故基本就能提前拦住了。
返回列表