Skip to content

07a|进程故障排查:卡死、飙高、OOM

上一篇学会了基础的进程操作——看进程、kill 进程、管服务。但生产环境里进程出问题,往往不是"在不在"这么简单。

进程突然卡住,kill -9 都杀不掉。CPU 突然飙到 800%。内存涨个不停最后进程消失。这些故障每一类都有自己的判定特征和排查路径。本篇讲企业环境里真实遇到的几类进程故障,每类给出"现象 → 判定 → 根因 → 处理"的完整链路。

一、僵尸进程(Z 状态)

僵尸进程是已经退出、但退出状态还没被父进程读取的进程。它不占 CPU、不占内存,只在进程表里占一个条目——但如果数量持续累积,会耗尽进程表导致系统无法 fork 新进程。

产生原因

子进程退出后,内核会保留它的退出状态等待父进程通过 wait() 收尸。如果父进程没有调用 wait,子进程就一直停留在 Z 状态。常见根因:父进程代码 bug 忘了 wait、父进程被 SIGSTOP 暂停无法响应、父进程自己阻塞在某个操作上。

排查

bash
# 第 1 步:看是否存在僵尸进程
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'
#  PID  PPID STAT CMD
# 5678  1234 Z+   [child_process] <defunct>

# 第 2 步:找到这些僵尸的父进程
ps -p 1234 -o pid,ppid,stat,cmd

# 第 3 步:统计僵尸总数
ps aux | awk '$8 ~ /Z/' | wc -l

top 输出顶部也会显示僵尸进程统计:

text
Tasks: 245 total, 1 running, 243 sleeping, 0 stopped, 1 zombie

处理

僵尸进程不能直接 kill——它已经死了,kill -9 对它无效。正确思路是处理它的父进程:

bash
# 第 1 步:让父进程收尸(发 SIGCHLD)
kill -CHLD <父进程PID>

# 第 2 步:如果父进程不响应,重启父进程
kill -TERM <父进程PID>
sleep 5
kill -KILL <父进程PID>
# 父进程死后,僵尸子进程会被 init(PID 1) 接管并立即清理

这是僵尸进程问题的核心思路:不是处理僵尸本身,而是处理它的父进程。如果父进程是关键业务,需要联系开发修复 fork/wait 逻辑。

二、D 状态进程(kill -9 杀不掉)

D 状态进程在内核态等待某个不可中断的操作完成,通常是磁盘 I/O。最危险的特征:kill -9 也无法杀掉——信号必须等到进程返回用户态才能处理,而 D 状态卡在内核里出不来。

常见根因

NFS、CIFS 等网络文件系统卡死(挂载的远程存储不可达)、磁盘硬件故障、内核 bug。

排查

bash
# 第 1 步:找出所有 D 状态进程
ps -eo pid,stat,wchan,cmd | awk '$2 ~ /D/'
#  PID STAT WCHAN            CMD
# 7890 D    rpc_wait_bit_killable  ls /mnt/nfs-share

# 第 2 步:看进程在内核中卡在哪里
cat /proc/7890/stack
# [<0>] rpc_wait_bit_killable+0x44/0x90
# [<0>] __rpc_execute+0x148/0x3a0
# ...

# 第 3 步:查 dmesg 是否有 I/O 错误
dmesg -T | grep -i -E "i/o error|nfs|timeout"

wchan 字段(wait channel)显示进程在内核中等待的函数。看到 nfs_*rpc_* 之类的字段,基本能判断是 NFS 卡死。

处理

D 状态进程必须等内核操作返回才能退出,处理取决于根因。

NFS 卡死场景:

bash
# 强制卸载 NFS 挂载点(卡住的进程仍可能无法释放,可能需要重启)
umount -f -l /mnt/nfs-share

# 永久解决:挂载时加 soft 选项,而不是默认的 hard
# /etc/fstab 中:
# nfs-server:/share /mnt/nfs-share nfs soft,timeo=30,retrans=3 0 0

磁盘硬件故障场景:更换硬件后才能彻底解决,期间只能重启系统让 D 状态进程释放。

一个重要的预防原则:任何远程存储的挂载都要用 soft 或 timeo 选项,避免远程不可达时本机进程进入 D 状态拖垮整台机器。

三、CPU 飙高排查

CPU 占用异常升高是生产环境最常见的进程故障之一。排查路径几乎是固定的。

第 1 步:定位是哪个进程吃 CPU

bash
top
# 按 P 按 CPU 排序,记录占用最高的进程 PID
# 假设是 PID 1234,占用 800%(8 核全跑满)

或命令式输出:

bash
ps -eo pid,user,pcpu,cmd --sort=-pcpu | head -10

第 2 步:进入进程内部,定位是哪个线程

bash
top -H -p 1234
# 输出会列出该进程下所有线程,按 P 排序
# 假设线程 PID(TID) 1245 占用最高

-H 让 top 显示线程级别。对 Java、Go、Node.js 等多线程服务,只看进程级别不够——8 核满载可能由某个线程独占,定位到具体线程才能继续追查。

第 3 步:根据语言分析线程在干什么

Java 应用

bash
# top -H -p 看到的 TID 是十进制,jstack 输出的 nid 是十六进制
printf "%x\n" 1245
# 输出:4dd

# 在 jstack 输出中找到对应线程
jstack 1234 | grep -A 30 nid=0x4dd
# 可以看到该线程的完整堆栈,定位到是哪段代码在死循环或热计算

Go 应用

bash
curl http://localhost:6060/debug/pprof/profile?seconds=30 > cpu.pprof
go tool pprof -top cpu.pprof

Python 应用

bash
pip install py-spy
py-spy top --pid 1234
# 或采样生成火焰图
py-spy record -o profile.svg --pid 1234

通用工具 perf

bash
perf top -p 1234
# 或保存数据后离线分析
perf record -F 99 -p 1234 -g -- sleep 30
perf report

CPU 飙高的常见根因

根因特征
死循环 / 热点代码单线程持续 100%,堆栈停留在某段代码
GC 频繁Java 进程 CPU 高且伴随内存压力,jstack 看到 GC 线程活跃
锁竞争多线程交替活跃,堆栈停留在 lock/synchronized
上下文切换过多vmstat 1 看 cs 列异常高
加密/压缩操作OpenSSL、gzip 等 CPU 密集函数占用多

四、内存异常增长(疑似泄漏)

服务运行时间长后内存占用持续增长、不下降,通常是内存泄漏。

第 1 步:确认是哪个进程占用增长

bash
# 按 RSS 排序看 top 10 进程
ps -eo pid,user,rss,vsz,cmd --sort=-rss | head -10

# 持续观察某进程的内存变化
while true; do
  ps -p 1234 -o pid,rss,vsz,pcpu,etime
  sleep 60
done

RSS(Resident Set Size)是常驻物理内存。RSS 在数小时或数天内持续增长,基本可以判定内存泄漏。

第 2 步:看进程的内存映射

bash
# 总览:进程的内存段构成
pmap -x 1234 | tail -20

# 详细看每个内存段
cat /proc/1234/smaps_rollup

smaps_rollup 是 Linux 内核 4.14+ 提供的汇总视图,直接给出整个进程的 PSS、RSS、Swap 总和:

text
$ cat /proc/1234/smaps_rollup
Rss:             2097152 kB
Pss:             1856000 kB
Anonymous:       1900000 kB
Swap:                  0 kB

第 3 步:按语言分析具体泄漏点

Java

bash
jstat -gc 1234 1000                        # 看 JVM 堆使用情况
jmap -dump:format=b,file=heap.bin 1234     # dump 堆快照分析
# 然后用 MAT、jvisualvm 等工具分析 heap.bin

Go

bash
curl http://localhost:6060/debug/pprof/heap > heap.pprof
go tool pprof -top heap.pprof

Python

bash
pip install memory_profiler objgraph
py-spy dump --pid 1234

Java 进程内存高但堆又正常

Java 进程的 RSS 高,但 jstat -gc 显示堆没满,常见原因:Metaspace 占用过大(类加载或反射过多)、Direct ByteBuffer 泄漏(Netty 等框架用堆外内存,JVM 工具看不到)、JIT 编译产生的 native 内存。

排查堆外内存需要用 NMT(Native Memory Tracking):

bash
# 启动时加 -XX:NativeMemoryTracking=detail
jcmd 1234 VM.native_memory summary

五、文件句柄耗尽(Too many open files)

应用日志里出现 Too many open files 错误,或网络连接突然无法建立,通常是文件描述符(fd)耗尽。

排查

bash
# 第 1 步:看进程当前打开的 fd 数量
ls /proc/1234/fd/ | wc -l

# 第 2 步:看该进程的 fd 上限
cat /proc/1234/limits | grep "Max open files"
# Max open files            1024                 4096                 files

# 第 3 步:看是什么类型的 fd 占用多(socket? 文件?)
ls -l /proc/1234/fd/ | awk '{print $11}' | sort | uniq -c | sort -nr | head
#    523 socket:[xxxxx]      ← socket 大量泄漏
#     20 /var/log/app.log
#      8 /dev/null

socket:[xxxx] 数量异常高通常是网络连接没正确关闭(连接泄漏);某个文件路径出现几百次是文件未正确 close。

处理

临时缓解——调高 fd 上限:

bash
# 单个进程的临时上限调整
prlimit --pid 1234 --nofile=65535:65535

# 永久修改(需要服务重启生效)
# /etc/security/limits.d/app.conf
app soft nofile 65535
app hard nofile 65535

systemd 管理的服务,在 unit 文件里设:

ini
[Service]
LimitNOFILE=65535

根本解决要修应用代码——调高上限只是缓兵之计,如果应用存在 fd 泄漏迟早会再次耗尽。需要联系开发分析:数据库连接是否用了连接池、HTTP client 是否复用了 keep-alive、文件操作是否用了 try-with-resources 或 defer close。

六、进程 hang 死无响应

进程仍在运行(STAT 是 S 或 R),但不响应任何请求——HTTP 接口超时、命令执行卡住。

第 1 步:看进程当前在干什么

bash
# strace 附加到进程,看它正在执行的系统调用
strace -p 1234
# read(15, ...   ← 卡在某个 read 系统调用上

strace 输出长时间没有新的系统调用,说明进程卡在了 read/write/poll 等阻塞调用上。fd=15 可以进一步追:

bash
ls -l /proc/1234/fd/15
# lrwx------ 1 app app 64 Jun 23 11:00 /proc/1234/fd/15 -> socket:[123456]

# 查看这个 socket 连接的对端
ss -tnap | grep 123456

第 2 步:看进程的线程级行为

bash
# 进程下所有线程都在干什么
for tid in $(ls /proc/1234/task/); do
  echo "=== Thread $tid ==="
  cat /proc/1234/task/$tid/stack
done

所有线程都停留在等待锁、等待 I/O 上,基本能判定是死锁或外部依赖卡死。

第 3 步:按语言分析

  • Javajstack 1234 看完整堆栈,工具会自动检测 deadlock
  • Go:发 SIGQUIT 让 Go runtime 打印所有 goroutine:kill -QUIT 1234
  • Pythonpy-spy dump --pid 1234

七、OOM 杀进程

进程突然消失,服务异常重启,需要确认是不是系统 OOM。

判定特征

bash
# 第 1 步:dmesg 查 OOM 记录
dmesg -T | grep -i "out of memory"
# [Tue Jun 23 10:25:30 2026] Out of memory: Killed process 1234 (java) total-vm:8533124kB, anon-rss:6291456kB ...

# 第 2 步:看完整的 OOM 上下文(被杀进程之前的内存分布)
dmesg -T | grep -B 30 "Killed process"

OOM Killer 触发条件:整个系统(或某个 cgroup)的物理内存耗尽,内核根据 oom_score 评分选择一个进程杀掉以释放内存。被选中的通常是占用内存最大、oom_score_adj 较高的进程。

系统 OOM vs cgroup OOM

容器环境里(K8s pod、Docker 容器),OOM 通常发生在 cgroup 级别——容器内存使用超过了 limits.memory

bash
# 容器内 OOM 的特征
dmesg -T | grep -i "memory cgroup out of memory"
# 或在 K8s 中:
kubectl describe pod xxx | grep -i oom
# 看到 OOMKilled 即为容器 OOM

cgroup OOM 只杀该 cgroup 内的进程,不影响宿主机其他容器和系统进程。

应对

短期:增加内存配额(物理机加内存、cgroup/Pod 调高 limits)。

长期:排查内存泄漏(见第四节)、调整应用配置(JVM 堆大小、连接池大小、缓存大小)、调整 oom_score_adj 让关键进程不被优先杀:

bash
# 给某进程降低被 OOM 杀的概率(值越小越不容易被杀)
echo -1000 > /proc/1234/oom_score_adj

# 或在 systemd unit 中:
# [Service]
# OOMScoreAdjust=-1000

八、fork 炸弹与进程数耗尽

应用 bug 或恶意脚本可能在短时间内 fork 出大量进程,耗尽系统进程表,导致整个系统无法创建任何新进程(fork: retry: Resource temporarily unavailable)。

排查

bash
# 系统总进程数
ps -e | wc -l

# 各用户的进程数
ps -eo user | sort | uniq -c | sort -nr

# 单个用户的进程上限
ulimit -u
cat /proc/1234/limits | grep "Max processes"

# 系统级进程数上限
cat /proc/sys/kernel/pid_max

防御

普通用户的进程数限制:

bash
# /etc/security/limits.d/50-default.conf
*    soft    nproc    4096
*    hard    nproc    8192

systemd 服务的进程数限制:

ini
[Service]
TasksMax=512

应急处置:

bash
# 杀掉某用户的所有进程
pkill -u <用户>

# 或者通过 systemd 的 user slice
systemctl kill user-1000.slice

九、systemd 服务启动失败

服务无法启动是另一类高频故障。几种常见现象和排查方向。

手动执行命令正常,通过 systemd 启动失败:环境变量缺失(登录 shell 有的变量 systemd 启动的进程没有)、工作目录未设置、命令未用绝对路径。

bash
systemctl show myapp | grep -E "Environment|WorkingDirectory|ExecStart"
journalctl -u myapp -n 50

服务启动后立即 failed:程序自身退出、Type 字段写错(程序前台运行却配成 Type=forking)、配置文件错误、依赖端口被占用。

bash
journalctl -u myapp -n 100 --no-pager     # 看程序退出原因
ss -lntp | grep ':8080'                   # 看端口是否被占用

修改 unit 后行为没变:最可能原因是忘记 systemctl daemon-reload。任何 unit 文件修改后都必须执行:

bash
systemctl daemon-reload
systemctl restart myapp

服务反复重启Restart=always 配合程序快速失败导致的死循环。检查 journalctl 中的退出原因,加上 RestartSec 拉长间隔,或改为 Restart=on-failure 配合 StartLimitBurst

实际生效的配置与 unit 文件不一致:可能存在 drop-in 覆盖配置。systemctl cat 会合并显示主 unit 文件和所有 drop-in:

bash
systemctl cat myapp
# 输出可能包含:
# /etc/systemd/system/myapp.service
# /etc/systemd/system/myapp.service.d/override.conf    ← drop-in 覆盖

排查"修改的配置为什么没生效",systemctl cat 是必查项——经常会发现某个 drop-in 文件覆盖了主 unit 里的设置。

排查的共同思路

这几类故障看起来各不相同,但排查思路是相通的:

  1. 先确认现象——是卡死、飙高、消失,还是启动不了。现象决定排查方向。
  2. 定位到具体进程——ps/top 找到出问题的 PID。
  3. 看进程的状态和资源——STAT 列、/proc/PID/ 接口、/proc/PID/stack
  4. 按语言生态深入——Java 用 jstack/jmap、Go 用 pprof、Python 用 py-spy。
  5. 根因在下游就治下游——很多进程故障的根因不在进程本身,而在数据库、外部依赖、磁盘 I/O。

下一篇转向磁盘管理——进程要读写的数据存在哪、磁盘满了怎么办。