7月14号凌晨3点17分,手机震了。
运维群里全是红色告警:order-service的Pod连续重启,OOMKilled。K8s在疯狂拉起新Pod,但起来后没几分钟又炸了。
我爬起来开电脑,连VPN。说实话虽然这种情况遇到过不少次了,但每次心跳还是会加速------线上OOM意味着用户在下不了单,每多一分钟都是钱。
最终40分钟定位根因,50分钟上线修复。这篇文章记录的是整个过程,不是事后整理的完美版,是带着慌乱和弯路的真实版。
0-5分钟:确认现状
第一件事不是看代码,是搞清楚"现在什么状况"。
打开K8s Dashboard,看几个关键信息:
Pod状态:CrashLoopBackOff
重启次数:12次(30分钟内)
最后退出原因:OOMKilled
内存限制:2GB
然后看Grafana的内存监控图:
内存曲线:正常在800MB左右波动
20分钟前开始陡峭爬升 → 1.2GB → 1.5GB → 2GB → OOM
从异常到OOM大约8分钟
看日志:
bash
最近几条日志都正常,没有异常堆栈
最后一条日志:处理订单 ORDER-2026-0714-0038721
然后就没有然后了------进程被kill了
没有异常堆栈是OOM最烦的地方------JVM被操作系统强杀,来不及打印任何东西。
初步判断:不是一次性大对象分配导致的OOM(那种会抛java.lang.OutOfMemoryError并打印堆栈),而是内存逐渐泄漏导致的------某个东西在持续吃内存,直到撑爆。
5-15分钟:拿Heap Dump
内存泄漏的OOM,关键证据就是heap dump。但进程已经重启了,之前的堆内存没了。
现在的任务:让它在下次OOM之前把dump抓下来。
第一步:加JVM参数
yaml
# K8s deployment加JVM参数
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-XX:HeapDumpGcPath=/tmp/
但问题是,OOMKilled是操作系统级别的kill,JVM参数不一定会触发heap dump(取决于JVM是否在GC时检测到内存不足)。
更保险的方案:用jcmd在运行中抓dump
bash
# 先找到新Pod
kubectl exec -it order-service-pod-xxx -- bash
# 找到Java进程PID
jps
# 手动抓heap dump
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof
但手动操作有个问题------你得在内存涨到临界点之前抓。太早抓,泄漏的对象还没积累到足够多;太晚抓,进程又OOM了。
我的做法是写个简单的监控脚本,内存到1.5GB时自动抓dump:
bash
#!/bin/bash
while true; do
# 获取JVM内存使用
MEM=$(jcmd <pid> VM.native_memory | grep "Total" | awk '{print $3}')
if [ "$MEM" -gt 1500000000 ]; then
echo "Memory high, dumping..."
jcmd <pid> GC.heap_dump /tmp/heapdump_$(date +%s).hprof
break
fi
sleep 5
done
等了大约6分钟,内存从800MB爬到1.5GB,脚本自动抓了dump。
15-30分钟:分析Heap Dump
有了dump文件,下一步是分析。工具我用的是MAT(Eclipse Memory Analyzer),免费且强大。
把dump文件从Pod里拷出来:
bash
kubectl cp order-service-pod-xxx:/tmp/heapdump_*.hprof ./heapdump.hprof
MAT打开后,第一件事不是看对象列表,而是看Dominator Tree(支配树)------它能告诉你"谁占的内存最多"。
打开Dominator Tree一看:
arduino
java.util.concurrent.ConcurrentHashMap @ 0x7f8a3b2c1d00
└─ size: 1,247,832,448 bytes (约1.2GB)
└─ 内部是大量 java.lang.String 对象
└─ 每个 String 内容都是 JSON格式的订单数据
一个ConcurrentHashMap占了1.2GB,里面有上万个String,每个都是订单JSON。
问题已经缩小到:某个ConcurrentHashMap在不断往里塞订单JSON,但从来不清理。
接下来用MAT的"Path to GC Roots"功能,看看这个Map被谁引用:
markdown
ConcurrentHashMap
← OrderCacheManager.cache (字段)
← OrderCacheManager @ 0x7f8a3b2c2e00
← Spring容器(单例Bean)
找到了------是我们的OrderCacheManager类里的cache字段。
30-35分钟:看代码
打开OrderCacheManager:
java
@Service
public class OrderCacheManager {
// 就是这个Map在吃内存
private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
// 订单创建时往缓存里塞
public void put(String orderId, String orderJson) {
cache.put(orderId, orderJson);
}
// 查询时从缓存里取
public String get(String orderId) {
return cache.get(orderId);
}
// 订单完成时移除缓存
public void remove(String orderId) {
cache.remove(orderId);
}
}
看起来有put有remove,逻辑上应该没问题。但问题出在remove的调用时机上。
查调用链:
java
// 订单完成时会调用remove
@EventListener
public void onOrderCompleted(OrderCompletedEvent event) {
orderCacheManager.remove(event.getOrderId());
}
OrderCompletedEvent事件在订单完成时触发。但不是所有订单都会"完成"------有些订单创建后一直处于"待支付"状态,用户不支付也不取消,这种订单永远不会触发onOrderCompleted,缓存就永远不会被清理。
而且,7月14号凌晨0点,运营搞了个促销活动,订单创建量是平时的5倍。大量"待支付"订单的JSON被塞进Map里,remove的速度远远跟不上put的速度,内存就这么被撑爆了。
35-40分钟:确认根因
为了验证我的判断,我看了一下这个Map的key分布:
用MAT的OQL(Object Query Language)查询:
vbnet
SELECT * FROM java.lang.String s
WHERE s.toString().startsWith("ORDER-2026-0714")
发现1.2GB的Map里有大约12万个entry,其中:
- 大约8万个key是"ORDER-2026-0714"开头(当天促销活动)
- 其中大部分对应的状态是"PENDING"(待支付)
根因确认 :OrderCacheManager.cache是一个无界Map,订单创建时put,完成时remove。但"待支付"状态的订单不会触发完成事件,导致Map持续增长。促销活动放大了这个问题,8小时内Map增长到1.2GB,撑爆了2GB的容器内存。
40-50分钟:修复
临时修复很简单------给Map加上大小限制,超出时淘汰最旧的:
java
@Service
public class OrderCacheManager {
// 用Caffeine替代ConcurrentHashMap,设置最大容量和过期时间
private final Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多1万条
.expireAfterWrite(30, TimeUnit.MINUTES) // 30分钟自动过期
.build();
public void put(String orderId, String orderJson) {
cache.put(orderId, orderJson);
}
public String get(String orderId) {
return cache.getIfPresent(orderId);
}
public void remove(String orderId) {
cache.invalidate(orderId);
}
}
加了maximumSize和expireAfterWrite两个保险:
- maximumSize防止无限增长
- expireAfterWrite保证即使remove没被调用,过期的也会自动清理
重新打包、CI/CD、滚动更新。50分钟时新版本上线。
50分钟后:验证
观察了30分钟:
- 内存稳定在850MB左右波动
- Caffeine的eviction统计:每分钟淘汰约200条过期entry
- 没有再出现OOM
确认问题解决。
复盘
根本原因
用无界的ConcurrentHashMap做本地缓存,没有过期策略和大小限制。在正常流量下"待支付"订单的积累速度慢,问题被掩盖了。促销活动放大了流量,8小时内Map增长到撑爆内存。
为什么没提前发现
- 代码review时没注意到------这个Map是半年前一个同事加的,当时流量不大,没暴露问题
- 没有内存监控告警------监控只看了Pod的内存使用率,但没有看JVM堆内的对象分布。内存从800MB涨到1.5GB的过程中,如果有人看一眼"哪个对象占内存最多",就能提前发现
- 压测没覆盖这个场景------压测用的是"正常下单→支付→完成"的流程,没有模拟"大量下单但不支付"的场景
改进措施
- 所有本地缓存必须设置上限和过期时间------加到代码规范里,review时强制检查
- 加JVM堆对象监控------用Micrometer监控每个Bean的内存使用,超过阈值告警
- 压测增加"异常流量"场景------不只测正常流程,还要测"只下单不支付"这种异常场景
- Pod内存limit从2GB提到4GB------给一个缓冲期,至少在OOM前有更多时间排查
我的OOM排查方法论
最后总结一下,以后再遇到OOM别慌,按这个流程来:
ruby
第0-5分钟:确认现状
→ Pod是否在CrashLoop?重启了几次?
→ 内存曲线是陡升还是缓慢爬升?(陡升=大对象分配,缓慢爬升=泄漏)
→ 有没有异常堆栈?
第5-15分钟:抓Heap Dump
→ 如果JVM还活着,jcmd手动抓
→ 如果JVM已经死了,加-XX:+HeapDumpOnOutOfMemoryError等下次复现
→ 写个脚本在内存到阈值时自动抓
第15-30分钟:分析Dump
→ MAT打开,看Dominator Tree找最大对象
→ 用Path to GC Roots找引用链
→ 定位到具体代码
第30-40分钟:确认根因
→ 看代码,理解为什么内存不释放
→ 验证假设(用OQL查询、看日志、看监控数据)
第40-50分钟:修复上线
→ 临时修复(加限制、改参数)
→ 滚动更新
→ 观察验证
最关键的一步是抓Heap Dump。 没有dump文件,OOM排查就是猜谜。有了dump,MAT几分钟就能告诉你"谁在吃内存"。
凌晨4点10分,我关上电脑回到床上。虽然只睡了一个多小时就要起来上班了,但至少心里踏实了------知道问题是什么,知道怎么修,知道下次怎么防。
做后端开发这些年,OOM、CPU飙高、GC频繁、线程死锁这些线上事故,每个都经历过不止一次。每次都是一身冷汗,但每次也都是最宝贵的成长。书本上学不到的东西,线上事故会教你。