发现:21 点 47 分,Pod 开始被 OOMKilled
2021 年 12 月 28 号晚上,年终大促的预热场。21:47 分监控群开始报:
[P1] promotion-service 实例不可用
事件类型:ContainerOOMKilled
命名空间:prod
Pod:promotion-service-7d9f8c4b5-x2nlk
容器内存:4.1 GiB / 4.0 GiB limit
重启次数:3 次(10 分钟内)
同一时刻,SkyWalking 9 上 promotion-service 的实例数从 6 掉到 3,剩下的 3 个实例 QPS 从 1200 冲到 2600,P99 从 38 ms 涨到 1100 ms。
这是我做架构之后碰到的第一起 P1 级内存事故,从头到尾 2 小时 18 分。我把完整过程记下来,包括中间走错的两条路。
排查:先看是不是"正常的高负载"
第一步,确认是内存泄漏还是流量上涨
看 Grafana 上这个服务一周的内存曲线。如果是流量导致的,堆占用应该是锯齿状——随请求涨,GC 后掉下来。但 12 月 26 号之后,Old Gen 的"谷底"在持续抬高:
日期 Old Gen 峰值 Full GC 后谷底 日均 QPS 12-23 1.42 GB 310 MB 980 12-25 1.88 GB 520 MB 1050 12-27 2.61 GB 1.10 GB 1130 12-28 21:00 3.72 GB 2.94 GB 1240 12-28 21:47 OOMKilled - 1200 → 2600QPS 只涨了 22%,但 Old Gen 谷底涨了 9 倍。典型的内存泄漏特征:看谷底,不看峰值。
Full GC 之后的堆还占 2.94 G,说明这些都是还有引用、GC 收不掉的活对象,不是垃圾。
第二步,抓现场
Pod 每次被 kill 就重来,来不及 dump。我先做了一件事:给 Deployment 加
preStop钩子,在容器被杀之前把堆倒出来。但 OOMKilled 是内核行为,preStop 不一定来得及。更稳的办法是加 JVM 参数自动 dump,同时把
HeapDumpPath挂到 PVC 上:env: - name: JAVA_OPTS value: >- -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/ -XX:+ExitOnOutOfMemoryError -Xmx3g -Xms3g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+PrintGCDetails -Xloggc:/data/logs/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=50M
-XX:+ExitOnOutOfMemoryError很关键。默认情况下 OOM 之后 JVM 进程还活着,但处于半死状态——所有依赖它的线程都会卡住,K8s 的 liveness 探针可能还判定它健康,于是流量继续打到一个不能干活的服务上。让它直接退出,K8s 才会快速重启。这次运气不错:21:52 那次重启前,dump 成功落地,3.1 G。
第三步,走错的那条路
我先用
jstat活着看了一个还在跑的实例:$ jstat -gcutil 1 1000 5 S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 100.00 87.42 91.23 95.81 93.02 34120 1842.331 218 412.882 2255.213 0.00 100.00 91.08 91.23 95.81 93.02 34121 1842.395 218 412.882 2255.277 0.00 100.00 94.77 91.23 95.81 93.02 34122 1842.461 218 412.882 2255.343老年代 91.23%,FGC 218 次,FGCT 412 秒,但老年代下不去。这里我犯了个错:看到
M(Metaspace)95.81%,我第一反应是元空间泄漏,怀疑是 Groovy 脚本或者动态代理类太多,花了 20 分钟去查ClassLoadingMXBean:$ jcmd 1 VM.classloader_stats | head -20 // 结果:总类数 18240,元空间 committed 268 MB,正常范围元空间 95.81% 只是因为它已经接近
MaxMetaspaceSize,属于"用满了但没泄漏"。方向错了。止损:先让服务能扛住
21:58,第一优先级不是找根因,是让服务活下来。三件事并行:
- 扩容:副本数从 6 拉到 16,用实例数硬扛内存增长,换取排查时间。
kubectl scale deployment promotion-service -n prod --replicas=16
- 定时重启:给所有副本加一个 cron,在内存涨到警戒线前主动重启掉最老的实例。这是很脏的办法,但有效。
#!/bin/bash # restart-oldest.sh 每 30 分钟跑一次 OLDEST=$(kubectl get pod -n prod -l app=promotion-service \ --sort-by=.status.startTime -o jsonpath='{.items[0].metadata.name}') AGE=$(kubectl get pod "$OLDEST" -n prod -o jsonpath='{.status.startTime}' | \ xargs -I{} date -j -f "%Y-%m-%dT%H:%M:%SZ" {} +%s | \ xargs -I{} echo $(( $(date +%s) - {} ))) if [ "$AGE" -gt 7200 ]; then # 存活超过 2 小时就换掉 kubectl delete pod "$OLDEST" -n prod --grace-period=30 fi
- 降级:把非核心的"凑单推荐"接口在 Nacos 配置里关掉,它占 34% 流量。
promotion: recommend: enabled: false22:15,服务恢复,P99 回到 62 ms。这时候才开始安心看 dump。
根因:一个"临时"的静态 Map
3.1 G 的 dump 用 MAT 打开,Dominator Tree 第一行:
Class Name Shallow Heap Retained Heap Percentage --------------------------------------------------------------------------------------------- java.util.concurrent.ConcurrentHashMap @ 0x6d88f1a20 64 2,486,127,304 79.83% |- java.util.concurrent.ConcurrentHashMap$Node[2097152] 16,777,232 2,486,126,984 79.83% | |- java.util.concurrent.ConcurrentHashMap$Node 32 1,204,984 0.04% | | '- com.xxx.promotion.vo.ActivitySnapshot 88 1,192 0.00% '- Total: 25 entries ---------------------------------------------------------------------------------------------
ActivitySnapshot,一个活动快照对象,塞了 128 万个在ConcurrentHashMap里。代码是这个:
@Component public class ActivitySnapshotCache { // "临时缓存一下,避免重复计算",注释是这么写的 private static final Map<Long, ActivitySnapshot> SNAPSHOT = new ConcurrentHashMap<>(); public ActivitySnapshot get(Long activityId) { return SNAPSHOT.computeIfAbsent(activityId, this::buildSnapshot); } private ActivitySnapshot buildSnapshot(Long activityId) { // 查库 + 查 Redis + 拼装,约 40 ms return ...; } }问题一眼可见:没有容量上限,没有过期,没有清理。
为什么 12 月 26 号之后突然加速?查 Git 记录:
$ git log --oneline -5 -- ActivitySnapshotCache.java 8f3c2a1 2021-12-26 14:22 feat: 活动快照按 SKU 维度缓存,提升凑单推荐性能 c91b04d 2021-09-08 10:15 feat: 新增活动快照缓存12 月 26 号那次改动,把 key 从
activityId(约 3000 个)换成了activityId * 100000 + skuId。key 空间从 3000 变成了 3000 × SKU 数 ≈ 130 万。改动本身是合理的(凑单推荐确实按 SKU 算),但那个 Map 是按"最多几千个 key"的假设写的。算一下增长曲线就对上了:
单条 ActivitySnapshot 平均 1.9 KB(含 SKU 列表、规则表达式字符串) 128 万 × 1.9 KB ≈ 2.48 GB 上线 44 小时 ≈ 每小时 2.9 万个新 key和监控上 Old Gen 谷底的抬升速度完全吻合。
为什么测试环境没发现
复盘会上这是我的第一个问题。三个原因:
- 测试环境的 SKU 只有 2000 个,key 空间 3000 × 2000 = 600 万是理论值,但没人会跑满,实际攒到几万个就停了。
- 测试环境的 Pod 一周才有人用一次,每次都是重启后跑两个小时就结束了,根本攒不到泄漏量。
- 我们 CI 里没有内存增长类的测试。
改进:五条,按优先级排
1. 换 Caffeine,加容量和过期(治本)
@Component
public class ActivitySnapshotCache {
private final Cache<String, ActivitySnapshot> cache = Caffeine.newBuilder()
.maximumSize(200_000)
.expireAfterWrite(Duration.ofMinutes(30))
.recordStats()
.build();
public ActivitySnapshot get(Long activityId, Long skuId) {
return cache.get(activityId + ":" + skuId, k -> buildSnapshot(activityId, skuId));
}
}
20 万条 × 1.9 KB = 380 MB,加上其他对象,老年代稳定在 900 MB 左右。命中率实测 96.3%,相比无上限的 Map(命中率 100%)只掉了 3.7 个点,但内存从 2.48 G 降到 380 MB。
2. 静态 Map 纳入代码扫描
在 SonarQube 里加了自定义规则,凡是 static final Map / static final List 且没有对应清理调用,直接判为 Blocker。扫出来全公司 14 处,其中 3 处是真问题。
3. 内存告警改成看"谷底"
之前的告警规则是"内存使用率 > 85%",这种规则对泄漏型问题反应太晚。改成两条:
# 老年代在 Full GC 之后的占用,持续上涨 → 泄漏信号
jvm_memory_used{area="heap",id="PS Old Gen"} / jvm_memory_max{area="heap"}
> 0.75 持续 10 分钟 → 警告
# 用速率告警:1 小时内 Old Gen 谷底上涨超过 200 MB
deriv(jvm_gc_memory_after_gc_used_bytes[1h]) > 55924 # 200MB/3600秒 ≈ 55.9 KB/s
→ 严重告警
4. 所有服务补齐 OOM 自动 dump
之前只有 30% 的服务配了 -XX:+HeapDumpOnOutOfMemoryError,这次之后全部补齐,dump 目录挂 PVC,并且加了 -XX:+ExitOnOutOfMemoryError。
5. 压测加"长稳"阶段
我们的压测以前只跑 30 分钟,看 TPS 和 P99。现在大促前必须跑一轮 8 小时稳定性测试,盯三条曲线:Old Gen 谷底、堆外内存(NMT)、线程数。
小结
- 判断内存泄漏看 Full GC 之后的谷底,不看峰值。这次峰值涨 2.6 倍、谷底涨 9.4 倍,谷底才是信号。
Metaspace使用率 95% 不等于元空间泄漏,用jcmd <pid> VM.classloader_stats确认再下结论,我在这上面浪费了 20 分钟。- 止损优先于定位。当时的三板斧:扩容(6→16)、定时换掉老实例(存活超 2 小时)、关掉非核心接口(34% 流量)。17 分钟恢复。
- 容器内存 limit 要大于
-Xmx加上堆外的余量。我们 4 G limit / 3 G 堆,只剩 1 G 给元空间、直接内存、线程栈,太紧。 -XX:+ExitOnOutOfMemoryError必须开,否则 JVM 半死不活,K8s 还判定它健康,流量继续打过去。- 静态
Map做缓存一定要有上限。这次的 key 空间被一次"合理"的需求改动从 3000 放大到 130 万,而缓存代码没有跟着改。缓存的容量假设要写在注释里。
这次事故真正的教训不是"忘了加过期时间",而是:那行 private static final Map 在代码评审时通过了两次。它能过,是因为我们都默认"这个 Map 只会装几千个活动"。容量假设没有写进代码,也没有写进评审清单,最后就只能靠一次 P1 事故来提醒。现在我在团队里的要求很简单——任何缓存,都要在代码里写明"最多装多少、凭什么这么算"。