记一次大促前压测OOM:CPU飙到300%,靠3个命令定位到元凶

54 阅读15分钟

生产问题排查:CPU 飙高、内存泄漏、慢 SQL 三场景实战

2025年双十一大促前压测,CPU飙到300%,服务直接被打挂。运维说"你们代码有问题",开发说"我们代码没问题"。最后靠 jstack + Arthas 三分钟定位到元凶:一行错误的 HashMap 初始化。这篇文章拆解 CPU飙高、内存泄漏、慢SQL——三种后端最常遇到的线上问题排查路径。

阅读约 15 分钟 | 系列第 1/17 篇


一、接口响应慢:分层排查体系

线上收到"接口变慢"的告警,最大的忌讳是一上来就猜测原因。标准做法是分层排查,逐层排除

第一层:链路追踪定位

通过 SkyWalking / Zipkin / Jaeger 查看调用链,定位耗时最长的 Span。判断慢在哪个环节——应用代码、SQL 查询、Redis 操作、对外 HTTP 调用、MQ 发送。

第二层:应用层(Arthas 三板斧)

dashboard              # 查看 JVM 整体状态:GC 频率、堆使用率、线程状态
thread -n 5            # 定位 CPU 使用率最高的 5 个线程
trace ClassName method # 追踪方法调用链,输出每一步耗时
watch ClassName method '{params, returnObj, throwExp}' -x 3  # 观察入参/返回值
monitor -c 5 ClassName method  # 统计方法调用次数、成功/失败率、平均/最大耗时
vmtool --action getInstances --className com.xxx.Entity --limit 10  # 查看大对象数量

trace 是最常用的命令——它以树状结构输出方法调用链的每一步耗时,一目了然地揭示哪个子调用耗时最长:

`---[95.00%] DispatcherServlet.doDispatch()
     `---[92.00%] OrderController.createOrder()   # 92% 时间在 Controller
          `---[88.00%] OrderService.createOrder()  # 88% 在 Service
               `---[70.00%] deliveryService.query()  # 70%!瓶颈在此
                    `---[68.00%] jdbc.executeQuery()  # SQL 耗时 68%

由此可直接定位到 SQL 慢的问题。

第三层:数据库层

常见慢原因:

  1. 未走索引:EXPLAIN 显示 type=ALL 全表扫描
  2. 索引区分度差:走了索引但 rows 扫描行数仍然很大
  3. 回表过多:需要通过覆盖索引消除回表
  4. 锁等待:通过 SHOW ENGINE INNODB STATUS 查看锁信息
  5. 连接池耗尽:通过 Druid / HikariCP 监控面板查看活跃连接数

第四层:缓存层

检查 Redis 慢日志(SLOWLOG GET 10),排查大 key(单个 Hash 包含数十万字段导致单次操作缓慢)及热 key(某个 key 的 QPS 极高导致单分片瓶颈)问题。

第五层:外部依赖

使用 CompletableFuture 将串行调用改为并行。配置超时与熔断(Sentinel / Hystrix),防止单个下游服务缓慢拖垮整个调用链路。


二、工具速查表

生产环境中机器负载高是常见问题,掌握以下命令是高效排查的前提。

JVM 层面

场景工具关键命令
CPU 飙高top + jstack / Arthastop -H -p PID, thread -n 5
内存泄漏jmap + MAT / Arthasheapdump /tmp/dump.hprof, 分析 Dominator Tree
GC 频繁jstat / GC 日志jstat -gcutil PID 1000, -Xlog:gc*
死锁jstackjstack PID | grep "BLOCKED" -A 15
类加载问题Arthasclassloader -t 查看类加载器树
方法耗时Arthas tracetrace com.xxx.Service method -n 5
SQL 慢查Druid 监控 / SlowLogDruid 内置 SQL 监控面板
Redis 慢Redis slowlogSLOWLOG GET 10

OS 层面

场景命令关注点
机器整体负载top / htopload average(1/5/15 分钟),CPU 使用率(us/sy/wa),内存使用
CPU 详情mpstat -P ALL 1各核心使用率分布,判断是否为单核打满
内存详情free -havailable vs free,buffer/cache 可回收
磁盘 IOiostat -x 1%util 接近 100% 表示磁盘瓶颈,await 为 IO 等待时间
磁盘空间df -h各分区使用率,日志爆满是常见非 OOM 的内存杀手
网络连接netstat -antp | grep ESTABLISHED | wc -l连接数是否正常;大量 TIME_WAIT 表示短连接过多
端口监听ss -tlnp比 netstat 更快,确认服务端口是否正常监听
文件句柄lsof -p PID | wc -l句柄泄漏导致 "Too many open files",ulimit -n 检查上限
进程线程数cat /proc/PID/status | grep Threads线程数异常增长表示可能线程泄漏
系统日志dmesg -T | tailjournalctl -u app-nameOOM Killer 日志、coredump、系统级错误

排查经历的标准描述逻辑

  1. 先用 top 获取整体状况——CPU 高?内存高?load average 异常?
  2. 若为 Java 进程,则切入 Arthas dashboard + thread -n 5
  3. 若磁盘 IO 高,则通过 iostat 确认——可能是大量日志写入或数据库 IO
  4. 若为网络问题,则通过 netstat 查看连接数——排查下游慢导致的连接堆积
  5. 定位后,JVM 层面使用 Arthas,SQL 层面使用 EXPLAIN,OS 层面使用上述命令

三、内存泄漏排查全流程

线上内存持续增长,最终导致 OOM,应如何排查?完整流程分为七步:

Step 1:确认是泄漏还是正常增长

使用 jstat -gcutil PID 1000 持续观察老年代使用率——若每次 Full GC 后老年代使用率持续上升(如从 60% 到 70% 再到 80%),则为泄漏;若 GC 后能回落到正常水位,仅为对象创建速度过快,则属于正常情况,需调优参数。

Step 2:在 OOM 发生前 dump 堆快照

jmap -dump:format=b,file=/tmp/heap.hprof PID

堆内存较大时 jmap 可能触发 STW,可加 -F 参数强制 dump。生产环境建议配置 -XX:+HeapDumpOnOutOfMemoryError 实现自动 dump。

Step 3:使用 MAT 分析 Dominator Tree

用 MAT 打开 hprof 文件,点击 Dominator Tree(支配树),按 Retained Heap 排序。

Dominator Tree 的核心概念:A 支配 B 表示从 GC Roots 到 B 的所有路径都经过 A,因此 A 的 Retained Heap 等于 A 被回收后能一并释放的内存总量。该视图可快速定位"最大的垃圾源头"。

Step 4:定位具体对象

在 Dominator Tree 中找到 Retained Heap 最大的对象,右键选择 "Path to GC Roots",再选择 "exclude weak/soft references",即可看到完整引用链。例如:HashMap$Node → ArrayList → SomeService.staticList,表明是静态集合不断 add 导致的内存泄漏。

Step 5:分析代码根因

常见泄漏场景:

  • 静态 Map 不断 put:缓存未设置过期时间或未设置容量上限
  • ThreadLocal 未 remove:线程池复用线程,Value 永远不会被释放
  • 监听器/回调未注销:Observer 模式中遗漏 removeListener 调用
  • 数据库连接未关闭:连接池中的连接持有大量结果集未释放
  • 内部类持有外部引用:非静态内部类隐式持有 Outer.this 引用

Step 6:修复验证

修复代码后进行灰度发布,观察老年代在 GC 后的使用率是否趋于稳定。

Step 7:建立预防机制

配置 JVM 参数 -XX:+HeapDumpOnOutOfMemoryError、设置老年代使用率超过 80% 的监控告警、定期 Review 静态集合和 ThreadLocal 的使用情况。


四、死锁排查

症状:部分接口超时无响应,其他接口正常;线程池活跃线程数持续升高;CPU 使用率不高(线程均在阻塞等待,而非自旋)。

# 使用 jstack dump 线程快照
jstack PID > /tmp/jstack.log
​
# 查看文件末尾,JVM 会自动检测死锁
grep "deadlock" jstack.log -i -A 20

jstack 输出的底部会直接打印死锁信息:Found one Java-level deadlock: 以及具体的线程和锁的持有/等待关系。

常见死锁原因

  1. 嵌套锁获取顺序不一致:线程 A 先锁 a 再锁 b,线程 B 先锁 b 再锁 a
  2. 环状等待链:线程 A 持有锁 A 等待锁 B,线程 B 持有锁 B 等待锁 C,线程 C 持有锁 C 等待锁 A
  3. 数据库死锁:两个事务以不同顺序更新同一组数据行

避免策略

  • 统一加锁顺序
  • 使用 tryLock(timeout) 替代 synchronized——超时后释放已持有锁并重试
  • 减小锁粒度,缩短锁持有时间
  • 优先使用无锁数据结构(ConcurrentHashMap、AtomicInteger)替代显式加锁

五、连接池耗尽:一个慢 SQL 的雪崩链

场景还原:某个慢 SQL 占用了大量连接(执行时间超过 30s),后续请求因无法获取连接而阻塞等待,连接池被占满(如 Druid 默认 maxActive=20),线程池队列随之饱和,触发拒绝策略,最终导致大面积超时。这就是典型的雪崩效应——一个慢 SQL 拖垮整个应用。

排查步骤

# Arthas dashboard 查看线程状态——大量线程 BLOCKED 在 Druid 获取连接处
# Druid 监控页面查看活跃连接数和等待队列长度
# 定位持有连接时间最长的线程(极可能是慢 SQL 的源头)

解决方案

级别措施
紧急手动 kill 慢 SQL:SHOW PROCESSLIST 定位后执行 KILL ID 释放连接
短期设置 SQL 超时:Druid queryTimeout=10s,超时自动断开并释放连接
长期慢 SQL 优化(加索引/改写 SQL/加缓存);连接池隔离(核心与非核心业务使用不同数据源);连接池参数调优

六、Full GC 频繁调优

现象:线上每隔几分钟触发一次 Full GC,每次 STW 持续 3-5 秒。

排查步骤:分析 GC 日志——-Xlog:gc*:file=/tmp/gc.log(JDK 9+),使用 GCeasy 或 GCViewer 工具分析。重点观察:每次 Young GC 后晋升到老年代的对象数量(Promotion Size)、老年代何时被填满从而触发 Full GC。

常见原因及优化方向

原因现象优化
年轻代太小对象未经过足够轮次就晋升老年代调大 -Xmn(设为总堆的 1/3 到 1/2)
Survivor 太小Minor GC 时对象直接进入老年代调小 -XX:SurvivorRatio(增大 Survivor 占比)
大对象直接进老年代GC 日志中出现 "humongous allocation"代码层面避免大对象,或调大 -XX:PretenureSizeThreshold
老年代太小正常晋升也频繁触发 Full GC调大 -Xmx(但需先排查以上三项,确认非内存泄漏)
MetaSpace 满GC 日志中出现 "Metadata GC Threshold"调大 -XX:MaxMetaspaceSize
内存泄漏GC 后老年代使用率持续上升参照第三章流程排查泄漏

实战案例:某次压测发现每 2 分钟一次 Full GC(STW 4s),GC 日志显示每次 Young GC(Eden 200MB)后约有 80MB 对象晋升老年代。分析发现 Survivor 区仅 20MB,根本容纳不下存活对象,导致对象直接进入老年代。调整方案:SurvivorRatio 从 8 调整为 3(Eden:S0:S1=3:1:1),Survivor 扩大至 50MB,晋升量降至约 10MB/次,Full GC 消失。


七、性能排查六步法

从现象到根因、从宏观到微观的标准化排查流程:

Step 1:确认问题范围(1 分钟)

先回答三个问题:

  • 是所有接口都慢,还是仅某个接口慢?(全局 vs 局部)
  • 是一直慢,还是某个时间点突然变慢?(持续 vs 突发)
  • 慢在哪个环节?(通过 SkyWalking / Zipkin 查看调用链各 Span 的耗时)

全局慢,大概率是 JVM GC / 连接池 / 机器资源问题。局部慢,大概率是某个 SQL / 下游调用 / 代码逻辑问题。

Step 2:定位瓶颈层级(5 分钟)

用户请求 → 网关(Nginx) → 应用(Spring) → 业务代码 → SQL/Redis/RPC
              │              │            │           │
              ↓              ↓            ↓           ↓
          超时配置       线程池/GC      Arthas trace  EXPLAIN/SlowLog
          连接数          内存         Watch 观察     Redis 慢日志

Step 3:应用层排查(Arthas 三板斧)

dashboard              # 一眼看全局:GC 频率、堆使用率、线程数、CPU
thread -n 10           # 定位 CPU 最高的线程,排查死循环或自旋
trace ServiceImpl method -n 20  # 追踪方法调用链,精确到每一步的耗时

Step 4:数据库层排查

若 trace 指向 SQL,按以下顺序分析:

  1. EXPLAIN 查看执行计划:type 是否为 ALL(全表扫描)、key 使用了哪个索引、rows 扫描行数是否合理
  2. 查看慢查询日志:通过 SHOW PROFILES 查看具体哪个阶段慢(Sending data / Creating tmp table / Sorting)
  3. 锁分析:通过 SHOW ENGINE INNODB STATUS 排查锁等待和死锁
  4. 连接池:通过 Druid / HikariCP 监控面板查看活跃连接数、等待队列、平均执行时间

Step 5:缓存/中间件层排查

  • Redis:SLOWLOG GET 20 查看近期慢命令,关注大 key(MEMORY USAGE key)和热 key
  • MQ:查看消费者积压的 lag 值,积压过多时增加消费者实例
  • RPC:检查超时配置是否合理、熔断器是否已打开

Step 6:验证修复效果

修复后必须有对比数据来证明效果。观察窗口至少持续 1 小时,以排除缓存预热、JIT 编译等干扰因素。


八、慢 SQL 优化实战

标准流程

  1. 开启慢查询日志long_query_time=1smin_examined_row_limit=1000

  2. EXPLAIN 分析执行计划:重点关注 type(ALL 最差,至少应达到 range)、key(是否命中索引)、rows(预估扫描行数)、Extra(Using filesort 表示需排序优化、Using temporary 表示使用了临时表)

  3. 常见优化手段

    • 建立合适的索引(联合索引遵循最左前缀原则)
    • 删除无用索引(无用索引会拖累写性能)
    • 改写 SQL:用 JOIN 替代子查询、用 UNION ALL 替代 OR、避免 SELECT *、避免在 WHERE 中对列使用函数
    • 大偏移量分页采用"先查 ID 再关联"策略
    • 大表考虑分库分表或同步至 ES

实战案例:Oracle 迁移 OceanBase 后查询从 2s 变为 5min+

  • 现象:某客户交割查询,Oracle 上执行 2 秒,OceanBase 上执行 5 分钟后超时
  • 排查过程:拆分 SQL 逐块测试(每次去掉一个条件,测量耗时差异),发现瓶颈由三部分组成:PG 函数(最大头,约 80% 的开销)+ spotTrade NOT EXISTS 子查询 + swapTrade/deals NOT EXISTS 子查询
  • 根因:OceanBase 优化器对 PL/SQL 函数在 WHERE 中的处理方式为逐行调用——表有 410 万行,两个子查询各扫一遍,PG 函数每行都调用一次。410 万行乘以 2 次全量扫描再乘以每次函数查参数表,导致时间爆炸式增长
  • 最终策略:在 Java 层面预计算日期参数,避免在 SQL 条件侧调用函数

通用经验:大表(超过 100 万行)加上 WHERE 中对列使用函数调用,几乎必然导致性能灾难。函数务必放在变量侧预计算,不可放在条件侧。数据库迁移不能仅验证功能正确性,必须使用真实数据量进行性能回归测试。

实战案例:接口从 200ms 逐渐变慢至 3s

  • 现象:某查询接口的响应时间随时间推移从 200ms 逐步增长至 3s,重启后恢复正常,之后再次逐步恶化
  • 排查:通过 jstat -gcutil 持续观察,发现老年代使用率持续上升,Full GC 后仅回落极少部分。执行 heap dump 后使用 MAT Dominator Tree 分析,发现一个静态 Map 中堆积了 50 万条数据,每次请求都向其中 put 数据但从不清理
  • 根因:缓存 Map 配置了 @Cacheable,但未设置过期时间和最大容量限制
  • 修复:改用 Caffeine Cache(maximumSize=10000, expireAfterWrite=30min),问题彻底解决

九、性能优化的五层优先级

按 ROI 从高到低排列:

业务优化(减少无效请求)> 缓存(避免查数据库)> 索引优化(查得更快)> 代码优化(算得更快)> 硬件(加机器)
   ROI 最高                         效果最明显               常用手段              最后手段

第一优先级:业务层优化(ROI 最高、风险最低)

  • 减少不必要的查询:将循环中逐条查数据库改为批量查询;将反复查部门名称改为一次 JOIN
  • 减少数据传输量:将 10 万行全量返回前端改为后端分页;将 SELECT * 改为仅查询需要的列
  • 异步化:下单后的短信/邮件/推送通知,通过消息队列异步处理
  • 削峰填谷:秒杀场景使用 MQ 或令牌桶限流

第二优先级:缓存层

浏览器缓存 → CDN 缓存 → Nginx 缓存 → Redis 缓存 → Caffeine 本地缓存(三板斧:最大容量防 OOM + 过期时间防脏数据 + 布隆过滤器防穿透)

第三优先级:数据库层

索引优化 → SQL 改写(避免 WHERE 中对列使用函数,用 UNION ALL 替代 OR)→ 读写分离 → 分库分表 → ES 同步

第四优先级:代码层

CompletableFuture 并行调用替代串行 → 连接池调优 → 序列化优化(Protobuf 替代 JSON)→ 数据结构选择(频繁 contains 操作使用 Set 而非 List)

第五优先级:JVM 与基础设施

GC 调优 → 硬件升级(SSD、增加内存)→ 水平扩展(增加实例 + 负载均衡)


总结

分层定位、逐层排除是线上问题排查的核心思维——从链路追踪(SkyWalking/Zipkin)确定慢在哪个 Span,到 Arthas trace 树状输出方法调用链的每一步耗时,再到数据库层的 EXPLAIN 分析和缓存层的 Redis 慢日志。三个最高频的生产事故各有标准排查路径:CPU 飙高通过 top -Hp PID 定位线程后结合 jstack 定位代码行(或 Arthas thread -n 5 一步到位);内存泄漏jmap -dump → MAT Dominator Tree(按 Retained Heap 排序)→ Path to GC Roots 的完整链路,其中静态集合无限增长和 ThreadLocal 未 remove 是最常见的泄漏来源;慢 SQL 用 EXPLAIN 关注 type/key/rows/Extra 四列,联合索引遵循最左前缀原则,避免在 WHERE 中对列使用函数。

连接池耗尽是最典型的雪崩场景——一个慢 SQL 占用连接 → 连接池满 → 线程阻塞等待 → 线程池饱和 → 拒绝策略触发 → 大面积超时。紧急止血用 KILL ID,长期方案靠 SQL 超时机制加连接池隔离。性能优化的优先级按 ROI 排序为:业务优化(减少无效请求)> 缓存(避免查数据库)> 索引优化(查得更快)> 代码优化(算得更快)> 硬件(加机器),核心能力是根据现象判断哪种手段的 ROI 最高。


如果这篇文章帮你少熬了一次夜,点个赞 + 收藏吧 🙏 下一篇:《Redis 核心原理:为什么快?六大数据结构深度拆解》 系列合集:《Java 后端核心知识图谱》17 篇,掘金Java合集