入门指南
源站写了 no-store,CDN 还是存了:定向字段一被选中,Cache-Control 整条失效
同一个 URL 可以给 CDN 和浏览器两套策略:CDN-Cache-Control 被 target list 选中后,Cache-Control 与 Expires 会被整条忽略,no-store、private、no-cache 也随之在那一层消失;写错一个值,整条字段又会被丢弃并回退。讲清选择顺序、none 的含义与 Age 惩罚。
定向字段被选中时,Cache-Control 整条不再作数。

先看答案
- 适合谁
- 自己发静态站、开了 CDN,想给边缘与浏览器配不同 TTL 的人
- 源站明明写了 no-store,边缘却还是存了的人
- 把 CDN-Cache-Control 当成 Cache-Control 的「覆盖版」或「补丁」的人
- 你会获得
- 它替换的是整条策略,不是补一条指令。
- 发布时间
- 实质更新
- 发布
- AOPC 编辑部
60 秒结论
一分钟看懂这篇文章
定向字段被选中时,Cache-Control 整条不再作数。
人负责
- 判断要不要给 CDN 单独一套策略
- 确认 private 与 no-store 有没有被重写一遍
AI 负责
- 读 target list 选出的是哪条定向字段
- 核对 Age 与两层各自的 max-age
- 确认 CDN 在 target list 上
- 把安全指令写进定向字段
- 核对 Age 与两层 TTL
- 按层分诊过期与泄露
直接答案
「后台那个页面我明明写了 Cache-Control: no-store,CDN 那边还是存了半小时。」
先别怀疑 CDN 有 bug。如果这个 CDN 实现了 CDN-Cache-Control、并且把它放进了自己的 target list,那么它这样做是规范写明的行为:
the cache MUST select the first (in target-list order) field with a valid, non-empty value and use its value to determine the caching policy for the response, and it MUST ignore the Cache-Control and Expires header fields in that response
注意后半句:忽略的是整条 Cache-Control 与 Expires,不是「只忽略和定向字段冲突的那几条」。于是 no-store 在那一层就不存在了——private、no-cache、must-revalidate 同样不存在。
RFC 9213 甚至直接把这种组合写成了示例:
CDN-Cache-Control: max-age=600
Cache-Control: no-store
原文对它的注解是「instruct a CDN cache to consider the response fresh for 600 seconds, while all other caches would be prevented from storing it」。一个头存、一个头不存,这是设计意图,不是事故。
事故出在你以为只加了一条 TTL,实际上换掉了整份策略。
| 源站发出的头 | 浏览器与其他缓存 | 在 target list 上的 CDN |
|---|---|---|
Cache-Control: no-store |
不存 | 不存(没有定向字段,回退到它) |
Cache-Control: no-store + CDN-Cache-Control: max-age=600 |
不存 | 存 600 秒,no-store 被忽略 |
Cache-Control: max-age=60 + CDN-Cache-Control: max-age=600 |
60 秒 | 600 秒 |
Cache-Control: max-age=60 + CDN-Cache-Control: none |
60 秒 | 不是 60 秒,而是「这一层没有指令」 |
只有 CDN-Cache-Control: max-age=600,字段解析失败 |
按没有它处理 | 按没有它处理,回退到 Cache-Control |
最后两行是本文最值得记住的两处,第四行尤其反直觉:none 不是「不要缓存」,它连「按 Cache-Control 来」都不是,第四节会拆开讲。
三句话先立住:
- 它不叠加,而是替换:被选中时,
Cache-Control与Expires在那一层整条作废; - 它只在 target list 上才生效:谁在表上、排第几,由实现决定,不由你决定;
- 它按 Structured Fields 解析:一个解析错误就让整条字段被忽略,悄悄退回单层策略。
一、它要解决的是「一条指令没法只对一层说」
RFC 9111 在讲 Cache-Control 的转发义务时,把局限写得很直白:
A proxy, whether or not it implements a cache, MUST pass cache directives through in forwarded messages, regardless of their significance to that application, since the directives might apply to all recipients along the request/response chain. It is not possible to target a directive to a specific cache.
Cache-Control 是一条链上的公共广播。s-maxage 是少数例外——它只对共享缓存说话——但 RFC 9213 指出这种「定向」并不覆盖所有指令:
while some cache directives (e.g., s-maxage) are targeted at a specific class of caches (for s-maxage, shared caches), targeting is not consistently available across all existing cache directives (e.g., stale-while-revalidate)
换句话说:你想让边缘用 stale-while-revalidate、浏览器不用,或者想让边缘 10 分钟、浏览器 1 分钟,Cache-Control 里没有一条现成的语法能表达。CDN-Cache-Control 补的就是这个位置——它不是「另一条指令」,而是一个装着指令的容器:
A Targeted Cache-Control Header Field (hereafter “targeted field”) is an HTTP response header field that has the same semantics as the Cache-Control response header field
同一套指令语义,换一个名字,指名道姓给某一类缓存看。
二、选中谁,由 target list 决定,不由你的意图决定
实现这个规范的缓存,各自维护一张有序表:
A cache that implements this specification maintains a target list. A target list is an ordered list of the targeted field names that it uses for caching policy, with the order reflecting priority from most applicable to least. The target list might be fixed, user configurable, or generated per request, depending upon the implementation.
三个关键词:有序、按实现、可能每次请求都不一样。所以:
- 你的
CDN-Cache-Control在不在表上,取决于这家 CDN 有没有实现; - 表上如果还有厂商自己的字段(RFC 举的例子是
ExampleCDN-Cache-Control),排在前面的会赢,CDN-Cache-Control连读都不读; - 表里没有任何一条合法非空的值时,才回退:
Note that this occurs on a response-by-response basis; if no member of the cache’s target list is present, valid, and non-empty, a cache falls back to other cache control mechanisms as required by HTTP
「按响应逐条判断」这一点很重要:它不是给站点开的全局开关,而是每份响应各自算一次。同一站点的两个页面,可以一个走定向策略、一个走 Cache-Control。
还有一条常被忽略的边界:
Targeted fields that are not on a cache’s target list MUST NOT change that cache’s behavior and MUST be passed through.
不在表上的定向字段,既不能改变这一层的行为,也必须原样转发。所以「我在响应里看到了 CDN-Cache-Control」并不能证明任何一层真的用了它——它可能只是被路过。
三、一旦选中,Cache-Control 与 Expires 整条作废
这是全文的核心机制,也是绝大多数误配的来源。规范用的词是 MUST ignore:
it MUST ignore the Cache-Control and Expires header fields in that response, unless no valid, non-empty value is available from the listed header fields.
Expires 一起被忽略,这点容易被漏掉:如果你的老配置靠 Expires 给边缘定时效,加了定向字段之后那个 Expires 在边缘就没人看了。
于是「哪些指令必须重写一遍」就有了明确答案。RFC 9213 列出了使用定向字段的缓存必须实现的五条:
max-agemust-revalidateno-storeno-cacheprivate
其余指令是 SHOULD。这五条之所以被单独点名,恰恰因为它们最容易被「整条忽略」带走:
| 你想要的边缘行为 | 只写在 Cache-Control 里 |
写了定向字段之后 |
|---|---|---|
| 边缘不要存 | no-store |
边缘不看了,要在定向字段里再写 no-store |
| 边缘每次都要问源站 | no-cache |
同上,要重写 no-cache |
| 边缘不许存用户相关响应 | private |
同上,要重写 private |
| 边缘陈旧后必须验证 | must-revalidate |
同上,要重写 must-revalidate |
| 边缘存 10 分钟 | — | CDN-Cache-Control: max-age=600 |
private 那一行值得单独说:CDN 是共享缓存,RFC 9111 对 private 的定义是「a shared cache MUST NOT store the response」。但只要这条 private 待在 Cache-Control 里、而这层 CDN 已经选中了定向字段,它就不会被读到。规范自己在安全考量里点了这个风险:
The ability to carry multiple caching policies on a response can result in confusion about how a response will be cached in different systems, potentially resulting in unintentional reuse of responses with sensitive information.
所以这不是「加了一条 TTL」,而是「在边缘那一层,把安全指令删了又没补」。判断方法很土但有效:凡是 Cache-Control 里带 private、no-store、no-cache、must-revalidate 的响应,只要加了定向字段,就逐条问一遍「这条在定向字段里还在吗」。
四、none 不是「不缓存」,是「这一层没有指令」
这是最容易被当成「关闭开关」的一处。规范示例里有一组:
Cache-Control: no-store
CDN-Cache-Control: none
原文的注解是「prevent all caches except for CDN caches from storing the response」。也就是说,CDN 可以存。为什么?因为 none 的定位是:
(Note that ‘none’ is not a registered cache directive; it is here to avoid sending a header field with an empty value, which would be ignored.)
它存在的唯一目的,是让这条字段非空、从而能被选中;它本身不是指令。而被选中的字段里如果只有一条不认识的指令,按 RFC 9111 的通用规则:
A cache MUST ignore unrecognized cache directives.
于是这一层的策略是空的:Cache-Control 已经因为「选中」被忽略,定向字段里又没有任何有效指令。剩下的能存不能存、存多久,就回到这一层自己的默认策略和 RFC 9111 的启发式规则(§3 的可缓存性判断、§4.2.2 的启发式寿命)。具体给多少秒,规范没有规定,各家实现不同——这是编辑判断,不是原文。
所以两组写法要分清:
| 你想表达 | 该写 | 不该写 |
|---|---|---|
| 这一层别存 | CDN-Cache-Control: no-store |
none |
| 这一层每次都要验证 | CDN-Cache-Control: no-cache |
none |
| 这一层我不下指令,按你的默认来 | none |
留空(空字段会被忽略) |
反过来说,none 真正的用途是「我只要把其他缓存锁住,边缘随意」——比如源站 no-store、但边缘有主动清除能力、愿意让它按默认缓存。它表达的是一种放手,不是禁止。
五、它按 Structured Fields 解析,不按 Cache-Control 解析
RFC 9213 说定向字段「has the same semantics as the Cache-Control response header field」,但紧接着在语法一节里给了警告:
Targeted fields are Dictionary Structured Fields (Section 3.2 of [STRUCTURED-FIELDS]).
以及一句提醒:
Note that while targeted fields often have the same syntax as Cache-Control fields, differences in error handling mean that using a Cache-Control parser rather than a Structured Fields parser can introduce interoperability issues.
语义相同,语法与错误处理不同。这带来两个后果。
后果一:值的类型是被推断出来的,写错类型会被拒绝消费。
Cache-Control 的指令值本来只有三种形态:没有值、quoted-string、token。映射到 Structured Fields 之后,没有值的变成 Boolean,quoted-string 变成 String,token 变成 Token/Integer/Decimal。max-age 的推断类型是 Integer,所以:
Implementations MUST NOT generate values that violate these inferred constraints on the cache directive’s value.
Implementations SHOULD NOT consume values that violate these inferred constraints.
max-age=600.5 在 Cache-Control 的宽松解析里可能被截断成 600,在 Structured Fields 里却是一个 Decimal,与 max-age 应有的 Integer 不符——规范要求实现「不应消费」这个值。同一份响应,两层读出了两个结果,这就是互操作性问题的具体形态。
还有一条顺带的规则,影响的是你以为能带参数的那些写法:
Parameters received on cache directives are to be ignored, unless other handling is explicitly specified.
后果二:整条字段的失败是「全有或全无」。
If a targeted field in a given response is empty, or a parsing error is encountered, that field MUST be ignored by the cache (i.e., it behaves as if the field were not present, likely falling back to other cache-control mechanisms present).
不是「跳过坏的那一条、保留好的」,而是整条当没写过。于是最阴的一类故障是:你在定向字段里写了个不合语法的值,边缘没报错、也没告警,直接退回 Cache-Control——如果那边是 max-age=60,你的「边缘 10 分钟」就悄悄变成了 1 分钟;如果那边是 no-store,边缘干脆不存了。上线后 TTL 与预期不符,先怀疑这条字段有没有被整条丢弃。
顺带纠正一个常见联想:名字以 -Cache-Control 结尾并不构成识别依据。
this suffix MUST NOT be used on its own to identify targeted fields; it is only a convention.
六、它不改缓存键,也不改 Vary
定向字段只回答一个问题:这一层拿到这份响应后,能存多久、要不要验证。 它不回答「这份响应该发给谁」。
- 缓存键仍然由方法、目标 URI(以及实现自己的规则)决定;
- 内容协商仍然由
Vary决定:同一个 URL 有中英文两份时,Vary: Accept-Language少写,缓存照样把中文页发给英文读者; - 边缘的「清」也仍然按缓存键走:TTL 给得再长,清不掉就是清不掉。
所以这三件事要分开排:
| 你遇到的症状 | 该看哪个机制 | 本文相关的一篇 |
|---|---|---|
| 边缘存太久、改了不生效 | 定向字段与 Cache-Control 的 TTL |
本篇;清缓存看 purge 篇 |
| 同一个 URL 发错了语言/编码 | Vary 与缓存键 |
Vary 篇 |
| 文件换了,客户端还说没变 | ETag/Last-Modified 与 304 |
ETag 篇 |
CDN-Cache-Control 不能替代其中任何一个。
七、不支持它的缓存必须转发,也可能把它删掉
两条方向相反的规则,合起来解释了「为什么线上有时看得到、有时看不到这个头」。
第一条是转发义务(前面引过):不在 target list 上的定向字段 MUST NOT change that cache's behavior and MUST be passed through。所以不支持的中间层不会因为你写了它而改变行为——写了不等于生效。
第二条是 CDN 自己的处置权:
CDN caches that use CDN-Cache-Control will typically forward this header so that downstream CDN caches can use it as well. However, they MAY remove it when this is undesirable (for example, when configured to do so because it is known not to be used downstream).
也就是说,一个支持它的 CDN 也可能在回给客户端时把它删掉。于是:
- 线上响应里没有
CDN-Cache-Control,不能推断源站没发; - 线上响应里有
CDN-Cache-Control,不能推断边缘用了它; - IANA 注册表里有这一条(
permanent、Reference 指向 RFC 9213),不能推断哪家 CDN 部署了它。
唯一可靠的证据是行为:Age 会不会增长、增长到超过 Cache-Control 的 max-age 之后还在不在。
八、Age 惩罚:边缘眼里还新鲜,浏览器眼里已经过期
这是分层 TTL 的副作用,规范用一整节讲它。
HTTP 缓存只有一个端到端的新鲜度模型:
HTTP caching has a single, end-to-end freshness model
而定向字段只让部分缓存看到更长的寿命:
a targeted cache might have longer freshness lifetimes available to it than other caches, causing it to serve responses that appear to be prematurely (or even immediately) stale to those other caches, negatively impacting cache efficiency.
规范给的例子正好是两组 TTL:
Age: 1800
Cache-Control: max-age=600
CDN-Cache-Control: max-age=3600
From the CDN’s perspective, this response is still fresh after being cached for 30 minutes, while from the standpoint of other caches, this response is already stale.
为什么会这样?因为 Age 不是「在这一层待了多久」,而是累计值:
The Age value is therefore the sum of the time that the response has been resident in each of the caches along the path from the origin server, plus the time it has been in transit along network paths.
算一下就很清楚(以下是按 RFC 9111 §4.2 公式做的编辑推论,不是原文):边缘存了 120 秒后把响应交给浏览器,浏览器收到 Age: 120,而它自己的寿命只有 60 秒——freshness_lifetime > current_age 不成立,这份响应在浏览器手里一拿到就已经是陈旧的。它每次都得回来问,只是问到了边缘而不是源站。边缘的 QPS 上去了,浏览器的缓存等于没用。
规范列了三种在部署里见过的缓解手法:
- 删掉
Age; - 把
Date改成当前时间; - 把
Expires改成「当前时间 +Cache-Control: max-age的值」。
然后立刻收回了强制性:
This specification does not place any specific requirements on implementations to mitigate these effects, but definitions of targeted fields can do so.
所以这三种都不是「标准做法」,而是取舍。删 Age 会让下游低估这份响应的真实年龄,于是一份在边缘已经放了 10 分钟的内容,到了浏览器手里还能再新鲜 60 秒——你把它「变年轻」的同时,也让旧内容多活了一会儿(编辑判断)。这也是为什么第八节要和第九节一起读:分层 TTL 的收益在边缘,代价在客户端。
九、什么时候值得用,以及三类典型事故
值得用的前提有两个,缺一不可:
- 你能主动清这一层(有 purge 能力、或 URL 本身换名);
- 你确实需要「同一个 URL、两层不同策略」——例如边缘 10 分钟、浏览器 1 分钟,或边缘要
stale-while-revalidate而浏览器不要。
只有一层缓存,或者清不掉,加它只是把「改完不生效」的时间窗口拉长。
事故 A:敏感响应被共享缓存存下来。
Cache-Control: private 写在原地,定向字段里只写了 max-age=600。边缘选中定向字段 → private 不被读取 → 共享缓存存了 10 分钟。这类事故不会报错,只会在某天被人发现「我看到了别人的页面」。
事故 B:想做「边缘别存」,写成了 none。
none 让这一层「没有指令」,不是「禁止存储」。要禁止就写 no-store。
事故 C:写错一个值,整条回退。
定向字段被整条忽略,边缘改用 Cache-Control。表现是 TTL 忽然变短(或变长),而且没有任何日志提示你「字段被丢了」。
分诊顺序(按这个顺序排,能少走弯路):
| 现象 | 先查 | 再查 |
|---|---|---|
| 边缘 TTL 与定向字段不符 | 定向字段是否被整条忽略(空值、类型错、语法错) | 这层 CDN 是否在 target list 上 |
边缘存了本该 private 的响应 |
定向字段里有没有重写 private |
有没有别的规则覆盖了响应头 |
| 改了源站,边缘还是旧的 | 边缘的实际 TTL 与 Age |
purge 是否真的清到了这个键 |
| 浏览器每次都回源/回边缘 | Age 是否已超过浏览器的 max-age |
是否用了第八节的三种缓解之一 |
| 同一份响应,两层行为不一致 | 两层的 target list 不同 | 是否有厂商字段排在前面 |
十、十分钟核对:四组命令
先说边界:curl 每次都是全新请求,没有本地缓存,它只能告诉你这一层发了什么、存了多久,不能告诉你浏览器会怎么处理。客户端行为要另用真实浏览器验证。
第一组:源站与边缘各发了什么
for u in https://example.com/ https://example.com/pricing; do
echo "== $u"
curl -sS -o /dev/null -D - "$u" \
| grep -iE '^(HTTP/|cache-control:|cdn-cache-control:|age:|date:|expires:)'
done
第二组:隔几秒再取,看 Age 是不是在长
for i in 1 2 3; do
curl -sS -o /dev/null -D - https://example.com/pricing \
| grep -iE '^(age:|date:|cache-control:|cdn-cache-control:)'
sleep 5
done
Age 增长说明这一层存了;不增长可能是没存、也可能是每次都回源。
第三组:内容到底是新的还是旧的
curl -sS https://example.com/pricing | grep -o 'v2026-11-02' | head -1
挑一个只在新版本里出现的字符串,比整页 hash 可靠——边缘可能压缩或改写内容,hash 对不上不代表是旧内容。
第四组:敏感路径的头
for u in https://example.com/account https://example.com/admin; do
echo "== $u"
curl -sS -o /dev/null -D - "$u" \
| grep -iE '^(HTTP/|cache-control:|cdn-cache-control:|age:)'
done
判读:
| 看到什么 | 说明 | 下一步 |
|---|---|---|
只有 Cache-Control,没有定向字段 |
源站没发,或被这层删了(MAY remove) |
先确认源站真的发了,再看这层是否支持 |
有定向字段,Age 一直不动 |
没被存(no-store/private/未被选中) |
查是不是回退到了 Cache-Control |
Age 已超过 Cache-Control 的 max-age,仍返回同一条 |
边缘用的是定向字段,这正是「两层不同 TTL」的证据 | 不是故障;如果浏览器行为不对,看第八节 |
| 定向字段的值里有小数、引号或空值 | 可能整条被忽略 | 改成规范类型后重测 |
Cache-Control 有 private,定向字段没有 |
边缘那一层没有 private |
补写 private 或 no-store |
同一 URL 两次取到的 Vary 不同 |
边缘在改写响应头 | 查响应头改写规则 |
十一、上线前十二项清单
- 这一层 CDN 是否真的实现了
CDN-Cache-Control?有没有官方文档可核对? - 它在不在这一层的 target list 上?表里有没有排在前面的厂商字段?
- 加了定向字段之后,
Cache-Control里哪些指令会在这层失效?逐条列出来。 private/no-store/no-cache/must-revalidate是否已在定向字段里重写?Expires还在用吗?它在边缘已经不作数了。- 定向字段的值是否符合 Structured Fields 的类型(
max-age必须是整数)? - 有没有空值、多余逗号、带参数的写法?这些会让整条字段被丢弃。
- 是否用了
none?确认你要的是「这一层不下指令」而不是「这一层别存」。 - 两层的 TTL 各是多少?浏览器拿到的
Age会不会一进来就超过它自己的max-age? - 这一层能不能主动清?清的时候是按哪个缓存键?
- 回滚时,旧响应头会不会也跟着回滚?别只回滚了内容。
- 上线后按第十节四组命令各跑一次,把
Age与两层的值抄进发布记录。
十二、六种误读,与本周能做的五件事
| 误读 | 原文怎么说 |
|---|---|
| 「所有 CDN 都支持」 | 表由实现维护(might be fixed, user configurable, or generated per request);IANA 注册不等于部署 |
「它一定覆盖 Cache-Control」 |
只在字段位于 target list 上、且值合法非空时才覆盖;否则回退 |
「它只是给 Cache-Control 加一条 TTL」 |
选中后 MUST ignore the Cache-Control and Expires header fields,是整条替换 |
「none 就是别缓存」 |
原文明确 'none' is not a registered cache directive;它的作用是让字段非空 |
| 「配了之后浏览器也会跟着用长 TTL」 | 不在 target list 上的缓存 MUST NOT change that cache's behavior,浏览器仍按 Cache-Control |
「Age 会被自动清掉」 |
规范 does not place any specific requirements on implementations,三种手法只是「见过」 |
本文不写:各家 CDN 的 target list 顺序、默认 TTL、支持情况与产品配置项名称,命中率数字,以及任何一种实现的
none默认寿命。这些都没有一手来源,写出来就是猜。
本周能做的五件事:
- 把站点响应按「有没有
private/no-store」筛一遍,看哪些同时带了定向字段。 - 挑一个加了
CDN-Cache-Control的 URL,用第十节第二组命令看Age到底长不长。 - 把
Cache-Control与定向字段并排写进发布检查单,逐条对齐,而不是只写 TTL。 - 找出所有写成
none的地方,确认你当时想的是「放手」还是「禁止」。 - 给敏感路径单独跑一次第四组命令,确认
private在两层都在。
边界说明
- 本文只引用 RFC 9213、RFC 9111 与 IANA 字段注册表的原文;每处英文引用都可在对应页面检索到。标注为「编辑判断」「编辑推论」的段落是从原文推出来的工程结论,不是规范原文。
- 不讨论具体 CDN、反向代理与浏览器对
CDN-Cache-Control的实现差异、支持比例与默认值;这些需要各家官方文档,本文没有取证。 - 不讨论
CDN-Cache-Control对 SEO、可访问性或合规的影响;没有一手来源支持具体结论。 - 文中
example.com、v2026-11-02与各段命令均为占位示例,不代表任何真实站点的配置。
来源
- RFC 9213: Targeted HTTP Cache Control — https://www.rfc-editor.org/rfc/rfc9213.txt核查于 2026/11/2(2026-11-02 访问;§1 多层缓存与
s-maxage的局限、§2 定向字段定义与同语义、§2.1 Structured Fields 语法与「整条忽略」、§2.2 target list 与整条替换、§2.3 Age 惩罚与三种缓解、§2.4 命名约定、§3CDN-Cache-Control的范围与转发/删除、§3.1 四组示例与none、§4 IANA 注册、§5 安全考量) - RFC 9111: HTTP Caching — https://www.rfc-editor.org/rfc/rfc9111.txt核查于 2026/11/2(2026-11-02 访问;§3 可缓存性、§4.2 新鲜与陈旧、§4.2.1 寿命计算与冲突裁决、§4.2.2 启发式寿命、§4.2.3
Age是各层停留时间之和、§5.2Cache-Control无法定向与转发义务、§5.2.2.4no-cache、§5.2.2.5no-store、§5.2.2.7private、§5.2.3 不认识的指令必须忽略) - IANA: Hypertext Transfer Protocol (HTTP) Field Name Registry — https://www.iana.org/assignments/http-fields/核查于 2026/11/2(2026-11-02 访问;
CDN-Cache-Control为permanent,类型Dictionary,Reference「RFC 9213: Targeted HTTP Cache Control」)