CPU 飙到 100% 却找不到高 CPU 进程?短命进程排查指南

本文是线上问题实战录系列的第 9 篇 叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防


问题现象

正值工作日早高峰,Zabbix 告警:报表导出节点 report-prod-02 CPU 使用率 99.2%,已持续 15 分钟。

值班运维登录服务器,习惯性地敲下 top

top - 09:12:15 up 34 days, 17:28,  2 users,  load average: 18.3, 12.7, 8.4
%Cpu(s): 68.5 us, 15.2 sy,  0.0 ni, 12.3 id,  2.8 wa,  0.6 hi,  0.6 si,  0.0 st

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 8721 tomcat    20   0   12.5g   2.5g  24568 S   9.2   8.1 145:23.12 java
 3456 mysql     20   0   8.2g   1.8g  12345 S   3.1   5.8 234:56.34 mysqld
 1289 root      20   0  345672   5432   2345 S   1.2   0.0  34:56.78 dockerd

CPU us 68.5%,但进程列表里 %CPU 最高的 Java 进程也只有 9.2%。所有进程的 %CPU 加起来不到 20%——剩下的 50% CPU 去哪了?

排查群讨论

SSH 登录 & 环境确认 top — CPU us 高但找不到进程

排查过程

第一步:确认不是监控误报

ps aux --sort=-%cpu 再确认一次:

$ ps aux --sort=-%cpu | head -10
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
tomcat      8721  9.2  8.1 12.5g 2.5g ?        Sl   Jun15 145:23 java
mysql       3456  3.1  5.8 8.2g 1.8g ?         Ssl  Jun10 234:56 mysqld

结果一样。又试了 htop,按 CPU 排序,依然没有异常进程。

/proc/statvmstat 都确认 CPU us 确实在 68% 以上——不是采集器的问题,CPU 确实在忙。

ps 按 CPU 排序

第二步:意识到「短命进程」的可能

%CPU 加总远小于 us 总量,只有一种解释:存在大量短暂存活的进程,在 top/ps 采集的间隙中诞生又消亡。

这类进程的特点: - 生命周期短(几秒到几十秒) - CPU 密集(压缩、加密、渲染等) - 创建频率高(每秒数十个) - 监控工具的采集间隔(通常 5-30 秒)完美错过

那用什么工具能抓到它们?不依赖进程存活的工具。

第三步:perf top 看热点函数

perf top 基于硬件采样,不关心进程是否还活着——它只统计 CPU 正在执行什么代码:

$ sudo perf top -K -g --sort=comm -n 15

Overhead  Shared Object          Symbol
  23.45%  [kernel]               [k] _raw_spin_unlock_irqrestore
  15.67%  libc-2.31.so           [.] __GI___libc_write
  12.34%  libcrypto.so.1.1       [.] AES_encrypt
   8.92%  [kernel]               [.] __deflate
   7.56%  libc-2.31.so           [.] __memcpy_avx_unaligned_erms
   6.78%  libz.so.1              [.] deflate
   5.45%  libpthread-2.31.so     [.] __pthread_mutex_lock
   4.56%  libz.so.1              [.] crc32

热点集中在 libz.so.1deflatecrc32——这是 zlib 压缩库的特征。有人在大量压缩数据。

perf top 热点函数

第四步:execsnoop 捕获短命进程

有了线索,用 execsnoop(bcc-tools 套件)直接追踪进程创建事件。它通过 eBPF 钩住 execve() 系统调用,每个新进程诞生时立即捕获,无论它活多久:

$ sudo execsnoop 2>/dev/null | head -30
PCOMM            PID    PPID   RET ARGS
gzip            19832  18721    0 gzip -c /data/exports/report_20260615_001.csv
sh              19833  18721    0 sh -c gzip -c /data/exports/report_20260615_002.csv
gzip            19834  19833    0 gzip -c /data/exports/report_20260615_002.csv
gzip            19836  18721    0 gzip -c /data/exports/report_20260615_003.csv
sh              19837  18721    0 sh -c gzip -c /data/exports/report_20260615_004.csv
gzip            19838  19837    0 gzip -c /data/exports/report_20260615_004.csv
...

抓到你了! 每秒 100+ 个 gzip 进程从 PID 18721(Java 进程)fork 出来,每个压缩完一个文件就退出。

$ execsnoop 2>/dev/null | wc -l
127
$ sleep 1; execsnoop 2>/dev/null | wc -l
118

每秒超过 100 个短命 gzip 进程诞生又消亡——CPU 就是被它们吃掉的。

execsnoop 捕获短命进程

第五步:pstree 确认父子关系

$ pstree -p 8721 | head -15
java(8721)─┬─{GC Thread-0}(8722)
           ├─{C2 CompilerThread0}(8724)
           ├─sh(19833)───gzip(19834)
           ├─sh(19837)───gzip(19838)
           ├─sh(19840)───gzip(19841)
           ├─sh(19843)───gzip(19844)
           └─sh(19846)───gzip(19847)

Java 进程通过 sh -c gzip ... 批量启动 gzip 子进程。/data/exports/ 目录下有 1458 个待压缩的 CSV 文件。

pstree 父子关系

第六步:perf record 深度确认

$ sudo perf record -g -a -- sleep 10
[ perf record: Captured and wrote 58.742 MB perf.data (142895 samples) ]

$ sudo perf report -n --stdio 2>/dev/null | head -15
# Overhead       Samples  Command  Shared Object      Symbol
# ........  ............  .......  .................  ......................
    23.45%         33512  gzip     [kernel.kallsyms]  [k] _raw_spin_unlock_irqrestore
    14.23%         20345  gzip     libz.so.1           [.] deflate
     9.67%         13821  gzip     libz.so.1           [.] crc32
     7.89%         11278  gzip     libc-2.31.so        [.] __GI___libc_write
     6.34%          9062  gzip     libz.so.1           [.] inflate

Command 列全部是 gzip——CPU 时间的绝对大头来自 gzip 进程,而不是 Java 主进程。

perf record 深入分析

根因分析

问题链路

报表导出请求高峰
  → Java ReportExportService 逐个压缩 CSV 文件
  → Runtime.exec("gzip -c file.csv > file.csv.gz")
  → 每个导出文件创建一个 OS 子进程
  → 并行导出 20+ 份报表 → 同时运行 50+ gzip 进程
  → gzip 是 CPU 密集型任务(deflate 压缩算法)
  → CPU us 飙到 99.2%
  → gzip 进程压缩完即退出(生命周期 15-60 秒)
  → top/ps 采集间隔 5-30 秒,完美错过
  → 运维看到 CPU 高但找不到凶手

为什么 top 抓不住短命进程?

topps 采集的是瞬间快照。它们读取 /proc/[PID]/stat 来获取进程的 CPU 使用率,计算方式是:

%CPU = (进程在采集间隔内的 CPU 时间) / (采集间隔) × 100%

如果进程的存活时间小于采集间隔,它在 proc 文件系统中存在的时间窗口太短,top/ps 要么完全看不到它,要么只看到它退出前的残留状态(%CPU 接近 0)。

这就好比用 30 分钟拍一张照片去抓一个在房间里只待了 1 分钟的人——你永远拍不到他。

为什么测试没发现?

  • 测试环境数据量小(几百 KB 的 CSV),gzip 瞬间完成,感觉不到 CPU 开销
  • 测试时单用户导出,不会出现并发几十个 gzip 同时运行的情况
  • Runtime.exec() 调用的子进程 CPU 开销不在 JVM 监控指标内,Arthas/VisualVM 都看不到
  • 常规性能测试只关注接口 RT 和 JVM 内 CPU,不监控 OS 级子进程

修复方案

V1(问题代码):Runtime.exec 调用外部 gzip

public void exportAndCompress(File csvFile) throws IOException {
    generateCsv(csvFile);
    // 每个导出文件启动一个 OS gzip 子进程
    String cmd = String.format("gzip -c %s > %s.gz",
        csvFile.getAbsolutePath(), csvFile.getAbsolutePath());
    Process process = Runtime.getRuntime().exec(cmd);
    // 子进程的 CPU/内存开销对 JVM 完全不可见
    // 无并发控制,50+ 文件同时压缩 -> 50+ gzip 进程
    int exitCode = process.waitFor();
    if (exitCode != 0) {
        throw new IOException("gzip failed: " + exitCode);
    }
}

V1 问题代码 — Runtime.exec

V2(修复代码):GZIPOutputStream + 线程池

private static final int MAX_CONCURRENT = 4;
private final ExecutorService compressPool =
    Executors.newFixedThreadPool(MAX_CONCURRENT);

public Future<?> exportAndCompress(File csvFile) {
    return compressPool.submit(() -> {
        generateCsv(csvFile);
        try (FileInputStream fis = new FileInputStream(csvFile);
             FileOutputStream fos = new FileOutputStream(csvFile + ".gz");
             GZIPOutputStream gzos = new GZIPOutputStream(fos)) {
            byte[] buf = new byte[8192];
            int len;
            while ((len = fis.read(buf)) > 0) {
                gzos.write(buf, 0, len);
            }
        }
    });
}

V2 修复代码 — GZIPOutputStream

修复要点:

维度 V1(Runtime.exec) V2(GZIPOutputStream)
子进程 每个文件一个 OS 进程 零子进程
CPU 可见性 JVM 监控看不到 JVM 内线程,全可见
并发控制 无限制 固定线程池 max 4
资源开销 fork + exec + 进程上下文切换 仅线程切换
跨平台 Linux only 纯 Java,全平台

验证结果

修复上线后,第二天早高峰监控:

top - 10:15:00 up 34 days, 18:31,  3 users,  load average: 2.3, 4.5, 6.7
%Cpu(s): 24.5 us,  8.2 sy,  0.0 ni, 64.3 id,  1.8 wa

  PID %CPU COMMAND
 8721 18.3 java
 3456  3.5 mysql
  • load 从 18.3 降到 2.3
  • CPU idle 从 12% 恢复到 64%
  • execsnoop 不再有大量 gzip 进程(仅零星系统进程)

修复后验证

避坑建议

1. Runtime.exec 是一把隐形的刀

每当你在 Java 代码中使用 Runtime.exec()ProcessBuilder,问自己三个问题:

问题 为什么重要
这个子进程消耗多少 CPU/内存? 子进程的资源不在 JVM 监控内,但实实在在消耗系统资源
同时会有多少个并发子进程? 无限制并发 = 资源耗尽
子进程的预期生命周期多长? 短命进程导致 top 级工具失效

原则:能用 Java 原生库就别调外部命令。 压缩用 GZIPOutputStreamZipOutputStream,PDF 用 iTextApache PDFBox,图片处理用 ImageIOThumbnailator,JSON 解析用 JacksonGson

2. 短命进程的排查工具箱

场景 工具 原理
看热点函数(不依赖进程存亡) perf top CPU 硬件采样,统计当前执行地址
捕获每个新进程 execsnoop (bcc-tools) eBPF 钩住 execve 系统调用
看进程父子关系 pstree -p 遍历 /proc 的 PPID 链
看进程已运行时间 ps -eo etimes,pid,%cpu,cmd etimes = 进程启动到现在的秒数
追踪进程生命周期 perf record -a 全系统采样,死后分析

3. 监控改进

  • top 的采集间隔默认 3-5 秒还不够短。对于短命进程场景,用 perf top 替代 top 做持续性诊断
  • execsnoopforkstat 的统计纳入周期性巡检脚本,检测异常高频的进程创建
  • JVM 监控 + OS 监控要配合看:JVM 内 CPU 低但系统 CPU 高 → 大概率有外部子进程
  • 在监控大盘上添加 top -b -n 1 | grep -E 'gzip|wkhtmltopdf|pdftk' 这类特定进程计数器

4. 代码审查要点

检查项 风险等级
代码中有 Runtime.getRuntime().exec() 🔴 必须评估子进程资源开销
代码中有 new ProcessBuilder(...) 🔴 同上
调用了 gziptarwkhtmltopdfpdftk 等外部工具 🟡 优先找 Java 原生替代方案
Shell 脚本通过 Java 调度 🟡 脚本中的子进程同样存在此问题

5. 诊断路径速查

CPU us 高但 top 找不到进程
  → perf top(确认热点函数)
    → 热点在 libz/libcrypto/deflate → 压缩/加密类子进程
    → 热点在 wkhtmltopdf/chromium → 渲染类子进程
    → execsnoop(确认短命进程身份)
    → pstree(定位父进程)
    → 代码审查(定位 Runtime.exec 调用点)

附:完整命令清单

短命进程诊断

sudo perf top -K -g --sort=comm -n 15                              # 看热点函数(最优先)
sudo execsnoop 2>/dev/null | head -30                               # 捕获短命进程
pstree -p <JAVA_PID> | grep -E 'sh|gzip|wkhtmltopdf'               # 确认父子关系
ps -eo pid,etimes,%cpu,cmd --sort=-%cpu | head -20                  # 看进程运行时间
sudo perf record -g -a -- sleep 10                                  # 全系统采样
sudo perf report -n --stdio 2>/dev/null | head -30                  # 分析采样结果

系统资源确认

top -b -n 1 | head -25                                              # 基础负载查看
vmstat 2 5                                                          # 系统状态
ps aux --sort=-%cpu | head -20                                      # 按 CPU 排序进程
cat /proc/loadavg                                                   # load 数据
cat /proc/stat | grep '^cpu '                                       # CPU 时间分布

进程创建统计

# 每秒进程创建数
sudo execsnoop 2>/dev/null | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
# 按命令名统计进程创建频率
sudo execsnoop 2>/dev/null | awk '{count[$1]++} END {for (c in count) print count[c], c}' | sort -rn
# 跟踪特定命令的进程
sudo execsnoop 2>/dev/null | grep gzip

Demo 验证

# 编译
mvn clean compile

# V1:用 Runtime.exec 启动外部 gzip 子进程(观察短命进程)
mvn exec:java -Dexec.args="v1"

# V2:用 GZIPOutputStream + 线程池(零子进程)
mvn exec:java -Dexec.args="v2"

# 或者在运行 V1 时,另开终端观察短命进程
sudo execsnoop 2>/dev/null | grep ShortLived

📖 全文带可复现 Demo 和排查截图 🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:源阅会 (82877104)