美国服务器上 Nginx 的 sendfile 其实一直没生效:TLS、HTTP/2、content filter、O_DIRECT 四道静默降级,strace 里那 30% 的 sys 时间就是这么来的
现象:口跑满 6Gbps,sendfile 计数却是 0
一台美国洛杉矶节点的静态分发机,10G 口,跑视频切片和固件包下载。业务侧反馈"带宽够、延迟正常,但 CPU 不对劲":top 里 8 个 nginx worker 的 %sys 加起来常年 30% 以上,%us 反而很低。按常理,纯静态分发在 Linux 上走 sendfile(2),数据从 page cache 直接进 socket buffer,用户态根本没有内存拷贝,%sys 应该低得可怜,CPU 大头应该是软中断和网卡驱动。
拉 strace 一挂,答案就出来了:
# 抓 10 秒,只看文件分发相关的 syscall
strace -f -c -e trace=sendfile,writev,write,read,pread64,splice \
-p $(pgrep -d, nginx) 2>&1 | head -20
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
61.32 4.812340 182 26410 writev
24.11 1.892011 411 4602 read
8.02 0.629780 137 4592 sendfile
------ ----------- ----------- --------- --------- ----------------
sendfile 有调用,但只有 4592 次,writev 却有 26410 次且占了 61% 的时间。这说明流量被**劈成了两条路径**:一部分文件还在走内核零拷贝,另一部分已经被拽回用户态做 read + writev。40% 的 CPU 就这么白烧了。
下面把 Nginx 决定"走不走 sendfile"的四道判定闸门逐个拆开。
底层:Nginx 是怎么决定用 sendfile 的
静态文件的处理链是:ngx_http_static_handler 打开文件,构造一个 ngx_buf_t,打上 in_file = 1、file_pos/file_last,然后交给 output filter 链。最终落到 ngx_linux_sendfile_chain 时,它会检查:
- 响应链里 第一个 buf 是不是 file buf(
b->in_file && !b->in_memory); - 连接有没有被别的模块关掉
c->sendfile; - 输出缓冲区里有没有掺进非文件的 buf。
注意 in_file 和 in_memory 这两个标志是互斥的语义:一个 buf 要么指向文件,要么指向内存。任何 content filter 只要想把数据"看一眼"或者"改一改",就必须先把 file buf 读进内存 buf,in_file 被清掉,ngx_linux_sendfile_chain 立刻退化。反过来,只要 buf 里出现一个 in_memory 的块,sendfile 链就被截断在前一块,后面的全走 writev。
这就是所有静默降级的共同机制——不是报错,不是日志,是静默地把 in_file 抹掉。
第一道闸:TLS(最常见,也最容易被忽略)
开了 listen 443 ssl 之后,数据必须经过用户态 OpenSSL 加密。加密函数的输入是一段明文内存,sendfile 直接从 page cache 塞进 socket 的语义在这里根本不成立。所以 Nginx 在 SSL 连接上根本不调用 sendfile,走的是 ngx_ssl_write → writev 路径,还多了一次 ssl_buffer_size 的回环拷贝。
实测数量级:同样一个 1GB 文件、10Gbps 出口、4 个 worker,HTTP 明文 sendfile 路径 sys 占比 3%~5%;HTTPS writev 路径 20%~30%。差的那 20 多个点,全是 memcpy 和 OpenSSL 的 AES-NI 开销。
ssl_buffer_size 是这条路线上唯一能动的旋钮,默认 16k:
# 大文件下载为主:放大 buffer,减少 SSL record 头开销和 writev 次数
ssl_buffer_size 32k;
# 小响应/API 为主:缩小,降低 TTFB
# ssl_buffer_size 4k;
但别指望它能救回零拷贝。真想在美国服务器上既跑 TLS 又吃 sendfile,只有三条路:
- 内核 TLS(kTLS):加密在内核完成,socket 恢复成"可以 sendfile 的 socket"。可惜 Nginx 官方树至今没有开 KTLS 的配置项,得自己 patch 或者
SSL_CTX_set_options(SSL_OP_ENABLE_KTLS)走第三方模块,生产环境慎用。 - TLS 卸载到前端:HAProxy / Envoy / 四层负载上做终止,回源走明文 HTTP,源站 nginx 就能回到 sendfile 路径。
- 静态资源交给边缘:我们后来把这批固件包的源站放在了轻云互联的美国服务器上,机器本身带大带宽,但真正跑 TLS 握手和分发的活儿挂到 CDN 边缘去了,源站只出明文回源流量,sys 立刻回到个位数百分比。
第二道闸:HTTP/2
这个比 TLS 更阴。很多人在 listen 443 ssl http2 之后,只记得 TLS 会让 sendfile 失效,却不知道 HTTP/2 自己又独立地废掉了 sendfile 一次。
原因是 HTTP/2 的 DATA frame 需要用户态封帧。文件 buf 必须先被切成不超过 16KB 的帧,每帧前面拼一个 9 字节的 frame header,再交给 writev。ngx_http_v2_filter_module 是一层 body filter,装上去之后 file buf 直接被读进内存。所以哪怕你把 TLS 卸载掉了,只要走 h2c,sendfile 照样不生效。
验证很简单,同一个 URL 分别用 HTTP/1.1 和 HTTP/2 拉:
# HTTP/1.1 明文
curl -o /dev/null --http1.1 http://127.0.0.1/big.bin
# HTTP/2 明文
curl -o /dev/null --http2-prior-knowledge http://127.0.0.1/big.bin
# 同时盯 syscall
strace -f -c -e trace=sendfile,writev -p $(pgrep -d, nginx)
结论:大文件分发场景,HTTP/1.1 才是 CPU 最优解。如果业务必须上 H2,那至少要把 .mp4 / .bin / .tar.gz / .zip 这类后缀单独拆到一个只开 http/1.1 的 server 块里——这是笔实打实的账,不是玄学。
第三道闸:content filter 链上的任何一个模块
除了 gzip,任何往响应体里伸手的模块都会把 in_file 抹掉。用 gzip -v 那种思路去查不够,要直接问 nginx 到底装了几层 filter。以 gzip 为例,ngx_http_gzip_filter_module 的 header filter 会先判断 r->headers_out.content_type 是否落在 gzip_types 列表里,不在就直接跳过,不装 body filter。所以 mp4/bin 这类二进制不会因为 gzip on 而退化——但如果你的 location 上还挂着这些,就一定会:
sub_filter:全部退化,无一例外;ssi on:退化;addition_types/add_before_body:退化;charset utf-8且 content-type 在charset_types(默认含text/html, text/plain, text/xml, application/javascript等)里且需要转码:退化;gzip on+ 把application/octet-stream塞进gzip_types(很多人为了"省带宽"这么干):大文件当场退化成 read+writev 再压缩,CPU 直接原地爆炸。
比较稳的做法是给下载路径用干净的空 location,把 filter 链彻底断掉:
location ~* \.(bin|tar|gz|zip|mp4|apk|iso)$ {
root /data/static;
sendfile on;
sendfile_max_chunk 2m;
tcp_nopush on;
tcp_nodelay on;
# 显式断掉 filter 链,别指望继承来的配置是干净的
gzip off;
gzip_static off;
ssi off;
sub_filter_once off; # 没有 sub_filter 指令时不生效,但要确认上级没配
charset off;
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
}
sendfile_max_chunk 值得单说。1.21.4 之前的默认值是 0,也就是单次 sendfile 不限量,内核会一直往 socket buffer 里塞直到 sndbuf 满。在高 BDP 的跨洋链路上(RTT 150ms+,ss -ti 里 cwnd 能涨到几 MB),单次 sendfile 调用可能在里面循环十几毫秒,同一个 worker 上其他连接的 EPOLLIN 事件全被延迟处理。设成 1m~2m 就是给它一个刹车。
# 看一眼实际发出去多少,cwnd 和 send 队列是否堆积
ss -tmi '( sport = :80 )' | grep -A1 ESTAB | head -40
第四道闸:directio + aio 的组合拳
第四道闸是配置写错的典型。O_DIRECT 和 sendfile 在内核层面是互斥的:sendfile 依赖 page cache,O_DIRECT 绕过 page cache。Nginx 一旦对某个文件命中 directio 阈值,就会加 O_DIRECT 打开,这条路只剩下 aio 读进用户态 buffer 再写 socket。
坑在于 Linux 上 aio on(native AIO)只对 O_DIRECT 生效,而且依赖内核版本。小于 directio 阈值的文件不会带 O_DIRECT 打开,此时 native AIO 不生效,读操作同步阻塞在 worker 线程里——症状就是"小文件一切正常,一上大文件,某个 worker 的 %sys 直接拉到 100%,其他 worker 闲得发慌"。
正确写法是直接上线程池,别碰 native AIO:
# 全局:定义线程池
thread_pool vod_pool threads=32 max_queue=1024;
location /vod/ {
root /data/vod;
aio threads=vod_pool; # 不是 aio on
directio 4m; # 只有 >= 4m 的文件走 O_DIRECT
directio_alignment 4k; # 默认 512,ext4/XFS 上建议 4k
sendfile on; # 小文件仍然吃零拷贝
output_buffers 2 512k;
}
检查 directio_alignment 的办法:如果对齐不对,read 会返回 EINVAL,nginx 的 error.log 里会出现 pread() failed (22: Invalid argument),同时文件分发直接 500。这个错很多人见过,但不知道根子是对齐。
还有一个组合坑:aio on 和 sendfile on 同时写在同一个 location 里,看起来像"两条路都留着",实际上大文件走 aio、小文件走 sendfile,日志和监控上完全看不出来谁在生效。要验证就靠 /proc/<pid>/io:
for p in $(pgrep nginx); do
echo "pid=$p"; cat /proc/$p/io | grep -E 'read_bytes|write_bytes|rchar|wchar'
done
# sendfile 生效时:read_bytes 极低,write_bytes 极低(数据不进用户态)
# writev 路径时:write_bytes 会跟着流量线性涨,这是最直接的判据
Apache 这边是一模一样的逻辑
用 Apache 分发静态资源的话,对应的开关是 EnableSendfile 和 EnableMMAP,判定条件完全一样:
EnableSendfile On
EnableMMAP On
# mod_ssl 下 EnableSendfile 同样失效,走 SSL 的 write 路径
# mod_deflate / mod_include / mod_substitute 挂上之后同样失效
额外提醒 EnableMMAP:如果 DocumentRoot 挂的是 NFS/CIFS,文件在传输过程中被截断或删除,进程会收到 SIGBUS 直接挂掉,不是抛异常,是内核信号。网络盘上的静态目录,EnableMMAP Off 永远不会错。同理,nginx 侧 open_file_cache 在网络盘上也要慎用,inode 缓存和真实文件状态容易不一致。EnableSendfile 在 NFS 上还可能触发内核 fallback,实际走的不是零拷贝,这一点 nfsstat -c 的 read/write 计数能对出来。
一张检查清单
下次在美国服务器上遇到"带宽跑满但 CPU 不对劲"的静态分发,按这个顺序查,比瞎调参数快十倍:
strace -c -e trace=sendfile,writev,read挂上去,看sendfile是不是 0 或者远低于预期;- 确认是不是 443 / HTTP/2 —— 这两个一旦开,sendfile 就已经被判死刑,别浪费时间调
sendfile_max_chunk; - 翻 location 有没有
gzip / sub_filter / ssi / charset / addition,用干净的空 location 隔离下载路径; - 检查
directio是否命中,aio是不是写成了aio on而不是aio threads=xxx; /proc/<pid>/io看write_bytes和出口流量是否同步增长,涨了就是进用户态了;- 确定是 TLS 拖累之后,要么卸载 TLS,要么接受现实——别去做"nginx 开 KTLS"的梦。
零拷贝这事儿没有"配了就好"的开关,它是一条由四五个模块共同守门的内核路径,任何一个环节决定插一脚,它就默默退化成两次 memcpy。而它退化的时候,nginx 一个字都不会打。