页面已发布,用户为什么还在用旧版本
源站已经换了文件,浏览器没有再请求它;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=600 | 1 次 | 第二次直接用缓存 |
Cache-Control: no-store | 2 次 | 不存,每次回源 |
Cache-Control: no-cache 与 ETag | 2 次 | 第二次带 If-None-Match: "v1",源站回 304,页面仍拿到完整内容 |
| 没有任何缓存相关的头 | 2 次 | 没有验证器、也没有新鲜度信息 |
只有 Last-Modified(10 天前) | 1 次 | 启发式新鲜度,见下一节 |
这些指令不是简单的优先级列表。例如 private, no-cache 可以让浏览器保存个性化页面,同时要求每次复用前验证。RFC 9111 的响应指令给出了精确定义。
二、新鲜度决定能否省掉请求
缓存判断响应的当前年龄是否小于新鲜度寿命。年龄计算还涉及 Date、传输时间、驻留时间,以及共享缓存返回的 Age;不能把每一跳接收到响应的时间都重新当作计时起点。缓存年龄与新鲜度说明展示了这个关系。
图中的验证分支发生在请求匹配到已有响应之后。即使响应仍新鲜,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:
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 也没有明确禁止共享存储,而且可能产生大量变体。采用共享缓存时,还应审查自定义缓存键、鉴权响应和代理覆盖规则。
五、让发布入口与静态资源采用不同策略
以下是一套设计示例,需结合实际部署验证:
| 资源 | 示例策略 | 发布要求 |
|---|---|---|
| 公共入口 HTML | Cache-Control: no-cache,配合正确的 ETag | 每次复用验证当前入口 |
| 内容哈希命名的 JS/CSS | Cache-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的影响、内容换版后的验证、普通刷新时的子资源(验证记录)
参考资料