Nginx 静态站的缓存策略:哪些该存一年,哪些一秒都不能存

静态站的缓存配置容易走两个极端:要么全都不缓存,白白浪费带宽;要么一刀切全缓存,改完线上半天不生效。

正确做法是按文件名是否带内容 hash 来分。

两类资源,两种策略

现代构建工具的产物大致分两类:

带 hash 的资源 —— index-a3f2c1.csschunk-8d4e.js。文件名里的 hash 由内容算出,内容一变文件名就变。这类文件可以放心缓存到天荒地老,因为它永远不会被”就地更新”。

入口 HTML —— index.html。它的 URL 是固定的,内容却随时可能变。这类文件必须每次回源校验。

location /_astro/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

location ~* \.html$ {
    add_header Cache-Control "no-cache, must-revalidate";
}

immutable 这个指令值得单独说:它告诉浏览器”这个文件永远不会变,用户按刷新键也别来问我”。没有它,用户刷新页面时浏览器仍会对每个资源发一次带 If-None-Match 的请求,拿一堆 304 回来——省了带宽,没省往返延迟。

no-cache 不是不缓存

这是个长期被误读的名字。

  • no-cache —— 可以存,但每次使用前必须回源校验(通常靠 ETag 换一个 304)
  • no-store —— 一个字节都不许存

对 HTML 你要的是前者。资源仍然缓存在本地,只是每次多一个轻量的校验请求;命中 304 时不传输正文,成本很低。用 no-store 会强制每次完整下载,没有必要。

HTML 为什么值得单独对待

HTML 不缓存不只是工程洁癖,它决定了你「改完多久能生效」。

页面标题、页脚文字、导航结构这些东西都只存在于 HTML 里。万一发现某处写错了一个字,或者需要紧急下线某个链接,你希望改完部署就生效——而不是等某个 CDN 节点的 TTL 慢慢过期,或者用户浏览器里还压着上周的副本。

带 hash 的资源缓存一年没有任何风险,因为改动必然带来新文件名。HTML 的 URL 是固定的,所以必须实时。这条边界画得很清楚。

顺手做的两件事

压缩。文本类资源开 gzip 就够用,gzip_comp_level 6 是压缩率和 CPU 的常见折中点。追求极致可以用 Brotli,需要额外编译模块,静态站也可以预压缩成 .br 文件直接分发。

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

注意 gzip_types 里不用写 text/html——Nginx 总是压缩它,写了反而会有告警。

关掉静态资源的访问日志。一个页面几十个资源请求,日志里全是噪音,真出问题时反而不好找。access_log off 放在资源的 location 里,HTML 的日志留着。

验证

改完配置别只看浏览器。用 curl 直接看响应头:

curl -sI https://example.com/ | grep -i cache-control
curl -sI https://example.com/_astro/index.a3f2c1.css | grep -i cache-control

第一条应该是 no-cache, must-revalidate,第二条应该是 public, immutable。两条都对,这套策略才算落地。