一次线上OOM排查实录:从jmap到代码重构,我踩过的坑全在这了

凌晨两点,告警群里一条消息把我炸醒:生产环境某服务频繁Full GC,内存占用飙到92%,已经触发熔断。这种场景,做过几年后端的兄弟应该都不陌生。今天把这次排查的完整过程复盘一下,希望能帮到遇到类似问题的朋友。

问题现场

先说下背景。这个服务是一个订单聚合网关,Spring Boot 2.7 + JDK 11,部署在K8s集群里,4个Pod,每个Pod分配了2G堆内存。平时QPS大概3000左右,内存一直很稳定,堆使用率在40%-50%之间波动。

那天晚上8点开始,某个Pod的堆内存突然开始线性上涨,大概40分钟就顶到了1.8G,然后就开始疯狂Full GC。

复制代码
2026-08-20 22:43:12 [GC (Allocation Failure) [PSYoungGen: 524288K->65536K(611328K)] 1048576K->892416K(2006016K), [Metaspace: 198234K->198234K(1245184K)], 0.1234567 secs] [Times: user=0.45 sys=0.02, real=0.12 secs]
2026-08-20 22:43:15 [Full GC (Ergonomics) [PSYoungGen: 65536K->65536K(611328K)] [ParOldGen: 826880K->826880K(1394688K)] 892416K->892416K(2006016K), [Metaspace: 198234K->198234K(1245184K)], [PSPermCap: 32768K->32768K(32768K)], 8.2345678 secs] [Times: user=15.67 sys=0.12, real=8.23 secs]

注意看这个Full GC的耗时------8.23秒,而且回收完堆内存几乎没降下来。这说明老年代里有大量对象是GC Root可达的,根本回收不掉。

第一反应:先抓现场

很多人遇到OOM第一反应是去翻代码,觉得哪里写错了。说实话,这个思路效率很低。正确的做法是先保留现场

我当时的操作:

复制代码
# 进入Pod
kubectl exec -it order-gateway-7d8f9c6b5-x2k4m -- /bin/bash

# 先dump堆内存(注意:这一步会让服务短暂STW,生产环境慎用)
jmap -dump:format=b,file=/tmp/heap.hprof 1

# 同时抓一份线程快照
jstack 1 > /tmp/thread_dump.txt

这里有个细节要说一下:jmap -dump 在生产环境是有风险的,它会触发一次全量堆遍历,如果你的堆很大(比如8G以上),STW时间可能达到几十秒甚至几分钟。所以一般建议提前在JVM启动参数里加上:

复制代码
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heap.hprof

这样OOM的时候会自动dump,不用手动操作。

分析heap dump

把hprof文件拉到本地,用MAT(Memory Analyzer Tool)打开。

先看Overview页的饼图,一眼就能看到最大的那个扇区------byte[] 占了堆内存的67%,大概1.2G。

然后点进去看Dominator Tree,发现这些byte数组的GC Root指向了同一个地方:

复制代码
java.util.concurrent.ConcurrentHashMap$Node
  └── java.util.ArrayList
        └── com.xxx.gateway.cache.ResponseCache$CacheEntry

看到 ResponseCache 这个类名,我心里就有数了------这是一个本地缓存,而且没有设置过期策略

根因定位

翻了下代码,果然找到了问题。这个服务有一个接口是用来聚合下游多个微服务的数据,为了减少重复调用,加了一个本地缓存:

复制代码
@Component
public class ResponseCache {
    
    // 问题就出在这里
    private static final Map<String, CacheEntry> CACHE = new ConcurrentHashMap<>();
    
    public void put(String key, Object value) {
        CACHE.put(key, new CacheEntry(value, System.currentTimeMillis()));
    }
    
    public Object get(String key) {
        CacheEntry entry = CACHE.get(key);
        if (entry == null) return null;
        return entry.getValue();
    }
    
    @Data
    @AllArgsConstructor
    static class CacheEntry {
        private Object value;
        private long timestamp;
    }
}

看到问题了吗?这个缓存只有put,没有evict。key是请求参数的MD5值,每个不同的请求参数都会生成一个新的key,缓存只增不减。

而且这个接口的参数里包含一个时间戳字段,客户端每次请求传的时间戳都不一样,导致缓存key几乎不会重复。也就是说,这个缓存实际上从来没被命中过,只是一直在往里塞数据。

一个永远不被命中的缓存,却一直在吃内存。 这就是典型的内存泄漏。

修复方案

修复方案有两个思路:

方案一:加过期淘汰(快速止血)

复制代码
private static final Map<String, CacheEntry> CACHE = new ConcurrentHashMap<>();

// 定时清理过期缓存,每5分钟执行一次
@Scheduled(fixedRate = 300000)
public void evictExpiredCache() {
    long now = System.currentTimeMillis();
    CACHE.entrySet().removeIf(entry -> 
        now - entry.getValue().getTimestamp() > 600000 // 10分钟过期
    );
}

这个方案简单粗暴,但有个问题:ConcurrentHashMapremoveIf 在数据量大的时候性能不太好,而且定时任务本身也有延迟。

方案二:换用Caffeine(推荐)

复制代码
private final Cache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10_000)          // 最多缓存1万条
    .expireAfterWrite(10, TimeUnit.MINUTES)  // 写入后10分钟过期
    .recordStats()                // 开启统计,方便监控命中率
    .build();

public void put(String key, Object value) {
    cache.put(key, value);
}

public Object get(String key) {
    return cache.getIfPresent(key);
}

Caffeine底层用的是 Window TinyLFU 淘汰算法,相比Guava Cache的LRU,在缓存命中率上有明显优势。而且它自带过期淘汰和容量上限,不会出现内存无限增长的问题。

最终我们选了方案二,上线后内存使用率稳定在35%-45%,Full GC再也没有出现过。

几点反思

回过头看这次事故,有几个点值得注意:

1. 本地缓存不是万能的

很多开发者觉得加个Map就是缓存了,但本地缓存天然有局限性:多实例部署时数据不一致、内存占用不可控、没有统一的监控手段。如果不是对延迟极度敏感的场景,优先考虑Redis

2. 缓存一定要设上限

不管用什么缓存方案,maximumSizeexpireAfterWrite 这两个参数一定要设。不设上限的缓存,迟早会吃掉你所有的堆内存。

3. 监控比排查更重要

如果我们在上线时就给这个缓存加了监控(比如缓存大小、命中率),在内存涨到1G的时候就能发现问题,而不是等到Full GC了才被动告警。

复制代码
// Caffeine的统计信息可以直接暴露给Prometheus
cache.stats().hitRate();      // 命中率
cache.stats().evictionCount(); // 淘汰次数
cache.estimatedSize();         // 当前缓存大小

4. 生产环境的JVM参数要配好

-XX:+HeapDumpOnOutOfMemoryError 这个参数,我见过太多项目没加。等到OOM了才发现没有dump文件,只能对着日志干瞪眼。


这次排查前后花了大概两个小时,但复盘下来收获还是挺大的。其实大部分线上问题,根因都不复杂,难的是快速定位保留现场的意识。

如果你也遇到过类似的内存问题,或者有更好的排查思路,欢迎在评论区交流。

相关推荐
吕海洋2 小时前
目前可用的docker源2026.08
运维·docker·容器
gsls2008085 小时前
Penpot Docker 部署与 MCP 服务配置文档
运维·docker·容器·原型·penpot
天蓝不会忘记025 小时前
k8s集群部署的方法原理
云原生·容器·kubernetes
红球yyds6 小时前
用anisble搭建k8s集群及原理
云原生·容器·kubernetes
Thneonl9 小时前
让恢复动作敢按下去:dry-run、审批门、自动回滚与 mutable twin
rust·kubernetes
艾伦_耶格宇10 小时前
【DOCKER容器实战】-2MySQL
运维·docker·容器
To_OC20 小时前
跑了3个Docker容器后,我终于搞懂镜像和容器到底啥关系
后端·docker·容器
SNOWPIAOP1 天前
双 RTX 5090 + DSH + Qwen 3.8:本地 Agent 已经能稳定完成复杂软件重构
重构·qwen3.8·dsh·双5090
ltl1 天前
mTLS 工程实践:服务间双向认证怎么落地
kubernetes