Tiana随笔

Nginx 前端资源缓存最佳实践:该缓存的钉死,该协商的协商

· Nginx

标签:#Nginx #缓存 #性能 #部署

Nginx 前端资源缓存最佳实践:该缓存的钉死,该协商的协商

一套可以直接抄的 Nginx 静态资源缓存配置:带哈希的资源缓存一年加 immutable,HTML 用 no-cache 协商,add_header 继承的坑,gzip 与 open_file_cache,以及怎么用 curl 验证 304 真的在工作。

这个博客上线之后,我盯后台访问日志盯了一阵子,发现一个有点尴尬的事实:同一个访客每次打开文章页,都会把我那几百 KB 的 JS 和 CSS 原样重新下载一遍。用户的浏览器明明上一秒还存着这些文件,却因为服务端没说"这东西你可以放心留着",只能每回都客气地再要一次。

静态资源缓存大概是运维工作里性价比最高的一件事:改十几行 Nginx 配置,回头客的页面加载直接从"网络请求一串"变成"基本只剩一个 HTML"。这篇文章把我现在线上跑着的完整配置和背后的每条理由都写下来,你可以直接抄,也可以只挑你需要的部分。

先想清楚一件事:缓存策略的本质是文件名变没变

配置之前得先建立一个判断框架,不然每条指令都像是玄学。

浏览器缓存资源靠的是 URL。同一个 URL,浏览器认为内容不会变(如果服务端允许它这么认为),就直接用本地的;URL 变了,才会重新下载。所以现代前端的缓存策略其实是两句话:

文件名里带内容哈希的资源(Vite 构建出来的 assets/index-B2xK9f.js 这种),内容一变文件名就变,可以放心让浏览器缓存一年甚至更久。旧文件永远不会再被请求,不存在"缓存了旧版本"的问题。

HTML 文件是唯一的例外。它引用了哪些哈希文件,这个信息只存在于 HTML 里,文件名永远是 index.html,内容却会随每次部署变化。所以 HTML 必须每次都向服务端确认有没有新版本,确认很快(没变就是一个 304,几百字节),但不确认就会出大事:用户拿着旧 HTML 去找旧 JS,新部署之后旧 JS 文件没了,白屏。

想明白这两句,下面所有配置都是它的推论。我以前把 index.html 也设成缓存一年,然后花了半个晚上排查"为什么部署完用户看到的还是旧页面",就是没建立这个框架的代价。

完整配置:可以直接抄的版本

假设你的前端是 Vite 构建的 SPA,产物在 /var/www/tiana/dist,静态文件服务这部分完整的 server 配置如下:

server {
    listen 443 ssl http2;
    server_name example.com;

    root /var/www/tiana/dist;
    index index.html;

    # 带 hash 的构建产物:缓存一年,且明确告诉浏览器"别来问我"
    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        try_files $uri =404;
    }

    # HTML 入口:每次都协商,绝不长缓存
    location = /index.html {
        add_header Cache-Control "no-cache" always;
    }

    # SPA 路由兜底:所有不匹配文件的路径都交给 index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 后台上传的图片:中等时长缓存
    location /file/ {
        add_header Cache-Control "public, max-age=604800" always;
    }

    # API 响应不缓存
    location /api/ {
        add_header Cache-Control "no-store" always;
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    gzip on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_vary on;
    gzip_types text/css application/javascript application/json
               image/svg+xml text/plain application/xml;

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

    sendfile on;
    tcp_nopush on;
}

注意这段配置默认你的构建产物都在 assets/ 目录下(Vite 默认行为),且引用路径以 /assets/ 开头。如果你的构建配置改过 assetsDir,把路径对应改掉即可。

下面逐块拆开讲为什么这么写,以及哪些地方最容易写错。

带 hash 的资源:immutable 是点睛之笔

location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    try_files $uri =404;
}

max-age=31536000 是一年,很多人写到这里就停了,但 immutable 这个指令值得单独说。没有它,浏览器在两种情况下仍会发起协商请求:用户手动刷新页面时,以及部分浏览器在地址栏回车时。也就是说,即使用户的硬盘里已经有缓存,按 F5 还是要为每个静态文件发一个 If-None-Match 请求等服务器确认。文件多、网络差的时候,这一串协商请求的耗时不比重新下载少多少。

immutable 的意思是"我以人格担保这个 URL 的内容永远不会变,刷新也不用问"。带上它之后,强刷新页面时带哈希的资源直接从磁盘读,一个请求都不发。这是 Firefox 最早支持的指令,Chrome 后跟进,如今主流浏览器都认。文件名里没有哈希的旧式项目用不了它(内容变了 URL 没变,immutable 就成了谎言),但只要你在用 Vite、Webpack 5、Rollup 这类默认产哈希文件名的工具,就没有理由不加。

try_files $uri =404 这行是防止缺失文件落到 SPA 兜底逻辑里。没有它,一个不存在的 /assets/xxx.js 会被下一层的 try_files ... /index.html 接住,返回一个 200 状态码、内容却是 HTML 的响应,浏览器解析 JS 时报一堆语法错误,排查起来非常迷惑。404 就让它是 404。

HTML:no-cache 不是不缓存

location = /index.html {
    add_header Cache-Control "no-cache" always;
}

很多人看到 no-cache 以为是"禁止缓存",其实它禁止的是"不经确认地使用缓存"。浏览器仍然会把 HTML 存下来,但每次使用前必须向服务器确认:带上 ETag 或 Last-Modified 问一句"变了没"。没变,服务器回 304,不传正文;变了,回 200 加新内容。对几 KB 的 HTML 来说,这个往返通常几十毫秒,换来的是部署后所有用户立刻拿到新版本,这笔账怎么算都划算。

注意这里用的是 location = /index.html 精确匹配,而不是 /。因为 / 这个 location 还承担着 SPA 路由兜底的职责(try_files $uri $uri/ /index.html),把缓存头加在那里,会让所有前端路由的响应都变成 no-cache,这本身无害(路由响应也就是 index.html),但会让配置的意图变得含糊。精确匹配让"入口文件永不长缓存"这件事只写在一个地方。

顺带一提 no-store 和 no-cache 的区别,这两个被混淆的概率极高:no-store 是彻底不存,每个请求都完整下载;no-cache 是存但必须确认。HTML 用 no-cache,含敏感信息的 API 响应才用 no-store。

上传目录和 API:两套不同的策略

/file/ 是这个博客存放上传图片的目录。这类文件的特点是名字基本不变但也不能保证(后台可以删了重传同名文件),所以我给的是一周的普通缓存:max-age=604800,不带 immutable。一周内重复访问直接命中缓存,一周后协商一次。你可以按自己内容的更新习惯调这个数,图片站可以给到一个月,频繁替换头图的可能给一天。

API 这边用的是 no-store。严格说,这个博客的业务接口全是 POST,浏览器本来就不会缓存 POST 响应,这行配置的意义在于防御未来:哪天加了 GET 接口(比如以后可能做的公开搜索建议),不记得处理缓存头时,这里兜底。防御性配置的价值不在当下,在你三个月后忘掉今天所有细节的时候。

add_header 的继承规则,一个经典的坑

这一节是我认为 Nginx 配置里最反直觉的一处,值得单独拎出来。

add_header 的继承规则不是"子级继承父级"这么简单,而是:只要当前 location 里出现了任何一条 add_header,它就不再继承上级的全部 add_header,一条都不继承。

也就是说,如果你在 server 块里写了:

server {
    add_header X-Content-Type-Options "nosniff" always;

    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }
}

那么 /assets/ 的响应里只有 Cache-Control,没有 nosniff。安全头在静态资源上全部失效,而且没有任何报错和警告,配置测试还能通过。我在给 /file/ 加 Content-Security-Policy: sandbox 的时候就撞过这个:沙箱头生效了,nosniff 却消失了,对着响应头看了好几分钟才反应过来是继承规则。

解法是在每个写了 add_header 的 location 里把需要的安全头补全,或者把公共安全头提成一个 include 文件在每个 location 里引入。前者啰嗦但直白,我选的后者:

# /etc/nginx/snippets/security-headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

然后每个 location 里:

location /assets/ {
    include snippets/security-headers.conf;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

啰嗦是啰嗦了点,但每处的头都是显式声明的,读配置的人不需要在脑子里模拟继承算法。另外提醒一句,这个坑同样适用于 expires 指令和 proxy_set_header,规则相同:子级一出现,父级全体退场。

gzip 那几行

gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_types text/css application/javascript application/json
           image/svg+xml text/plain application/xml;

几个要点:gzip_types 默认只压 text/html,CSS 和 JS 必须手动加,这是又一个"以为开了其实没开"的经典项。gzip_vary on 会给响应加 Vary: Accept-Encoding,告诉 CDN 和代理缓存要区分压缩与未压缩两个版本,前面有 CDN 时这个头是必需的。压缩级别 5 是个中庸选择,6 到 9 的收益递减而 CPU 翻倍,静态文件如果追求极致可以预压缩(gzip_static on 配合构建时生成 .gz 文件),运行时零 CPU 成本,个人博客流量小的话不太值得折腾。

图片别放进来。JPG、PNG、WebP 本身已是压缩格式,gzip 它们只会白烧 CPU,偶尔还会变大。

open_file_cache:让 Nginx 自己也省点事

前面所有缓存都是给浏览器和中间层看的,open_file_cache 是 Nginx 自己的文件描述符缓存。每个静态文件请求默认都要 open、stat、close 一轮,开了这个缓存之后,热路径上的文件元数据直接内存命中。对个人博客这种量级,它的收益不大,但配置没有副作用,属于"顺手就开了"的项。

sendfile on 让文件发送走内核态零拷贝,tcp_nopush on 配合它把响应头和文件首块一起发包。这两个是老生常谈的基础项,几乎所有 Nginx 优化文章都会提,提的原因很简单:它们确实该开。

怎么验证配置真的生效了

配完不验证等于没配,验证方法比想象中简单。

第一步,改完先测语法再重载,这个习惯能救命:

nginx -t && systemctl reload nginx

第二步,用 curl 直接看响应头,别全信浏览器面板:

curl -sI https://example.com/assets/index-B2xK9f.js | grep -iE "cache-control|etag|content-encoding"
curl -sI https://example.com/ | grep -i cache-control

期望看到:JS 是 max-age=31536000, immutable 加 etag,HTML 是 no-cache。

第三步,验证 304 协商真的在工作。用 curl 把 ETag 带回去再请求一次:

ETAG=$(curl -sI https://example.com/ | grep -i etag | tr -d '\r' | cut -d' ' -f2)
curl -sI -H "If-None-Match: $ETAG" https://example.com/ | head -1

第二行返回 HTTP/2 304 就说明协商缓存链路是通的。这个验证很重要,因为 304 出问题时往往不是 Nginx 的错,而是中间某层代理把 ETag 吃了。

第四步才是浏览器:DevTools 的 Network 面板,普通刷新看有没有 (disk cache) 或 (memory cache) 字样,强刷(Cmd+Shift+R)看资源是否跳过请求。注意浏览器强刷发的请求头里有 Cache-Control: no-cache,immutable 资源连这个都省掉,这正是它的价值。

几个我见过或踩过的坑,最后打包送上

改了配置但页面上看不变化,八成不是 Nginx 的问题,是浏览器还在用旧缓存。用无痕窗口或 curl 验证,别和自己浏览器的缓存搏斗。

老项目文件名没有哈希时,有人用 app.js?v=20260923 这种查询串做版本控制。能用,但它让每次版本更新都变成缓存全失效,而且某些代理层会忽略查询串导致缓存混乱。有条件就上构建工具的哈希文件名,把版本问题交给构建链而不是约定。

上了 CDN 之后情况多一层:max-age 管浏览器,s-maxage 管 CDN,两者可以不同。常见做法是 CDN 缓存一天、浏览器缓存一年,配合 CDN 的按 URL 清除能力。这时候 immutable 只影响浏览器行为,CDN 认不认要看厂商,别想当然。

location 匹配顺序的坑:Nginx 不是按书写顺序匹配,精确匹配(=)最优先,然后是 ^~ 前缀,再普通前缀按最长匹配,最后正则。如果你发现某条 location 死活不生效,先回头查是不是被更长的前缀或某条正则截了胡。

最后一条是心态上的:缓存配置的正确状态是"一次配对,长期不管"。这套配置我在博客上线时折腾了两天,之后一年多没有再动过,期间发布了几十次新版本,没有出过一次缓存导致的事故。好的基础设施就该是这样,配置它的时候费点心,配置完就从你的世界里消失。

相关文章