揭秘"幽灵 CPU":top 抓不到的短命进程,才是真正的 CPU 杀手

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


问题现象

有一个冷知识:top 看到的 CPU 占用,不代表服务器所有的 CPU 消耗。当进程存活时间小于 top 采样间隔时,它们就是"隐形"的。这就是短命进程问题。我遇到的一次案例:报表导出节点 CPU 99.2%,top 显示的进程 CPU 加总不到 20%。刚开始也以为是监控工具坏了,后来发现是定时任务在作祟。这个定时任务每分钟启动一个 Shell 脚本,脚本内部用 & 并发启动了大量子进程来做报表导出。每个子进程只活几百毫秒,但几百个子进程同时跑,CPU 瞬间打满。关键在于,等 top 刷新时它们已经变成僵尸或退出了。这篇文章会分享如何用 pidstat、perf、systemd-cgtop 抓出这些"幽灵"。

排查过程

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

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

bash 复制代码
$ 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 确实在忙。

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

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

这类进程的特点:

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

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

第三步:perf top 看热点函数

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

bash 复制代码
$ 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 压缩库的特征。有人在大量压缩数据。

第四步:execsnoop 捕获短命进程

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

bash 复制代码
$ sudo execsnoop 2>/dev/null | head -30
PCOMM            PID    PPID   RET ARG![排查群讨论](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/caa7cae4299c4ed4b01780a820958f4e~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgQWnmi4bku6PnoIHnmoTmm7nmk40=:q75.awebp?rk3s=f64ab15b&x-expires=1783081960&x-signature=2eYouJaTXfOK38ybxY2GrdCERdw%3D)S
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 出来,每个压缩完一个文件就退出。

bash 复制代码
$ execsnoop 2>/dev/null | wc -l
127
$ sleep 1; execsnoop 2>/dev/null | wc -l
118

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

第五步:pstree 确认父子关系

bash 复制代码
$ 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 文件。

第六步:perf record 深度确认

bash 复制代码
$ 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 主进程。

根因分析

问题链路

bash 复制代码
报表导出请求高峰
  → 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 使用率,计算方式是:

bash 复制代码
%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

java 复制代码
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);
    }
}

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

java 复制代码
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);
            }
        }
    });
}

修复要点:

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

验证结果

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

bash 复制代码
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. 诊断路径速查

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

附:完整命令清单

短命进程诊断

bash 复制代码
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                  # 分析采样结果

系统资源确认

bash 复制代码
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 时间分布

进程创建统计

bash 复制代码
# 每秒进程创建数
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 验证

bash 复制代码
# 编译
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
相关推荐
IT_陈寒1 小时前
Python里这个赋值坑,连老司机都能翻车
前端·人工智能·后端
唐青枫2 小时前
推荐一个 Zig Web 工程骨架:wing-app
后端
葫芦和十三12 小时前
图解 MongoDB 13|WiredTiger 存储引擎:B-tree、页和 checkpoint 三件套
后端·mongodb·agent
葫芦和十三12 小时前
图解 MongoDB 14|Cache 与淘汰:WiredTiger 的内存治理
后端·mongodb·面试
IT_陈寒15 小时前
Vue这个坑我跳了两次,原来问题出在这
前端·人工智能·后端
ServBay16 小时前
9 个 Python 第三方库推荐,不用 AI 都好像多出一个团队
后端·python
用户83562907805116 小时前
如何使用 Python 添加和管理 Excel 批注(完整示例)
后端·python
用户83562907805117 小时前
使用 Python 管理 Excel 工作表:创建、复制、删除与重命名
后端·python
lizhongxuan17 小时前
Agent Tool
后端