HTTP 缓存控制头 no-cache、no-store 和 max-age 实际上意味着什么
缓存控制指令的实用解析——浏览器和CDN实际上对no-cache、no-store、max-age、s-maxage和ETag的处理方式。包括在生产环境中最常困扰开发者的错误。
你可能已经写过 Cache-Control: no-cache 并假设浏览器会完全跳过缓存。事实并非如此。 no-cache 表示“在从缓存提供之前必须重新验证。”如果你实际上希望响应永远不会被存储,那么应使用 no-store。这种误解会导致生产环境中的过时数据错误,这些问题很难诊断,因为在首次加载时网络标签看起来一切正常。
我们来逐一明确每个指令——浏览器如何处理它,CDN/代理如何处理它,以及因混淆而产生的错误。
完整的缓存控制指令参考
| 指令 | 浏览器行为 | CDN / 代理行为 | 典型用途 |
|---|---|---|---|
max-age=N | 缓存 N 秒 | 缓存 N 秒(除非 s-maxage 覆盖) | 静态资源、API 响应 |
s-maxage=N | 被忽略 | 缓存 N 秒 | CDN 的 TTL 与浏览器分离 |
no-cache | 缓存但每次请求都重新验证 | 缓存但每次请求都重新验证 | 频繁变化的内容配合 ETag |
no-store | 不存储任何地方 | 不存储任何地方 | 认证响应、敏感用户数据 |
must-revalidate | 不提供过时内容——重新验证或失败 | 不提供过时内容——重新验证或失败 | API 响应,过时即失效 |
proxy-revalidate | 被忽略 | 在共享缓存中不提供过时内容 | CDN 特有的必须重新验证 |
private | 浏览器可以缓存 | 不得缓存 | 用户特定页面 |
public | 任何缓存都可以存储 | 可以缓存 | 共享静态资源 |
immutable | 在最大年龄内永不重新验证 | 因 CDN 而异 | 哈希/版本化的资产 |
stale-while-revalidate=N | 在获取新鲜内容的同时,提供 N 秒的过时内容 | 因 CDN 而异 | 在不产生硬过时的情况下提升速度 |
max-age 和 s-maxage:浏览器与 CDN 的 TTL
max-age=N 告诉浏览器和 CDN 响应在多少秒内是新鲜的。N 秒后,缓存的响应变为过时,必须重新验证后才能使用。
s-maxage=N 是 CDN 专用的。浏览器完全忽略它。如果你想让 CDN 缓存一小时,而浏览器只缓存 5 分钟:
Cache-Control: max-age=300, s-maxage=3600
浏览器缓存 5 分钟。CloudFront、Fastly、Nginx 等会使用 3600 秒。一个常见陷阱:设置 max-age=0 以为它会禁用缓存。实际上并不会。 max-age=0 表示响应立即过时——浏览器仍然会缓存它并重新验证,很可能会收到 304 响应。如果你永远不想缓存它,请使用 no-store.
no-cache:并非你以为的那样
Cache-Control: no-cache 并不表示“不要使用缓存”。它表示“在从缓存提供之前,必须先与服务器重新验证。”
当浏览器拥有一个带有 no-cache:
- 的缓存响应时,其序列如下:
- 新请求到达该缓存资源
If-None-Match浏览器将缓存响应的 ETag(通过 - )或 Last-Modified 时间戳发送给服务器
304 Not Modified如果内容未改变 → 服务器返回 - ,无响应体
200 OK如果内容已改变 → 服务器返回
,带有新的响应 no-store相比 no-cache 的优势在于:你仍然可以获得 304 响应带来的带宽节省。如果你的内容偶尔会变化,但变化时必须是最新状态,
结合 ETag 是正确的组合。
Cache-Control: no-store no-store:真正的“不要缓存”
表示响应不得在任何地方存储——既不在浏览器缓存,也不在 CDN 或中间代理中。没有副本,绝对禁止。
- 适用于:
- 认证响应(登录令牌、会话数据)
- 敏感个人数据
一次性内容(支付确认、一次性验证码页面) no-store 一个细微之处: pageshow 不会阻止页面出现在浏览器的前进/后退缓存(bfcache)中。浏览器为导航性能保留一个内存快照,该快照与 HTTP 缓存是分开的。如果你需要处理登出后按后退按钮的问题,请监听 event.persisted.
事件并检查
必须重新验证:无过时宽容 must-revalidate 移除了这种宽容:一旦缓存响应过时,缓存必须重新验证或返回 504 错误。任何情况下都不允许提供过时内容。
# Without must-revalidate: CDN may serve stale if origin is slow or down
Cache-Control: max-age=3600
# With must-revalidate: stale = error, not a fallback
Cache-Control: max-age=3600, must-revalidate
适用于 API 响应,其中提供过时数据会破坏功能——例如库存数量、价格、认证状态,而不仅仅是看起来稍有不同。
private 与 public:CDN 中导致用户数据泄露的 bug
private 表示响应是为特定用户准备的。浏览器可以缓存它,但共享缓存(CDN、反向代理)必须不能缓存。
public 明确允许任何缓存——包括共享缓存——存储该响应。某些缓存仅在你明确标记时才会缓存经过身份验证的请求响应 public.
现实中的这个 bug:开发者将 Cache-Control: public, max-age=3600 从静态资源复制到包含用户特定数据的页面上。CDN 缓存了该响应。用户 B 发起相同请求,从缓存中获取了用户 A 的页面。这不是理论问题——GitHub 在 2018 年就曾出现过类似情况。请明确标记认证或用户特定的响应 private ,即使你认为你的 CDN “知道”不应缓存它们。
ETag 和条件请求
ETag 是服务器用来表示“这是响应的指纹”的方式。浏览器将 ETag 与缓存的响应一起存储,并在下一次请求时通过 If-None-Match发送回去。如果内容未改变,服务器返回 304 Not Modified ,无响应体——与 no-cache相同的 freshness 验证,节省大量带宽。
这 no-cache + ETag 流程:
→ GET /api/config HTTP/1.1
← HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "abc123"
[full response body]
→ GET /api/config HTTP/1.1
If-None-Match: "abc123"
← HTTP/1.1 304 Not Modified
[no body — browser uses its cached copy]
两种 ETag 类型:
- 强 ETag (
"abc123")——字节完全一致。对于 CDN 的范围请求支持是必需的。 - 弱 ETag (
W/"abc123")——语义等价但不一定字节一致。适用于浏览器重新验证,不适用于范围请求。
Nginx 会自动从文件的修改时间和大小生成 ETag。Express 默认不添加 ETag——需使用 app.set('etag', 'strong') 或 etag 中间件显式配置。
Last-Modified 和 If-Modified-Since
与 ETag 的概念相同,但更粗略——基于时间戳而非内容哈希。服务器包含 Last-Modified;浏览器在后续请求中发送 If-Modified-Since 。
问题:如果你重新部署,文件修改时间更新但内容未改变,缓存会不必要地失效。基于内容哈希的 ETag 就不会遇到这个问题。尽可能使用 ETag,将 Last-Modified 作为不支持 ETag 的服务器的后备方案。
Vary:悄悄增加你缓存数量的头部
这 Vary 头部告诉缓存,响应可能因其他请求头部而不同。每个这些头部的唯一组合都会得到一个独立的缓存条目。
Vary: Accept-Encoding
这告诉缓存为 gzip、brotli 和身份编码分别存储不同的响应。这是正确且常见的。危险的情况是: Vary: Cookie。每个用户都有唯一的 Cookie,因此每个用户都会获得自己的缓存条目——实际上禁用了共享缓存。许多框架会静默添加 Vary: Cookie 。如果你的 CDN 缓存命中率在没有合理 max-age 值的情况下异常低,请检查响应头部是否存在 Vary: Cookie 从会话中间件悄悄渗入的情况。
Vary: * 实际上意味着“完全不缓存”——每个请求都被视为唯一。对于 CDN 来说,这等同于 no-store 。
使用查询参数进行缓存破坏
当你需要强制下载版本化的资产时,追加查询参数是标准做法——查询字符串是 URL 的一部分,因此浏览器和 CDN 都将其视为新资源:
/app.js?v=2.1.4
/styles.css?hash=a1b2c3d4e5f6
如果你动态构建缓存破坏参数,这些参数来自内容哈希或版本字符串,可能包含特殊字符,请确保在追加前对其进行百分号编码。如果手动测试或构建 URL, URL编码器/解码器 可以快速处理该问题。
三个最常困扰开发者的错误
1. 使用 no-cache 而实际想使用 no-store。 如果你处理的是认证响应、登出端点或包含 PII 的内容,你应该使用 no-store. no-cache 仅将数据留在浏览器缓存中(标记为过时); no-store 则会彻底移除数据痕迹。当用户共享设备时,这种区别至关重要。
2. 未为 CDN 设置 s-maxage。 在此处粘贴 .github/workflows/*.yml 内容 s-maxage,你的 CDN 会使用 max-age。如果 max-age 是浏览器新鲜度的短时间(例如 60 秒),那么 CDN 也会缓存 60 秒——这可能不是你想要的结果。明确分离这两个 TTL。
3. 在返回用户数据的端点上使用 public。 这是安全事件,而不仅仅是性能问题。任何个性化或经过身份验证的响应都应为 private。默认为 private ,仅在真正共享的资源上才使用 public 。
整合应用
思维模型: no-cache 是关于新鲜度控制——响应存在于缓存中,只需在使用前获得服务器的批准即可。 no-store 是关于不留痕迹。 max-age 是浏览器的 TTL。 s-maxage 是 CDN 的独立 TTL。ETag 是实现廉价重新验证的关键。
在任何涉及用户数据的端点上,正确区分 private/public 。这个单一错误——将静态资源的缓存头复制到认证端点——一旦 CDN 开始缓存个性化响应,就会演变为跨用户数据泄露。
