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 打三个时间段的带宽曲线,比看任何参数表都实在。