墨线与电路 INK & WIRE

HTTP · 2026.10.12

HTTP 缓存是一场三方谈判

浏览器、CDN、源站三方各自持有不同的缓存策略。带错一个 Cache-Control,用户看到的就是三个月前的旧页面。这篇把 ETag、Vary、stale-while-revalidate 放进同一张图里讲清楚。

陈墨 5 分钟阅读 #HTTP#缓存#性能

缓存问题的绝大多数 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.jspublic, max-age=31536000, immutable
无指纹静态资源/favicon.icopublic, max-age=3600
HTML 文档/index.htmlno-cache(可存但需验证)
私有接口/api/meprivate, no-store

immutable 是关键的一笔:它告诉浏览器「这个 URL 的内容永不变化,别在刷新时还发验证请求」。只对文件名里带内容哈希的资源使用,否则用户永远拿不到更新。

HTML 用 no-cache 而不是 no-store。no-cache 的语义是「可以存,但每次使用前必须向源站验证」;no-store 是「根本不存」。前者能省掉重复传输的字节,后者省事但浪费带宽。

http
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
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 的扩展指令,语义非常好用:

http
Cache-Control: public, max-age=60, stale-while-revalidate=600

读法:60 秒内直接用缓存;60 到 660 秒之间,先立刻返回旧内容,同时在后台发起重新验证,成功后更新缓存。用户感知到的是「永远很快」,代价是可能看到最多 10 分钟的旧数据。

配套还有 stale-if-error:

http
Cache-Control: public, max-age=60, stale-if-error=86400

源站 5xx 或超时的时候,继续用缓存顶住,最多顶一天。这条对新闻站、商品页这类「旧一点无所谓,打不开很严重」的场景是性价比最高的配置。

反向的指令是 must-revalidate:过期后不许再用旧值,必须验证成功才能用。涉及金额、库存、权限的接口应当加上它。

http
Cache-Control: no-cache, must-revalidate, private

五、CDN 这一层容易漏的三件事

CDN 有自己的缓存层级,行为和浏览器不完全一样:

  1. CDN 通常不缓存带 Set-Cookie 的响应。很多 CDN 会直接跳过或剥离这个头。如果你的登录接口依赖 Set-Cookie 却又被 CDN 缓存了,会出现用户拿到别人会话的灾难。
  2. s-maxage 覆盖 max-age,且只对共享缓存生效。这是给 CDN 用的专用旋钮:
http
Cache-Control: public, max-age=60, s-maxage=600

浏览器存 60 秒,CDN 存 10 分钟。两端各取所需。

  1. 清除(purge)不是免费的。按 URL 精确清除通常有速率限制,按标签/前缀清除往往要额外付费。所以设计 URL 结构时就该想好「将来怎么清」——把版本号放进路径(/v3/...)比事后求 CDN 前缀清除更可靠。

六、一个真实的排障路径

现象:发版后部分用户仍看到旧页面,强制刷新后恢复。

排查顺序:

  1. 看 HTML 的响应头。Cache-Control 是否被写成了 max-age=86400?如果是,浏览器在一天内不会回源问。
  2. 看有没有 ETag。没有 ETag 且 max-age 很大,等于把 HTML 变成了不可回收资源。
  3. 看 CDN 的回源头。CDN 面板上的 TTL 是否覆盖了源站的 Cache-Control?很多 CDN 默认 TTL 是 2 小时,源站设得再短也没用。
  4. 看 Service Worker。它是最顽固的一层,常常绕过 HTTP 缓存直接接管。

结论通常是:HTML 不该被长缓存。正确姿势是 HTML 短缓存(no-cache 或 max-age=0, must-revalidate),静态资源用指纹长缓存。这样发版只需替换 HTML,用户下次导航就拿到新引用。

nginx
# 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;
}

七、三条可以立刻执行的规则

  1. 默认不缓存,显式声明缓存。 让「忘记设」的后果是慢,而不是错。
  2. HTML 与静态资源分策略。 前者短,后者长且带指纹。
  3. 私有数据加 private,涉及权限的加 must-revalidate。 让共享缓存碰不到它。

缓存设计的本质是「你愿意为用户看到多久以前的数据」,这是一个业务问题,不是一个 HTTP 头的问题。想清楚这一点,剩下的只是措辞。