大带宽服务器的Nginx/Apache新手避坑指南 - 20261002

大带宽服务器本身不骗人,10Gbps 就是 10Gbps。骗人的是配置里那几个看起来像"性能开关"的参数——它们大部分时候要么互相打架,要么被你用错了地方。下面六个坑,全是拿真机跑出来的。

一、sendfile、aio、directio:三个都开,等于三个都没生效

<!-- 新手最爱抄的"性能全家桶" -->
location /download/ {
    sendfile on;
    aio on;
    directio 4m;
    output_buffers 4 256k;
    tcp_nopush on;
}

这五行在很多"Nginx 调优大全"里都能见到,抄的人大概觉得开关越多性能越好。真实情况是这三个东西在 Linux 上互相踩。

规则就两条:

  • directio 一旦对某个文件生效,该文件就以 O_DIRECT 打开,IO 绕开 page cache、数据直接落到用户态缓冲区——这条路径上根本没有 sendfile 什么事,sendfile 被自动旁路。
  • aio on 在 Linux 上只对 O_DIRECT 打开的文件有效。你只写 aio on 不写 directio,它就是个空开关,内核收不到任何 io_submit。

所以上面那段配置的实际效果是:小于 4MB 的文件走 sendfile,aio on 白写;大于 4MB 的文件既不进 page cache 也不走 sendfile,而是把数据读进用户态再 write 到 socket——大带宽下这一趟多出来的内存拷贝非常显眼,perf top 里能看到 copy_user_enhanced_fast_string 一路飙升。

想证实这件事,别猜,直接数系统调用(strace 本身有开销,在测试机上做):

# 一边下载一个 1GB 文件,一边跑这个
timeout 10 strace -f -c -p $(cat /run/nginx.pid) 2>&1 | egrep 'sendfile|write|read'

如果 sendfile 计数是 0、write 上千次,说明当前压根没在搞零拷贝。用 bpftrace 看得更干净:

bpftrace -e 'tracepoint:syscalls:sys_enter_sendfile64 { @[comm] = count(); }'

正确写法是按数据特征分两个 location,别混着来:

# A. 热数据集能整个塞进 page cache —— 走零拷贝
location /static/ {
    sendfile on;
    tcp_nopush on;
}

# B. 单文件几百 MB 起,反复冲刷 page cache —— 绕开缓存,别让 worker 阻塞
location /big/ {
    sendfile on;              # 小于阈值的文件继续享受零拷贝
    directio 32m;             # 超过 32MB 走 O_DIRECT
    directio_alignment 4k;    # ext4/XFS 上给 4k,硬 RAID 上给 512k
    aio threads;              # 线程池,不是内核 AIO,worker 不阻塞在 read 上
}

注意 aio threads 和 aio on 完全不是一回事:前者是 Nginx 自己的线程池扛阻塞读,后者才是走 io_submit。大文件顺序读用线程池就够了,io_submit 的队列深度在单流顺序读上帮不上什么忙。

二、HTTPS 一开,前面的 sendfile 就是白写的

这条知道的人不多:Nginx 对 SSL 连接从来不走 sendfile。原因不复杂——加密必须在用户态做,数据得先从内核搬进 OpenSSL 的缓冲区,加密结果再 write 回内核,两趟拷贝省不掉。

# 统计 10 秒内 sendfile64 的调用次数,先打 HTTP 端口
perf stat -e 'syscalls:sys_enter_sendfile64' -a -- sleep 10
# 换成压 443,计数大概率是 0

这不是 bug,是物理限制。绝大多数发行版编译出来的 Nginx 也不带 kTLS,所以别指望了。HTTPS 大带宽下真正该调的是这个:

server {
    listen 443 ssl http2;
    ssl_buffer_size 64k;   # 默认 16k
}

ssl_buffer_size 决定单条 TLS record 最大能装多少明文,默认 16k。发一个 100MB 的文件,意味着至少 6400 次 OpenSSL 写操作;调到 64k 后这个数字降到 1600 左右,系统调用开销立刻下来。

但要把话说全:大 record 是有代价的。小响应场景下它会攒着数据不发,TTFB 直接变差。纯 API 服务反而应该调小:ssl_buffer_size 4k;。静态分发和 API 网关,这两个值不要共用一份 conf。

三、先跪的通常是存储,不是网络

10Gbps ≈ 1250MB/s。在你动 Nginx 任何一个参数之前,先测一下盘到底能不能吐出这么多字节。

# 别用 dd 默认的 512 字节块,直接上 fio 打顺序读
fio --name=seqread --rw=read --bs=1M --direct=1 --iodepth=32 \
    --numjobs=4 --size=8G --filename=/data/fio.test \
    --runtime=30 --time_based --group_reporting

同时看 iostat,重点不是 %util(NVMe 上意义有限),是 rMB/s 和 await:

iostat -xm 1 5
# rMB/s  = 实测顺序读带宽
# await  = 单次 IO 平均延迟
# aqu-sz = 平均队列深度

如果 rMB/s 卡在 200 上下、await 却在两位数毫秒,那么 10Gbps 端口对你来说就是个装饰品,Nginx 参数调到天亮也没用。

最阴的一类情况是云盘:不少产品的吞吐是按容量配额下发的,容量小、吞吐上限就低,和你的带宽包之间毫无关联。我们线上那批轻云互联的 10Gbps 机器之所以敢直接上生产压测,原因就在这里——NVMe 裸读能稳定跑到 3GB/s 以上,端口才真正喂得满。存储这一层如果先天不足,后面所有优化都是在给漏水的桶擦地。

四、gzip_types 里加 application/octet-stream,是在给 CPU 上刑

gzip on;
gzip_types text/plain application/json application/octet-stream;  # ← 灾难在这行
gzip_comp_level 6;

application/octet-stream 会命中 zip、7z、mp4、iso 这些本来就熵饱和的数据。Deflate 对它们几乎压不动,CPU 却要全功率跑一遍。实测一下就很直观:

head -c 200M /dev/urandom > /tmp/rand.bin
gzip -6 -c /tmp/rand.bin | wc -c
# 输出在 200000000 上下浮动,等于一个字节没省

更糟的是 gzip_comp_level:默认值 1 和手动调到 6 相比,普通文本也就多省 3~5% 的体积,CPU 却多烧好几倍。大带宽服务器上带宽本来就不贵,CPU 才是稀缺资源,这笔账怎么算都是亏。

正确姿势是运行时零压缩——预压缩 + gzip_static:

gzip on;
gzip_min_length 1k;
gzip_comp_level 2;
gzip_types text/plain text/css text/xml application/json application/javascript image/svg+xml;
# 视频 / 压缩包 / ISO 一律排除

gzip_static on;   # 直接发同目录下预压好的 .gz,不现压
gzip_vary on;     # 前面挂 CDN 时必须有,否则缓存串味

五、Apache 的 EnableMMAP,送你一个 SIGBUS

# 默认就是这样,绝大多数人从没改过
EnableMMAP On
EnableSendfile On

Apache 的 EnableMMAP On 会让子进程把文件 mmap 进地址空间。平时没问题,但只要文件在传输过程中被原子替换或截断——rsync 的默认行为、CI 部署的默认行为——已经映射了旧 inode 的子进程去访问越界页,内核直接甩一个 SIGBUS 过来,子进程当场死。prefork 模式下就是一片子进程重启风暴,日志里全是 Bus error。

复现只要两条命令:

while true; do curl -s -o /dev/null http://127.0.0.1/big.iso; done &
for i in $(seq 1 200); do cp /tmp/new.iso /var/www/html/big.iso; done

grep -iE "SIGBUS|Bus error|SIGSEGV" /var/log/httpd/error_log

修法没有别的选择,关掉:

EnableMMAP Off
# 在某些文件系统上(overlayfs、NFS、部分 FUSE 挂载)sendfile 会返回 EINVAL,
# 表现为请求直接 500,error_log 持续刷 "sendfile: Invalid argument"
EnableSendfile Off

如果你的 error_log 里出现 sendfile: Invalid argument,说明这个挂载点压根不支持 sendfile,别硬扛,关掉用普通 read/write。关掉之后多出来的那点拷贝开销,比请求 500 便宜一万倍。

六、access_log 不加 buffer,最先炸的是系统盘

# 默认无缓冲,每个请求一次 write 系统调用
access_log /var/log/nginx/access.log main;

大带宽服务器上如果跑的是小文件高 QPS,这一行能把系统盘写穿——很多云主机系统盘 IOPS 本来就紧巴巴的。加上 buffer 立刻不一样:

access_log /var/log/nginx/access.log main buffer=64k flush=5s;
open_log_file_cache max=1000 inactive=20s valid=1m min_uses=2;

代价要说清楚:flush 周期内进程被 kill -9,缓冲区里的日志会丢(正常退出会刷新)。对审计有要求的场景用 flush=1s 折中,别直接关 buffer。

还有一点常被忽略:日志字段本身也是 IO。$http_user_agent 和 $http_referer 平均能占掉单行一半以上的体积,静态分发场景里这两个字段基本没人看,从 log_format 里删掉,同样的 QPS 下日志盘写入量直接砍半。

大带宽服务器的调优顺序永远是:先确认存储吐得出来 → 再确认协议栈路径对得上 → 最后才去抠那些零碎参数。反过来做,你会花一整晚改 nginx.conf,然后发现瓶颈在盘上。