但凡在 VPS 上跑过一段时间 PHP 站,几乎都会遇到同一个现象:网站能打开,但慢得离谱,探针一看 CPU 又不高,内存也还剩一半。你以为是数据库慢,去开慢查询日志,结果一条都没有;你以为是网络差,可 TTFB 忽快忽慢,同一分钟里两次请求能差出三倍。这时候真正的问题往往藏在 PHP-FPM 的状态页里——进程全被"占着",但没有任何一个在正常工作。
这篇文章讲的是 PHP-FPM 的进程池被"卡住"这一类故障:状态页怎么读、什么信号说明天真的出了问题、以及从请求指纹一路查到代码行号的完整路径。适合 LNMP 单机、有一定 Linux 基础的个人站长。
一、为什么"CPU 不高但网站慢"最该看 FPM
Nginx + PHP-FPM 的分工要先理清楚。Nginx 负责接连接、收请求、发静态文件;动态请求通过 FastCGI 协议交给 FPM 的 worker 进程处理。FPM 的 worker 数量是固定的(由 pm.max_children 决定),它是一批"工位"。请求进来占一个工位,处理完释放。
如果某个请求处理得慢,它就一直占着工位不放。工位被占满之后,后续请求只能在 Nginx 的 listen 队列里排队,排满就拒绝。整个过程中,PHP 进程可能大部分时间在等 IO(数据库、外部 HTTP 接口、DNS 解析、文件锁),所以 CPU 使用率依然很低——这就是"慢但 CPU 不高"的典型形态。
判断这件事最直接的工具是 FPM 的状态页。它能告诉你:总共几个 worker、几个在忙、几个空闲、listen 队列里堆了多少、哪些请求已经跑了多长时间。
二、把状态页开起来(安全地开)
状态页在池配置文件里打开,通常在 /etc/php/8.2/fpm/pool.d/www.conf:
pm.status_path = /fpm-status
ping.path = /fpm-ping
ping.response = pong
改完 systemctl reload php8.2-fpm。然后在 Nginx 里给这个路径开一个只允许本机访问的 location:
location = /fpm-status {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
}
注意两个安全点。第一,deny all 必须在最后,且不要把这台机器的公网 IP 加进去;状态页会暴露完整请求 URL,等于把站点的所有后台地址和参数名送给扫描器。第二,?full 参数(/fpm-status?full)会列出每个正在处理的请求的完整 URI 和脚本路径,更敏感,只在排障时临时开,查完关掉。
用 curl 直接读:
curl -s "http://127.0.0.1/fpm-status"
输出大致是这样:
pool: www
process manager: dynamic
start time: 12/Oct/2026:03:11:02 +0000
accepted conn: 184320
listen queue: 0
max listen queue: 214
listen queue len: 511
idle processes: 3
active processes: 17
total processes: 20
max active processes: 20
max children reached: 312
slow requests: 47
三、逐字段读:哪些数字是真信号
max children reached > 0 是最强的信号。它表示从 FPM 启动以来,曾经有过"工位不够、请求排队"的时刻。这个计数器是累计值,不会归零,所以关键看它是否在持续增长——隔十分钟取两次,数值涨了就是现在正在发生。
max listen queue 表示历史上队列最深堆到多少。这个值只要超过个位数,就说明曾经出现过明显的排队。要是它上千,那段时间用户看到的基本就是 502 或长时间白屏。
active processes / total processes 接近 1 时,工位已满。但要注意 pm=dynamic 下 total 是会伸缩的,在 idle processes: 0 且 active 等于 pm.max_children 时才算真的打满。
slow requests 需要配合 request_slowlog_timeout 才会有意义。这个参数一旦设置,FPM 会在请求超时后把该请求的 PHP 调用栈 dump 到 slowlog 文件里——这是定位问题代码最有效的手段,后面细说。
accepted conn 只反映累计处理量,单看没有意义,要做速率对比:两分钟内涨了多少,能反推实际 QPS。
还有一个常被忽略的字段:listen queue len。它是内核 somaxconn 和 FPM 配置里 listen.backlog 取小值的结果。默认 511 看着够用,但如果队列经常堆到几百,说明瓶颈已经不在 FPM 而在下游依赖了。
四、用 ?full 抓现行:谁在占着工位
FPM 支持按 JSON 输出,方便脚本处理:
curl -s "http://127.0.0.1/fpm-status?full&json" | jq '.processes[] | select(.state=="Running") | {pid, request_uri, request_duration, script}'
返回里 request_duration 是毫秒级的请求已运行时长。连查三次,每次间隔两秒,把三次结果里 都出现且 duration 不断增长的 pid 挑出来——这就是真正的"卡住"进程,而不是恰好在处理中的正常请求。
for i in 1 2 3; do
curl -s "http://127.0.0.1/fpm-status?full&json" \
| jq -r '.processes[] | select(.state=="Running") | "\(.pid) \(.request_duration) \(.request_uri)"'
echo '---'
sleep 2
done
拿到卡住的 pid 之后,看它到底卡在哪:
# 看系统调用,重点找 read/futex/connect 卡住的栈
cat /proc/<pid>/stack 2>/dev/null
# 或用 strace 跟 3 秒,不跟踪子进程,避免拖慢业务
timeout 3 strace -p <pid> -f -e trace=network,read,write 2>&1 | tail -20
常见的几种栈形态对应的病因:
read(4, ...)卡在某个 fd 上 —— 该 fd 多半是数据库连接或外部 HTTP 请求,用lsof -p <pid>反查是哪个地址。connect(...)长时间不返回 —— DNS 解析或外部服务不通,TCP 连接超时通常是 20 秒以上。futex—— 文件锁或 flock 竞争,常见于缓存文件、session 文件、日志写入。poll/epoll_wait时间很短 —— 正常等事件,不是问题。
五、慢日志:让 FPM 自己告诉你哪行代码慢
比起逐个人肉 strace,慢日志是更省事的办法。配置:
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
request_terminate_timeout = 60s
request_slowlog_timeout 是"超过多久就 dump 栈",request_terminate_timeout 是"超过多久直接杀掉 worker"。前者是诊断,后者是保护,两个都要设。
慢日志内容形如:
[12-Oct-2026 04:21:33] [pool www] pid 21455
script_filename = /var/www/html/detail.php
[0x00007f3] curl_exec() /var/www/html/lib/fetch.php:88
[0x00007f4] fetch_remote_json() /var/www/html/lib/fetch.php:112
[0x00007f5] render_detail() /var/www/html/detail.php:41
直接给出函数名和行号。上面的例子说明卡在 curl_exec——一个没设超时的远程请求。这是 PHP 站最常见的工位占用原因之一,修复只要两行:
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 5,
]);
$out = curl_exec($ch);
同理,数据库连接也要有超时:PDO 的 PDO::ATTR_TIMEOUT、MySQL 的 connect_timeout 和 max_execution_time 都应该设。没有超时的 IO 调用是 FPM 杀手。
六、参数怎么算才不拍脑袋
常见的错误是看到 max children reached 就无脑把 pm.max_children 调大。这往往会让情况更糟:memory 被吃光,触发 swap,所有 worker 一起变慢。
先算内存上限。单个 PHP 进程的内存峰值可以用 pm.status?full 里的 last_request_memory,或者用 ps 取 RSS:
ps -o pid,rss,cmd -C php-fpm8.2 | awk 'NR>1 {s+=$2; n++} END {printf "avg %.1f MB over %d procs\n", s/n/1024, n}'
假设单进程 60MB,机器 2GB,留给系统的 400MB,那么 max_children 不该超过 (2048-400)/60 ≈ 27。而如果它已经是 20 还在打满,说明问题不是数量不够,而是每个请求占总时间太长——该修的是单请求耗时,不是加人。
至于 pm 模式:小站(每天几千 PV)用 static 更省事,固定 8 个 worker,避免动态伸缩带来的秒级毛刺;中站用 dynamic,pm.start_servers 设成 max 的 25%,pm.max_spare_servers 设成 50%,pm.min_spare_servers 设成 10%。ondemand 只适合极低流量、内存极度紧张的场景,因为它的回收和拉起有延迟,容易让首字节时间变差。
七、一个真实的排查时间线
某内容站,2 核 2G,日均 1.2 万 PV。症状:白天高峰期偶发 502,持续 30 秒左右自行恢复。
第一步查状态页,发现 max children reached 已经 312,max listen queue 214,active = total = 20。确认是工位打满。
第二步 ?full 三连拍,找到一个 pid 反复出现、duration 从 8000 涨到 15000。strace 显示卡在 connect。
第三步开慢日志,五分钟后抓到三条,全部指向 lib/thumb.php:133 的 file_get_contents——缩略图生成逻辑在抓取一个外部图床,那个图床当天不稳定。
第四步修复:给该调用加 3 秒超时 + 失败时降级到本地占位图;把缩略图生成改成异步队列,前台请求不再等它。
第五步观察:max children reached 不再增长,P95 从 2.1 秒降到 380 毫秒,502 消失。
整个过程没动过 max_children,也没加内存。这就是"先定位、再扩容"的价值。
八、预防:把状态页做成监控
别等出事才去看。写个每分钟跑的脚本,把关键指标打到日志,超过阈值就告警:
#!/bin/bash
# /usr/local/bin/fpm-watch.sh
S=$(curl -s --max-time 3 "http://127.0.0.1/fpm-status?json")
[ -z "$S" ] && echo "fpm status unreachable" >&2 && exit 1
ACT=$(echo "$S" | jq -r .active_processes)
TOT=$(echo "$S" | jq -r .total_processes)
QP=$(echo "$S" | jq -r '.listen_queue | tonumber')
echo "$(date +%FT%T) active=$ACT total=$TOT queue=$QP"
if [ "$QP" -gt 20 ]; then
echo "WARN listen queue $QP" >&2
fi
if [ "$ACT" -ge "$TOT" ] && [ "$TOT" -gt 0 ]; then
echo "WARN fpm saturated $ACT/$TOT" >&2
fi
配置到 cron 每分钟一次,输出重定向到日志文件,再用 logrotate 管控。真正有价值的是这份日志的基线——出问题那天,你才能一眼看出「平时 queue 都是 0,今天 200」。
九、小结
PHP-FPM 工位被堵住这类故障,排查顺序可以固化为:状态页确认饱和 → ?full 定位卡住的 pid → strace / 慢日志找卡住的调用 → 修代码里的无超时 IO → 最后才考虑调参数。绝大多数情况下,第 4 步就结束了。
还有两点容易被忽略:一是 pm.max_children 的正确计算方式取决于内存而不是 CPU;二是所有外部调用都必须有超时——没有超时的 curl、file_get_contents、PDO、Redis,都是定时炸弹。
下次再遇到「网站慢但 CPU 不高」,先别急着升级套餐,打开状态页看看工位有没有被占满。