大带宽服务器 PHP 大响应单连接卡在 1.2Gbps:`tcp_wmem/RTT` 硬顶 + FastCGI 8KB 分帧的四次拷贝账
先说结论:10G 大带宽服务器上,PHP 接口返回一个 20MB 的 JSON,客户端跨区域拉,单连接吞吐大概率停在 1.1~1.4Gbps 之间,怎么调 Nginx worker 都不动。因为瓶颈根本不在带宽,在发送缓冲区除以 RTT,以及 PHP 到内核之间那条每次都按 8KB 切碎的写入路径。
本文用一台 10G 上行、PHP 8.2 + FPM + Nginx 的机器复现,客户端在另一个区域,RTT 约 30ms。全程给命令和实测数字。
一、先把"慢"拆成两层:syscall 层和 BDP 层
压测命令,单连接、不落盘:
curl -s -o /dev/null -w "speed=%{speed_download} time=%{time_total}\n" \
http://10.x.x.x:8080/api/export/large.json
结果大约 speed=143000000,也就是 1.14Gbps。同时看 CPU:
mpstat -P ALL 1 3
# 典型输出关键列
# CPU %usr %sys %soft %idle
# 3 18.20 62.40 4.10 15.30
# 4 15.90 66.10 3.80 14.20
%sys 打到 60% 以上,说明时间全花在内核态。这时候去看 syscall 分布:
strace -c -f -p $(pgrep -d, -f 'php-fpm: pool') 2>&1 | tail -15
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
58.41 4.112903 3 1248092 writev
24.77 1.744021 2 983311 read
9.12 0.642118 1 412003 epoll_wait
------ ----------- ----------- --------- --------- ----------------
20MB 的响应体,产生了 124 万次 writev。按 8160 字节有效载荷算,20MB / 8160 ≈ 2570 次每请求,乘上并发就对上号了。这就是第一层账。
二、8KB 分帧是编译期定死的,调 php.ini 没用
php-src 里 sapi/fpm/fpm/fastcgi.c 顶部:
#define FCGI_BUF_SIZE 8192
#define FCGI_HDR_SIZE 8
PHP 的输出从 SAPI 往下走时,会按这个尺寸切成 FastCGI STDOUT record,每个 record 头部带 8 字节,然后一次 writev(2) 送进 socket。也就是说:
- 你在 PHP 里
echo大字符串,或者readfile()一个大文件; output_buffering调到 4096、65536 甚至On都没意义——那只是聚合到 FPM SAPI 之前的用户态 buffer;- 进了 FastCGI 层,照样按 8160 字节切,照样一次 syscall 一次
copy_from_user。
用 perf 抓一下就明白了:
perf top -p $(pgrep -d, -f 'php-fpm: pool')
# 排行前列:
# 22.31% copy_user_enhanced_fast_string
# 11.04% unix_stream_sendmsg
# 8.77% __sys_sendmsg
这条路径的完整拷贝账是这样的(PHP 生成内容、Unix socket、Nginx 缓冲够大、sendfile 发给客户端):
- PHP 用户态 buffer → 内核 socket sndbuf:copy 1
- Nginx
read()→ Nginx 用户态 buffer:copy 2 - Nginx
write()→ 客户端 socket sndbuf:copy 3 - 网卡 DMA 取 sndbuf:无 CPU 拷贝
如果 Nginx 的 fastcgi_buffers 装不下,走 fastcgi_temp_path,还要多一次写盘 + 一次从 page cache 的读取,那就是五跳。默认配置下 fastcgi_buffers 8 4k 只有 32KB,20MB 响应必落盘。
ls -l /var/cache/nginx/fastcgi_temp/ | head
# 压测期间能看到 0000000001 之类的文件反复出现又消失
改法(客户端不是慢速公网的前提下):
location ~ \.php$ {
fastcgi_pass unix:/dev/shm/php-fpm.sock;
fastcgi_buffers 128 128k; # 16MB 常驻内存
fastcgi_buffer_size 128k;
fastcgi_busy_buffers_size 256k;
fastcgi_max_temp_file_size 0; # 禁止落盘,超出部分边收边发
}
注意 fastcgi_max_temp_file_size 0 的适用边界:客户端一旦慢,Nginx 就会同步等下游,FastCGI 连接被占住。内网/同区域高速客户端场景放心用,公网慢客户端场景要配合 limit_rate 或干脆走 X-Accel-Redirect。
三、真正的硬顶:`tcp_wmem / RTT`,跟带宽没关系
上面调完,你会看到 %sys 降下来一些,但单连接吞吐还是卡在那个数。因为第二层账还没算。
先看默认值:
sysctl net.ipv4.tcp_wmem net.core.wmem_max
# net.ipv4.tcp_wmem = 4096 16384 4194304
# net.core.wmem_max = 212992
注意这两个值的关系:TCP 自动调优(tcp_sndbuf_expand)能爬到的高度是 tcp_wmem[2],而 setsockopt(SO_SNDBUF) 的上限被 net.core.wmem_max 锁死。很多人只改了 tcp_wmem 没动 wmem_max,以为生效了,其实没生效。
单连接的吞吐上限,可以粗暴地写成:
max_throughput ≈ sndbuf_effective / RTT
4MiB / 0.03s ≈ 133 MiB/s ≈ 1.12 Gbps
跟实测的 1.14Gbps 完全吻合。这就是为什么 10G 口跑不满——一条 TCP 连接的发送窗口填不满一根 10G 管道,需要 ~37.5MB 的窗口在 30ms RTT 下才能填满。一个 20MB 的响应本身都还不到一个窗口的量。
用 ss 直接读现场:
ss -tim dst 203.0.113.45:443
# ESTAB 0 0
# skmem:(r0,rb212992,t0,tb4194304,f0,w0,o0,bl0,d0)
# wscale:7,7 rto:204 rtt:30.12/0.4 mss:1448 cwnd:84
# send 114000000bps pacing_rate 118000000bps
tb4194304 就是发送缓冲区实测上限 4MiB。cwnd:84 乘 mss:1448 也就 121KB,因为 BBR 没开、丢包一发生 cwnd 就掉。这里的每一项都在指向同一个结论。
调整:
sysctl -w net.core.wmem_max=67108864
sysctl -w net.ipv4.tcp_wmem="4096 262144 67108864"
sysctl -w net.core.default_qdisc=fq
sysctl -w net.ipv4.tcp_congestion_control=bbr
sysctl -w net.ipv4.tcp_notsent_lowat=262144
几个坑说明白:
tcp_wmem是三元组,中间那个是初始值,改大中间值会让空闲连接起步就占内存,高并发场景别乱加。tcp_notsent_lowat不提升峰值吞吐,但能把"发了但没确认"的队列压到 256KB,降低尾延迟抖动。大响应场景收益明显。- BBR 只解决拥塞控制,解决不了对端接收窗口。对端
tcp_rmem不开大,你的tcp_wmem开到 64MB 也白搭——两端都要调。 - 接收方的
wscale小于 7 的话,窗口缩放上限本身就是个天花板。用ss -tim看左右两个 wscale 值。
这套 `fq + bbr + 大 wmem + tcp_notsent_lowat` 的组合,轻云互联那些 10G 大带宽机型出厂基本是按这个模板预置的,省得自己一台上万行参数的机器上反复 sysctl -w 试错。但如果你拿到的机器还是 `cubic + pfifo_fast`,上面那四条就是必改项。
四、如果绕不开 PHP 发数据,那就别让它发
调参只能把天花板往上推,但 PHP 侧那 124 万次 writev 是消不掉的。真正把拷贝次数降到 0 的做法是让 Nginx 自己发:
<?php
// 1. 生成内容先落盘(/dev/shm 走 tmpfs,避免真磁盘 IO)
$tmp = '/dev/shm/export_' . $jobId . '.json';
$fh = fopen($tmp, 'wb');
foreach ($rows as $row) {
// 逐行写,避免 json_encode 整个大数组把内存打爆
fwrite($fh, json_encode($row, JSON_UNESCAPED_UNICODE) . "\n");
}
fclose($fh);
// 2. 交给 Nginx 用 sendfile 发
header('Content-Type: application/json');
header('X-Accel-Redirect: /_internal_export/' . basename($tmp));
// 注意:这里不能再输出任何内容,X-Accel-Redirect 后 PHP 的输出会被丢弃
location /_internal_export/ {
internal;
alias /dev/shm/;
add_header Content-Type "application/json";
}
这条路径下,数据从 tmpfs 的 page cache 经 sendfile(2) 由 SG-DMA 直接进 socket,用户态拷贝 0 次,syscall 从 2570 次降到个位数。Nginx 那边也不再需要 fastcgi_buffers 撑住整块内容。
代价是 /dev/shm 默认只有物理内存的一半,多个大导出并发会把它撑爆。要么加个清理 cron,要么显式 rm,要么走真正的磁盘 + aio threads。别只改 Nginx 不管 tmpfs 水位。
五、最后一层:10G 打满时软中断会替你踩刹车
前面三层都处理完,吞吐到 6~8Gbps 时 %soft 会开始变成新的瓶颈。检查:
mpstat -P ALL 1
cat /proc/interrupts | grep -i -E 'eth0|ens|bond'
如果所有队列都落在 CPU0,那就是单核软中断。开 RPS 或者调 RSS 队列:
ethtool -L ens1f0 combined 8
for q in /sys/class/net/ens1f0/queues/rx-*/rps_cpus; do
echo ffff > $q
done
sysctl -w net.core.netdev_max_backlog=65536
顺便说一句:irqbalance 在 NUMA 机器上不一定识趣,最好手动绑 /proc/irq/N/smp_affinity_list,让网卡中断和跑 Nginx worker 的核在同一 NUMA node 上。跨 node 收包的开销,比你调什么 PHP 参数都大。
六、账目汇总
路径 用户态拷贝 syscall/20MB
PHP echo/readfile → FastCGI → Nginx 3 ~2570
+ fastcgi_temp_path 落盘 4 ~5140
X-Accel-Redirect + sendfile 0 ~5
单连接吞吐的账:
capability = min(tcp_wmem[2], peer_rwnd) / RTT
4MiB / 30ms = 1.12 Gbps
64MiB / 30ms = 17.1 Gbps (此时受 10G 物理口限制)
写 PHP 高并发服务的人最容易犯的错,是把"带宽"当成一个可以靠 Nginx 参数打开的阀门。带宽是账单上的数字,能不能跑出来取决于发送窗口、RTT、以及你的数据在内核里被搬了几次。这三件事,一件都不在 worker_connections 里。