给接口加了 Cache-Control: no-cache 之后,CDN 上仍然能看到旧内容。
多数人的第一反应是「缓存没生效」,于是换成 no-store,问题消失。但「换成 no-store 就好了」这个结论会把一层很重要的机制盖住:no-cache 从来不是「不要存」,它说的是「用之前先问一句」。
规范里这几个指令的措辞差别很细,读错一个字,行为就差一个数量级。
no-cache 到底要求了什么?
RFC 9111 第 5.2.2.4 节,不带参数的 no-cache 定义是这么写的:
The no-cache response directive, in its unqualified form (without an argument), indicates that the response MUST NOT be used to satisfy any other request without forwarding it for validation and receiving a successful response.
拆开看:不得未经校验就用于满足另一个请求。它约束的是「复用」,不是「存储」。
紧接着的一句把动机说清楚了:这允许源服务器阻止缓存不经联系它就使用响应来满足请求------即使对于那些被配置为可以发送陈旧响应的缓存。
所以 no-cache 的实际效果是:响应可以被存下来,但每次要用它之前,缓存必须回源做一次校验(通常是带 If-None-Match 或 If-Modified-Since 的条件请求)。源站回 304,就用存的那份;回 200,就用新的。
如果链路中间有个缓存不实现校验、或者把这条指令打折了,你看到的就是旧内容------此时问题不在指令,在那层缓存的实现。
那什么才是不让存?
no-store。第 5.2.2.5 节:
The no-store response directive indicates that a cache MUST NOT store any part of either the immediate request or the response and MUST NOT use the response to satisfy any other request.
两个「MUST NOT」:不得存储请求和响应的任何部分,也不得用这个响应去满足其他请求。
还有一条更硬的旁证:规范第 3 节列「缓存不得存储」的条件时,出现的是 no-store,没有 no-cache。
| 指令 | 是否允许存储 | 复用前是否必须校验 | 适用场景 |
|---|---|---|---|
| no-store | 不允许 | --- | 含敏感信息、一次性内容 |
| no-cache | 允许 | 必须 | 内容会变但要省带宽 |
| max-age=0 | 允许 | 效果接近必须 | 想复用但要求立刻校验 |
| max-age=600 | 允许 | 600 秒内不必 | 稳定内容 |
第二行和第三行经常被当成同一件事。差别在语义上:no-cache 是明确表态「必须校验」,而 max-age=0 是说「新鲜期为零」,缓存按新鲜度公式算出来必然要校验。多数实现效果相同,但 no-cache 的意图更难被忽略------它挡得住某些只看 max-age 的简化实现。
最后一行才是真正省流量的那个。如果内容确实十分钟才变一次,no-cache 是在白白浪费校验的往返。
新鲜度是怎么算的?
规范第 4.2 节给了一个判定式和两个定义:
text
response_is_fresh = (freshness_lifetime > current_age)
「新鲜」的响应指年龄还没超过新鲜期的响应;「陈旧」则是超过了。新鲜期是从源站生成到过期时刻之间的长度;年龄是从生成或成功校验之后流逝的时间。
两个数都不是表面看来的那么简单。freshness_lifetime 的取值有优先级,第 4.2.1 节按「首个匹配项」的顺序列了四条:
- 如果缓存是共享的,并且有
s-maxage,用它的值; - 如果有
max-age,用它的值; - 如果有
Expires,用它减去Date; - 以上都没有,可能适用启发式新鲜度。
这里的顺序藏着一个常见误解:s-maxage 会盖掉 max-age,但只在共享缓存上 。浏览器是私有缓存,它只看 max-age;CDN 是共享缓存,它先看 s-maxage。所以「浏览器上是新内容、CDN 上是旧内容」这种分裂现象,第一嫌疑人就是这两条指令同时存在。
current_age 也不是简单的「现在减生成时间」。第 4.2.3 节列了一串计算,涉及 apparent_age、response_delay、corrected_age_value、corrected_initial_age、resident_time,还要取最大值。
为什么要修正?因为响应在链路上传输也要花时间。一个在源站已经放了 5 秒的响应,走到缓存又花了 2 秒,它的年龄应该是 7 秒而不是 5 秒。不修正的话,链路上每过一跳就多「年轻」一次,缓存会比你预期的更久才过期。
没有显式过期时间时会怎样?
会走启发式,而启发式比大多数人想的激进。
第 4.2.2 节说:源服务器并不总是提供显式过期时间,缓存可以 在没有显式时间时分配一个启发式过期时间,使用其他字段值(例如 Last-Modified)来估算。规范不规定具体算法,但对结果施加最坏情况约束。
然后是一句很具体的建议:如果响应有 Last-Modified,鼓励缓存使用一个不超过「该时间至今间隔」某个比例的启发式值,典型设置是这个比例的 10%。
这句话要正着读。如果一个资源上次修改是 100 天前,按 10% 算,启发式新鲜期可以有 10 天。也就是说,一个完全没写任何缓存指令的响应,可能在缓存里安静地躺十天。
这是「我只删了缓存头,为什么还是旧内容」这个问题的正解。删掉指令不等于禁用缓存,反而把控制权交给了缓存的猜测。
还有一条硬约束:当存储的响应里有显式过期时间时,缓存不得用启发式来确定新鲜度。有就得按有来,不能被猜。
must-revalidate 的特殊之处在哪?
第 5.2.2.2 节的定义是:一旦响应变陈旧,缓存在成功向源站校验之前,不得复用它来满足另一个请求。
真正特别的是下一段:
In all circumstances, a cache MUST NOT ignore the must-revalidate directive; in particular, if a cache is disconnected, the cache MUST generate an error response rather than reuse the stale response. The generated status code SHOULD be 504 (Gateway Timeout) unless another error status code is more applicable.
断网的时候,缓存必须报错,而不是拿旧的顶上去。状态码应当是 504。
这和第 4.2.4 节的一般规则呼应:缓存不得生成陈旧响应,除非它是断开的、或者客户端/源站明确允许。而 must-revalidate 的作用,就是把「断开时的例外」也堵上。
所以对「宁可报错也不能给错数据」的接口,must-revalidate 是比 no-cache 更贴切的表达。前者说的是「陈旧之后必须校验,校验不了就报错」,后者说的是「用之前必须校验」------而后者在某些实现里,断开时仍可能按陈旧规则放行。
规范还给了一句使用建议:只有当「校验失败会导致操作不正确」时,服务器才应该用 must-revalidate,举的例子是静默未执行的金融交易。
这几条指令我整理过一页速查,几个项目的网关配置说明各存一份。重复排版的部分用 AI墨衍 同步过去,具体赋值还是按各自的业务算。
多条指令同时出现时听谁的?
第 4.2.1 节有一段处理冲突的说明,两句话都很实用:
- 同一个指令出现多个值时(比如两行
Expires、多个max-age),要么用第一个,要么把响应视为陈旧; - 指令之间冲突时(例如同时有
max-age和no-cache),应当遵守最严格的那条。
第二个是安全默认:宁可多校验一次,不要多给一次旧数据。所以 max-age=3600, no-cache 的实际效果是按 no-cache 处理,那个 3600 白写了。
还有一个细节值得记:带参数的 no-cache(no-cache="Set-Cookie" 这种形式)的语义是只对列出的字段生效,缓存可以在排除这些字段后复用响应。但规范有一句 Note 承认现实:
The qualified form of the directive is often handled by caches as if an unqualified no-cache directive was received; that is, the special handling for the qualified form is not widely implemented.
也就是说,你写了带参数的形式,很可能被当成整体 no-cache 处理。想做字段级控制,别指望这一句。
怎么验证自己的配置真的生效了?
看响应头没用,要看缓存的实际行为。
bash
# 第一次:看返回的缓存相关头与 Age
curl -sI <你的地址>/api/data
# 第二次:带上第一次的 ETag,验证是否走 304
curl -sI -H 'If-None-Match: "<第一次的 ETag>"' <你的地址>/api/data
# 观察 Age 头是否递增:递增说明命中了缓存而不是回源
Age 头是判断「这条响应在缓存里待了多久」的最直接证据。第一次请求 Age: 0 或者没有这个头,连续几次请求后 Age 递增,说明中间那层确实在缓存并在复用。
反过来,如果每次都 Age: 0,说明缓存根本没存------这时候才该去怀疑是不是被 no-store 或者某种隐式规则挡住了。
第三个要看的是一致性:同一份内容在浏览器和 CDN 上看到的结果不一样,就去比对 max-age 和 s-maxage 是不是同时存在。这是排查「一端新一端旧」最快的一条路。
本文引用的原文出自 RFC 9111 (HTTP Caching)的第 3 节(存储条件)、第 4.2 节(新鲜度判定)、第 4.2.1 节(新鲜期计算与冲突处理)、第 4.2.2 节(启发式新鲜度)、第 4.2.3 节(年龄计算)、第 4.2.4 节(发送陈旧响应)、第 5.2.2.2 节(must-revalidate)、第 5.2.2.4 节(no-cache)、第 5.2.2.5 节(no-store)。资料整理自 IETF 官方 RFC 文本。