从一次页面刷新说起
假设你维护一个电商网站,首屏依赖一个体积较大的 CSS 文件 app.css。为了减少重复下载,你给响应设置了 Cache-Control: max-age=31536000,希望浏览器一年内直接使用本地缓存。可是用户反馈,每次点击浏览器刷新按钮,网络面板里依然能看到对 app.css 的请求,虽然响应状态是 304 Not Modified,但往返延迟依然存在,弱网下尤其明显。
问题出在哪里?max-age 只定义了新鲜期,但没有告诉浏览器“这个文件的内容永远不会变”。当用户执行刷新操作时,浏览器会忽略新鲜期,强制发起条件请求来验证资源是否仍然有效。这正是 immutable 指令要解决的场景。而另一个指令 stale-while-revalidate 则处理另一种常见情况:资源会更新,但可以容忍短暂使用旧版本,同时后台悄悄获取新版本。
本文以静态资源的更新与部署为贯穿场景,讨论这两个扩展指令的机制、适用边界,以及如何与标准缓存指令配合,在新鲜度与性能之间做出工程决策。
浏览器缓存的基本决策:新鲜、过期与验证
HTTP 缓存的核心是“新鲜度”概念。RFC 9111 定义,缓存存储的响应如果处于新鲜状态,就可以直接复用,无需与源服务器通信。新鲜期的计算通常依赖 Cache-Control 的 max-age 指令,它表示响应生成后多少秒内保持新鲜。例如 max-age=3600 意味着响应生成后 1 小时内可以直接复用。
当缓存响应过期后,浏览器并不会立即丢弃它,而是可能通过条件请求进行“验证”。条件请求携带 If-None-Match(基于 ETag)或 If-Modified-Since(基于 Last-Modified)头,源服务器据此判断资源是否变化。如果未变化,返回 304 Not Modified,响应体不需要重新下载;如果变化,返回 200 OK 和新响应体。
这里有一个容易被忽略的细节:即使响应仍在新鲜期内,某些用户操作也会触发验证。例如,用户点击浏览器的刷新按钮,或地址栏回车,浏览器通常会忽略新鲜期,直接发送条件请求。这是浏览器为了确保用户看到最新内容而采取的行为。对于内容不变的静态资源,这种验证请求是多余的,白白增加了延迟。
RFC 9111 还描述了“启发式缓存”:当响应没有显式过期信息时,缓存可以基于 Last-Modified 等字段估算新鲜期。但启发式缓存的估算并不精确,可能造成资源过早过期或长期不更新。生产环境中,显式设置 max-age 或 Expires 是更可靠的做法。
immutable:告诉浏览器“这个资源不会变”
immutable 是 Cache-Control 的一个扩展指令,由 Facebook 提出并提交标准化,MDN 将其归类为“扩展 Cache-Control 指令”。它的语义是:响应正文在新鲜期内不会改变。当浏览器收到带有 immutable 的响应时,即使用户刷新页面,也不会发送条件请求,而是直接使用本地缓存,直到新鲜期结束。
这个指令特别适合内容寻址的静态资源,例如文件名包含内容哈希的 JavaScript 和 CSS 文件。比如 app.a1b2c3.js,只要文件内容不变,哈希就不变,URL 也不变;一旦内容更新,构建工具会生成新的哈希,产生新的 URL。这种资源天然满足“不变”的条件,可以安全地设置很长的 max-age 并加上 immutable。
但需要注意,immutable 只影响新鲜期内的行为。如果资源已经过期,浏览器仍会发送条件请求进行验证。因此,immutable 必须与足够长的 max-age 配合使用,否则意义有限。
另一个限制是浏览器支持。MDN 指出,immutable 在 Firefox 中仅对 HTTPS 事务生效。此外,如果用户强制刷新(Ctrl+F5 或 Cmd+Shift+R),浏览器通常会忽略缓存指令,直接向服务器请求最新资源,immutable 也无法阻止。
stale-while-revalidate:用旧版本换速度
stale-while-revalidate(简称 SWR)同样是一个扩展指令,它的语法是 stale-while-revalidate=<seconds>。MDN 的解释是:客户端愿意接受陈旧的响应,同时在后台异步检查新响应。秒值表示在初始过期后,客户端愿意接受陈旧响应的时间长度。
工作流程如下:假设响应设置 max-age=60, stale-while-revalidate=3600。在 0~60 秒内,缓存新鲜,直接使用。60 秒后,缓存过期,但进入 SWR 窗口(60~3660 秒)。此时浏览器收到请求,会立即返回缓存的旧版本(即使已过期),同时异步发起条件请求到源服务器。如果服务器返回 304,则缓存继续有效,并更新新鲜期;如果返回 200 和新内容,则替换缓存,下次请求使用新版本。用户在这个过程中无感知,因为页面已经用旧版本渲染。
SWR 的价值在于消除了“过期后第一次请求”的等待时间。如果没有 SWR,过期后的第一个请求必须等待服务器验证完成才能拿到响应,延迟取决于网络往返。而 SWR 让这个请求立即返回,验证在后台进行,不阻塞用户。
但 SWR 也有代价:用户可能短暂看到旧版本内容。对于静态资源,如果内容更新不频繁,且旧版本可以安全使用,这个代价通常可以接受。如果资源内容必须实时(例如股票行情),SWR 就不合适。
贯穿场景:静态资源更新与部署
让我们回到电商网站的静态资源。假设你使用构建工具生成带哈希的文件名,例如 app.8f3k2.js、style.4d2a.css,并部署到 CDN。每次发布新版本,这些文件的 URL 都会变化,而旧 URL 的文件内容永远不变。
在这种情况下,最佳实践是对这些文件设置:
Cache-Control: public, max-age=31536000, immutable
max-age=31536000(一年)确保缓存长期有效,immutable 防止刷新时的多余验证。由于文件名带哈希,内容变化时 URL 变化,浏览器自然请求新文件,旧文件即使还在缓存中也不会被引用。
但并非所有静态资源都带哈希。例如 logo.png、favicon.ico 这类文件名固定、但可能被替换的资源,就不能使用 immutable,否则用户可能永远看到旧版本。此时可以设置较短的 max-age,或者使用 stale-while-revalidate 来平衡。
例如,对于可能更新但更新频率低的图片,可以设置:
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
这样,图片在 1 小时内新鲜,之后 24 小时内,用户会看到旧图片,但后台会异步更新缓存。如果图片更新了,用户下次访问就能看到新版本;如果没更新,缓存继续有效。
对比:immutable 与 stale-while-revalidate 的权衡
下表从多个维度对比 immutable 与 stale-while-revalidate,帮助你在具体场景中做选择。
| 维度 | immutable | stale-while-revalidate |
|---|---|---|
| 核心语义 | 新鲜期内内容不变,禁止刷新验证 | 过期后允许使用旧版本,后台异步验证 |
| 适用资源 | 内容寻址的静态资源(带哈希) | 文件名固定但可能更新的资源 |
| 对刷新操作的影响 | 阻止条件请求 | 不阻止,但过期后仍返回旧版本 |
| 新鲜期要求 | 必须配合长 max-age | 通常配合较短 max-age |
| 用户可见的新鲜度 | 新鲜期内绝对一致 | 可能短暂看到旧版本 |
| 性能收益 | 消除刷新时的验证延迟 | 消除过期后首次请求的等待 |
| 风险 | 内容变化但 URL 不变时,用户永远看到旧版本 | 后台验证失败时,旧版本可能被长期使用 |
| 典型配置 | max-age=31536000, immutable | max-age=3600, stale-while-revalidate=86400 |
从表中可以看出,immutable 更激进,适合内容不可变且 URL 可变的资源;stale-while-revalidate 更温和,适合内容可能变化但可容忍短暂延迟的资源。两者并非互斥,也可以组合使用,例如:
Cache-Control: max-age=3600, stale-while-revalidate=86400, immutable
但这样的组合意义不大,因为 immutable 已经表明内容不变,SWR 的后台验证通常不会带来新内容,反而增加无谓请求。除非你预期资源可能在新鲜期内被替换(例如运维手动覆盖文件),但这种做法本身就违背了 immutable 的前提。
请求流程:从缓存命中到后台验证
下面的流程图展示了 stale-while-revalidate 的完整请求处理过程,以图片资源为例。
flowchart TD
A[浏览器发起资源请求] --> B{缓存中是否有响应?}
B -- 否 --> C[向服务器发送请求]
C --> D[服务器返回200和新响应]
D --> E[存入缓存并返回给页面]
B -- 是 --> F{响应是否新鲜?}
F -- 是 --> G[直接使用缓存,不发送网络请求]
F -- 否 --> H{是否在SWR窗口内?}
H -- 是 --> I[立即返回旧响应给页面]
I --> J[后台发起条件请求验证]
J --> K{服务器返回什么?}
K -- 304 --> L[更新缓存新鲜期,保留旧响应体]
K -- 200 --> M[用新响应替换缓存]
H -- 否 --> N[发送条件请求,等待响应]
N --> O{服务器返回什么?}
O -- 304 --> P[更新缓存新鲜期,返回缓存]
O -- 200 --> Q[用新响应替换缓存并返回]
图中关键转折点在于 H 节点:当缓存过期但仍在 SWR 窗口内时,浏览器选择“立即返回旧版本”,而不是等待验证。这避免了用户感知的延迟,但代价是可能短暂使用旧内容。如果不在 SWR 窗口内,则必须等待验证完成,这是标准的过期处理流程。
生产环境配置建议与失败模式
在实际部署中,建议根据资源类型分层设置缓存策略。
对于带哈希的静态资源,使用 immutable 并设置长 max-age。这是最安全的做法,因为内容永远不会变。但要注意,如果你使用 CDN,CDN 节点也可能缓存这些资源,immutable 对 CDN 同样有效,但 CDN 的缓存策略可能由 s-maxage 控制,需要单独配置。
对于不带哈希但更新不频繁的资源,使用 stale-while-revalidate。但要设置合理的 SWR 窗口,不宜过长。如果窗口过长,用户可能长时间看不到更新,尤其是在内容已经变化的情况下。例如,一个新闻网站的 Logo 更新了,但 SWR 窗口设置为 7 天,那么用户可能在一周内看到旧 Logo。
一个常见的失败模式是:开发者对带哈希的资源错误地使用了 stale-while-revalidate,而忽略了 immutable。这会导致每次过期后都触发后台验证,虽然用户无感知,但增加了服务器负载。更糟糕的是,如果资源内容确实不变,验证请求返回 304,白白消耗带宽。
另一个失败模式是:对不带哈希的资源使用 immutable。例如,你有一个 config.json,文件名固定,但内容会随配置更新。如果设置 immutable,用户刷新页面时不会验证,可能永远使用旧配置,直到 max-age 过期。如果 max-age 设置得很长(如一年),用户将长期看到错误配置。
因此,选择指令前必须明确资源的内容可变性。immutable 只适用于内容绝对不变且 URL 可变的资源;stale-while-revalidate 适用于内容可能变化但可容忍短暂延迟的资源。
可观测性与诊断
要确认缓存策略是否生效,可以观察浏览器开发者工具的 Network 面板。
- 如果资源显示
from memory cache或from disk cache,说明缓存命中,没有发送网络请求。 - 如果显示
304 Not Modified,说明发送了条件请求,但内容未变化。对于带immutable的资源,刷新时不应出现304,除非资源已过期。 - 如果显示
200 OK,说明资源已更新或缓存未命中。
还可以通过 Age 响应头判断缓存驻留时间。Age 表示响应在中间缓存(如 CDN)中存储的秒数,浏览器会将其从新鲜期中扣除。例如,max-age=3600,但 Age: 100,则浏览器认为剩余新鲜期为 3500 秒。
在服务器端,可以监控条件请求的日志。如果带 immutable 的资源频繁出现 304 请求,说明配置可能有问题,或者用户强制刷新。如果带 SWR 的资源后台验证频繁,但内容很少变化,可以适当延长 max-age 或缩短 SWR 窗口。
适用边界与未来方向
immutable 和 stale-while-revalidate 都是扩展指令,并非所有浏览器都支持。MDN 的兼容性表显示,immutable 自 2015 年起在主流浏览器中可用,但 Firefox 仅支持 HTTPS。stale-while-revalidate 的支持情况类似,但需要注意,不识别这些指令的浏览器会忽略它们,行为退化为标准的 max-age 处理。因此,配置时不能依赖这些扩展指令,而应将其视为增强。
另外,这两个指令只影响浏览器缓存和私有缓存,对共享缓存(如 CDN)的行为可能不同。CDN 通常有自己的缓存控制逻辑,stale-while-revalidate 在 CDN 上的支持需要确认。如果 CDN 不支持,它可能直接回源验证,导致性能下降。
未来,HTTP 缓存标准可能会进一步演进,但当前阶段,理解这两个指令的机制和边界,足以帮助你在静态资源更新场景中做出合理的配置决策。
最终,选择哪种指令取决于你的资源特性和用户期望。如果追求极致的加载性能,且资源内容不可变,immutable 是首选;如果需要在新鲜度和性能之间折中,stale-while-revalidate 提供了优雅的降级方案。