服务器无缘无故变慢,uptime 里的 load average 飘到 20,top 里最上面几个进程的 CPU 占用加起来却不到 50%,这是小内存 VPS 上非常典型的一种场景。新手的第一反应是「CPU 不够,升级配置」,但真相往往是:进程数失控了,或者有一批僵尸进程根本杀不掉,或者 load 高的元凶压根不是 CPU 而是磁盘 IO 等待。
这篇文章按「从表象到根本原因」的顺序,讲一套在单台 VPS 上把进程问题查清楚的完整方法。所有命令都是实际可跑的,所有坑都是真实踩过的。如果你的站是 PHP + Nginx + MySQL 这种最常见的自建组合,这篇文章里的场景大概率能对上号。
一、先读懂 load average:它不是 CPU 使用率
最常见的误解是把 load average 当成 CPU 占用率。uptime 输出的三个数字分别是 1 分钟、5 分钟、15 分钟的平均负载,它的定义是「处于可运行状态(R)和不可中断睡眠状态(D)的进程数量之和」。
可运行状态好理解:正在排队等 CPU 的进程。关键在于不可中断睡眠(D 状态)——这类进程在等磁盘 IO、等 NFS、等某些内核锁,它们不占用 CPU,但会一直算进 load。所以一台 1 核 VPS 上 load 是 4,可能是四个 PHP 在抢 CPU,也可能是一个进程卡在坏盘上做 IO 等待,加上三个正常进程。两种情况的处理方式完全不同。
判断方法是把 load 和 CPU 使用率对照看:
uptime; top -bn1 | head -12; vmstat 1 5vmstat 输出里最关键的两列:r 是运行队列长度(长期大于 CPU 核数说明 CPU 真的不够),b 是阻塞进程数(大于 0 且持续存在,说明有 IO 或锁等待),wa 是 IO 等待占 CPU 时间的百分比。如果 load 很高但 wa 接近 0、r 也不大,那几乎可以确定是 D 状态进程在撑 load,方向应该转向磁盘和挂载点,而不是 CPU。
还有一个容易被忽略的点:容器化环境下(比如 LXC/OpenVZ 的 VPS),/proc/loadavg 显示的可能是宿主机的负载,而不是你容器的。这种情况下你看到 load 20 但自己机器闲得很,是正常的。验证方法看 cat /proc/loadavg 与容器内的进程数是否匹配。
二、僵尸进程:为什么 kill -9 对它无效
僵尸进程(Z 状态)是很多人搞不明白的东西。先说结论:僵尸进程不占 CPU、不占内存、不占文件描述符,它只占一个 PID。它的存在意义是「进程已经死了,但父进程还没读取它的退出状态」。在 ps 里状态是 Z,命令名外面带 <defunct>。
因为进程已经死了,所以 kill -9 对它毫无作用——没有进程可供杀死。唯一能让它消失的办法是让父进程调用 wait() 收尸。父进程正常退出时,它所有的僵尸子进程会被 init(PID 1)接管并自动回收。
所以处理僵尸进程的正确姿势永远是:找到它的父进程,处理父进程。
ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/'
ps -eo stat | grep -c '^Z'第一行列出所有僵尸及其 PID/PPID,第二行数总量。拿到 PPID 之后,ps -fp <PPID> 看这个父进程是什么。常见的几种情况:
情况一,父进程是 PHP-FPM master。这意味着某个 worker 在 fork 子进程(比如 exec()、shell_exec()、proc_open() 调用外部命令)之后没有正确回收。PHP-FPM 的 master 一般会处理 SIGCHLD,但如果你在 php-fpm.conf 里把 process_control_timeout 设得太短,或者 worker 在被 master 杀掉时正好有子进程在跑,就会留下一批僵尸。这类僵尸会在 worker 轮换之后自然消失,数量不多(个位数)可以不管。
情况二,父进程是 cron 或某个你自己写的守护脚本。这最麻烦——如果那个父进程是长期不退出、又没有 wait 逻辑的脚本,僵尸会一直累积。判断依据是僵尸数量持续增长。解决办法是修父进程的逻辑,或者干脆重启它。
情况三,父进程本身也成了僵尸,或者父进程的 PPID 是 1 但僵尸还在。这说明 init 没有正确回收,通常发生在 PID 1 不是 systemd(比如容器里 PID 1 是 bash 或某个应用)的环境。这种情况下唯一的办法是重启那个「伪 init」进程,或者给容器加 --init 参数。
真正需要紧张的情况只有一个:僵尸数量在持续上涨。如果稳定在几个、十几个,那只是噪音,不值得为它熬夜。当它涨到几千个时,会先撞上 PID 上限(kernel.pid_max,默认 32768 或 4194304),届时新进程无法创建,表现为 fork 失败、SSH 登不上、服务全挂——这才是真正的故障。
cat /proc/sys/kernel/pid_max
ps -e --no-headers | wc -l这两个数字对比一下,就知道离危险线还有多远。
三、找到吃满 CPU 的那个 PHP 脚本
假设场景是:站点响应变慢,top 里看到三四个 php-fpm 进程各占 20%~30% CPU,持续不降。这时候需要知道的是「哪个脚本、哪篇文章、哪个请求」在跑。光看进程名没有意义,所有 worker 都叫 php-fpm: pool www。
第一步,抓到高 CPU 进程的 PID:
ps -eo pid,pcpu,pmem,etime,args --sort=-pcpu | head -10第二步,看这个进程的当前栈。PHP-FPM 有一个内置的状态页能直接显示「每个 worker 正在处理哪个 URL、跑了多久」,这是最有价值的信息源。先开启它:
; /etc/php/8.2/fpm/pool.d/www.conf
pm.status_path = /fpm-status
ping.path = /fpm-ping然后在 Nginx 里只允许内网访问这个路径:
location = /fpm-status {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}之后 curl -s "http://127.0.0.1/fpm-status?full" 会列出每个 worker 的状态、当前请求的 URI、已经运行了多久。如果某个 worker 卡在同一个 URI 上超过 5 秒,那个页面就是嫌疑犯。这个方法比 strace 轻得多,也比看日志直观。
第三步,如果没有状态页或者状态页显示不出内容,用 strace 附加上去看系统调用。注意 strace -p 会让目标进程短暂停顿,在生产环境上用要短促、只用一次:
timeout 3 strace -p 12345 -f -c-c 是汇总模式,三秒后输出各类系统调用的耗时统计。如果看到大量 read / lseek 指向同一个文件,那基本就是在读大文件(常见于「一次性 file_get_contents 整个日志文件」、「读整个 JSON 数据文件」这种写法);如果大量 poll / recvfrom 挂在某个 fd 上,那是在等外部 HTTP 接口或数据库响应。
第四步,如果连 strace 都看不出所以然,可以直接看这个进程的调用栈。PHP-FPM 配合 gdb 能打出一份 C 层栈,但对普通人来说太晦涩。更实用的是让 PHP 自己吐出来:开启 request_slowlog_timeout,跑到慢的请求会把完整的 PHP 调用栈写进 slowlog,能直接看到是哪个函数的哪一行:
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
request_terminate_timeout = 60s然后在 slowlog 里会看到类似这样的记录,一眼就能定位到脚本和行号:
[18-Oct-2026 14:23:11] [pool www] pid 28471
script_filename = /www/wwwroot/zz1984/index.php
[0x00007f...] file_get_contents() /www/wwwroot/zz1984/var/Widget/Archive.php:412
[0x00007f...] render() /www/wwwroot/zz1984/var/Widget/Archive.php:180这类栈里最常见的是 file_get_contents 拉远程接口、curl_exec 没有超时、PDOStatement->execute 跑慢查询。request_slowlog_timeout 建议设成 5 秒,request_terminate_timeout 设成 60 秒——后者一定要大于前者,否则慢日志还没写完进程就被杀了,日志里只剩残缺的一段。
四、进程数失控:pm.max_children 该怎么算
还有一种情况是 load 高但没有单个进程特别突出,而是有几百个 php-fpm 进程。这就是典型的「进程数没管住」。
ps -eo comm | sort | uniq -c | sort -rn | head -10如果看到 php-fpm 有 200 多个,先确认配置:pm 用的是 dynamic 还是 static?pm.max_children 设了多少?很多一键安装包(宝塔之类)默认给 pm.max_children = 300,这个值在 2GB 内存的机器上是灾难性的——每个 PHP worker 实际占用 30~60MB 内存,300 个理论上要 9GB 以上,结果是疯狂 swap,然后 OOM Killer 开始随机杀进程。
内存预算的计算方法:
ps -eo rss,comm | awk '$2 ~ /php-fpm/ {s+=$1; n++} END {printf "workers=%d avg=%.1fMB total=%.0fMB\n", n, s/n/1024, s/1024}'拿到平均单个 worker 的内存占用(注意 RSS 里有共享内存,实际增量会比 RSS 小),然后用「可用内存 × 70% ÷ 平均占用」算出上限。2GB 内存、平均 40MB 的话,pm.max_children 设 30 左右是合理的,设 300 就是在等 OOM。
另外两个参数也值得调:pm.max_requests 让 worker 处理 N 个请求后重启,能缓解释放不掉的内存(PHP 的 memory_limit 管不到扩展层的泄漏),设 500~1000 比较常见;pm.start_servers 和 pm.min/max_spare_servers 在 dynamic 模式下决定进程池的伸缩节奏,如果 max_spare_servers 设得太高,空闲时也会保留大量进程白占内存。
还有个大坑:emergency_restart_threshold 和 emergency_restart_interval。如果 worker 频繁异常退出(比如被 OOM 杀),FPM master 会不断重开新 worker,表现为 CPU 周期性飙高、进程号不断变化。开启这两个参数能让 master 在短时间内多次失败后自己重启,至少避免僵死状态:
emergency_restart_threshold = 10
emergency_restart_interval = 1m
process_control_timeout = 10s五、D 状态进程与 IO 等待:常被忽略的另一半
回到第一节的问题:如果 load 高但 CPU 空闲,那就是 D 状态进程。查这个用:
ps -eo pid,stat,wchan:30,comm | awk '$2 ~ /D/'
iostat -x 1 3
iotop -oPawchan 列显示进程卡在哪个内核函数上,常见的有 io_schedule(等 IO)、rpc_wait_bit_killable(等 NFS)、flock_lock_inode(等文件锁)。iostat -x 里如果 %util 接近 100 且 await 几十毫秒,说明磁盘已经是瓶颈了。
这类情况在小内存 VPS 上非常常见,而且往往和 swap 有关——内存不够时内核把匿名页换到 swap 上,读回来就是随机 IO,机械盘或者低配云盘的 IOPS 撑不住,于是大量进程进入 D 状态。判断方法:vmstat 里 si / so 两列(swap 进出)是否非零。如果持续有值,那问题的根子是内存不足,而不是进程管理。
这个链条很容易被误判:负载高 → 以为 CPU 不够 → 加 CPU → 依然慢。实际路径是:PHP worker 太多 → 内存耗尽 → 开始 swap → 磁盘 IO 打满 → 进程 D 状态 → load 飙升。正确的修法是减 worker,不是加 CPU。
六、把排查固化成脚本
上面这些命令如果每次故障都要重新回忆一遍,效率太低。建议写一个 /usr/local/bin/loadcheck,一次性把所有关键数据抓下来存档:
#!/bin/bash
OUT=/var/log/loadcheck-$(date +%Y%m%d-%H%M%S).txt
{
date; uptime
echo "--- top ---"; top -bn1 | head -20
echo "--- vmstat ---"; vmstat 1 5
echo "--- 进程数 ---"; ps -eo comm | sort | uniq -c | sort -rn | head -10
echo "--- 僵尸 ---"; ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/'
echo "--- D状态 ---"; ps -eo pid,stat,wchan:25,comm | awk '$2 ~ /D/'
echo "--- 内存 ---"; free -m
echo "--- 磁盘 ---"; df -h; iostat -x 1 2
} > "$OUT" 2>&1
echo "$OUT"再加一条 cron,每 5 分钟检查 load 是否超过阈值,超了就执行这个脚本并邮件通知。这样下次故障时,你手里有一份「故障发生当时」的现场快照,而不是靠事后回忆。事后补抓的数据几乎没有价值——进程已经退出、负载已经恢复,你看到的只是一切正常。
七、几条实战结论
第一,先分清是 CPU 问题还是 IO 问题,方法就是对比 load 和 vmstat 的 r / b / wa。这一步做错,后面所有动作都是白费。
第二,僵尸进程本身无害,数量稳定就忽略;只有持续增长才需要顺藤摸瓜找到父进程。永远不要试图 kill 僵尸。
第三,定位「哪个脚本在吃 CPU」,最有效的工具不是 strace,而是 PHP-FPM 的状态页加 slowlog。前者告诉你「在等什么」,后者告诉你「是哪行代码」。
第四,进程数问题本质是内存预算问题。pm.max_children 不要照抄安装包的默认值,用实测的单个 worker 内存占用反推。
第五,故障现场的留存比事后分析重要得多。一个每 5 分钟抓一次快照的脚本,成本十几行代码,收益是在真正出事那天不用猜。
把这几条串起来,一台配置不高的 VPS 也能跑得稳。很多时候不是机器不够用,是我们从来没认真看过它到底在忙什么。