Nginx 静态资源缓存配置思路

首页慢三秒最先查出来的东西是一张 12MB 的背景图,每次刷新都完整重下一遍。我从构建产物长缓存、HTML no-cache、图片规则、304、gzip_static 一路加到 open_file_cache,最后留了一份每加一类资源都要过的清单。
那张让首页慢了三秒的图
我这站首页性能整改之前,访问一次首页其实有 14 个并发 API 调用,瓶颈其实主要在后端。但最先让我去翻缓存这一块的,是移动端反复刷首页时一张背景图越下越慢。
我用浏览器开发工具看了一下:那张图 12 MB。每次刷新,浏览器都会跟服务器重发请求,Nginx 也没给缓存头,304 都不发,等于完整重新拉一遍。第一次访问 800ms,第二次访问还是 800ms。
我这才意识到:我之前的缓存策略只在「构建产物」上设了,对 public/ 目录下的图片根本没设。回去查 nginx.conf,原本 location ~* \.(png|jpg|jpeg|gif)$ 一句都没写,全走的默认 expires off。
第一刀:先给带 hash 的构建产物长缓存
Vite 构建出来的 assets/index-abc123.js、assets/index-9f4c.css,文件名都带 hash,文件改了文件名也会改。这种资源可以放心给一年的缓存,浏览器只要记住文件名就够了。
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
add_header X-Cache-Layer "assets";
}immutable 这词是关键。告诉浏览器「缓存期间这文件不会变」,浏览器就不会傻乎乎带着 If-Modified-Since 头来问,省一次往返。开源工具 Lighthouse 跑出来 LCP 直接降了 0.3 秒。
access_log off 不是必须的,但 assets/ 是高 QPS 路径,关掉日志能省下不少磁盘 IO。我现在所有带 hash 的静态目录都关。
第二刀:HTML 永远 no-cache
HTML 是入口,它指向最新的 JS 和 CSS。如果给 HTML 长缓存,用户打开网站就停留在老版本上。我把这块单独处理:
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires -1;
add_header X-Cache-Layer "html-entry";
}no-cache 不是「不缓存」,而是「缓存但每次都要跟服务器确认」。must-revalidate 是强约束,浏览器没收到 304 就不能用本地那份。两者一起用最稳。
第三刀:手写的图片缓存规则
public/ 目录下是我的封面、缩略图、头像。这些图片会偶尔换,但不会天天换。给个 7 天或 30 天的窗口期比较合理。
location ~* \.(png|jpg|jpeg|webp|avif)$ {
if ($uri ~* "^/file/tiana-") {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
if ($uri ~* "^/uploads/") {
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
}第一个 if 命中我博客正封面(文件名是固定的,不像构建产物有 hash)。第二个 if 命中评论头像这类可能换的内容。
这里曾经有过问题:if 块里写 add_header 会让外层 server 级别继承下来的响应头全部失效。这是 Nginx 经典坑。我当时以为是 header 没生效,curl 看了半天,最终是整个 server 级里所有 add_header 都消失了。要么把 add_header 写 location 里整组重发,要么用 map + include 拆分。
第四刀:让浏览器主动用上 304
光给长缓存不够,还要让浏览器在缓存过期前能拿到 304,让它继续用本地副本。ETag 是关键。
Nginx 默认开 etag on,文件 mtime + inode 算出来个指纹。内容不变 ETag 不变,浏览器带 If-None-Match 来问,服务器回 304 + 空 body。
但有件事得说清楚:在 expires 期间浏览器根本不会问服务器。所以 Cache-Control: max-age=3600 + ETag 同时存在时,max-age 起效,ETag 是兜底,超期之后浏览器才来问。
第五刀:gzip_static 与预压缩
前端构建时我开了 gzip,把 .js.gz .css.gz 部署上服务器。Nginx 配 gzip_static on; 后,浏览器发 Accept-Encoding: gzip 时直接返回 .gz 文件,省 CPU。
gzip_static on;
gzip_static_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_proxied any;
gzip_vary on;但 gzip_static 不要对图片开。JPEG/PNG/WebP 本身已经压过了,再用 gzip 包一层白白耗 CPU。我之前改 types 没分清,跑了一遍 CPU 真的能看到尖峰。
第六刀:关于 open_file_cache
服务器起来的时间长一点之后,磁盘缓存就派上用场了。Nginx 自己会缓存常用文件的 fd 和 mtime,避免每次都走 stat()。
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;坑:open_file_cache_errors on 这个参数我一开始开了,结果 ACME 续签时 503 一下午。原因是它把 404 也缓存了,导致 Let's Encrypt 的挑战请求被错误地复用 404 结果。open_file_cache_errors 默认就是 off,不要开。
我现在每加一类资源都过的清单
加新的图片目录、新字体、新 JSON 配置之前我会按这个清单走一遍:
- 文件名是否会变?不变的可以长缓存 + immutable;带 hash 的可以一年
- 内容更新频率?经常变的设短窗口期(7 天),并准备好 purge 机制
- 是否经过脚本处理?需要脚本处理的资源,最好走单独的 location 单独设缓存
- 服务器是否会顺便记录日志?高 QPS 静态关 access_log 真的省 IO
这套做完之后,我博客的缓存命中率稳定在 99% 以上,移动端首屏 LCP 从 3.4 秒掉到 1.2 秒。