线上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 慢的问题。
第三层:数据库层
常见慢原因:
- 未走索引:EXPLAIN 显示
type=ALL全表扫描 - 索引区分度差:走了索引但
rows扫描行数仍然很大 - 回表过多:需要通过覆盖索引消除回表
- 锁等待:通过
SHOW ENGINE INNODB STATUS查看锁信息 - 连接池耗尽:通过 Druid / HikariCP 监控面板查看活跃连接数
第四层:缓存层
检查 Redis 慢日志(SLOWLOG GET 10),排查大 key(单个 Hash 包含数十万字段导致单次操作缓慢)及热 key(某个 key 的 QPS 极高导致单分片瓶颈)问题。
第五层:外部依赖
使用CompletableFuture将串行调用改为并行。配置超时与熔断(Sentinel / Hystrix),防止单个下游服务缓慢拖垮整个调用链路。
二、工具速查表
生产环境中机器负载高是常见问题,掌握以下命令是高效排查的前提。
JVM 层面
| 场景 | 工具 | 关键命令 |
|---|---|---|
| CPU 飙高 | top + jstack / Arthas | top -H -p PID,thread -n 5 |
| 内存泄漏 | jmap + MAT / Arthas | heapdump /tmp/dump.hprof, 分析 Dominator Tree |
| GC 频繁 | jstat / GC 日志 | jstat -gcutil PID 1000,-Xlog:gc* |
| 死锁 | jstack | jstack PID | grep "BLOCKED" -A 15 |
| 类加载问题 | Arthas | classloader -t查看类加载器树 |
| 方法耗时 | Arthas trace | trace com.xxx.Service method -n 5 |
| SQL 慢查 | Druid 监控 / SlowLog | Druid 内置 SQL 监控面板 |
| Redis 慢 | Redis slowlog | SLOWLOG GET 10 |
OS 层面
| 场景 | 命令 | 关注点 |
|---|---|---|
| 机器整体负载 | top/htop | load average(1/5/15 分钟),CPU 使用率(us/sy/wa),内存使用 |
| CPU 详情 | mpstat -P ALL 1 | 各核心使用率分布,判断是否为单核打满 |
| 内存详情 | free -h | available vs free,buffer/cache 可回收 |
| 磁盘 IO | iostat -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 | tail、journalctl -u app-name | OOM Killer 日志、coredump、系统级错误 |
排查经历的标准描述逻辑:
- 先用
top获取整体状况——CPU 高?内存高?load average 异常? - 若为 Java 进程,则切入 Arthas
dashboard+thread -n 5 - 若磁盘 IO 高,则通过
iostat确认——可能是大量日志写入或数据库 IO - 若为网络问题,则通过
netstat查看连接数——排查下游慢导致的连接堆积 - 定位后,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-A20jstack 输出的底部会直接打印死锁信息:Found one Java-level deadlock:以及具体的线程和锁的持有/等待关系。
常见死锁原因:
- 嵌套锁获取顺序不一致:线程 A 先锁 a 再锁 b,线程 B 先锁 b 再锁 a
- 环状等待链:线程 A 持有锁 A 等待锁 B,线程 B 持有锁 B 等待锁 C,线程 C 持有锁 C 等待锁 A
- 数据库死锁:两个事务以不同顺序更新同一组数据行
避免策略:
- 统一加锁顺序
- 使用
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,按以下顺序分析:
- EXPLAIN 查看执行计划:type 是否为 ALL(全表扫描)、key 使用了哪个索引、rows 扫描行数是否合理
- 查看慢查询日志:通过
SHOW PROFILES查看具体哪个阶段慢(Sending data / Creating tmp table / Sorting) - 锁分析:通过
SHOW ENGINE INNODB STATUS排查锁等待和死锁 - 连接池:通过 Druid / HikariCP 监控面板查看活跃连接数、等待队列、平均执行时间
Step 5:缓存/中间件层排查
- Redis:
SLOWLOG GET 20查看近期慢命令,关注大 key(MEMORY USAGE key)和热 key - MQ:查看消费者积压的 lag 值,积压过多时增加消费者实例
- RPC:检查超时配置是否合理、熔断器是否已打开
Step 6:验证修复效果
修复后必须有对比数据来证明效果。观察窗口至少持续 1 小时,以排除缓存预热、JIT 编译等干扰因素。
八、慢 SQL 优化实战
标准流程
- 开启慢查询日志:
long_query_time=1s,min_examined_row_limit=1000 - EXPLAIN 分析执行计划:重点关注
type(ALL 最差,至少应达到 range)、key(是否命中索引)、rows(预估扫描行数)、Extra(Using filesort 表示需排序优化、Using temporary 表示使用了临时表) - 常见优化手段:
- 建立合适的索引(联合索引遵循最左前缀原则)
- 删除无用索引(无用索引会拖累写性能)
- 改写 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
如果本文帮你省下了一次半夜被报警叫醒排查的时间,欢迎收藏 + 点赞🙏
