Nginx 传静态文件为什么快:sendfile 零拷贝、tcp_nopush 与 tcp_nodelay 成对配置及 aio 取舍实战

Nginx 传静态文件为什么快:sendfile、零拷贝与 aio 的取舍实战

打开 nginx.conf,几乎所有的优化教程都会让你写上这么三行:

sendfile on;
tcp_nopush on;
tcp_nodelay on;

照抄的人很多,但能说清这三个指令各自在解决什么问题的人不多。更常见的情况是:有人为了「优化」把 sendfile 关掉、把 aio 打开,结果性能反而下降;或者在小文件为主的站点上开了 tcp_nopush 却没配对 tcp_nodelay,导致每个响应都多出几十毫秒的延迟。

这篇文章把 Nginx 传静态文件的这条路径彻底讲一遍:一个文件从磁盘到用户浏览器,中间到底经过了几次内存拷贝,sendfile 砍掉了哪几次,tcp_nopush 和 tcp_nodelay 为什么必须成对出现,以及什么时候该考虑 aio 和 directio。

先看没有 sendfile 的时候:四次拷贝

假设 Nginx 要发送一个 100KB 的图片文件给用户。在最朴素的实现里(也就是 sendfile off 的情况下),数据的流动路径是这样的:

  1. 内核从磁盘读取文件内容,拷进内核缓冲区(page cache)。这次拷贝由 DMA 完成,不占 CPU。
  2. 应用程序调用 read(),内核把数据从内核缓冲区拷贝到用户空间的应用程序缓冲区。这次拷贝由 CPU 执行。
  3. 应用程序调用 write(),把用户空间的数据拷贝回内核的 socket 发送缓冲区。又是一次 CPU 拷贝。
  4. 内核把 socket 缓冲区的数据交给网卡,由 DMA 完成最后一次传输。

这一来一回,数据在用户空间和内核空间之间被搬运了两次,全是 CPU 在做无用功。对于大文件、高并发的静态资源服务,这部分开销非常可观。

sendfile 系统调用解决的正是这个问题。它的原理是:让数据直接在内核内部从文件描述符流向 socket 描述符,完全不经过用户空间。

开启 sendfile 后,路径缩短为:

  1. 内核从磁盘读文件到 page cache(DMA)。
  2. 内核直接把数据从 page cache 送给 socket 缓冲区(这一步在支持 scatter-gather DMA 的网卡上,甚至只是传递描述符,连拷贝都不需要)。
  3. 网卡发送(DMA)。

用户空间完全被绕过了。这就是所谓的「零拷贝」——严格说不是零次拷贝,而是减少了 CPU 参与的数据拷贝次数。少了两次用户态和内核态之间的往返,对大文件静态资源服务的吞吐量提升非常明显。

sendfile 的正确用法和它的边界

开启很简单,在 http、server 或 location 块里写:

sendfile on;

但 sendfile 有两个必须知道的限制:

限制一:只能用于「从文件到 socket」这种单向传输

sendfile 不能用于需要修改内容的场景。比如 Nginx 的 gzip 压缩——如果要把文件压缩后再发出去,那么数据必须先进用户空间被压缩,sendfile 就用不上了。这就是为什么「开了 gzip 之后 sendfile 效果打折」的原因。

同理,如果你用了 Nginx 的动态模块去改写响应体(比如某些 WAF 规则会做内容替换),也会让 sendfile 失效。这不是配置错误,是机制决定的。

限制二:小文件反而可能更慢

对很小的文件(比如几 KB 的图标),sendfile 省下的拷贝时间本来就微不足道,而系统调用本身的固定开销没变,收益接近于零。所以不要指望开了 sendfile 就能让所有静态资源加速——它主要的舞台是大文件和高并发。

tcp_nopush 与 tcp_nodelay:一对必须成对的配置

这两个指令的命名很容易让人困惑,因为它们看起来是矛盾的:一个叫「不推送」,一个叫「不延迟」。实际上它们作用在 TCP 数据传输的不同阶段,而且是互补关系,不是互斥关系。

tcp_nopush 管的是「攒够再发」

tcp_nopush on 实际开启的是 TCP 的 TCP_CORK 选项。它的行为是:先不急着把数据发出去,等缓冲区攒够一个 MSS(最大报文段大小)或者确认数据发完了,再一次性推送。

这解决什么问题?想象 Nginx 要发送一个 HTTP 响应,它由「响应头」和「响应体(文件内容)」两部分组成。如果不用 tcp_nopush,Nginx 可能先把头作为一个包发出去,再单独发文件内容——如果文件很小,就变成了两个小包。网络上传输小包的开销(每个包的头部开销、系统调用、网卡中断)占比很高,这就是所谓「愚蠢窗口综合征」。

开启 tcp_nopush 后,Nginx 会先把响应头和文件内容填进缓冲区,攒成一个完整的数据块再发。对静态文件服务来说,这直接减少了包的数量。

tcp_nodelay 管的是「立刻发」

tcp_nodelay on 开启的是 TCP 的 TCP_NODELAY 选项,行为正好相反:关闭 Nagle 算法,数据一来就发,不等缓冲区满。

Nagle 算法原本是为了减少小包数量(把多个小数据攒起来一起发),但在交互式场景下会带来延迟——比如 WebSocket、SSE 这类长连接服务,每个消息都需要及时送达,等缓冲区攒满就太晚了。所以 tcp_nodelay 主要是为这类场景服务的。

那它们怎么会「成对」?

关键点在于:Nginx 内部会把这两个指令配合使用。tcp_nopush 负责在发送文件内容时把数据攒满,而 tcp_nodelay 负责在数据传输的最后一段(比如响应结束、或者遇到 keepalive 的边界时)立刻清空缓冲区,不留尾巴。

如果没有 tcp_nodelay,光有 tcp_nopush,那么最后一个不满 MSS 的数据块可能会在缓冲区里干等到超时才发出,给每个响应平白增加几十毫秒的延迟。反过来,如果只开 tcp_nodelay 不开 tcp_nopush,就失去了合并小包的能力。

所以标准配置是三个一起开:

sendfile on;
tcp_nopush on;
tcp_nodelay on;

这三个的组合语义是:用零拷贝发送大文件,发送时把内容攒成整包(省带宽),但最后一定要及时把尾巴推出去(省延迟)。

大文件与 aio/directio:什么时候该考虑

默认情况下,Nginx 读文件是同步阻塞的——worker 进程发起读磁盘请求后,要等它返回才能干别的。对于大部分静态资源都在 page cache 里的场景(热文件),这个等待几乎不存在,没问题。

但当你的站点有大量「冷文件」(很少被访问、每次都要从磁盘读)时,同步读会让 worker 进程卡在磁盘 IO 上,影响并发处理能力。这时候可以考虑两个选项:

aio:异步文件 IO

location /downloads/ {
    aio on;
}

开启 aio 后,Nginx 可以把读文件的请求异步化,worker 进程在等待磁盘返回期间可以继续处理其他连接。但要注意几个前提:

  • 编译 Nginx 时需要带 --with-file-aio,官方预编译包一般带,自己编译可能没带。用 nginx -V 确认。
  • 文件系统要支持,ext4/XFS 支持良好。
  • aio 与 sendfile 存在一定冲突:因为 aio 的目的是异步读入用户空间,而 sendfile 是内核内部直接传输,两条路径的机制不同。Nginx 的处理方式是:开启 aio 后,sendfile 会被忽略或者只在特定路径生效。所以不要以为三个都开了就三个收益都拿到。

directio:绕过 page cache

directio 4m;      # 大于 4M 的文件采用直接 IO

directio 让 Nginx 在读文件时绕过 page cache,直接从磁盘读到用户空间。为什么有人要这么做?因为对于大文件顺序读(比如视频、大压缩包),经过 page cache 反而会污染缓存,把有用的热数据挤出去,而且多一次拷贝。

但 directio 的代价是:它必须与 aio 配合(单独开 directio 会让读操作变成阻塞的),而且对小文件是纯粹的负面影响。所以典型配置是「大文件走 directio,小文件走 sendfile」:

location /files/ {
    sendfile on;          # 小文件走零拷贝
    directio 4m;          # 超过 4M 的大文件走直接 IO
    aio on;               # 配合 directio 实现异步
    directio_alignment 4k;
}

但我要给一个务实的建议:对绝大多数个人站长的站点,不要轻易开 aio 和 directio。它们带来的复杂性远超收益——配置组合容易出错、效果高度依赖访问模式、一旦配错性能反而下降。只有当你的站点确实是大文件下载站、或者冷文件占比很高、并且你已经能通过压测确认 page cache 命中率低的时候,才值得去折腾。默认的 sendfile on 已经能覆盖 90% 的场景。

open_file_cache:另一个常被忽略的优化

前面讲的是「怎么把文件内容更快地送出去」,但还有一个环节常被忽略:Nginx 每次处理静态文件请求,都要先做一系列 stat 系统调用来确认文件存在、权限对不对、修改时间是什么。open_file_cache 就是把这部分结果缓存起来,减少 stat 调用。

open_file_cache          max=1000 inactive=60s;
open_file_cache_valid    60s;
open_file_cache_min_uses 2;
open_file_cache_errors   on;

它的语义是:缓存最近用过的文件元信息,最多 1000 条,60 秒不用就淘汰;每个条目 60 秒校验一次;被访问至少 2 次的文件才进缓存;连「找不到文件」的负结果也缓存(这对抵御扫描器大量请求不存在的路径很有用)。

但它有个重要的副作用:文件更新后,缓存里的元信息可能还是旧的。如果你的站点会频繁替换同名文件(比如配置文件、某些资源),缓存可能导致 Nginx 一段时间内仍认为旧文件有效。对发布流程规范的站点(文件名带哈希指纹)这不是问题,对会覆盖同名文件的场景就要注意,改完文件后可以 nginx -s reload 或等缓存过期。

怎么验证配置真的生效了

改完配置不能只看文件,要看实际效果。几个可靠的验证方法:

# 1. 确认配置真的被加载(展开所有 include)
nginx -T | grep -E "sendfile|tcp_nopush|tcp_nodelay"

# 2. 用 curl 看响应头,确认没有意外的 Content-Encoding 或分块
curl -sI https://example.com/static/app.js

# 3. 用 curl 测量传输阶段耗时
curl -o /dev/null -s -w \
  "dns:%{time_namelookup} connect:%{time_connect} \
   ttfb:%{time_starttransfer} total:%{time_total} size:%{size_download}\n" \
  https://example.com/static/big.jpg

# 4. 观察 TCP 层:抓包看包的大小和数量
tcpdump -i eth0 -n "tcp port 443" -w /tmp/cap.pcap

第 4 步用 tcpdump 抓包后,用 Wireshark 或者 tcpdump -r 看这个响应用了几个包、包的大小是不是接近 MSS。如果看到大量 100 字节左右的小包,说明 tcp_nopush 没有起到合并作用。这是判断「配置是否真生效」最直接的证据。

另一个常用工具是 nginx -T 配合压测:用 ab 或 wrk 对静态文件反复压测,对比改配置前后的 requests per second。但要注意,如果文件都在 page cache 里,压测反映的是「传输路径」的差异,不包含真实磁盘 IO,所以压测数据要结合访问模式解读。

常见误配与后果对照

最后列几个我见过最多的错误组合,以及它们各自的后果:

  • 只开 sendfile,没开 tcp_nopush/tcp_nodelay:能用,但每个响应可能多发几个小包,吞吐量没榨干。
  • 开了 tcp_nopush 没开 tcp_nodelay:每个响应的最后一个数据块可能等到超时才发,表现为单个请求的延迟莫名偏高,尤其是小响应。
  • 为了「优化」关掉 sendfile 开 aio:大概率性能下降,因为 aio 只对冷文件有收益,热文件反而少了零拷贝的红利。
  • 对小文件目录开 directio:每个请求都绕过 page cache 去读磁盘,延迟飙升。
  • sendfile 开着同时用 gzip:sendfile 对需要压缩的内容无效,别以为是配置问题,是机制如此。

小结

Nginx 传静态文件的性能,核心就是一条链路:减少数据拷贝,减少系统调用,减少网络包数量,同时不牺牲延迟。

sendfile 砍掉用户态与内核态之间的两次拷贝,是静态资源服务最重要的优化;tcp_nopush 把响应攒成整包发送,减少包数量;tcp_nodelay 保证最后一个包及时推出,不让延迟受害。这三个是「保底三件套」,几乎所有站点都该开。

aio 与 directio 是进阶选项,只在大文件、冷文件场景下才有意义,配错了反而更慢,不要盲目跟风。open_file_cache 则是另一个维度的优化,针对的是元数据开销而非数据传输。

验证配置是否生效,别只看配置文件,要用 nginx -T 看实际加载、用 curl -w 看分阶段耗时、必要的时候抓包看包的大小和数量。只有可测量的优化才是真的优化。

Last modification:September 30th, 2026 at 01:29 pm

Leave a Comment