CPU 飙高排查:负载告诉你的,可能只是冰山一角

上个月凌晨两点,监控群里弹出一条告警:核心服务 CPU 使用率飙到 95% 以上,持续了将近二十分钟。值班同事第一时间 ssh 上了机器,top 一跑,看到 Java 进程 PID 排第一,二话不说直接重启。服务确实短暂恢复,但第二天同一时间段,同样的告警又响了。

重启解决不了的问题,得换个思路查。

从负载说起

一次服务器 CPU 飙高的排查实录:从负载到火焰图

先看几个基础指标,别急着下结论。

# 查看系统负载
uptime
# 14:32  up 42 days,  3:17,  1 user,  load average: 12.4, 8.7, 6.2

# 查看 CPU 详细构成
mpstat -P ALL 1 5
# %usr  %system  %iowait  %steal  %guest  %idle
#  78.5     12.3     0.0     0.0     0.0   9.2

load average 12.4 意味着平均有 12.4 个任务在等待 CPU 或 I/O。mpstat 显示 %usr 占 78.5%,说明主要是用户态 CPU 开销,不是内核或 I/O 阻塞。这个信号很重要——如果是 iowait 高,方向完全不一样。

再看具体进程:

# 按 CPU 排序,看清谁在吃资源
top -bn1 | head -20

# 或者用 ps 更灵活
ps aux --sort=-%cpu | head -10

找到可疑 PID 之后,下一步是确认线程级情况:

# Java 进程看线程
top -H -p <PID>

# 或者用 jstack 拿线程快照
jstack <PID> > thread_dump.txt

这一层排查能告诉你"哪个进程"和"哪些线程"在干活,但还回答不了"在干什么活"。要深入到方法级别,得靠采样。

为什么是火焰图

传统的 top 和 jstack 是瞬时快照,容易漏掉间歇性热点。火焰图(Flame Graph)由 Brendan Gregg 提出,核心思想是统计采样:在一段时间内高频记录调用栈,调用次数越多,对应栈帧越宽。

它有两个优势:一是能把 CPU 热点以直观的热力图形式呈现;二是采样对业务性能影响极小,线上也能用。

生成火焰图的标准链路是:perf record → perf script → flamegraph.pl 渲染。整个过程不侵入代码,不依赖额外 Agent。

采样的实现细节

先确认环境支持:

# 检查 perf 是否可用
perf record --help

# 检查内核参数,线上需谨慎
cat /proc/sys/kernel/perf_event_paranoid
# -1: 允许所有用户;2: 仅 root;1/0 介于两者之间

perf_event_paranoid 等于 2 时普通用户无法采样,生产环境常见的安全配置就是这个值。如果权限不够,要么临时调整(有风险),要么用 root 执行,要么换用 bpftrace 等基于 eBPF 的工具。

正式采样命令:

# 采集 30 秒,针对指定 PID,记录调用栈
perf record -F 99 -p <PID> -- sleep 30

# -F 99 表示采样频率 99Hz,即每秒约 99 次入栈

采集完成后生成脚本文件:

perf script > perf.out

如果有 JVM,建议同时用 -J 参数让 perf 解析 Java 符号:

perf record -F 99 -p <PID> -J -- sleep 30

-J 会让 perf 尝试读取 JVM 的内部符号表,生成的栈里会出现 Java 方法名而非仅仅是 native 地址。前提是 JVM 启动时带了 -XX:+PreserveFramePointer(Java 9+ 默认开启)且 perf 版本足够新。

接下来用火焰图脚本把脚本文件变成 SVG:

# 克隆 flamegraph 工具(如果还没有)
git clone https://github.com/brendangregg/FlameGraph.git

# 生成火焰图
./FlameGraph/flamegraph.pl perf.out > flamegraph.svg

直接在浏览器打开 SVG,就能看到一个层层叠加的彩色图表。越宽的方法占比越大,越靠上的层级越接近调用栈顶。

这次排查发现了什么

拿回火焰图之后,我一眼就看到了问题所在。

栈顶是一个业务方法 OrderQueryService.batchEnrich(),下面挂着一个循环,循环里反复调用一个本地缓存的 reload 逻辑。再往下看,热点集中在 ConcurrentHashMap.computeIfAbsent() 的某个分支上。

粗看代码没问题:缓存未命中时 compute 一下,命中就直接返回。但火焰图显示的调用频次不对劲——同一个 key,几乎每次请求都在触发 compute 分支。

把代码拉出来一看,发现了陷阱:

// 问题代码
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();

public CacheEntry getOrLoad(String key) {
    return cache.computeIfAbsent(key, k -> {
        // 这里有个同步 HTTP 调用,耗时 200-500ms
        CacheEntry entry = remoteLoader.load(k);
        return entry;
    });
}

computeIfAbsent 在 key 不存在时会持有 bucket 锁,同步执行 function。而缓存的过期策略是懒加载——只有访问时才知道是否过期,过期后重新 load。高峰时段大量请求同时涌入,key 刚好过期,多个线程同时触发 compute,虽然 ConcurrentHashMap 会保证只有一个线程真正执行 function,但其他线程会自旋等待,CPU 就耗在这些无意义的等待上。

更麻烦的是,这个缓存没有设置最大容量,理论上可以无限增长,虽然实际业务量没到那个程度,但在测试环境压测时已经能看到明显的 CPU 抖动。

修复与后续

修复方案分两步。首先是给缓存加上 TTL 和容量上限,用 Caffeine 替换手写实现:

Cache<String, CacheEntry> cache = Caffeine.newBuilder()
    .maximumSize(2000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build();

Caffeine 内部用了分段锁和 W-TinyLFU 淘汰算法,比 ConcurrentHashMap 手动管理效率高得多,而且过期是主动驱逐,不会出现"所有 key 同时过期"的突发 compute 风暴。

第二步是做了压测回归。用 JMH 写了个简单的 benchmark,对比修复前后的 QPS 和 CPU 占用,结果 CPU 从 92% 降到 34%,QPS 提升了约 2.8 倍。

几个容易踩的坑

采样频率别设太高。 很多教程推荐 -F 997,但对于 Java 应用来说,99Hz 已经足够还原热点分布。频率越高,采样开销越大,甚至可能放大噪声。

注意符号解析。 裸的 perf record 拿到的栈大多是 native 地址,读起来很痛苦。用 -J 参数解析 JVM 符号,或者配合 perf-map-agent 这样的工具,能把 Java 栈和 native 栈拼起来,读图效率高很多。

别只看 CPU,要对照业务日志。 火焰图告诉你"哪里耗时",但不告诉你"为什么这个时候在跑"。把采样时间段和日志里的错误高峰、请求量高峰对齐,才能确认热点和业务场景的关联,避免误判。

线上采样前先评估影响。 perf 本身是内核级工具,理论上会影响被采样进程的调度。虽然 99Hz 的开销可以忽略不计,但还是建议在低峰期做,或者先在测试环境验证一遍流程,熟悉后再上生产。

写在最后

这次排查从头到尾不超过两小时。top 定位到进程,jstack 排除掉线程死锁的可能,perf + 火焰图直接指向了缓存逻辑。没有埋点、没有打日志、没有翻代码一行行找,一张图就把问题锁死了。

CPU 飙高这种事,告警只是开始。真正重要的是建立一套从宏观指标到微观栈帧的排查路径——先判断是 CPU、内存还是 I/O 的问题,再用合适的工具逐级下钻。工具不多,但每一层解决的问题不一样。用对了,排查就是顺水推舟;用错了,就只能靠重启续命。

posted @ 2026-11-04 06:00  框架师  阅读(6)  评论(0)    收藏  举报