线上CPU飙高怎么排查?从日志→代码→根因的三层定位法,附3个真实案例

📅 2026/7/18 23:16:53 👁️ 阅读次数 📝 编程学习
线上CPU飙高怎么排查?从日志→代码→根因的三层定位法,附3个真实案例

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

问题场景:线上服务突然变慢,CPU 飙到 90%+,日志刷屏但看不出根因——给你 15 分钟,你会怎么排查?

读完收获:① 掌握 CPU/内存/慢SQL 三类问题的标准排查路径 ② 获得可直接套用的 jstack/jmap/Arthas 分析模板 ③ 学会从现象→日志→代码→根因的逆向定位思维

本文是《Java 后端核心知识图谱》系列第 1 篇(共 17+2 篇)。


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

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

第一层:链路追踪定位

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

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

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

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 进程,则切入 Arthasdashboard+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-A20

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 超时:DruidqueryTimeout=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 频率、堆使用率、线程数、CPUthread-n10# 定位 CPU 最高的线程,排查死循环或自旋trace ServiceImpl method-n20# 追踪方法调用链,精确到每一步的耗时

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定位代码行(或 Arthasthread -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 最高。


系列专栏:《Java 后端核心知识图谱》17+2 篇,Java专栏
下一篇:《Redis 核心原理:六大数据结构 + RDB/AOF双引擎 + 缓存一致性方案》
配套代码:PrismAI
如果本文帮你省下了一次半夜被报警叫醒排查的时间,欢迎收藏 + 点赞🙏