凌晨3点被叫醒:线上OOM,我用这套流程40分钟定位根因

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增长到撑爆内存。

为什么没提前发现

  1. 代码review时没注意到------这个Map是半年前一个同事加的,当时流量不大,没暴露问题
  2. 没有内存监控告警------监控只看了Pod的内存使用率,但没有看JVM堆内的对象分布。内存从800MB涨到1.5GB的过程中,如果有人看一眼"哪个对象占内存最多",就能提前发现
  3. 压测没覆盖这个场景------压测用的是"正常下单→支付→完成"的流程,没有模拟"大量下单但不支付"的场景

改进措施

  1. 所有本地缓存必须设置上限和过期时间------加到代码规范里,review时强制检查
  2. 加JVM堆对象监控------用Micrometer监控每个Bean的内存使用,超过阈值告警
  3. 压测增加"异常流量"场景------不只测正常流程,还要测"只下单不支付"这种异常场景
  4. 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频繁、线程死锁这些线上事故,每个都经历过不止一次。每次都是一身冷汗,但每次也都是最宝贵的成长。书本上学不到的东西,线上事故会教你。


相关推荐
用户298698530141 小时前
HTML 转 Word 指南:新手入门教程
人工智能·后端·python
letisgo51 小时前
JDK21 + Spring AI 2.0 入场指南:2026 年 Java 开发者 AI 架构全景图
java·ai·idea·jdk21
白仑色1 小时前
Spring Boot 从切面统一控制事务
java·spring boot·aop·spring事务
Csvn1 小时前
🐍 Day1 : Python 环境搭建 — 现代 Python 工作流
后端·python
Nturmoils1 小时前
工位上这台"竖着叠"的双屏,让我改bug终于不用来回切窗口了
后端
Flynt1 小时前
Java并行流,让我debug了一整天
java
42tr_k1 小时前
用 OpenDataLoader PDF 搭一个兼容 MinerU 的解析服务
后端·python
aircrushin2 小时前
Claude 5 之后,上下文工程该做减法了
前端·人工智能·后端
Csvn2 小时前
📊 SQL 入门 Day 16:数据插入技巧
后端·sql