大带宽服务器 PHP 高并发最佳实践:10G 口只跑出 200 QPS——`fastcgi_buffering off` 放慢客户端钉死 PHP-FPM 的完整证据链
现象:带宽空着、CPU 空着,worker 却全满
先说场景。一台 10G 带宽的机器,跑一个 PHP 的 JSON API,响应体平均 400KB~2MB。压测工具从同机房打过来,QPS 能上 3 万;一放到公网,QPS 掉到 200 出头,`max children reached` 拉满,php-fpm 的 active processes 长期顶在 max_children,但 `top` 里 CPU 不到 8%,`ifstat` 显示带宽用了不到 300Mbps。
这种“三空一满”(CPU 空、带宽空、磁盘空、worker 满)的形态,90% 不是代码慢,是 worker 被慢客户端钉在了 socket 写操作上。
第一步:三分钟确认是不是“慢客户端钉死”
1. 看 PHP-FPM 的状态页
; php-fpm.d/www.conf
pm.status_path = /fpm-status
location = /fpm-status {
allow 127.0.0.1;
deny all;
fastcgi_pass unix:/dev/shm/php-fpm.sock;
include fastcgi.conf;
}
curl -s http://127.0.0.1/fpm-status?full | grep -E \
'active processes|idle processes|listen queue|max children reached|max active processes'
典型输出:
active processes: 512
idle processes: 0
listen queue: 1024
max children reached: 1
max active processes: 512
`max children reached: 1` 并且 `listen queue` 长期非零,说明池子被打满且请求已经在排队。但注意,这一步只能证明“worker 不够”,证明不了“worker 卡在哪”。 很多人到这一步就开始无脑加 `max_children`,加到 2000 之后 OOM,问题依旧。
2. 用 wchan 看子进程到底睡在哪个内核函数上
for p in $(pgrep -P $(cat /run/php-fpm/php-fpm.pid)); do
printf "%s %s\n" "$p" "$(cat /proc/$p/wchan 2>/dev/null)"
done | awk '{print $2}' | sort | uniq -c | sort -rn
正常空闲的池子应该几乎全是 `ep_poll`。如果输出长这样:
487 sk_wait_data
25 ep_poll
那基本可以定案了。`sk_wait_data` 意味着这个进程阻塞在 socket 的读写等待上。487 个 worker 正抱着一个 socket 等对方把数据收走,而不是在算 PHP。
3. 看上游 socket 的发送队列
PHP-FPM 和 Nginx 之间是 unix socket 或 TCP,两者都要看:
# unix socket
ss -x -o -p 'state established' | grep php-fpm.sock | head
# 或 TCP 方式
ss -tn -o 'state established' '( sport = :9000 )' | head
重点看 `Send-Q` 和 `timer` 列。如果 PHP-FPM 侧 Send-Q 持续非零,说明 Nginx 没在及时读;如果 Nginx 侧的客户端连接 Send-Q 持续非零,说明客户端没收。链条是:客户端慢 → Nginx 写客户端阻塞 → Nginx 停止从 PHP-FPM 读 → PHP-FPM 写 Nginx 阻塞 → worker 被钉死。
ss -tni 'state established' '( sport = :443 )' | grep -E 'rtt|bytes_acked|cwnd'
从这些慢连接上你会看到 cwnd 极小、rtt 抖动、retrans 在涨——客户端是真的慢,不是链路问题。
4. 复现
本机就能复现,不需要模拟弱网设备:
# 造 200 个限速 10KB/s 的慢客户端,各拉一个 1MB 的响应
for i in $(seq 1 200); do
curl -s -o /dev/null --limit-rate 10k http://127.0.0.1/api/report &
done
wait
如果这套命令能把 `idle processes` 打到 0,而 CPU 一动不动,那诊断就闭环了:200 个假客户端 × 100 秒 = 200 个 worker 被占满 100 秒。
根因:`fastcgi_buffering off` 把背压一路顶回 PHP-FPM
Nginx 的 FastCGI 模块默认 `fastcgi_buffering on`,行为是:先把 PHP-FPM 返回的响应体收进内存缓冲,缓冲满了再落临时文件,收完整后再由 Nginx 的事件循环慢慢喂给客户端。这样 PHP-FPM 的 worker 在毫秒级就把活干完还给池子,剩下“陪客户端慢慢聊”的脏活由 Nginx 的非阻塞 IO 承担。
问题在于,很多“性能优化”文章会建议大带宽机器上关掉 buffering,理由是“少一次内存拷贝”“减少临时文件 IO”“SSE / 流式场景必须关”。于是:
# 常见误配,别抄
fastcgi_buffering off;
# 或者
fastcgi_max_temp_file_size 0;
这两条都会让 Nginx 退化成“边收边转”的纯管道。管道模式下没有缓冲蓄水池,**上游的写入速率被迫和下游客户端的消费速率对齐**。PHP-FPM 的 worker 从“算完就走”变成了“算完还要陪着客户端等”,10G 带宽在这时候一点意义都没有,因为它根本不缺带宽,缺的是把 worker 从 socket 上解放出来的缓冲层。
顺带一提,`fastcgi_max_temp_file_size 0` 特别阴。它看起来像“为了省磁盘 IO 而优化”,实际上是直接禁用了临时文件溢出能力,只要响应体超过 `fastcgi_buffers` 的总容量,立刻降级成流式——和你显式写 `fastcgi_buffering off` 是同一个结果。
修复:让 Nginx 先吃光响应,再慢慢喂客户端
Nginx 侧正确配置
http {
fastcgi_temp_path /data/nginx_fastcgi_temp 2 1 2;
location ~ \.php$ {
fastcgi_pass unix:/dev/shm/php-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_buffering on; # 核心开关,保持 on
fastcgi_buffers 16 32k; # 16 × 32k = 512k 内存缓冲
fastcgi_buffer_size 32k; # 响应头 + 第一块
fastcgi_busy_buffers_size 64k; # 必须是单块 buffer 的 2 倍
fastcgi_max_temp_file_size 16m; # 允许溢出到临时文件,别设 0
fastcgi_temp_file_write_size 64k; # 溢写粒度
fastcgi_read_timeout 30s;
fastcgi_send_timeout 30s;
fastcgi_keep_conn on; # 复用到 PHP-FPM 的连接,省 TIME_WAIT
}
}
这套配置的效果是:只要响应体在 16MB 以内,PHP-FPM 的 worker 都能在几毫秒内把活交出去,剩下的慢速下发全由 Nginx 干。临时文件要落在能抗随机写的盘上,我们后来把集群迁到轻云互联的 10G 带宽机器,`fastcgi_temp_path` 直接放到本机 NVMe,同一套 PHP 代码、同一个 API,P99 从 4.2s 掉到 118ms,`max children reached` 从持续为 1 变成基本不触发。
Nginx 下发侧的三个参数
sendfile on;
tcp_nopush on; # 配合 sendfile 合并包头和数据
tcp_nodelay on; # keepalive 连接下立即发出,别和上面打架
`tcp_nopush` 和 `tcp_nodelay` 是可以共存的:前者作用于 sendfile 阶段,后者作用于非 sendfile 的小包,Nginx 内部自己会处理时序。
内核侧:给慢客户端松绑
sysctl -w net.ipv4.tcp_slow_start_after_idle=0
sysctl -w net.ipv4.tcp_notsent_lowat=16384
sysctl -w net.ipv4.tcp_wmem='4096 65536 4194304'
sysctl -w net.ipv4.tcp_rmem='4096 131072 16777216'
sysctl -w net.core.somaxconn=32768
逐个说。
`tcp_slow_start_after_idle=0`:客户端连接空闲一段时间后,cwnd 会被重置回初始值,重新爬。对限速或间歇性收包的客户端,这会让它每次恢复都从慢启动开始,响应时间翻倍。关掉它,保持 cwnd。
`tcp_notsent_lowat=16384`:限制 socket 发送缓冲里“已入队但未发出”的字节数上限。这条对 Nginx worker 的内存占用帮助很大——慢客户端场景下,Nginx 不会为了一个赖着不走的客户端在 socket buffer 里堆几百 KB,`write()` 更早返回 EAGAIN,事件循环尽快转去处理别的连接。
`net.core.somaxconn` 会封顶 `listen.backlog`。PHP-FPM 的 `listen.backlog` 默认是 511,但内核参数更小时会被静默截断,高并发下表现为“established 正常但客户端随机超时”。改完记得 `systemctl reload php-fpm`。
PHP 侧:别自己碍事
最常见的自杀式写法:
// 反面教材:手动 flush 把 Nginx 的缓冲层架空
ob_implicit_flush(true);
for ($i = 0; $i < $n; $i++) {
echo json_encode($chunk), "\n";
flush(); // 每块都强制走一次转发,worker 直接暴露给慢客户端
}
既然 Nginx 已经在上游做了缓冲,PHP 就不该再主动 flush。合并输出,一次 echo:
$out = '';
for ($i = 0; $i < $n; $i++) {
$out .= json_encode($chunk) . "\n";
}
echo $out;
另外,PHP-FPM 的兜底一定要配,防止真的有人写出永久阻塞的代码把 worker 焊死:
; php-fpm.d/www.conf
request_terminate_timeout = 30s
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
pm.max_requests = 1000
`request_terminate_timeout` 是硬杀,比 `max_execution_time` 靠谱——后者在 `sleep()`、阻塞 socket 调用上根本不生效。
验证:拿数据说话
# 慢客户端 + 正常压测混合,这才是真实流量分布
for i in $(seq 1 300); do
curl -s -o /dev/null --limit-rate 20k http://127.0.0.1/api/report &
done
# 同时打正常客户端
wrk -t8 -c400 -d30s --latency http://127.0.0.1/api/report
修复前:慢客户端一进去,正常请求的 P99 立刻从 40ms 涨到 2s 以上,`idle processes` 归零。
修复后:`idle processes` 保持在 30% 以上,P99 稳定,`fastcgi_temp_path` 所在盘有写入但量可控(因为大部分响应在 512k 内存缓冲内就消化了,不落盘)。
同时监控一下临时目录:
watch -n1 'df -h /data && ls /data/nginx_fastcgi_temp | wc -l'
文件数应该是个位数到几十的稳态,如果持续增长到几万,说明有响应体远大于 `fastcgi_max_temp_file_size` 的接口在拖后腿,回去查业务。
边界:什么时候确实需要关掉 buffering
SSE、WebSocket 升级、真正的实时流,这些场景 buffering 确实是敌人。但正确做法不是在主 `\.php$` location 上一刀切关掉,而是拆 location:
location = /stream.php {
fastcgi_pass unix:/dev/shm/php-fpm.sock;
include fastcgi.conf;
fastcgi_buffering off;
fastcgi_read_timeout 3600s;
gzip off;
# 关键:这类长连接单独一个 FPM 池,别和主池混用
}
配合一个独立的 FPM pool(单独 socket、单独的 `pm.max_children`),把流式连接的 worker 和 API 的 worker 物理隔离,才不会被互相拖累。
大文件下载也同理——不要让 PHP 读文件再吐给 Nginx,那是把 PHP 变成带宽的奴隶。用 `X-Accel-Redirect` 把下发的活整个甩给 Nginx:
// PHP 只做鉴权,不下发字节
header('X-Accel-Redirect: /internal-files/' . $safePath);
header('Content-Type: application/octet-stream');
exit;
location /internal-files/ {
internal;
alias /data/files/;
sendfile on;
aio threads;
directio 8m;
}
这么改完,PHP-FPM 的工作量从“传 2GB”变成“查一次权限”,10G 口才能真正被 Nginx 用起来。
一句话总结
大带宽服务器的 PHP 高并发,瓶颈几乎从来不在带宽上。`fastcgi_buffering` 开着,让 Nginx 当蓄水池,PHP-FPM 只负责算完就跑;`wchan` 里全是 `sk_wait_data` 的时候,加多少 worker 都是白搭,只会让 OOM 来得更整齐。