目录

Nginx-07 动静分离与静态资源优化

前置阅读:Nginx-06 反向代理与负载均衡

1. 为什么要动静分离

一个典型的页面请求会拉取 1 个 HTML + 几十个静态资源(JS/CSS/图片/字体)。如果这些都走后端应用:

  • 后端进程被大量简单的文件读取占满,处理不了真正的业务请求
  • 应用框架的中间件链(路由、鉴权、日志、序列化)对返回一张图片来说全是无谓开销
  • 后端语言的文件 I/O 性能远不如 Nginx 的 sendfile 零拷贝

动静分离就是让 Nginx 直接返回静态资源,只把动态请求转给后端。收益是量级上的:Nginx 返回静态小文件能做到 10 万+ QPS,而后端应用通常在几千。

server {
    listen 80;
    server_name example.com;
    root /var/www/dist;

    # 静态:Nginx 直接返回
    location ~* \.(js|css|png|jpe?g|gif|svg|ico|woff2?|ttf|eot|mp4|webp)$ {
        expires 30d;
        add_header Cache-Control "public";
        access_log off;
    }

    # 动态:转给后端
    location ^~ /api/ {
        proxy_pass http://backend;
        include snippets/proxy-headers.conf;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

注意 /api/ 用了 ^~,原因见第 05 篇——不加的话 /api/config.js 会被上面的正则截胡。

2. sendfile 零拷贝

2.1 传统读文件发网络的流程

用户态                内核态
                     ┌────────────────┐
                     │  磁盘/页缓存   │
read()  ←──拷贝1─────└────────────────┘
┌────────────┐
│ 用户缓冲区 │
└────────────┘
write() ──拷贝2────→ ┌────────────────┐
                     │  socket 缓冲区 │
                     └────────────────┘
                              │ 拷贝3 (DMA)
                              ↓ 网卡

4 次上下文切换(read 进内核、read 回用户态、write 进内核、write 回用户态)+ 4 次数据拷贝(DMA 磁盘→页缓存、页缓存→用户缓冲、用户缓冲→socket缓冲、DMA socket→网卡)。

2.2 sendfile 的流程

用户态                内核态
                     ┌────────────────┐
                     │  磁盘/页缓存   │
sendfile() ───────→  └────────┬───────┘
(只传 fd 和偏移量)           │ 拷贝(或仅传描述符)
                     ┌────────▼───────┐
                     │  socket 缓冲区 │
                     └────────┬───────┘
                              │ DMA
                              ↓ 网卡

2 次上下文切换 + 2 次数据拷贝。如果网卡支持 SG-DMA(scatter-gather DMA),连页缓存到 socket 缓冲区的拷贝也能省掉,变成真正的「零拷贝」——只传递内存描述符,数据一次都不动。

http {
    sendfile on;

    # 单次 sendfile 调用最多传输的字节数。
    # 限制它可以避免一个大文件的传输长时间占住 worker,
    # 导致其他连接饥饿。生产建议设置。
    sendfile_max_chunk 2m;
}

sendfile 的限制

  • 只能用于「从文件到 socket」,所以只对静态文件有效,反向代理的响应用不上(数据来自上游 socket 而不是文件)。
  • 数据不经过用户态,所以过滤器模块拿不到数据。开了 gzipsub_filter 处理某个响应时,sendfile 对这个响应自动失效——因为必须把数据读到用户态才能压缩/替换。

2.3 tcp_nopush 与 tcp_nodelay

sendfile   on;
tcp_nopush on;
tcp_nodelay on;
  • tcp_nopush 对应 TCP_CORK:像给管道加个塞子,攒够一个完整的 MSS 再发。避免响应头单独发一个小包、文件内容再发一个包。
  • tcp_nodelay 对应关闭 Nagle 算法:小包立即发送,不等 ACK。

两者看起来矛盾,但 Nginx 内部会协调:传输过程中用 nopush 攒包,到最后一个包时切换到 nodelay 立刻发出。所以三个一起开就是最优配置,不需要纠结。

2.4 aio 与 directio

sendfile 有个前提:文件在 page cache 里。如果不在(冷数据),sendfile 会阻塞等磁盘 I/O,把整个 worker 卡住。

# main 块
thread_pool default threads=32 max_queue=65536;

http {
    sendfile on;
    aio threads;              # 用线程池做异步读,避免阻塞 worker
    directio 8m;              # 大于 8M 的文件用 O_DIRECT 绕过 page cache
    output_buffers 2 512k;
}

为什么大文件要 directio 绕过 page cache?因为一个几 GB 的视频文件读进 page cache,会把其他热点小文件从缓存里挤出去(缓存污染)。绕过之后大文件直读磁盘,page cache 留给真正需要它的小文件。

注意 directiosendfile 互斥:开了 directio 的文件不走 sendfileO_DIRECT 要求数据进用户态缓冲区)。所以这套配置的效果是:小文件走 sendfile 零拷贝,大文件走 directio + aio 异步直读。这正是想要的。

什么时候不该开 aio threads:数据集小于内存、文件基本都在 page cache 里的场景。此时读文件本来就不阻塞,加线程池只是白增加调度开销。官方测试在大文件随机读场景吞吐提升 9 倍,但在小文件全命中缓存场景是负优化。

3. 压缩

3.1 gzip

http {
    gzip on;

    # 加上 Vary: Accept-Encoding,让 CDN/代理正确区分压缩和未压缩版本。
    # 不加的话可能给不支持 gzip 的客户端返回压缩内容。
    gzip_vary on;

    # 小于这个大小不压缩。太小的内容压缩后可能反而变大(gzip 有头部开销)。
    gzip_min_length 1k;

    # 压缩级别 1-9。
    # 1 → 快但压缩率低;9 → 慢且 CPU 高,压缩率提升很小。
    # 4-6 是性价比区间,推荐 5。
    gzip_comp_level 5;

    # 对代理来的请求也压缩。
    # any = 无条件压缩;也可以用 expired/no-cache/no-store/private/auth 等细分
    gzip_proxied any;

    # 压缩用的缓冲区
    gzip_buffers 16 8k;

    # 只对 HTTP/1.1 及以上压缩(1.0 的代理可能不支持)
    gzip_http_version 1.1;

    # 要压缩的 MIME 类型。
    # ⚠️ text/html 是默认必压的,不要写也不能写在这里
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/json
        application/javascript
        application/x-javascript
        application/xml
        application/xml+rss
        application/rss+xml
        application/atom+xml
        application/vnd.api+json
        image/svg+xml
        font/ttf
        font/otf;

    # 对老 IE 禁用(IE6 及以下有 gzip bug)
    gzip_disable "msie6";
}

不要压缩的内容

类型 原因
图片(jpg/png/gif/webp) 本身已是压缩格式,再压几乎无收益,纯浪费 CPU
视频音频(mp4/mp3) 同上
压缩包(zip/gz/7z) 同上
woff2 字体 woff2 内部已用 Brotli 压缩
小于 1k 的内容 压缩收益不抵开销

woffwoff2 要区分woff 没压缩(可以 gzip),woff2 已经内置 Brotli 压缩(不要再压)。

压缩效果参考(gzip_comp_level 5):

类型 压缩率
HTML 70-80%
CSS 75-85%
JS 65-75%
JSON 80-90%
SVG 70-80%

3.2 gzip_static —— 预压缩

每次请求都实时压缩是浪费 CPU。静态文件在构建时就可以压好:

# 需要 --with-http_gzip_static_module
gzip_static on;
# on   : 有 .gz 文件就用,没有就实时压缩(需要 gzip on)
# always: 总是用 .gz,即使客户端不支持 gzip(配合 gunzip 模块)

工作方式:请求 app.js 时,Nginx 先找 app.js.gz,找到就直接返回它并加上 Content-Encoding: gzip——完全不消耗 CPU 压缩

构建流程里加一步:

# 生成预压缩文件(用最高级别,因为是构建时一次性的)
find dist -type f \( -name "*.js" -o -name "*.css" -o -name "*.html" -o -name "*.svg" \) \
    -exec gzip -9 -k {} \;
# -k 保留原文件

静态资源必开 gzip_static。构建时用 -9 压到最小,运行时零 CPU 开销,两头都占便宜。

3.3 Brotli

Brotli 是 Google 的压缩算法,比 gzip 小 15-25%,所有现代浏览器都支持。

# 编译安装 ngx_brotli
git clone --recurse-submodules https://github.com/google/ngx_brotli
cd nginx-1.26.2
./configure --add-dynamic-module=../ngx_brotli ...
make modules
cp objs/*.so /etc/nginx/modules/
# main 块
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

http {
    brotli on;
    brotli_comp_level 5;         # 1-11,5-6 是性价比区间
    brotli_min_length 1k;
    brotli_static on;            # 优先用 .br 预压缩文件
    brotli_types text/plain text/css application/json
                 application/javascript image/svg+xml;

    # 同时保留 gzip 做降级(不支持 br 的客户端)
    gzip on;
    gzip_static on;
    # ...
}

Nginx 会根据客户端的 Accept-Encoding 自动选:支持 br 用 Brotli,否则用 gzip。

构建时同时生成两种:

find dist -type f \( -name "*.js" -o -name "*.css" \) | while read f; do
    gzip -9 -k "$f"
    brotli -q 11 -k "$f"       # -q 11 是最高级别
done

Brotli 的最高级别(11)压缩极慢,只适合构建时用,绝对不要在运行时设 brotli_comp_level 11

4. 浏览器缓存

4.1 expires 与 Cache-Control

# expires 会同时设置 Expires 和 Cache-Control: max-age
expires 30d;
# → Expires: <30天后的GMT时间>
#   Cache-Control: max-age=2592000

expires -1;         # Cache-Control: no-cache
expires epoch;      # Expires: 1970-01-01, Cache-Control: no-cache
expires off;        # 不添加任何缓存头
expires max;        # 2037年,Cache-Control: max-age=315360000
expires modified +7d;   # 从文件修改时间算起
expires @15h30m;    # 当天 15:30 过期

现代做法是直接用 Cache-ControlExpires 是 HTTP/1.0 的遗产):

add_header Cache-Control "public, max-age=31536000, immutable";

Cache-Control 的常用指令:

指令 含义
public 任何缓存(浏览器、CDN、代理)都可以缓存
private 只有浏览器可以缓存,CDN 不行(用户相关内容必须用这个)
no-cache 可以缓存,但每次使用前必须向服务器验证
no-store 完全不缓存(敏感数据)
max-age=N 缓存 N 秒
s-maxage=N 只对共享缓存(CDN)生效,优先于 max-age
immutable 内容永不改变,浏览器连刷新时都不发验证请求
must-revalidate 缓存过期后必须验证,不能用过期内容
stale-while-revalidate=N 过期后 N 秒内可以先用旧的,同时后台更新

no-cacheno-store 的区别是高频面试题:no-cache 是「可以存但用之前要问」(还能走 304,省带宽),no-store 是「一个字节都不许存」(每次全量下载)。

4.2 按资源类型分层的缓存策略

server {
    root /var/www/dist;

    # ① 带内容 hash 的构建产物:永久强缓存
    # 文件名如 app.a1b2c3d4.js,内容变了文件名一定变
    location ~* \.[0-9a-f]{8,}\.(js|css|woff2?)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    # ② 图片/字体:长缓存但保留验证能力
    location ~* \.(png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf)$ {
        expires 30d;
        add_header Cache-Control "public, max-age=2592000";
        access_log off;
    }

    # ③ 无 hash 的 JS/CSS:短缓存 + 协商缓存
    location ~* \.(js|css)$ {
        expires 1h;
        add_header Cache-Control "public, max-age=3600, must-revalidate";
    }

    # ④ HTML 入口文件:绝不缓存
    # 这是整个策略的关键——HTML 里引用了带 hash 的资源,
    # HTML 必须每次都拿最新的,否则发版后用户还是旧页面
    location ~* \.html?$ {
        expires -1;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # ⑤ API 响应:不缓存
    location ^~ /api/ {
        proxy_pass http://backend;
        add_header Cache-Control "no-store";
    }
}

核心思路入口 HTML 不缓存 + 资源文件名带 hash + 资源永久缓存。这套组合让「发版立即生效」和「资源命中率极高」同时成立,是现代前端部署的标准方案。

4.3 协商缓存 Last-Modified 与 ETag

强缓存过期后,浏览器会带条件头来问「变了没」:

# Nginx 默认对静态文件自动生成这两个头,通常不用管
etag on;               # 默认 on,值由「文件修改时间 + 文件大小」生成
if_modified_since exact;   # 默认 exact,精确比较;也可以设 before

流程:

第一次请求:
  → GET /app.js
  ← 200, Last-Modified: Mon, 03 Aug 2026 10:00:00 GMT
        ETag: "68af1234-2a5f"

缓存过期后再次请求:
  → GET /app.js
    If-Modified-Since: Mon, 03 Aug 2026 10:00:00 GMT
    If-None-Match: "68af1234-2a5f"
  ← 304 Not Modified(响应体为空,只有几百字节的头)

多台 Nginx 时 ETag 可能不一致:Nginx 的 ETag 由「文件 mtime + size」生成。如果多台机器上同一个文件的 mtime 不同(部署时间不同),ETag 就不一样,导致缓存反复失效。

解决办法:

  • 部署时用 rsync -ttar 保留 mtime
  • 或者关掉 ETag 只用 Last-Modified(同样依赖 mtime,问题一样)
  • 或者根本不依赖协商缓存:文件名带 hash + 强缓存(推荐)

5. 大文件与断点续传

location /download/ {
    root /data/files;

    # 允许 Range 请求(默认开启,别关)
    # 客户端可以发 Range: bytes=1000-2000 只取一段
    max_ranges 10;                 # 限制单次请求的 Range 段数,防 Range 攻击

    # 大文件优化
    sendfile on;
    sendfile_max_chunk 2m;         # 避免单个大文件长时间占住 worker
    aio threads;
    directio 8m;
    output_buffers 2 512k;

    # 不压缩(大文件多是已压缩格式,且压缩会破坏 Range)
    gzip off;

    # 限速:单个连接 5MB/s
    limit_rate 5m;
    # 前 20MB 不限速(让用户感觉"秒开"),之后才限
    limit_rate_after 20m;
}

limit_rate单个连接的限速。一个客户端开 10 个并发下载就能占 50MB/s,所以要配合 limit_conn

limit_conn_zone $binary_remote_addr zone=dl_conn:10m;

location /download/ {
    limit_conn dl_conn 2;      # 每 IP 最多 2 个并发下载
    limit_rate 5m;
}

也可以用变量动态限速(比如 VIP 用户不限速):

map $cookie_vip $rate {
    default  "1m";
    "1"      "0";       # 0 = 不限速
}

location /download/ {
    limit_rate $rate;
}

5.1 用 X-Accel-Redirect 做鉴权下载

这是文件下载服务的标准做法(第 04 篇提过):

# 客户端请求这里
location /download/ {
    proxy_pass http://backend;     # 后端只做鉴权,然后返回 X-Accel-Redirect
    include snippets/proxy-headers.conf;
}

# Nginx 内部实际发文件的地方
location /internal-files/ {
    internal;                      # 客户端直接访问返回 404
    alias /data/files/;
    sendfile on;
    aio threads;
    directio 8m;
    limit_rate 5m;
}

后端(Go):

func Download(c *gin.Context) {
    fileID := c.Param("id")

    // 鉴权 + 查文件路径,几毫秒的事
    if !hasPermission(c, fileID) {
        c.JSON(403, gin.H{"error": "forbidden"})
        return
    }
    path, name := lookupFile(fileID)

    // 把传输工作交给 Nginx,本函数立即返回
    c.Header("X-Accel-Redirect", "/internal-files/"+path)
    c.Header("Content-Disposition", `attachment; filename="`+name+`"`)
    c.Header("Content-Type", "application/octet-stream")
    // 注意:不要写任何响应体
}

收益:一个 1GB 文件的下载,后端 goroutine 只占用几毫秒就释放了,传输完全由 Nginx 的 sendfile 完成。不用这个技巧的话,后端要陪着客户端传输几分钟,还要自己实现 Range 断点续传。

6. 防盗链

6.1 基于 Referer

location ~* \.(jpg|jpeg|png|gif|webp|mp4)$ {
    # none    : 允许没有 Referer 的请求(直接在地址栏打开、某些浏览器不发 Referer)
    # blocked : 允许 Referer 被防火墙/代理删掉的请求(值为 "Referer:" 或非 http 开头)
    # server_names : 允许 Referer 是本站 server_name 的
    valid_referers none blocked server_names
                   *.example.com example.com
                   ~\.google\.  ~\.baidu\.;

    if ($invalid_referer) {
        return 403;
        # 或者返回一张"盗链警告"图(对用户更友好,也是一种宣传)
        # rewrite ^ /images/anti-leech.png break;
    }
}

valid_referers 会设置 $invalid_referer 变量:匹配上任一条件则为空字符串,否则为 1

Referer 防盗链的局限:Referer 是客户端可伪造的头,curl -H "Referer: https://example.com" 就能绕过。它只能防「网页里直接 img 引用」这种低成本盗链,防不住有心的人。

真正可靠的方案是签名 URL,需要 --with-http_secure_link_module

location /protected/ {
    # 期望的格式:/protected/file.mp4?md5=xxx&expires=1234567890
    secure_link $arg_md5,$arg_expires;

    # 签名的原文:过期时间 + URI + 客户端IP + 密钥
    secure_link_md5 "$secure_link_expires$uri$remote_addr my_secret_key";

    # $secure_link 的值:
    #   ""  → 签名错误
    #   "0" → 签名正确但已过期
    #   "1" → 签名正确且未过期
    if ($secure_link = "") { return 403; }
    if ($secure_link = "0") { return 410; }   # 410 Gone 表示链接过期

    root /data;
}

生成签名的 Go 代码:

func signedURL(uri, clientIP, secret string, ttl time.Duration) string {
    expires := time.Now().Add(ttl).Unix()
    raw := fmt.Sprintf("%d%s%s %s", expires, uri, clientIP, secret)
    sum := md5.Sum([]byte(raw))
    // Nginx 用的是 base64url 编码,且去掉了 padding
    sig := base64.URLEncoding.EncodeToString(sum[:])
    sig = strings.TrimRight(sig, "=")
    return fmt.Sprintf("%s?md5=%s&expires=%d", uri, sig, expires)
}

注意签名原文里的空格和顺序必须和 Nginx 配置里的 secure_link_md5 完全一致,这是调试时最容易出错的点。把 $remote_addr 加进去可以防止链接被分享给别人用。

7. SPA 部署的完整配置

单页应用(Vue/React)部署有几个固定的坑,一次性解决:

server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;
    root /var/www/dist;
    index index.html;

    include snippets/ssl-common.conf;

    charset utf-8;

    # 压缩
    gzip on;
    gzip_static on;
    brotli on;
    brotli_static on;

    # ① 带 hash 的资源:永久缓存
    location ~* \.[0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|svg)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
        try_files $uri =404;     # 找不到就 404,绝不能回退到 index.html
    }

    # ② 其他静态资源
    location ~* \.(png|jpe?g|gif|svg|ico|webp|woff2?|ttf|mp4)$ {
        expires 30d;
        add_header Cache-Control "public";
        access_log off;
        try_files $uri =404;
    }

    # ③ API 代理
    location ^~ /api/ {
        proxy_pass http://backend/;
        include snippets/proxy-headers.conf;
        add_header Cache-Control "no-store";
    }

    # ④ 前端路由兜底
    location / {
        try_files $uri $uri/ /index.html;
    }

    # ⑤ index.html 单独设置:绝不缓存
    location = /index.html {
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        add_header Pragma "no-cache";
        expires -1;
    }

    # ⑥ 屏蔽 sourcemap(生产不应该暴露源码)
    location ~* \.map$ {
        return 404;
    }
}

7.1 三个必须注意的点

① 静态资源不能回退到 index.html

# ❌ 错误:一个不存在的 js 文件会返回 index.html(200 + HTML 内容)
# 浏览器尝试把 HTML 当 JS 解析,报 "Uncaught SyntaxError: Unexpected token '<'"
# 这个报错让人一头雾水,实际根因在 Nginx 配置
location / {
    try_files $uri $uri/ /index.html;
}

# ✅ 静态资源的 location 里显式 =404
location ~* \.(js|css)$ {
    try_files $uri =404;
}

② index.html 必须禁止缓存

如果 index.html 被缓存了,发版后用户加载的还是旧 HTML,里面引用的是旧的 hash 文件名。这些旧文件可能已经被清理,导致白屏。

③ 发版时要保留旧版本的资源文件一段时间

用户正在浏览页面时你发了版,此时他触发的懒加载会请求旧 hash 的 chunk。如果部署时把旧文件删了,就会报 ChunkLoadError

正确的部署方式是增量覆盖而不是清空重建

# ✅ 只添加新文件,保留旧文件
rsync -av --exclude='index.html' dist/ /var/www/dist/
cp dist/index.html /var/www/dist/index.html      # index.html 最后覆盖

# ❌ 不要这样
rm -rf /var/www/dist/* && cp -r dist/* /var/www/dist/

旧文件定期清理(比如保留最近 3 个版本)。

8. 目录列表与其他

# 自动生成目录索引(内网文件服务器用,公网慎用)
location /files/ {
    root /data;
    autoindex on;
    autoindex_exact_size off;     # 显示 KB/MB 而不是字节数
    autoindex_localtime on;       # 用本地时间而不是 UTC
    autoindex_format html;        # 也支持 json / xml / jsonp
    charset utf-8;                # 中文文件名必须设,否则乱码
}

# 强制下载而不是浏览器内打开
location /download/ {
    add_header Content-Disposition "attachment";
    root /data;
}

# 返回 1x1 透明 gif(埋点上报接口)
location = /track.gif {
    empty_gif;
    access_log /var/log/nginx/track.log main;
    expires -1;
}

# 图片实时处理(需要 --with-http_image_filter_module)
location ~* /thumb/(\d+)x(\d+)/(.*)$ {
    alias /data/images/$3;
    image_filter resize $1 $2;
    image_filter_jpeg_quality 85;
    image_filter_buffer 10m;
}

autoindex 在公网开启等于把文件列表公开了,容易泄露敏感文件。只在内网或明确需要的目录开

9. 静态资源安全

server {
    # 屏蔽隐藏文件和版本控制目录
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    # 屏蔽备份文件和源码
    location ~* \.(bak|old|orig|save|swp|sql|log|conf|env|ini|sh|py)$ {
        return 404;
    }

    # 屏蔽 sourcemap
    location ~* \.map$ {
        return 404;
    }

    # 上传目录禁止执行任何脚本
    location ^~ /uploads/ {
        root /data;
        # 只允许图片类型
        location ~* \.(jpe?g|png|gif|webp)$ {
            expires 30d;
        }
        # 其他一律拒绝(防止上传 .php/.jsp 被执行)
        location ~* \.(php|jsp|asp|aspx|sh|py|pl|cgi)$ {
            return 403;
        }
    }

    # 安全响应头
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

X-Content-Type-Options: nosniff 很重要:禁止浏览器猜测 Content-Type。没有它的话,一个内容是 HTML 的 .txt 文件可能被浏览器当 HTML 执行,造成 XSS。

10. 性能验证

# 1. 确认压缩生效
curl -H "Accept-Encoding: gzip, br" -I https://example.com/app.js
# content-encoding: br
# vary: Accept-Encoding

# 2. 确认缓存头
curl -I https://example.com/app.a1b2c3d4.js
# cache-control: public, max-age=31536000, immutable

# 3. 确认 304 协商缓存
curl -I -H 'If-None-Match: "68af1234-2a5f"' https://example.com/logo.png
# HTTP/2 304

# 4. 压测静态文件
wrk -t8 -c200 -d30s https://example.com/app.js
# 关注 Requests/sec 和 Latency 分布

# 5. 确认 sendfile 生效(看系统调用)
strace -p $(pgrep -f "nginx: worker" | head -1) -e trace=sendfile,read,write -c
# 应该能看到 sendfile 调用,而不是大量 read/write 配对

# 6. 看 page cache 命中情况
vmstat 1
# 关注 bi(block in,从磁盘读)列,如果一直很高说明缓存没起作用

11. 面试题

Q:什么是零拷贝?sendfile 怎么实现的?

传统的「读文件发网络」需要 4 次数据拷贝和 4 次上下文切换(磁盘→页缓存→用户缓冲区→socket缓冲区→网卡)。sendfile 系统调用让数据在内核态直接从页缓存送到 socket 缓冲区,省掉了两次用户态拷贝和两次上下文切换。如果网卡支持 SG-DMA,连页缓存到 socket 缓冲区的拷贝也能省掉,只传递内存描述符,实现真正的零拷贝。

Q:开了 gzipsendfile 还生效吗?

不生效。gzip 属于 body filter,必须把数据读到用户态才能压缩,而 sendfile 的前提是数据不进用户态。所以对同一个响应两者是互斥的。这也是 gzip_static(预压缩)的价值——预压缩文件可以直接用 sendfile 发出去,既压缩了又零拷贝。

Q:no-cacheno-store 的区别?

no-cache 允许缓存存储内容,但每次使用前必须向服务器验证(可以走 304,省带宽);no-store 完全禁止存储,每次都要全量重新下载。敏感数据用 no-store,需要保证新鲜度但想省带宽的用 no-cache

Q:强缓存和协商缓存的区别?

强缓存(Cache-Control: max-age / Expires)在有效期内浏览器不发任何请求,直接用本地副本。协商缓存(ETag + If-None-MatchLast-Modified + If-Modified-Since)会发一个请求去问服务器变没变,没变返回 304(响应体为空)。强缓存省一次往返,协商缓存省响应体的带宽。

Q:前端发版后用户还是旧页面,怎么解决?

根因通常是 index.html 被缓存了。正确策略是:入口 HTML 设 Cache-Control: no-cache(每次验证),资源文件名带内容 hash 并设 max-age=31536000, immutable。这样 HTML 每次都拿最新的,而 hash 不变的资源永久命中缓存。另外部署时要增量覆盖、保留旧版本资源一段时间,避免用户在浏览过程中懒加载旧 chunk 时报 ChunkLoadError

Q:SPA 部署时用 try_files $uri $uri/ /index.html; 有什么隐患?

任何不存在的路径都会返回 index.html 且状态码 200。当浏览器请求一个不存在的 .js 文件时,会拿到 HTML 内容却按 JS 解析,报 Unexpected token '<'——报错信息完全指不到真正的原因。修复方法是给静态资源的 location 单独配 try_files $uri =404;

Q:X-Accel-Redirect 在文件下载场景解决了什么问题?

让后端只负责鉴权(几毫秒),文件传输交给 Nginx 的 sendfile。后端进程/协程立即释放,不用陪着客户端传几分钟,也不用自己实现 Range 断点续传和限速。这是受权限控制的大文件下载的标准架构。

Q:directiosendfile 能同时生效吗?为什么要配 directio 8m

不能,两者互斥(O_DIRECT 要求数据进用户态缓冲区)。配 directio 8m 的意图是分流:小于 8M 的文件走 sendfile 零拷贝,大于 8M 的大文件走 directio 绕过 page cache 直读。这样避免大文件把 page cache 里的热点小文件挤出去(缓存污染)。

Q:Referer 防盗链可靠吗?

不可靠。Referer 是客户端可任意伪造的请求头,curl -H "Referer: ..." 就能绕过。它只能拦住「网页里直接 <img src> 引用」这类低成本盗链。真正可靠的是签名 URL(secure_link 模块),把过期时间和客户端 IP 也签进去。


上一篇:Nginx-06 反向代理与负载均衡 | 下一篇:Nginx-08 缓存机制