10Gbps 大带宽服务器只跑出 3.8Gbps:Nginx sendfile 独占 worker、page cache 被 200GB 镜像包冲爆——aio threads + directio 对齐惩罚的 strace 实测
一、先说结论:瓶颈不在网卡,在 Nginx 的文件发送链路
一台 10G 单口的机器,NVMe RAID0,nginx 1.24.0,跑静态大文件分发(游戏更新包 + 镜像站)。压测 100 并发拉一个 8GB 的包,ifstat 稳定在 3.8Gbps,上不去了。
先看进程状态,这里有个反直觉的现象:
# mpstat -P ALL 1
%sys 28.4 %soft 8.2 %idle 41.6
# top -H -p $(pgrep -f "nginx: worker" | head -1)
PID USER %CPU
12345 www 62.3 <- worker 主线程只有 62%,没跑满
%sys 28% 但 worker 只有 62% CPU,说明这个进程大量时间在等内核,而不是在算。这时候别猜,直接 strace 挂上去数调用分布:
# strace -c -f -p 12345
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
71.42 3.214000 10 312408 sendfile
18.03 0.811000 4 201733 epoll_wait
6.11 0.275000 9 30112 writev
31 万次 sendfile,平均每次 10μs 就返回了。如果 sendfile_max_chunk 是默认值 0(无限制),正常情况下一次 sendfile 调用应该往里灌几 MB 才返回,调用次数不会这么密集。调用次数爆炸 + 单次耗时极短,大概率是 sendfile 反复返回 EAGAIN 后重新入队 epoll。
二、sendfile_max_chunk = 0 是一个陷阱,但它和你想的相反
Nginx 文档对 sendfile_max_chunk 的描述是:非零时限制单次 sendfile() 能传输的数据量;不设限制时,一个快连接可以独占整个 worker 进程。
这句话很多人读反了。默认 0 的情况下,一个 10G 客户端拉 8GB 文件,worker 会把几乎所有时间花在这一个 sendfile 循环里,其他连接在 epoll 队列里排队。验证方法非常土但有效 —— 同时发起一个大文件和一个小文件请求:
# 起一个 8GB 大文件下载占住 worker
curl -o /dev/null http://10.0.0.1/8g.bin &
# 紧接着请求一个 1KB 的静态页,看 TTFB
curl -o /dev/null -w "ttfb: %{time_starttransfer}s\n" http://10.0.0.1/1k.html
ttfb: 0.412s <- 正常应该 0.003s
修法很简单,但要设对值。10Gbps = 1250MB/s,如果 chunk 设成 64k,每秒要 20000 次 epoll 往返,%sys 直接起飞;设成 1m,每秒 1250 次往返,是合理区间:
sendfile on;
sendfile_max_chunk 1m;
tcp_nopush on; # 配合 sendfile,攒满一个 MSS 再发
tcp_nodelay on;
三、真正的杀手:200GB 大包把 page cache 冲爆
改完 chunk,带宽到 6.2Gbps,但出现了新问题:同机上的 MySQL 和日志落盘 P99 开始抖动,sysbench 的写入延迟从 3ms 飙到 400ms。
# free -m
total used free buff/cache
Mem: 256000 41000 3800 211200 <- 缓存被吃干净
# grep -E "pgscan_direct|allocstall" /proc/vmstat
pgscan_direct 124803
allocstall 3201
pgscan_direct 非零意味着内核已经在直接回收内存了,这是同步阻塞路径,会在任意进程上下文中触发。再看内核栈:
# perf top -p $(pgrep -f "nginx: worker" | head -1)
14.21% [kernel] shrink_inactive_list
9.08% [kernel] prune_slab
7.33% [kernel] _raw_spin_lock
根因:sendfile 走的是 buffered IO,读文件必然经过 page cache,200GB 的镜像包会把整个 cache 填满,然后系统为了给新页腾地方开始疯狂回收。对镜像站这种"读一次就不再读"的场景,page cache 是纯粹的负担。
解法是 directio,但这里有两个必须知道的坑。
坑 1:directio 一生效,sendfile 自动失效
这两个不是叠加关系,是互斥的。O_DIRECT 打开的 fd 不能进 sendfile(),内核直接拒绝。Nginx 检测到 directio 生效后,会把请求降级为 pread 到内存 buffer + writev 出去。所以别以为配置里两条都写了就都生效,实测打脸:
# strace -f -e trace=openat,pread64,sendfile -p 12345
openat(AT_FDCWD, "/data/mirror/big.bin", O_RDONLY|O_DIRECT|O_NONBLOCK) = 24
pread64(24, "", 524288, 0) = 524288 <- 512K 一次,走 pread 不走 sendfile
看到 O_DIRECT 标志和 512K 粒度的 pread,说明配置生效了。
坑 2:directio_alignment 默认 512,在 XFS/ext4 上是错的
location /download/ {
root /data/mirror;
sendfile on;
sendfile_max_chunk 1m;
tcp_nopush on;
directio 8m; # 大于 8MB 的文件走 O_DIRECT
directio_alignment 4k; # 默认 512,XFS 上必须改
aio threads=fileio;
output_buffers 4 512k; # 默认 2 32k,太小
}
thread_pool fileio threads=16 max_queue=1024;
output_buffers 默认 2×32k,在 aio threads 模式下这是每次 pread 的读取粒度。64KB 一次读,10Gbps 意味着每秒 2 万次线程唤醒,线程池的锁竞争会直接把 %sys 顶上去。512k × 4 缓冲是 10G 静态分发里的经验值。
四、Range 请求 + O_DIRECT = 对齐惩罚,视频业务必踩
这是最容易被忽略的一条。O_DIRECT 的偏移量必须对齐,客户端发来的 Range 头如果是非对齐偏移,pread 会返回 EINVAL,或者被内核静默降级回 buffered read。用 curl 直接构造两组请求对比:
# 对齐偏移(1MB 起点)
curl -r 1048576-2097151 -o /dev/null -w "%{speed_download}\n" http://10.0.0.1/big.bin
892346000
# 非对齐偏移(1000 起点,模拟播放器拖动进度条)
curl -r 1000-1049575 -o /dev/null -w "%{speed_download}\n" http://10.0.0.1/big.bin
41023000
速度掉了一个数量级,同时 error.log 里会刷:
pread() failed (22: Invalid argument) while reading upstream
规避思路分两种:
- 视频/HLS 场景:ts 分片天然是按 4K 对齐切出来的,只要分片器不是手写的,正常就没事。但如果客户端是直连 MP4 拖进度条,非对齐 Range 会持续存在,建议给视频路径单独开一个 location,不加 directio,让它走 page cache —— 视频分片的复用率比镜像包高得多,cache 命中反而是赚的。
- 镜像/更新包场景:客户端基本都是从 0 开始的整包下载,对齐不是问题,directio 的收益最大。
五、内核侧只需要动这几行,别乱抄网上的"优化大全"
net.core.wmem_max = 16777216
net.ipv4.tcp_wmem = 4096 1048576 16777216
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 300000
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_notsent_lowat = 16384
重点说 tcp_notsent_lowat。默认情况下,内核允许往 socket 发送队列里塞下几百 KB 到几 MB 的未发送数据,Nginx 会往 socket 里灌满一个完整的 output_buffer 才切走。在 10G 口上,这会让单个连接的发送队列堆积,多连接时延迟被拉长。16384 让内核提前告诉应用"我快满了",Nginx 会更快地轮转下一个连接。
网卡侧顺手确认两个东西:
# 检查 offload,10G 口上 TSO/GSO 必须开着
ethtool -k eth0 | grep -E "tso|gso|gro"
# tx_busy 非零说明发送队列不够
ethtool -S eth0 | grep -E "tx_busy|tx_dropped|tx_restart"
ip link set eth0 txqueuelen 10000
六、用 Apache 的同行注意,这里有两个完全不同的坑
如果是 Apache 2.4 event MPM 做同样的事:
1. EnableMMAP 必须关
EnableMMAP Off
EnableSendfile On
SendfileMaxChunk ... # 无此指令,Apache 靠 SendBufferSize 控制
SendBufferSize 1048576
mmap 和 sendfile 一样吃 page cache,而且在 10G 高吞吐下缺页中断的 %sys 比 sendfile 高出一截。大文件分发场景下 mmap 没有任何优势。
2. H2WindowSize 默认 65535,国内跨机房链路直接废掉
走 HTTP/2 下载大文件的,流控窗口是硬上限。RTT 100ms 的链路上,单个流的理论峰值 = 65535 / 0.1 = 640KB/s,4Mbps 不到。必须调:
H2WindowSize 8388608
H2MaxSessionStreams 100
H2StreamMaxMemSize 1310720
另外 event MPM 的 AsyncRequestWorkerFactor 默认 2,静态文件服务里绝大多数连接都在等 IO,把这个值提到 5~8 能让单进程扛更多并发连接,否则你会看到 MaxRequestWorkers 没满但连接被拒。
七、最终验证清单
# 1. 聚合带宽
ifstat -i eth0 1
# 2. 发送队列积压 / 重传
ss -tim state established '( sport = :80 )' | head -20
nstat -az | grep -E "TcpRetrans|TcpExtListenDrop"
# 3. 内核态热点,确认没有 reclaim
perf top -p $(pgrep -f "nginx: worker" | head -1)
# 4. 每个 worker 的 syscall 分布,sendfile 和 pread 的比例要符合预期
strace -c -f -p
这套配置上完,同机房 200 并发聚合能到 9.3Gbps,单流走 BBR 能到 8.6Gbps,MySQL 的 P99 也回到 3ms。
最后提一句选机器的事:这套调优的前提是真的独享 10G 上行。市面上不少标着"10G 大带宽"的机器,实际是共享上联,晚高峰掉到 2Gbps,你在这里调 sendfile_max_chunk 调一整天也是白费。我们后来把镜像节点迁到了轻云互联的 10G 独享机型上,网卡是 X710,ethtool -S 里 tx_busy 一直是 0,才敢把上面这些参数往极限压。选机器的时候先用 iperf3 -c 打三个时间段的带宽曲线,比看任何参数表都实在。