HTTP · 2026.10.12
HTTP 缓存是一场三方谈判
浏览器、CDN、源站三方各自持有不同的缓存策略。带错一个 Cache-Control,用户看到的就是三个月前的旧页面。这篇把 ETag、Vary、stale-while-revalidate 放进同一张图里讲清楚。
缓存问题的绝大多数 bug,都不是「没设缓存」,而是「设了但设错」。更麻烦的是,设错的代价往往延迟暴露:开发机上永远是最新的,只有线上某个城市的某个用户,看到的还是上个月的版本。
要理解这件事,得先承认缓存不是一方的决定:浏览器有一层,CDN 有一层,源站有最终解释权。三方谈不拢,就会出现「我明明发版了」。
一、缓存键先于缓存策略
在讨论 max-age 之前,先问:这个响应凭什么被复用?
缓存键(cache key)通常由 URL 和 Vary 头共同决定。默认情况下只有 URL。这意味着:
/api/user对 A 用户和 B 用户返回不同内容,但缓存键相同 → 串号。/api/data?lang=zh和/api/data?lang=en是不同 URL(查询串参与键) → 没问题。- 同一 URL 因
Accept-Language返回不同内容,却没配Vary→ 串号。
所以首要动作是:会因请求头变化的响应,要么 Vary,要么 URL 上带版本参数。
但 Vary 不是免费的。Vary: Accept-Encoding, Accept-Language, User-Agent 会让 CDN 为每种组合存一份副本,命中率断崖式下跌。有些 CDN 甚至直接对宽泛的 Vary: * 拒绝缓存。
实践中的取舍:Vary: Accept-Encoding 是必须的(gzip 与 br 不能混用),语言差异优先用独立 URL 而不是 Vary。
二、四类资源,四套策略
把站内资源分成四类,策略就清楚了:
| 资源类型 | 例子 | 策略 |
|---|---|---|
| 指纹静态资源 | app.3f7a2c.js | public, max-age=31536000, immutable |
| 无指纹静态资源 | /favicon.ico | public, max-age=3600 |
| HTML 文档 | /index.html | no-cache(可存但需验证) |
| 私有接口 | /api/me | private, no-store |
immutable 是关键的一笔:它告诉浏览器「这个 URL 的内容永不变化,别在刷新时还发验证请求」。只对文件名里带内容哈希的资源使用,否则用户永远拿不到更新。
HTML 用 no-cache 而不是 no-store。no-cache 的语义是「可以存,但每次使用前必须向源站验证」;no-store 是「根本不存」。前者能省掉重复传输的字节,后者省事但浪费带宽。
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "8f2a1c-1a2b3c"
Last-Modified: Sat, 24 Oct 2026 02:11:00 GMT
三、验证:ETag 与 Last-Modified 的优先级
浏览器带着 If-None-Match: "8f2a1c-1a2b3c" 回来。源站比对 ETag,一致则返回:
HTTP/1.1 304 Not Modified
ETag: "8f2a1c-1a2b3c"
304 只有头,没有体。它省的是带宽,不省一次 RTT——所以首次加载后的每次导航仍然要往返源站。
当 ETag 和 Last-Modified 同时存在时,ETag 优先级更高,If-None-Match 存在时 If-Modified-Since 会被忽略。这解释了为什么某些框架自动生成的弱 ETag 会让强验证失效。
关于强弱 ETag:
ETag: "abc"是强验证,要求字节级完全一致。ETag: W/"abc"是弱验证,语义上「等价」即可。
nginx 默认会把 Last-Modified 转成弱 ETag;如果上游应用自己发了 ETag,nginx 会同时保留两者。混合环境下最省心的做法是统一由一端生成,别让两层各写一个。
四、stale-while-revalidate:用旧值换速度
这是 Cache-Control 的扩展指令,语义非常好用:
Cache-Control: public, max-age=60, stale-while-revalidate=600
读法:60 秒内直接用缓存;60 到 660 秒之间,先立刻返回旧内容,同时在后台发起重新验证,成功后更新缓存。用户感知到的是「永远很快」,代价是可能看到最多 10 分钟的旧数据。
配套还有 stale-if-error:
Cache-Control: public, max-age=60, stale-if-error=86400
源站 5xx 或超时的时候,继续用缓存顶住,最多顶一天。这条对新闻站、商品页这类「旧一点无所谓,打不开很严重」的场景是性价比最高的配置。
反向的指令是 must-revalidate:过期后不许再用旧值,必须验证成功才能用。涉及金额、库存、权限的接口应当加上它。
Cache-Control: no-cache, must-revalidate, private
五、CDN 这一层容易漏的三件事
CDN 有自己的缓存层级,行为和浏览器不完全一样:
- CDN 通常不缓存带
Set-Cookie的响应。很多 CDN 会直接跳过或剥离这个头。如果你的登录接口依赖Set-Cookie却又被 CDN 缓存了,会出现用户拿到别人会话的灾难。 s-maxage覆盖max-age,且只对共享缓存生效。这是给 CDN 用的专用旋钮:
Cache-Control: public, max-age=60, s-maxage=600
浏览器存 60 秒,CDN 存 10 分钟。两端各取所需。
- 清除(purge)不是免费的。按 URL 精确清除通常有速率限制,按标签/前缀清除往往要额外付费。所以设计 URL 结构时就该想好「将来怎么清」——把版本号放进路径(
/v3/...)比事后求 CDN 前缀清除更可靠。
六、一个真实的排障路径
现象:发版后部分用户仍看到旧页面,强制刷新后恢复。
排查顺序:
- 看 HTML 的响应头。
Cache-Control是否被写成了max-age=86400?如果是,浏览器在一天内不会回源问。 - 看有没有
ETag。没有 ETag 且max-age很大,等于把 HTML 变成了不可回收资源。 - 看 CDN 的回源头。CDN 面板上的 TTL 是否覆盖了源站的
Cache-Control?很多 CDN 默认 TTL 是 2 小时,源站设得再短也没用。 - 看 Service Worker。它是最顽固的一层,常常绕过 HTTP 缓存直接接管。
结论通常是:HTML 不该被长缓存。正确姿势是 HTML 短缓存(no-cache 或 max-age=0, must-revalidate),静态资源用指纹长缓存。这样发版只需替换 HTML,用户下次导航就拿到新引用。
# HTML:每次验证
location ~* \.html$ {
add_header Cache-Control "no-cache" always;
}
# 指纹资源:一年
location ~* \.[0-9a-f]{8,}\.(js|css|woff2|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
}
七、三条可以立刻执行的规则
- 默认不缓存,显式声明缓存。 让「忘记设」的后果是慢,而不是错。
- HTML 与静态资源分策略。 前者短,后者长且带指纹。
- 私有数据加
private,涉及权限的加must-revalidate。 让共享缓存碰不到它。
缓存设计的本质是「你愿意为用户看到多久以前的数据」,这是一个业务问题,不是一个 HTTP 头的问题。想清楚这一点,剩下的只是措辞。