Skip to content

页面已发布,用户为什么还在用旧版本 ​

源站已经换了文件,浏览器没有再请求它;CDN 清理完成,用户仍然拿到旧页面。排查这类问题,需要沿请求路径确认哪一层允许复用,以及复用的是哪个响应版本。

HTTP 缓存围绕三个判断工作:响应能否存储、请求是否匹配已有响应、响应能否直接复用。资源的内容变化,不会主动通知所有已经保存它的客户端。

本文的机制依据 RFC 9111、RFC 9110 和相关规范;浏览器一侧的行为在 Chromium 153 里实测(见文末实验),共享缓存(代理、CDN)没有做实验,相关部分是按规范的说明,不代表某个产品的默认配置。

一、存储与直接复用分别控制 ​

常用响应指令的含义如下。表中讨论没有字段限定参数的普通写法,代理额外配置也可能改变实际策略。

响应指令对后续缓存使用的约束
no-store缓存不得存储该请求或响应;不会自动清除此前存下的同 URL 响应
no-cache可以存储,但每次复用前必须成功验证
private共享缓存不得存储;私有缓存仍需遵守新鲜度等条件
public明确允许共享存储,仍受其他相关约束影响
max-age=N指定新鲜度寿命,单位为秒
s-maxage=N对共享缓存覆盖 max-age,不改变私有缓存的寿命
must-revalidate变陈旧后必须成功验证才能复用,不能因断网任意返回旧响应

在 Chromium 153 里对同一个地址连续 fetch 两次,源站实际收到的请求数:

源站的响应头源站收到说明
Cache-Control: max-age=6001 次第二次直接用缓存
Cache-Control: no-store2 次不存,每次回源
Cache-Control: no-cache 与 ETag2 次第二次带 If-None-Match: "v1",源站回 304,页面仍拿到完整内容
没有任何缓存相关的头2 次没有验证器、也没有新鲜度信息
只有 Last-Modified(10 天前)1 次启发式新鲜度,见下一节

这些指令不是简单的优先级列表。例如 private, no-cache 可以让浏览器保存个性化页面,同时要求每次复用前验证。RFC 9111 的响应指令给出了精确定义。

二、新鲜度决定能否省掉请求 ​

缓存判断响应的当前年龄是否小于新鲜度寿命。年龄计算还涉及 Date、传输时间、驻留时间,以及共享缓存返回的 Age;不能把每一跳接收到响应的时间都重新当作计时起点。缓存年龄与新鲜度说明展示了这个关系。

缓存查找URL + Vary允许直接复用?新鲜度 + 请求/响应约束返回已存响应无需验证请求匹配是向上游请求有验证器则带条件否304:更新元数据复用匹配的旧响应体200:新表示可存储时保存新体未匹配:普通请求
图 1 · 示意:请求先匹配 URL 和 Vary,再判断是否允许直接复用;需要验证时,304 更新元数据并复用旧体,200 保存新表示

图中的验证分支发生在请求匹配到已有响应之后。即使响应仍新鲜,no-cache 或请求中的验证要求也可能使它不能直接复用。

以可公开缓存的资源为例,源站设 max-age=600,CDN 在响应年龄已经 500 秒时交给浏览器,剩余新鲜时间约为 100 秒,而非重新获得 600 秒。实验里源站直接返回 max-age=600 和 Age: 599,浏览器取一次、等 2 秒再取,源站收到了 2 次:浏览器确实把上游声明的年龄算了进去。实际排查应读取整条链上的响应头与时间信息。

没有显式新鲜度不等于不能缓存。规范允许满足条件的响应采用启发式新鲜度:实验里只带 Last-Modified(10 天前)、没有 Cache-Control 的响应,第二次请求没有回源,而什么头都没有的响应两次都回源了。启发式的具体时长由浏览器决定,实验没有测量,本文也不承诺固定比例。很多「发布后用户还在用旧版本」正是来自这里:源站没有写缓存策略,服务器软件默认带上了 Last-Modified。需要稳定发布行为的入口页面,应明确写出策略。

三、验证请求可以省掉响应体 ​

源站可以给表示附加实体标签(ETag),客户端验证时发送 If-None-Match:

http
GET /index.html HTTP/1.1
If-None-Match: "entry-v4"

在 GET 的条件求值确认表示未变化时,源站返回 304 Not Modified,缓存继续使用已经保存的响应体,并按规则更新元数据。变化了则返回新的 200 表示。没有可用缓存体时,只有一个 304 不能给客户端拼出页面。条件请求的求值规则见 RFC 9110:If-None-Match。

ETag 是表示的验证器,不保证一定是文件哈希,也不是数据库主键。弱标签可以支持缓存验证,但不能据此证明字节完全相同。源站必须让验证器反映对应表示的变化;返回内容变了而标签不变,会让客户端继续复用旧体。支持 If-Modified-Since 时,也要遵守与 If-None-Match 同时存在时的求值规则。

四、同一个 URL 可能有多个响应版本 ​

若源站按 Accept-Language 返回不同语言,应返回 Vary: Accept-Language,让缓存匹配请求时考虑该字段。压缩表示可以使用 Vary: Accept-Encoding。Vary 参与选择已有响应,不定义响应的新鲜时间;Vary: * 的响应不能用来匹配后续请求。Vary 文档解释了这种表示选择。

共享缓存服务多个用户。个性化页面应明确使用 private,敏感内容需要禁止存储时使用 no-store;不能把 Cookie 的存在当作自动保护。单靠 Vary: Cookie 也没有明确禁止共享存储,而且可能产生大量变体。采用共享缓存时,还应审查自定义缓存键、鉴权响应和代理覆盖规则。

五、让发布入口与静态资源采用不同策略 ​

以下是一套设计示例,需结合实际部署验证:

资源示例策略发布要求
公共入口 HTMLCache-Control: no-cache,配合正确的 ETag每次复用验证当前入口
内容哈希命名的 JS/CSSCache-Control: public, max-age=31536000, immutable同一 URL 内容不覆盖;先上传资源再更新入口
可私有存储的用户页面Cache-Control: private, no-cache验证时仍完成必要鉴权
禁止缓存的敏感响应Cache-Control: no-store另行检查日志、应用缓存及客户端保存路径

immutable 表示响应在新鲜期间不会改变,减少用户刷新页面时的验证;它不能阻止源站实际覆盖同一个 URL。实验里对页面做普通刷新,两个 max-age=600 的脚本都没有回源,带不带 immutable 在 Chromium 153 里没有区别,因为这个版本的普通刷新本来就只重新验证页面本身;其他浏览器的刷新行为不同,这个扩展对它们才有作用。内容哈希命名把变更变成新 URL,只有「内容不覆盖」这一约束成立时,长缓存策略才可靠。RFC 8246规定了该扩展。

发布时保留仍可能被旧入口引用的资源,否则旧页面能打开,却在加载脚本时得到 404。CDN 清理通常只影响它控制的节点;已经在浏览器里且仍可直接复用的响应不会因此主动发请求。Service Worker 的 Cache API 与浏览器 HTTP 缓存也应分别检查。

实际验收用连续请求核对:首次获取、允许的新鲜期内复用、验证未变化、内容变化后的新表示,以及不同语言/用户的隔离。记录源站与代理请求数、状态码、验证器、Age 和响应体版本。开发者工具的禁用缓存与刷新方式会改变请求条件,不能仅凭一张网络面板截图推断正常访问行为。需要核查跨源响应的可读性时,可转到浏览器的跨源限制;它与响应缓存复用是不同的判断。


配套实验

  • codesphere-labs/engineering/http-cache-reuse:Playwright 1.63.0 驱动的 Chromium 153,本地源站记录每次收到的请求与验证头:各种缓存响应头下第二次请求是否回源、条件请求与 304、Age 的影响、内容换版后的验证、普通刷新时的子资源(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权