前端技术
#HTTP缓存#Cache-Control#immutable#stale-while-revalidate#静态资源

HTTP 缓存中的 immutable 与 stale-while-revalidate:静态资源更新场景下的新鲜度与性能平衡

本文以静态资源更新场景为例,分析 Cache-Control 的 immutable 与 stale-while-revalidate 指令如何控制浏览器缓存行为,减少重复请求并提升加载速度。你将理解启发式缓存、显式过期与后台重新验证的机制,并获得配置建议与适用边界。

从一次页面刷新说起

假设你维护一个电商网站,首屏依赖一个体积较大的 CSS 文件 app.css。为了减少重复下载,你给响应设置了 Cache-Control: max-age=31536000,希望浏览器一年内直接使用本地缓存。可是用户反馈,每次点击浏览器刷新按钮,网络面板里依然能看到对 app.css 的请求,虽然响应状态是 304 Not Modified,但往返延迟依然存在,弱网下尤其明显。

问题出在哪里?max-age 只定义了新鲜期,但没有告诉浏览器“这个文件的内容永远不会变”。当用户执行刷新操作时,浏览器会忽略新鲜期,强制发起条件请求来验证资源是否仍然有效。这正是 immutable 指令要解决的场景。而另一个指令 stale-while-revalidate 则处理另一种常见情况:资源会更新,但可以容忍短暂使用旧版本,同时后台悄悄获取新版本。

本文以静态资源的更新与部署为贯穿场景,讨论这两个扩展指令的机制、适用边界,以及如何与标准缓存指令配合,在新鲜度与性能之间做出工程决策。

浏览器缓存的基本决策:新鲜、过期与验证

HTTP 缓存的核心是“新鲜度”概念。RFC 9111 定义,缓存存储的响应如果处于新鲜状态,就可以直接复用,无需与源服务器通信。新鲜期的计算通常依赖 Cache-Controlmax-age 指令,它表示响应生成后多少秒内保持新鲜。例如 max-age=3600 意味着响应生成后 1 小时内可以直接复用。

当缓存响应过期后,浏览器并不会立即丢弃它,而是可能通过条件请求进行“验证”。条件请求携带 If-None-Match(基于 ETag)或 If-Modified-Since(基于 Last-Modified)头,源服务器据此判断资源是否变化。如果未变化,返回 304 Not Modified,响应体不需要重新下载;如果变化,返回 200 OK 和新响应体。

这里有一个容易被忽略的细节:即使响应仍在新鲜期内,某些用户操作也会触发验证。例如,用户点击浏览器的刷新按钮,或地址栏回车,浏览器通常会忽略新鲜期,直接发送条件请求。这是浏览器为了确保用户看到最新内容而采取的行为。对于内容不变的静态资源,这种验证请求是多余的,白白增加了延迟。

RFC 9111 还描述了“启发式缓存”:当响应没有显式过期信息时,缓存可以基于 Last-Modified 等字段估算新鲜期。但启发式缓存的估算并不精确,可能造成资源过早过期或长期不更新。生产环境中,显式设置 max-ageExpires 是更可靠的做法。

immutable:告诉浏览器“这个资源不会变”

immutableCache-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.jsstyle.4d2a.css,并部署到 CDN。每次发布新版本,这些文件的 URL 都会变化,而旧 URL 的文件内容永远不变。

在这种情况下,最佳实践是对这些文件设置:

Cache-Control: public, max-age=31536000, immutable

max-age=31536000(一年)确保缓存长期有效,immutable 防止刷新时的多余验证。由于文件名带哈希,内容变化时 URL 变化,浏览器自然请求新文件,旧文件即使还在缓存中也不会被引用。

但并非所有静态资源都带哈希。例如 logo.pngfavicon.ico 这类文件名固定、但可能被替换的资源,就不能使用 immutable,否则用户可能永远看到旧版本。此时可以设置较短的 max-age,或者使用 stale-while-revalidate 来平衡。

例如,对于可能更新但更新频率低的图片,可以设置:

Cache-Control: public, max-age=3600, stale-while-revalidate=86400

这样,图片在 1 小时内新鲜,之后 24 小时内,用户会看到旧图片,但后台会异步更新缓存。如果图片更新了,用户下次访问就能看到新版本;如果没更新,缓存继续有效。

对比:immutable 与 stale-while-revalidate 的权衡

下表从多个维度对比 immutablestale-while-revalidate,帮助你在具体场景中做选择。

维度immutablestale-while-revalidate
核心语义新鲜期内内容不变,禁止刷新验证过期后允许使用旧版本,后台异步验证
适用资源内容寻址的静态资源(带哈希)文件名固定但可能更新的资源
对刷新操作的影响阻止条件请求不阻止,但过期后仍返回旧版本
新鲜期要求必须配合长 max-age通常配合较短 max-age
用户可见的新鲜度新鲜期内绝对一致可能短暂看到旧版本
性能收益消除刷新时的验证延迟消除过期后首次请求的等待
风险内容变化但 URL 不变时,用户永远看到旧版本后台验证失败时,旧版本可能被长期使用
典型配置max-age=31536000, immutablemax-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 cachefrom disk cache,说明缓存命中,没有发送网络请求。
  • 如果显示 304 Not Modified,说明发送了条件请求,但内容未变化。对于带 immutable 的资源,刷新时不应出现 304,除非资源已过期。
  • 如果显示 200 OK,说明资源已更新或缓存未命中。

还可以通过 Age 响应头判断缓存驻留时间。Age 表示响应在中间缓存(如 CDN)中存储的秒数,浏览器会将其从新鲜期中扣除。例如,max-age=3600,但 Age: 100,则浏览器认为剩余新鲜期为 3500 秒。

在服务器端,可以监控条件请求的日志。如果带 immutable 的资源频繁出现 304 请求,说明配置可能有问题,或者用户强制刷新。如果带 SWR 的资源后台验证频繁,但内容很少变化,可以适当延长 max-age 或缩短 SWR 窗口。

适用边界与未来方向

immutablestale-while-revalidate 都是扩展指令,并非所有浏览器都支持。MDN 的兼容性表显示,immutable 自 2015 年起在主流浏览器中可用,但 Firefox 仅支持 HTTPS。stale-while-revalidate 的支持情况类似,但需要注意,不识别这些指令的浏览器会忽略它们,行为退化为标准的 max-age 处理。因此,配置时不能依赖这些扩展指令,而应将其视为增强。

另外,这两个指令只影响浏览器缓存和私有缓存,对共享缓存(如 CDN)的行为可能不同。CDN 通常有自己的缓存控制逻辑,stale-while-revalidate 在 CDN 上的支持需要确认。如果 CDN 不支持,它可能直接回源验证,导致性能下降。

未来,HTTP 缓存标准可能会进一步演进,但当前阶段,理解这两个指令的机制和边界,足以帮助你在静态资源更新场景中做出合理的配置决策。

最终,选择哪种指令取决于你的资源特性和用户期望。如果追求极致的加载性能,且资源内容不可变,immutable 是首选;如果需要在新鲜度和性能之间折中,stale-while-revalidate 提供了优雅的降级方案。

资料来源

  1. RFC 9111: HTTP Caching
  2. MDN: Cache-Control
  3. Google Web Fundamentals: HTTP caching
  4. Cache-Control - HTTP | MDN