HTTP 缓存的文档一向不好读,因为 RFC 是按字段组织的,而实际使用是按场景组织的。我更习惯把它拆成三层来想:
- 能不能不发请求——强缓存,由
Cache-Control: max-age决定。 - 发了请求能不能不传正文——协商缓存,由
ETag/Last-Modified决定。 - 过期了能不能先用着——由
stale-while-revalidate/stale-if-error决定。
三层各自解决不同的问题,配置时按这个顺序想就不容易乱。
第一层:Cache-Control 的指令
先把几个容易混的指令区分清楚:
| 指令 | 含义 |
|---|---|
max-age=N | N 秒内新鲜,浏览器直接用本地副本,不发请求 |
s-maxage=N | 只对共享缓存(CDN、反向代理)生效,覆盖 max-age |
no-cache | 可以存,但每次使用前必须回源验证 |
no-store | 完全不许存,用于含敏感信息的响应 |
private | 只允许浏览器缓存,CDN 不得缓存 |
must-revalidate | 过期后必须验证,不许返回陈旧副本 |
immutable | 新鲜期内即使用户刷新也不发验证请求 |
最常被误用的是 no-cache。它的字面意思容易让人以为是「别缓存」,实际语义是「存下来,但用之前问一下」。真正的「别存」是 no-store。
另一个细节:Expires 是 HTTP/1.0 的遗产,用绝对时间,受客户端时钟影响。同时出现时 Cache-Control: max-age 优先。除非要兼容非常古老的客户端,否则没有必要再发 Expires。
第二层:ETag 与 Last-Modified
强缓存过期后,浏览器会带上验证器发一个条件请求:
GET /app.js HTTP/1.1
If-None-Match: "6a1f-62c9d4e1"
If-Modified-Since: Tue, 18 Feb 2025 03:12:45 GMT
服务端比对后如果没变,返回 304 Not Modified,不带正文。省下的是带宽,省不掉的是一次往返——所以 304 并不「免费」,在移动网络上一次 RTT 可能就是两三百毫秒。
两个验证器的差别:
Last-Modified精度只到秒,一秒内多次修改识别不出来;而且文件内容没变、只是重新部署导致 mtime 变化,也会误判为已修改。ETag是内容指纹,精确,但生成有成本。多台服务器必须保证同一份内容算出相同的 ETag,否则用户在负载均衡后面来回跳,缓存会被反复击穿。
nginx 默认的 ETag 由文件 mtime 和 size 拼成(形如 "67b3d8a5-1a2f"),多机部署时 mtime 往往不一致。解决办法要么在部署流程里用 rsync -t 保留时间戳,要么干脆关掉 ETag 只用 Last-Modified,要么在构建时把内容哈希写进文件名。
指纹文件名:让第二层变得多余
最省事的策略是让静态资源的 URL 随内容变化:
/assets/app.4f2a9c1e.js
/assets/main.b7d03e88.css
内容变了,URL 就变了,所以旧 URL 的内容永远不会变,可以放心设一年的强缓存:
location ~* ^/assets/.+\.[0-9a-f]{8}\.(js|css|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
immutable 的作用是:用户按下刷新键时,浏览器默认会对所有资源发验证请求,加了这个指令就会跳过。对一个引用了几十个资源的页面来说,这能省掉几十次 304 往返。
而 HTML 入口文件必须走另一套规则——它的 URL 不能变,所以要保证每次都拿到最新的:
location = /index.html {
add_header Cache-Control "no-cache";
etag on;
}
这里用 no-cache 而不是 no-store:文件仍然被缓存着,只是每次用之前验证一下,没变就返回 304,几百字节的开销换来了即时的发布生效。
第三层:过期之后先用着
stale-while-revalidate 允许在响应过期后的一段时间内,先把旧副本交给用户,同时在后台悄悄刷新:
Cache-Control: public, max-age=60, stale-while-revalidate=600
含义是:60 秒内直接用;60 到 660 秒之间也直接用,但触发一次后台更新;超过 660 秒才必须等待新响应。对于首页推荐、榜单这类「稍微旧一点没关系」的接口非常合适,用户几乎永远不会等待回源。
配套的还有 stale-if-error:
Cache-Control: public, max-age=300, stale-if-error=86400
源站返回 5xx 或者超时的时候,缓存层可以把一天内的旧副本顶上去。这是一个成本极低的可用性兜底。nginx 侧对应的是:
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_lock 值得单独说:它保证同一个 key 同时只有一个请求回源,其余请求等待结果。没有它的话,热点内容一过期,成百上千个请求会同时打到源站——也就是缓存击穿。
Vary 的坑
Vary 告诉缓存「这个响应还取决于哪些请求头」。用得对可以正确区分压缩格式,用错了会让缓存命中率归零:
Vary: Accept-Encoding # 正确,取值只有有限几种
Vary: User-Agent # 危险,UA 几乎每个都不同
Vary: * # 等于禁用缓存
Vary: User-Agent 会让 CDN 为每一种 UA 存一份副本,实际效果接近不缓存。要做设备区分,用 Sec-CH-UA-Mobile 这类取值有限的 Client Hints,或者干脆在源站按路径分流。
怎么验证配置生效了
浏览器 DevTools 的 Size 列会显示 (disk cache) 或 (memory cache),但要注意:直接在地址栏回车和按 F5 刷新的行为不同,后者会强制验证。命令行确认更可靠:
# 看响应头
curl -sI https://example.com/assets/app.4f2a9c1e.js | grep -i cache
# 验证 304 是否正常工作
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "6a1f-62c9d4e1"' \
https://example.com/index.html
在 nginx 里加一个缓存状态头,排查时非常有用:
add_header X-Cache-Status $upstream_cache_status always;
取值有 MISS、HIT、EXPIRED、STALE、UPDATING、REVALIDATED、BYPASS。把它写进 access log 再做聚合,就能算出真实的命中率——这比看 CDN 控制台的汇总数字要准,因为你能按 URI 维度拆开看是哪些路径拖低了命中率。
一句话版本
带指纹的静态资源用 max-age=31536000, immutable;HTML 用 no-cache 加 ETag;接口按容忍度配 max-age 加 stale-while-revalidate;Vary 只写 Accept-Encoding。这四条能覆盖绝大多数场景。