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 的情况下),数据的流动路径是这样的:
- 内核从磁盘读取文件内容,拷进内核缓冲区(page cache)。这次拷贝由 DMA 完成,不占 CPU。
- 应用程序调用
read(),内核把数据从内核缓冲区拷贝到用户空间的应用程序缓冲区。这次拷贝由 CPU 执行。 - 应用程序调用
write(),把用户空间的数据拷贝回内核的 socket 发送缓冲区。又是一次 CPU 拷贝。 - 内核把 socket 缓冲区的数据交给网卡,由 DMA 完成最后一次传输。
这一来一回,数据在用户空间和内核空间之间被搬运了两次,全是 CPU 在做无用功。对于大文件、高并发的静态资源服务,这部分开销非常可观。
sendfile 系统调用解决的正是这个问题。它的原理是:让数据直接在内核内部从文件描述符流向 socket 描述符,完全不经过用户空间。
开启 sendfile 后,路径缩短为:
- 内核从磁盘读文件到 page cache(DMA)。
- 内核直接把数据从 page cache 送给 socket 缓冲区(这一步在支持 scatter-gather DMA 的网卡上,甚至只是传递描述符,连拷贝都不需要)。
- 网卡发送(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 看分阶段耗时、必要的时候抓包看包的大小和数量。只有可测量的优化才是真的优化。