前言

性能工具篇讲了"怎么用",这篇讲"怎么破案"——一个 CPU 告警的完整排查复盘,还原每一步的推理过程。

一、案发

22:14  告警: prod-api-03 CPU > 90%, 持续 5 分钟
影响:  该节点上的服务 P99 延迟 300ms → 2.8s

二、第一现场:top 全景

top                              # 按 P(CPU排序)按 1(展开核)
load average: 14.2, 12.8, 9.5        ← 8核机器, load 14 明显过载
%Cpu(s): 92.3 us, 5.1 sy, 0.0 wa, 0.0 id, 0.0 st
        ▲
        └ 92% 是 us(用户态)!        ← 关键线索①: 应用在烧CPU, 不是内核/IO

  PID USER    %CPU %MEM  S  COMMAND
 8473 app     780.0  8.2  R  java            ← 元凶进程(多线程吃满约8核)
 9102 app       2.1  3.1  S  sidecar

推理:

  1. us 高 → 应用代码问题(死循环/正则回溯/GC 风暴),而不是内核态(系统调用/锁)或 IO
  2. wa=0, st=0 → 排除磁盘慢与宿主机超卖
  3. 进程 8473 一家吃掉 780% → 线程级下钻

三、线程级下钻

# 看该进程内哪个线程在烧
top -H -p 8473
  PID USER    %CPU  S  COMMAND
 8501 app     198.0  R  java          ← 线程 8501 烧了约2核
 8502 app     198.0  R  java
 8503 app     190.0  R  java
 8473 app       5.2  S  java

三个线程几乎打满各 2 核。把线程号转成 16 进制(Java 的线程栈用 nid=0x… 标识):

printf "%x\n" 8501        # 2135
jstack 8473 | grep -A 20 "nid=0x2135"
"worker-3" #123 daemon prio=5 os_prio=0 tid=0x... nid=0x2135 runnable
   at com.demo.parser.RegexParser.match(RegexParser.java:87)
   at com.demo.parser.RegexParser.parse(RegexParser.java:45)
   ...

线索指向:正则匹配模块。看代码:

// RegexParser.java:87
private static final Pattern BAD = Pattern.compile("(a+)+b");
// 典型的灾难性回溯(ReDoS 模式)!
// 输入 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaX"(不匹配b)时, 回溯组合爆炸

初步结论:一段恶意/异常输入触发正则回溯风暴,三个 worker 线程全陷进去 → CPU 100% → 处理队列堆积 → 延迟飙升。

四、用 perf 佐证(不是 Java 也能用这招)

# 对进程采样 10 秒
perf top -p 8473
# 或采样落盘
perf record -F 99 -p 8473 -g -- sleep 10
perf report --stdio | head -30
    62.41%  libjava.so  [.] re_match
     18.20%  libjava.so  [.] re_compile
     ...
     re_match
     RegexParser.match
     ...

re_match(正则引擎)占 62% ——与 jstack 结论互相印证。

火焰图(一图看穿):

git clone https://ghfast.top/https://github.com/brendangregg/FlameGraph
perf record -F 99 -p 8473 -g -- sleep 15
perf script | FlameGraph/stackcollapse-perf.pl | FlameGraph/flamegraph.pl > cpu.svg
# 浏览器打开: 一片"高原"正对着 re_match → 热点一目了然

五、应急与根治

应急(5 分钟内恢复)

# 1. 隔离: 从负载均衡摘除该节点(别急着重启, 保留现场)
# 2. 抓证据
jstack 8473 > /tmp/jstack-$(date +%F-%H%M).txt   # 已抓
perf record ...                                   # 已采样
# 3. 临时止血: 限流该接口 / kill 卡死线程
curl -X POST http://localhost:9090/actuator/throttle -d 'regex_api=off'

根治

// 1. 修正则: 消除嵌套量词
Pattern.compile("a+b");                    // 等价且线性时间
// 或用占有优先量词断掉回溯
Pattern.compile("(?>(a+))b");

// 2. 输入长度限制 + 匹配超时兜底
if (input.length() > 1024) throw new BizError("input too long");

// 3. 加监控: 该接口处理耗时 P99 告警(这次是 CPU 告警先响, 属侥幸)

六、方法论沉淀:CPU 高的分流决策树

top 一眼看四数:
├─ us 高 → 应用代码问题
│    ├─ Java:  top -H + jstack(16进制nid)  / arthas thread -n 5
│    ├─ Go:    top -H + pprof(http://x/debug/pprof/profile)
│    └─ C/通用: perf top / 火焰图
├─ sy 高 → 系统调用/上下文切换/锁
│    └─ vmstat 看 cs/in, strace -c -p PID 统计调用, 锁竞争查 /proc/PID/stack
├─ wa 高 → IO 瓶颈(CPU 在等盘)→ 转 iostat 路线(性能篇)
├─ st 高 → 宿主机超卖 → 找云厂商/迁移
└─ si 高 → 软中断(网络包)→ mpstat -P ALL 看, nethogs 找流量源

load 高但 CPU 空闲? → D 状态进程排队(IO/NFS 卡死),vmstat 看 b 列,ps -eo pcpu,stat,wchan -p 查等待点。

七、工具对照卡

工具 一句话
top -H -p PID 线程级 CPU 定位第一步
jstack / arthas Java 栈快照(nid 十六进制对线程)
perf top / record 采样热点函数(内核用户态都行)
火焰图 宽 = 热,一眼找宽路
strace -c 统计系统调用开销(sy 高时用)
pprof Go 程序的 profile 标配

八、复盘清单

  • 告警先摘流量、保现场,再动手查(这次顺序对了)
  • us/sy/wa/st 四象限分流,避免瞎忙
  • 线程 → 栈 → 代码行,证据链完整
  • perf 与 jstack 交叉验证
  • 根治后加"耗时维度"监控(CPU 只是症状放大器)
  • 正则这类"埋雷代码"进 code review 检查项

小结

CPU 排查的完整闭环:top 定方向(us/sy/wa/st)→ 线程级下钻(top -H)→ 栈/采样定位(jstack/perf/pprof)→ 代码级根因 → 应急+根治+监控补位。工具不难,难的是每一步都带着假设去验证。


本文是「Linux 运维」系列第 14 篇。