Nginx 静态资源与页面加速:零拷贝、缓存验证与 Brotli 压缩联动

围绕静态资源与页面加载加速,讲解 sendfile 与 tcp_nopush 的零拷贝路径、ETag 与 Last-Modified 的条件请求语义、浏览器 Cache-Control 缓存策略、gzip 与 Brotli 压缩联动,以及缓存命中与 CDN 配合的性能验证方法。

一个页面的加载体验,一半取决于静态资源能否被快速、高效地送达浏览器:HTML、CSS、JavaScript、图片与字体构成了页面体积的大头。Nginx 天生擅长静态文件服务,但要把它榨干,需要理解几条并行的加速路径:sendfile 的零拷贝避免用户态数据搬运、ETag 与 Last-Modified 让浏览器只取真正变化的部分、Cache-Control 让资源在浏览器本地直接命中、Brotli 压缩让传输体积进一步缩小。本文逐条拆解这些机制,并给出静态资源服务的完整配置与性能验证方法。

一句话总结: 静态资源加速由零拷贝、条件请求、浏览器缓存与压缩四条路径叠加而成,Nginx 配置到位后静态文件几乎不再成为性能瓶颈。

1. 静态资源服务的性能目标

一句话总结: 静态资源优化的目标是「少传、少算、少往返」:压缩缩小体积,缓存避免重复传输,条件请求只传差异。

静态资源服务的优化目标可以概括为三个「少」。第一是少传:通过压缩(gzip/Brotli)与图片格式优化缩小响应体积,同样的带宽服务更多用户。第二是少算:通过浏览器缓存与 CDN 缓存,让大多数请求根本不抵达源站。第三是少往返:通过条件请求(ETag/Last-Modified),让浏览器只下载真正变化的部分而不是整个文件。

# 观察当前静态资源的实际加载表现
curl -s -o /dev/null -w 'code=%{http_code} size=%{size_download} time=%{time_total}s\n' \
  https://example.com/static/app.js

# 查看响应头中的缓存与压缩信息
curl -sI https://example.com/static/app.js

一个健康的静态资源响应头应当同时具备几类信息:Content-Type 正确标注 MIME 类型、Content-Encoding 标明压缩算法、ETag 与 Last-Modified 提供验证依据、Cache-Control 指示浏览器缓存策略。任何一个缺失,都意味着某条加速路径没有生效。

2. sendfile 与零拷贝

一句话总结: sendfile 让 Nginx 在内核态直接把磁盘数据送入网卡,省去用户态缓冲区拷贝;tcp_nopush 与 tcp_nodelay 调节数据包发送时机。

普通文件读取的路径是「磁盘 → 内核缓冲区 → 用户态缓冲区 → 套接字缓冲区 → 网卡」,数据多次在用户态与内核态之间拷贝。sendfile on 让 Nginx 直接调用内核的 sendfile 系统调用,磁盘数据在内核内部直达网卡,省掉两次用户态拷贝。对于大文件与高并发静态服务,这是立竿见影的优化。

# 静态资源服务的核心配置
http {
    sendfile           on;          # 开启零拷贝
    tcp_nopush         on;          # 数据包攒满再发,配合 sendfile
    tcp_nodelay        on;          # 小包即时发送,用于 keepalive 下的交互场景

    keepalive_timeout  65;
    types_hash_max_size 2048;

    include            /etc/nginx/mime.types;
    default_type       application/octet-stream;
}

tcp_nopush 与 sendfile 搭配使用:Nginx 先把响应头与数据攒在一起,等待 TCP 窗口积攒到一定量再一次性发送,减少小包数量、提高带宽利用率。tcp_nodelay 则用于 keepalive 连接上的交互式小请求(如 API),保证小响应不被 Nagle 算法延迟。二者看似矛盾,实际服务的是不同场景:大文件传输用 nopush,交互小响应用 nodelay。

# 针对大文件下载场景,进一步调优
location ~* \.(mp4|mkv|zip|tar\.gz)$ {
    sendfile          on;
    tcp_nopush        on;
    output_buffers    32 64k;      # 增大输出缓冲,适配大文件
    aio               on;          # 启用异步 IO,避免阻塞 worker
    directio          4m;          # 超过 4M 的大文件走直接 IO,绕过页缓存
    directio_alignment 512;
}

directio 适合超大文件:它绕过操作系统的页缓存,直接从磁盘读入用户空间再发送,避免大文件反复污染页缓存导致其他资源命中率下降。aio 则让磁盘读取不阻塞 worker 进程。这些高级开关只对真实的大文件下载场景有意义,常规 Web 站点不需要也不应该全部打开。

3. 条件请求与 ETag

一句话总结: ETag 与 Last-Modified 让浏览器用 If-None-Match / If-Modified-Since 做验证,资源未变时服务器返回 304 空响应体。

浏览器缓存命中有两种:强缓存(不请求服务器直接使用本地副本)与协商缓存(请求服务器但仅验证「是否变了」)。协商缓存的基础是条件请求。Nginx 默认生成 ETag(基于文件的 mtime 与 size)与 Last-Modified,浏览器下次请求时携带 If-None-Match 或 If-Modified-Since,Nginx 验证未变就返回 304 Not Modified,响应体为空,仅需几百字节开销。

# 条件请求验证:ETag 默认开启,显式声明以便后续微调
server {
    listen      80;
    server_name example.com;
    root        /srv/www/static;

    location /static/ {
        etag  on;                        # 默认开启
        # 自定义 ETag 前缀:发布号变化时强制全部失效
        etag_format "v1-%x-%x";
        add_header Cache-Control "no-cache";  # 每次都要验证,但只传差异
    }
}

etag_format 允许自定义 ETag 的生成格式。生产实践中常用「发布版本号 + 文件特征」拼接:当静态资源整体重新部署、版本号变更时,所有 ETag 都变化,浏览器会全量重新拉取,避免因 mtime 巧合导致「内容已变但 ETag 未变」的陈旧缓存问题。

理解 304 与 200 的区别是排查缓存问题的关键:

# 首次请求:200,携带 ETag
curl -sI https://example.com/static/app.css | grep -E 'HTTP|ETag'

# 带上 If-None-Match 再次请求:304,无响应体
curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'If-None-Match: "v1-5f3a-2b7c"' \
  https://example.com/static/app.css

4. 浏览器缓存策略

一句话总结: Cache-Control 决定资源在浏览器端的缓存生命周期,带指纹的文件用长缓存、可变文件用 no-cache 验证,是静态资源的黄金法则。

条件请求避免了「重新下载」,但浏览器缓存策略决定了「是否还需要发起请求」。Cache-Control 是浏览器缓存行为的权威指令。静态资源缓存有一条黄金法则:带内容指纹(文件名含 hash)的资源可以长期缓存,不带指纹、可能变化的文件应当每次验证。

# 按文件名特征区分缓存策略
server {
    root /srv/www/static;

    # 带 hash 指纹的资源:一年长缓存,无需回源验证
    location ~* \.(css|js)$ {
        # 若文件名形如 app.8f3a2b.css 则长缓存;否则短缓存
        if ($uri ~ "-[0-9a-f]{8,}\.") {
            add_header Cache-Control "public, max-age=31536000, immutable";
        }
        # 未带指纹的同类文件:短缓存 + 协商
        add_header Cache-Control "public, max-age=3600";
    }

    # 图片与字体:中等时长缓存
    location ~* \.(png|jpg|jpeg|webp|svg|woff2)$ {
        add_header Cache-Control "public, max-age=2592000";   # 30 天
    }

    # HTML 页面:绝不长缓存,保证内容即时更新
    location ~* \.html$ {
        add_header Cache-Control "no-cache";                   # 协商验证
    }
}

immutable 是给带指纹资源的额外声明:它告诉浏览器「这个资源永远不会变,本地命中了直接使用,连条件请求都不用发」。配合 CDN 时,s-maxage 可以为共享缓存单独设定时长:

add_header Cache-Control "public, max-age=3600, s-maxage=86400";
# 浏览器缓存 1 小时,CDN 缓存 1 天,源站收到未命中请求可 304 快速响应

缓存策略的坑集中在「过期设置太激进导致发布不可见」:CSS/JS 未带指纹却设置了一年缓存,用户就永远看不到新样式。规避办法是发布流程强制指纹化(构建工具自动在文件名里注入内容 hash),并让 HTML 始终 no-cache,这样指纹变了浏览器自然拉新资源。

5. 压缩联动:gzip 与 Brotli

一句话总结: gzip 是普适压缩,Brotli 在相同体积下压缩率更高,Nginx 通过 Brotli 模块或反代层为静态资源叠加更小的传输体积。

传输体积的缩小与缓存互补:缓存管「少传」,压缩管「传得少」。gzip 早已普及,文本类资源(HTML/CSS/JS/JSON/SVG)压缩率通常在 60% 以上。Brotli 是 Google 推出的新一代压缩算法,同等级压缩率比 gzip 高 5%~15%,现代浏览器默认支持,Nginx 通过 ngx_brotli 模块提供支持。

# gzip 基础配置(Nginx 内置)
http {
    gzip              on;
    gzip_comp_level   5;
    gzip_min_length   1024;                 # 小于 1KB 不压缩,避免浪费
    gzip_types
        text/plain text/css application/json
        application/javascript application/xml
        image/svg+xml application/font-woff2;
    gzip_vary         on;                   # 响应头标记 Vary: Accept-Encoding
}

# Brotli 配置(需要 ngx_brotli 模块)
http {
    brotli            on;
    brotli_comp_level 6;
    brotli_min_length 1024;
    brotli_types
        text/plain text/css application/json
        application/javascript application/xml
        image/svg+xml application/font-woff2;
}

启用 Brotli 后,Nginx 会按客户端的 Accept-Encoding 协商:支持 Brotli 的浏览器收到 Content-Encoding: br,只支持 gzip 的收到 gzip。gzip_vary on 让响应携带 Vary: Accept-Encoding,避免中间缓存把压缩版本错误地分发给不支持解压的客户端。

# 验证压缩是否生效
curl -sI -H 'Accept-Encoding: br' https://example.com/static/app.js \
  | grep -E 'Content-Encoding|Content-Length'
# 期望看到: Content-Encoding: br

压缩的权衡在于 CPU:压缩级别越高,体积越小,但压缩耗时越长。动态响应(HTML)建议用 4~6 级,避免高压缩级拖慢首字节;静态资源因为可以预压缩,可以放开。更彻底的方案是在构建期预生成 .gz / .br 文件,Nginx 用 gzip_static / brotli_static 直接发送预压缩文件,运行时零压缩开销。

# 预压缩文件直发,运行时零压缩开销
http {
    gzip_static on;      # 有 .gz 文件就发 .gz,否则才现场压缩
    gzip_vary    on;     # 仍然标记 Vary,避免缓存错发
}

# 同理,brotli_static 优先发送 .br 预压缩文件
http {
    brotli_static on;
    brotli_vary    on;
}
# 构建期预生成压缩文件(示例脚本)
gzip -k -9  dist/app.js dist/app.css dist/index.html
brotli -k -q 11 dist/app.js dist/app.css

# 验证:命中了 .br 预压缩文件,Content-Encoding 应为 br
curl -sI -H 'Accept-Encoding: br' https://example.com/static/app.js \
  | grep -E 'Content-Encoding|Content-Length'

预压缩需要构建流水线配合:每次构建产出静态文件的同时生成 .gz 与 .br 副本,并把二者一并发布。发布脚本要在部署后校验压缩文件与源文件的时间戳一致,避免「源文件更新了、预压缩文件还是旧的」这类隐蔽问题——此时 Nginx 会发送过期压缩版本,用户拿到旧内容且无法通过 ETag 区分。

6. 缓存命中与 CDN 配合

一句话总结: 静态资源的最优路径是「CDN → 浏览器缓存」两层拦截,源站只服务未命中,配合日志与观测确认命中率。

在浏览器缓存之上还有 CDN 这一层共享缓存。用户请求先命中 CDN 边缘节点,边缘未命中才回源到 Nginx。这样静态资源只在「第一次有人请求」时才打到源站,热资源的源站命中率可以做到接近零。Nginx 作为源站的角色,主要任务是为 CDN 提供正确的缓存指令与稳定的响应。

# 作为 CDN 源站:为共享缓存明确标注可缓存性
location ~* \.(css|js|png|jpg|webp|woff2)$ {
    add_header Cache-Control "public, max-age=2592000, s-maxage=604800";
    expires 30d;
    add_header X-Cache-Status "MISS";      # 观察:未命中时标记
}

# 配合反向代理层,在 Nginx 内部也做一层磁盘缓存
location /static/ {
    proxy_cache        static_cache;
    proxy_cache_key    "$uri$is_args$args";
    proxy_cache_valid  200 304 7d;
    proxy_pass         http://origin_static;
}

观测静态资源链路,要在每一层留下命中痕迹。源站侧在访问日志中记录响应码与字节数:200 表示真的回源了,304 表示协商命中,两者都应当在 CDN 命中率报表里趋近于零。CDN 侧则通过命中率指标确认边缘缓存是否正常工作。若 CDN 命中率长期偏低,优先检查源站返回的 Cache-Control 是否允许缓存,以及 Vary 是否导致缓存碎片化。

7. 性能验证与调优

一句话总结: 静态资源性能用「首字节、传输量、命中率」三个指标验证,针对单资源逐个检查响应头即可定位是哪条路径未生效。

验证静态资源服务是否调优到位,可以用三个指标量化。首字节时间(TTFB)反映 sendfile 与网络路径;传输字节数反映压缩是否生效;缓存命中率反映 Cache-Control 与 CDN 配置是否正确。逐个检查响应头就能定位问题:

# 完整查看响应头,核对五件事
curl -sI https://example.com/static/app.js

# 1. Content-Type 是否准确      → 影响浏览器解析
# 2. Content-Encoding 是否 br/gzip → 压缩生效
# 3. ETag / Last-Modified 是否存在 → 协商缓存可用
# 4. Cache-Control 时长是否符合预期 → 浏览器缓存策略
# 5. Vary: Accept-Encoding 是否在   → 避免缓存错发

压测静态资源要区分「纯文件服务」与「小文件高并发」。纯文件服务看吞吐(MB/s)与并发连接数,关注 worker_processes 与 worker_connections 是否匹配;小文件高并发看每秒请求数(rps)与 99 分位延迟,关注 keepalive 是否开启、open_file_cache 是否命中:

# 文件描述符与元数据缓存,显著降低小文件请求的开销
open_file_cache          max=10000 inactive=60s;
open_file_cache_valid    30s;
open_file_cache_min_uses 2;
open_file_cache_errors   on;

server {
    listen       80;
    keepalive_requests 1000;      # 单条 keepalive 连接复用更多请求
    root /srv/www/static;
}

open_file_cache 缓存文件描述符、大小与 mtime 等元数据,避免每个请求都做一次 stat 系统调用。对于图片站、图标库这类「海量小文件」场景收益明显。压测时还应开启 access_log off 或采用异步日志,排除日志 IO 对结果的影响,得到接近真实能力的数值。

8. 总结

环节要点
性能目标少传、少算、少往返,三条路径叠加
sendfile零拷贝直达网卡,tcp_nopush 攒包发送
条件请求ETag/Last-Modified,304 只传差异
浏览器缓存带指纹长缓存 immutable,HTML 用 no-cache
压缩联动gzip 普适 + Brotli 更高压缩率,预压缩零开销
CDN 配合源站标注可缓存性,观察各层命中率
验证手段TTFB、传输量、命中率三个指标逐个核对响应头
调优细节open_file_cache 缓存元数据,keepalive 提升复用

静态资源加速的每条路径都简单,但四条路径要协同生效,关键在配置与发布流程的一致性:构建时指纹化资源、Nginx 上分区设置缓存策略、预压缩减少运行时开销、CDN 层承接绝大多数流量。做到这些,静态资源就从「要优化的环节」变成「几乎不占资源的水管」。接下来把视角从性能转到运维,看如何用指标与日志把 Nginx 的运行状态变成可观测的数据。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx Ingress Controller:Kubernetes 云原生网关的路由、证书与金丝雀发布
  2. Nginx 监控与可观测性:stub_status 指标、Prometheus 集成与容量规划
  3. Nginx TLS 进阶与 mTLS:客户端证书认证、证书轮换与 TLS 1.3 优化