Tiana随笔

Nginx 静态资源缓存配置思路

· Nginx

标签:#Nginx #缓存 #前端

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 秒。

相关文章