美国服务器上 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 一个字都不会打。