PHP-FPM 进程池被堵死的排查实录:状态页读法、?full 抓卡死进程与无超时 IO 的修法

但凡在 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 不高」,先别急着升级套餐,打开状态页看看工位有没有被占满。

Last modification:October 19th, 2026 at 07:23 pm

Leave a Comment