缓存有两个独立决定:能否不联系服务器就复用已存响应,以及如何检查它是否仍代表当前资源。“两只钟”只是便于记忆的比喻。新鲜度确实是年龄计算;校验是比较,不是另一个计时器。缓存中的响应可能按策略仍然新鲜,却已经不同于刚发布的源站,这并不一定违反 HTTP。
新鲜度:多久可以不询问就复用
Cache-Control: max-age=60 给响应 60 秒的新鲜度寿命。缓存将寿命与当前年龄比较,年龄需要考虑 Date、Age、传输耗时与存储时间。恰好到 60 秒时已经陈旧,不会再多新鲜一秒。共享缓存有 s-maxage 时优先采用它;没有显式寿命时,某些响应也可以获得启发式新鲜度。
新鲜响应通常可以复用,但仍须匹配缓存键与 Vary 字段,并遵守请求及响应指令。即使按年龄仍新鲜,no-cache 也要求复用前成功校验。no-store 禁止存储,不是“每次重新验证”的同义词。public 允许共享缓存在原本受限制的某些情形下存储响应,它不指定缓存时长。
对于会变化的 HTML,较短的显式寿命加校验通常有用。这意味着新发布的内容可能要等到寿命结束才通过合规缓存可见,属于有意接受的代价。整树发布不会使浏览器或 CDN 中已有的条目失效。内容寻址的资源则可使用较长寿命,前提是字节变化时 URL 也变化。
校验:询问已存表示是否仍然匹配
ETag 是不透明校验器,不是供客户端解码的校验和。Last-Modified 是时间戳校验器。两者都不指定显式的新鲜度寿命,服务器也不一定同时提供它们。不过,在允许启发式新鲜度的情形下,缓存可以利用 Last-Modified 估算寿命。例如下面是分别表达寿命与校验器的示意头部:
Cache-Control: max-age=60, must-revalidate
ETag: "release-a"
过期后,GET 携带 If-None-Match: "release-a",选中的表示仍匹配时可得到 304 Not Modified,变化时则得到带新正文的 200。缓存根据 304 更新适用的元数据,再复用已存正文。没有相应的已存表示,304 本身没有可用的正文。If-None-Match 使用弱比较,因此验证成功不一定证明字节完全相同;If-Range 及部分响应拼接有更严格的要求。
服务器可以用字节或其他可靠变化的元数据生成 ETag。强标签必须区分数据不同的表示,包括适用时的不同内容编码。给 gzip 与 identity 的不同字节发送相同强 ETag,不能靠缩短寿命修复。Vary: Accept-Encoding 还告诉缓存哪个请求头参与了表示选择。
用小练习分开两个决定
这个 Python 模型刻意不包含启发式寿命、网络延迟及变体选择。age 表示已经算好的当前年龄;条件请求面对的服务器校验器暂时没有变化。模型只比较两个固定标签字符串,并未实现 HTTP 实体标签解析、标签列表或完整的弱比较规则。
lifetime = 60
stored_etag = '"release-a"'
server_etag = '"release-a"'
for age in (59, 60, 61):
fresh = age < lifetime
action = "reuse without validation" if fresh else (
"validate: 304, reuse body" if stored_etag == server_etag
else "validate: 200, replace body"
)
print(age, action)
第一行直接复用;后两行校验,并在 304 后复用正文。把 server_etag 改成 "release-b":陈旧的两种情况变成 200,59 秒的情况仍然复用已有响应。这就是新鲜度策略用内容可见性延迟换取较低延迟与源站负载的含义。
值得断言的契约
应测试匹配和变化的校验器、新鲜度边界,以及所选编码。还要决定源站故障时怎么办。HTTP 在特定条件或扩展下允许复用陈旧响应;must-revalidate 则禁止在响应陈旧后未经成功校验就复用,即使客户端已经断网。它用可用性换取更严格的正确性约束。单有校验器没有这样的承诺。
因此,有用的发布记录应写明缓存策略、选中的表示、实际条件响应状态,以及可接受的更新延迟。强制要求两个校验器头部和一次 304,既不能证明每个缓存都有当前字节,也不能消除所有带宽浪费,更不能代替对真实中间层配置的测试。
来源
- RFC 9111:HTTP Caching,第 4.1–4.3、5.2 节中的变体、年龄、校验与指令,已核验 2026-10-15。
- RFC 9110:HTTP Semantics,第 8.8、13.1.2、13.1.5 节中的校验器与比较规则,已核验 2026-10-15。