跳到主要内容

一次生产环境 OOMKilled 告警引发的深度排查:从 JVM 内存到代码漏洞的全链路分析

· 阅读需 11 分钟

"在分布式系统的复杂世界里,每一个看似无害的告警,都可能是一条通往问题核心的线索。”

Intro - 新的挑战

几个月前,秉着对 backend engineering 的热爱,我加入了公司的 chatbot 团队,负责一个 Java 项目 bot-gateway 的开发。有意思的是,我们的服务在过去几个月里一直不稳定,每天总有 Kubernetes Pods 时不时地重启。不过由于业务繁忙,这个问题一直被搁置。更有意思的是,作为一名新加入的 Java 小兵,在狂奔开发业务功能两个月后,终于赶上了 Engineering Excellence Sprint. 怀着对技术问题的好奇心和解谜的热情,我主动请缨深入调查这个问题。

排查过程充满挑战,但最终的结果令我非常满意。可以说这次从业务代码到 JVM 内存模型,从 k8s pod 管理到线上监控告警,都详细捋了一遍。在此将整个过程记录下来,与大家分享。

一 / 突如其来的 Pod 重启

一切都始于一个看似平静的夜晚,我们的告警系统突然被触发:

[FIRING:1] Container has been restarted. Reason: OOMKilled

这个告警来自我们的核心服务之一 bot-gateway

eks-prod-help-center-Channel-Just-Eat-Takeaway-com-Slack-09-02-2025_04_59_PM.png

OOMKilled 这个词对任何 SRE 或开发工程师来说都意味着麻烦。研究了下,它表示容器因内存耗尽而被其宿主(Kubernetes 节点)无情地杀死。而且更恼火的是,这一切发生得如此突然且暴力, 应用层根本来不及留下任何有价值的“遗言”,没有应用层日志,而容器日志中也只有寥寥数语:

Task ... ran out of memory
... deleted with exit code 137

exit code 137 表示进程收到了SIGKILL信号而被终止。

Event-Management-All-Events-Datadog-09-02-2025_04_12_PM.png

服务一次次地重启,影响了用户体验,也给团队带来了巨大压力。于是我开始了深入调查之旅。

二 / 理论基础准备

之前对 JVM 并不熟悉,正好借此机会,夯实我的理论基础。

这里两个核心概念的区分至关重要:

OOMKilled vs java.lang.OutOfMemoryError

  • OutOfMemoryError (OOM Error): 这是 JVM 的“内部矛盾”。JVM发现自己的**堆内存(Heap)**不足,会主动抛出异常。这是一个相对“优雅”的失败方式。
  • OOMKilled: 这是容器的“外部冲突”。当 Kubernetes 发现整个容器的总内存占用(堆内存 Heap + 非堆内存 Non-Heap 和原生内存 Native memory 等)超过了设定的上限 (limits.memory),为了保护整个节点的稳定,操作系统会像一个无情的城管,会强制将这个失控的进程(容器)驱离。

JVM内存模型简介

  • 堆内存 (Heap Memory):存储代码中new出来的所有对象实例,被分为新生代 New Gen老年代 Old Gen。对象在新生代中诞生,经历数次垃圾回收(GC)后依然存活的,会被晋升到老年代。如果老年代持续增长且无法回收通常意味着内存泄漏
  • 非堆内存 (Non-Heap Memory):在 Java 8 及以后版本中,主要指元空间(Metaspace),存储类的定义、方法、字段等元数据。元空间持续增长通常表明类加载器泄漏(ClassLoader Leak
  • (TODO 补链接到另一篇 JVM 内存 blog)

当然,我一开始也并不清楚到底是容器配置有问题(比如这个 Java 应用就是需要这么大的内存,申请的机器内存 1G 过小),还是咱的应用代码有问题,只能硬着头皮开始淌水。

三 / 抽丝剥茧的排查过程

在请教各类 AI 老师 (ChatGPT, Gemini, DeepSeek…) 后,我开始了以下计划:监控指标分析、内存快照 Heap Dump 检查和错误日志追踪。

一、监控指标的“异常心电图”

我们首先打开了 JVM Metrics 监控仪表盘。修复前的图表令人震惊:

JVM-Metrics-Datadog-before.png

最明显的异常是 GC Old Gen Size (老年代大小) 图表。可以清晰地看到,老年代的内存占用呈现出一种只增不减、持续攀升的态势。这强烈暗示有大量对象被错误地长期持有,无法被 GC 回收, Heap usage 常年保持在 500M 左右。对于一个主要数据都放 Redis 缓存,无需保存状态的 gateway 服务来说,这个数字显得有些奇怪。

二、深入内存快照(Heap Dump)的“犯罪现场”

为了看清这些“老赖”对象的真面目,我们需要获取 heapdump. 同时,为了让 pod 不再被容器直接杀死,而是更温柔地让应用层抛出 OutOfMemoryError,我尝试了添加各种 JVM Flags,也算是做了些 JVM 调优:

<jvmFlags>
<jvmFlag>-server</jvmFlag>
<jvmFlag>-XX:MinRAMPercentage=40</jvmFlag>
<jvmFlag>-XX:MaxRAMPercentage=60</jvmFlag>
<jvmFlag>-XX:MaxDirectMemorySize=192m</jvmFlag>
<jvmFlag>-Xss512k</jvmFlag>
<jvmFlag>-XX:NativeMemoryTracking=summary</jvmFlag>
<jvmFlag>-XX:+UnlockDiagnosticVMOptions</jvmFlag>
<jvmFlag>-XX:+HeapDumpOnOutOfMemoryError</jvmFlag>
<jvmFlag>-XX:HeapDumpPath=/dumps</jvmFlag>
<jvmFlag>-Dio.netty.leakDetection.level=PARANOID</jvmFlag>
<jvmFlag>-javaagent:/library/dd-java-agent.jar</jvmFlag>
<jvmFlag>-Ddd.jmxfetch.enabled=true</jvmFlag>
<jvmFlag>-Ddd.jmxfetch.statsd.enabled=true</jvmFlag>
</jvmFlags>

这些参数的作用包括:

  • 先是限制 JVM 内存使用范围,这样内存用尽时会直接抛 OutOfMemoryError, 保留案发现场的错误栈
  • 将线程栈限制为 512k(考虑到没有过于复杂的逻辑)
  • 设置 DirectMemory 上限 192M
  • 开启 NativeMemory Tracking,便于使用 jcmd diff 分析 NativeMemory
  • 配置 HeapDumpOnOutOfMemoryError 并将 heapdump 保存到 k8s 容器 volume
  • 启用 Datadog agent jmxfetch

我们通过 Spring Actuator 的 /heapdump端点,捕获了一份堆内存的快照文件(.hprof)。这里有个小插曲,虽然我之前有 k8s pods exec 权限,可以轻松 shell 进 pod 执行各种 JDK 命令和下载 heapdump, 不过出于安全考虑,SRE 最近收回了这一权限,只能通过 k8s pods debug 方式操作。所以我只能挑了一台看起来内存马上要爆了的机器,使用 kubectl 转发机器 8080 端口流量到本地,然后本地访问 Spring Actuator 的 localhost:8080/heapdump下载 heapdump 到本地。

使用内存分析工具MAT (Memory Analyzer Tool)打开它,信息量还是挺大的:

  1. 嫌疑人A:巨大的byte[]数组与NettyDominator Tree视图显示,堆中有几个异常巨大的byte[]数组。通过 Path to GC Roots追溯其引用链,发现它们最终都指向了reactor.netty的内存池组件 (PoolChunk)。这让我们初步怀疑,是否存在 Netty 缓冲区泄漏,于是添加了 -Dio.netty.leakDetection.level=PARANOID JVM 参数,以便在日志中捕获 Netty 内存分配未释放的报错。

    heapdump_hprof_dominator_tree.png

    heapdump_byte_array_path_to_GC.png

  2. **嫌疑人B:行为诡异的DatadogClassLoader**Leak Suspects报告则指出了另一个问题:大量的java.util.zip.ZipFile$Source(打开的JAR文件句柄)和java.lang.Class对 象实例被一个名为DatadogClassLoader的类加载器持有。这不仅解释了非堆内存(Non-Heap)缓慢增长的原因,也揭示了一个由监控探针引发的慢性资源泄漏。

三、决定性的错误日志(The Smoking Gun)

配置好 JVM 参数后,我就开始 monitoring 了。放了一天一夜,没有收到具体日志,但发现 pods 重启频率明显降低。正当我一头雾水一筹莫展时,我又尝试再次调用 Spring actuator 接口 dump 一份 heap hprof,好巧不巧,这次机器可能真的出于崩溃边缘了,这个接口返回了 500,原因是 java.lang.OutOfMemoryError: Java heap space

heapdump-OOMError.png

这就有意思了,原来它是会抛 OutOfMemoryError 的呀。赶紧扩大搜索范围,关键词 "java.lang.OutOfMemoryError" 哐哐哐一顿搜日志,结果居然搜出来了好几个!原来它们都埋在了一堆 error 日志中。除了这一个是 /heapdump 接口触发的,其余的全部来自另一个逻辑:

JVM OOM Error

java.lang.OutOfMemoryError: Java heap space
at java.desktop/java.awt.image.DataBufferByte.<init>(DataBufferByte.java:93)
...
at javax.imageio.ImageIO.read(ImageIO.java:1466)
...
at com.justeattakeaway.botgateway.service.evidence.validators.impl.ImageValidator.readImage(ImageValidator.java:75)

真相大白! 这个堆栈追踪如同一束光,照亮了整个案件的核心。原来错误发生在我们的ImageValidator服务中!我迅速看了下代码,我们有一个功能允许用户上传食品问题图片供客服处理。在上传图片时,后端会进行校验(如图片尺寸、文件大小和格式等),这些校验依赖 ImageValidator, 而它内部则调用了ImageIO.read()来处理用户上传的图片,这个方法会将完整的、未压缩的图片像素数据全部加载到堆内存中。

这意味着我们在内存中会完整保存用户上传的图片,长期累积下来极其消耗资源,这些大对象也没法被 GC,就会变成 Old Gen 钉子户,在堆中赖死赖活。更严重的安全隐患是,恶意用户可以上传一个文件大小不大但分辨率极高的“图片炸弹”,瞬间耗尽所有内存。例如,一张 20000x20000 像素的图片,在内存中需要约 1.6 GB 的空间!这是一个致命的代码漏洞。

四、拨乱反正 - 根除三大顽疾

现在,我们对整个问题有了完整的画像:

  1. 慢性病:Datadog Agent 的类加载器泄漏,缓慢抬高了非堆内存基线
  2. 并发症:Netty 的潜在缓冲区泄漏,增加了堆内存压力
  3. 导火索:ImageValidator 中危险的图片处理逻辑,成为压垮骆驼的最后一根稻草

我们的解决方案也必须对症下药,三管齐下:

  1. 紧急修复(治本):重构ImageValidator。放弃直接调用ImageIO.read(),改用更安全的ImageReader API,在完整解码图片前先读取尺寸元数据。一旦尺寸超过预设的安全阈值,直接拒绝该图片。
  2. 流程改造(固本):我们将整个文件处理流程从基于byte[]的方式重构为基于InputStream的流式处理,8KB 8KB 地分块读取到内存中。从源头开始,一旦 size 超过预设的大小,直接拒绝该图片,避免了将大文件一次性加载到内存中的问题。
  3. 长期治理(除病根):确定 Datadog Java Tracer 版本(1.39.0)并制定升级计划。升级到最新版同时解决了已知的类加载器泄漏和一些次要日志错误。

五 / 雨过天晴 - 稳定的系统

部署修复补丁后,效果立竿见影。JVM Metrics 仪表盘呈现出前所未有的平稳:

JVM-Metrics-Datadog-09-02-2025_04_04_PM.png

  • 堆内存(Heap Usage) 不再有致命的尖峰,呈现出健康的周期性波动,从 500MB 稳定在 150MB。
  • 老年代(Old Gen Size) 不再持续增长,稳定在一个合理的水平 400MB -> 100MB。
  • New Gen Size: 90 MB -> 30MB.
  • OOMKilled告警彻底沉寂

最终章 / 经验与反思

这次惊心动魄的排查之旅(期间因业务线上问题,导致配置好的 JVM Flags 被多次回滚),给我们留下了宝贵的经验:

  1. 万物皆有关联::复杂的生产问题往往是多个看似无关的因素(应用漏洞、依赖泄漏、框架使用)叠加的结果。
  2. 理论指导实践::对 JVM 内存模型等基础知识的深刻理解,是正确解读监控数据和内存快照的钥匙。
  3. 工具是侦探的眼睛:熟练运用监控系统(Metrics)、内存分析器(MAT)和框架自带的诊断工具,是高效定位问题的根本
  4. 防御性编程: 永远不要相信用户的输入。对不可信数据(如上传的文件)进行严格的、内存安全的校验,是保证系统健壮性的生命线。

这次经历不仅解决了一个棘手的生产问题,更让我对 JVM、容器化和分布式系统有了更深的理解。每一个生产环境的问题都是宝贵的学习机会,正是通过这些挑战,我们才能不断成长为更好的工程师,更好的 Builder, Solver, Collaborator.

30 岁异国重启生活:选择与自由的探索

· 阅读需 3 分钟

Hangout in a dutch town

日子飞快,距离 23 年四月底飞来荷兰工作已经九个月了。当然现在的我仍然在苦苦等待 GP,仍然看不懂路边的荷兰标语,不过也终于可以喘口气,在 2024 年初给自己做一些回顾。

自打开始决定试试海外工作后,就开始全方面地准备了。当时 ChatGPT 还没这么普遍应用,我用着谷歌翻译写完了英文简历,鼓起勇气给各种公司投了简历,又操着一口蹩脚英语磕磕绊绊面完试。幸好之前的工作支持 WFH,上班前下班后就继续做题、投简历、准备面试、跟 Cambly 老师聊人生。还学习小红书博主 环猪猪润,甚至把名字都改得类似,每天看大家更新也会受到鼓舞。好像自己并没有经历过那么漫长难熬的备润过程。就这样,尽管也刷了 LeetCode 100 题,练了两个多月口语,在拒信也差不多收到手软的情况下,不知道是否称得上幸运,也顺利拿到了几个 offer. 有日本,有荷兰,简单分析考虑后,还是选择了荷兰。

于是四月底一周内火速离职、打包完八年的行李、卖了近两百本书,告别父母和老友们,就踏上了北京直飞阿姆的航班。累到疲惫,以至于在航班上的我根本没有精力去想接下来要面对的是什么。落地后,被一望无际的大草原和大蓝天震惊了一会儿。稍作休息四五天,就入职了新公司开始了新的工作。好像昨天的我还在北京高楼大厦车水马龙打工人,下一秒就呆在了风景优美人烟稀少的荷兰小城。加上从来没有在外留学过,这是我第一次非常沉浸式地在一个纯英文,甚至夹杂一些小语种的环境中生活工作。扑面而来的新鲜感、文化冲击,倒着时差盯着完完全全看不懂的荷兰语,嚼着冰冷三明治还要和同事微笑说 噢味道还不错,走在路上隐隐约约感觉到了周围异样目光,只记得第一个月的我过得异常恍惚,全是碎片。就这么突然成为了别人眼中的“外国人”,在吃到热乎的食物会热泪盈眶,这才艰难发现自己有个铁打不动的中国胃,以及语言沟通能力于我、于融入来说是有多么重要。

回过头来想,我当时做这样的选择,一部分出于对外面世界的向往,另一部分也有出于对某些压抑生活的恐惧。疫情期间,和口语老师聊到了目标和梦想,我对她说,我只希望自己能拥有“随时都有选择” 的权利,这是我可能一直在追求的东西。

Welcome

· 阅读需 1 分钟

Hello, I started writing again.

This time, I used Docusaurus, and it worked pretty good.

This is my personal website; I hope I can continue to ponder and summarize since I believe it is a good habit.

Goal

So I want to accomplish the following:

  • organize my knowledge and keep track of what I've lately learned;
  • experiment with new productivity strategies, stay motivated and avoid burnout;
  • improve my English(as a non-native English speaker);
  • become more self-disciplined so that I have more time for the activities listed above.

Uber 高性能 Web App 优化实践

· 阅读需 8 分钟

原文 - Building m.uber: ENGINEERING A HIGH-PERFORMANCE WEB APP FOR THE GLOBAL MARKET

Performance matters on mobile.

又是一篇关于性能优化的实践。

m.uber 团队对 m.uber - 即他们的超级轻量 web app 做了一些性能优化的工作。

范围全面,从代码到打包到部署到缓存,都有涉及。

TL;DR

Performance Tools

从十个 React 迷你设计模式谈开去

· 阅读需 15 分钟

很早之前就一直在读的一篇文章,10 个React Mini 设计模式,一边做 Creator 项目,也一边终于把它精读完。

结合自己的开发时候的项目经验,做了点笔记。

Creator 项目是一个多端(Web + Mobile)React SPA,且有一些表单填写和复杂的交互组件。

自己单独封装了一个很简单的基于 Node EventEmitterStore,开发过程中收获很大,这些细节之后可以细说。 产品那边后来又加了「置顶」功能,类似双向数据通信的 EventEmitter 逻辑有点太乱了,所以还是狠心花时间升级成了 Redux + Immutable.js + Normalizr 技术栈,果然省心很多。

原文作者说你是不是天天写 React, 写着写着发现自己可能经常用来实现需求的,也总是那么几个方法,往大了讲其实就是开发中的 设计模式。在这里我们称为 Mini Patterns

Bug makers or bug fixers

· 阅读需 6 分钟

世界上只有两种人: Bug makers or bug fixers.

​ —— 郫县豆瓣

可能是周一不宜上线吧。

下午在做某个需求的时候,突然收到了一个之前修过但却并没有修好的小 Bug。

接着又收到了一个刚上线的新需求报过来的线上 Bug 反馈,然后紧接着同一个需求的两个、三个 Bug… 一大片,炸开了。

最后, PM 说,”这次 Bug 太多了,都先回滚吧。”

只是突然觉得,已经工作了一年多近两年的我,写代码的时候依然不走心。

其实作为程序员,被人指出你代码的 bug 是难受的,除了尴尬,有时候甚至真的会感到羞耻,会有点看不起自己也觉得会被人看不起。

我只希望自己能够在每次写代码的时候永远记住这种感觉。

之前也是遇到过各种奇奇怪怪的线上 Bug,其实大部分都是自己写的时候不用心、测试时候不上心导致。

而最近,写的时候居然总有种匆匆忙忙急着完成任务的感觉,可结果却是既没有快速完成,也没有保证质量,既不好也不快,反而耽误了很多时间在 Debug 上。

十足的 Bug Maker.

其实仔细反思一下,我是知道原因的。

可能我们都有病

· 阅读需 9 分钟

焦虑、抑郁?

昨天在朋友圈偶然看到了一个关于「成电延时摄影」视频,突然很好奇地点进去看了下。伴随背景音乐响起,晨雾中的主楼,晨光中的银杏大道,蓝天下的宿舍楼,黄昏下的品学楼,夜幕降临的图书馆,灯光辉煌的体育馆,还有一直没机会上过课的新教学楼,碧波荡漾的东湖西湖…
以成都的天气,作者肯定是花了很多心血的,虽然不排除后期,但是一共七千五百张照片,一帧帧下来,每一幅都是曝光完美,好像从来没有见过那么美的成电。 仔细想想,毕业离开学校近两年,从来没有很认真地怀念过自己的大学。
每次跟人提起,就只能用一个词来形容,黑暗。
那段时间,我经历了入学的迷茫、苦闷,无疾而终的异地感情和孤立,梦想与现实差距带来的焦虑、抑郁,人际关系紧张的被忽视、被抛弃、被不理解,以及进入大学就再没有好过的睡眠。特别严重的一段时间,白天如行尸走肉,不想读书,也无法集中注意力,晚上必须服用安眠药才能入睡,走在路上还有着被害妄想症心理。 同时我也很疑惑,为什么身边的那些人就可以那么无忧无虑,那么轻易得到自己想要的东西。

Fun with Codemod & AST

· 阅读需 14 分钟

TL;DR

  • Facebook 为了解决「大型代码库」迁移,基于 AST 造了个工具 Codemod
  • 基于 Codemod 又构建了 JavaScript 代码迁移专用的工具 jscodeshiftReact-codemod
  • 理解这些工具背后的原理有助于从一个单纯的「API 使用者」变成一个工程师般的「创造者」
  • Demo Time!Let's write a codemod
  • 一些有价值的参考

豆瓣、北京、工作,及终于与自己握手言和的 2016

· 阅读需 23 分钟

在隆冬,我终于知道,我身上有一个不可战胜的夏天。 — 加缪

豆瓣

在 2015 年末,我在笔记本上这样写着:

有时候在想,能加入豆瓣也真的算是一种幸运。豆瓣这家公司,之前或现在,算是由复旦和华科这两所我很喜欢的学校的人组成,所以有些时候,对于这里的各种相似人生观、世界观及价值观会特别认同。就算偶尔也会对自己现在的能力与效率感到一点焦虑,不过不管怎么说,只要不退步,总是会前进。

15 年夏天,我加入了豆瓣。
对,就是这个文艺得出了名的互联网公司。
还记得 14 年底我正上大四,尴尬的时间,面临找工作选公司的窘境。
而当时的我,喜欢拍照听歌,弹弹吉他,看些好玩的杂书,见到迷人的设计会兴奋不已,所以在那所死板沉寂绩点为王的工科学校,自认为算是个文艺青年。但是我也一直喜欢硬件,喜欢消费电子,喜欢拆装,享受通过自己动手创造出东西的过程,喜欢 Web,也能写点代码,人生某个阶段曾把成为一名 Geek 作为理想之一。于是不想浪费自己的底子,想着还是可以干点有技术含量的活,那么就去文艺气质的公司做点技术活吧,在不毁自己三观的同时还能养活自己,我是这么想的。 幸运的是,在我做决定的四个月后,我收到了来自豆瓣的 Offer。

跟随前进者的步伐

· 阅读需 4 分钟

每个人,在人生不同阶段,或多或少是需要一些鸡血的。

自从工作以来,也确实觉得自己懒癌,且很多事情缺乏自控力和自律。

只记得乔老爷子说过,

自由从何而来?从自信来,而自信则是从自律来!先学会克制自己,用严格的日程表控制生活,才能在这种自律中不断磨练出自信。自信是对事情的控制能力,如果你连最基本的时间都做控制不了,还谈什么自信?

是不是真的出自乔老爷子之口还没有经过求证,但当时看到这句话的时候,确实是突然会心一击。

一直想做起来的个人博客,无论是实实在在的文章内容,到表面的 UI / 主题 / 页面,再到更底层的网络优化 / VPS 配置 / 自动运维,这块真是在一拖再拖。

其实自己一直有在关注一些有意思的同行,这里先贴几个自己缺乏灵感的时候会去光临的个人网站清单。