Cloudflare 支持了 HTTP 里最丑陋的那部分:Vary
Vary 告诉缓存哪些请求字段可能影响响应,却不告诉它哪些差异真的重要。Cloudflare 现在让 Cache Rules 支持 Vary,源站可以按自己声明的字段分别缓存,而不是让所有请求共用一个副本。
中文
复制

响应头 Vary 被人称为“HTTP 里我们至今没能改进的最丑陋的部分”。同一篇文章还把它描述成一种“糟糕、拼凑出来的机制”,在各个中间层之间“互操作性差得惊人”。看到这种评价,懂行的工程师通常会举起双手慢慢后退。这算不上是对 Vary 的背书,但丑陋不等于没用。一个 URL 可以有不止一种正确的响应。比如服务器可能给不同浏览器返回不同的图片格式。如果缓存忽略 Vary,就可能把错误的字节喂给某个请求。但如果它把每个原始头值都当成不同的东西,那么几个相似的请求就能膨胀成成千上万个几乎无法复用的缓存条目。Vary 告诉缓存哪些请求字段可能影响响应,但它没有告诉缓存哪些差异才真正重要。现在所有套餐的 Cache Rules 都已支持 Vary。源站仍然负责声明哪些请求头可能影响响应,但由你决定 Cloudflare 如何处理每一个。你可以归一化已知的协商类请求头,在细微差异确实重要时原样透传具体值,或者在变化太不可预测时直接绕过缓存。源站声明可能有哪些变化,而由你决定多大的变化对缓存来说才真正有意义。
Vary 的工作方式
Vary 是一个标准 HTTP 响应头,用来告诉中间缓存(比如 Cloudflare)哪些请求字段可能影响源站返回的响应。网站用 Vary 在同一个 URL 上提供不同的语言、图片格式、压缩方式或地区内容。假设一个 URL 会产生两种都有效的表示。浏览器请求一个网页:
GET /catalog HTTP/1.1
Host: example.com
Accept: text/html
源站返回 HTML,并指明 Accept 是一个可能影响响应的字段:
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=3600
Vary: Accept
API 客户端可以用不同的偏好请求同一个 URL:
GET /catalog HTTP/1.1
Host: example.com
Accept: application/json
这一次,正确的响应是 JSON。Vary: Accept 头告诉缓存,光靠 URL 不足以在多个响应之间做出选择,还必须考虑请求的 Accept 值。
如果没有 Vary,先进入缓存的响应会被同时发给两个客户端。如果 HTML 先到,API 客户端收到的是标记语言,JSON 解析器随即报错;如果 JSON 先到,期待网页的浏览器拿到的就是一段 API 响应。
Vary 能防止缓存把错误的响应发给请求方。但它引出一个更难的问题:当两个请求携带不同的头字段值时,它们是否真的需要不同的响应?
当正确的缓存变得毫无用处
Vary 可以告诉缓存哪些请求字段可能影响响应,却无法告诉缓存这个响应代表什么。举个例子:假设某个源站只提供英语、法语和德语三种内容。一个客户端可能发送:
Accept-Language: en-US, fr;q=0.8
另一个客户端则可能请求:
Accept-Language: fr;q=0.8, en-GB
这里两个请求都偏好英语。源站的响应完全可能把两者映射到同一份英语响应上。但缓存比较的是原始值,无法安全地认定二者等价——它们的顺序不同,语言标签也不同(而源站并不区分这些差异)。于是缓存可能把它们存成两个变体,哪怕响应体逐字节完全相同。
这正是 Vary 的核心问题。应用往往从海量的请求值中生成一小撮有限的表示。源站清楚成千上万种语言偏好最终只会落到三种受支持的语言上,缓存通常不知道。
当响应在多个字段上变化时,问题会成倍放大。一个字段有十种取值,产生十个变体;三个字段各有十种取值,就可能产生 1,000 种组合。真实头字段的基数可以远高于此:User-Agent 的取值数量庞大,cookie 可能因访问者而异,偏好类头字段还会在顺序、格式(空格和制表符都算数!)以及质量值上各不相同。
结果就是:缓存可以完全正确,却几乎永远处于冷状态(条目从未被复用)。相同的响应散落在各个条目里,而这些条目流量太低,无法保持热度、留在缓存中。它们占用容量,相互驱逐,拉低缓存命中率,把更多请求推回源站。驱逐能清掉冷条目,却不能因为响应相同就把它们合并。
一项分析覆盖了近 5 万个热门网站的 1.2 亿多条响应,发现近 3000 个网站在四个或更多字段上存在差异。有的甚至在 10 个、23 个乃至 47 个字段上存在差异。我们希望确保客户在合适的时候有工具可用 Vary,但又不会用得过头,导致缓存形同虚设。
有些高基数差异是刻意为之。CDN 或反向代理可能会注入地理位置之类的值,以便可预测地划分内容。只要可能的取值受控、且各个组件对其含义理解一致,这种做法就行得通。缺少这些约束,缓存就会碎成一堆可能永远不会被复用的变体。
这正是我们支持 Vary 时需要解决的设计难题。我们既要保留足够的变化以返回正确的响应,又不能放任请求之间的偶然差异毁掉缓存效率。
Cache Rules 如何控制 Vary
Cloudflare 客户此前已有几种方式处理类似 Vary 的协商内容:绕过缓存交给源站处理;在自定义缓存键或其他规则中复现源站的协商逻辑;使用 Worker;或者使用 Vary for images 之类的功能。
这些选项依然有用,但要么放弃缓存,要么重复应用逻辑,要么需要额外写代码,要么只覆盖较窄的场景。Vary in Cache Rules 把支持拆成两个决策,或许正好补上这些既有功能之间的空缺:
- 源站用
Vary指明哪些请求头可能影响响应。 - Cache Rule 决定 Cloudflare 如何处理每个请求头的值。
Cache Rule 不会强制每个响应都带上 vary。如果源站没有返回 Vary,Cloudflare 就照常缓存响应,不过规则仍可能在把请求转发给源站之前改写 Accept 和 Accept-Language。
当源站确实返回 Vary 时,Cloudflare 会对其列出的每个请求头套用配置好的操作。没有单独设置的请求头则使用规则的默认操作。可用的操作有三种:
| 操作 | Cloudflare 的行为 | 适用场景 |
|---|---|---|
normalize | 在选择缓存变体之前对请求头做归一化,让等价的请求能够共用同一份缓存响应。对 Accept、Accept-Language、Accept-Encoding 应用各自的头部规则。对于其他头部,它会去掉可选空白,并按原始顺序合并重复的头部行,保留大小写和内部空白。 | 推荐作为协商类头部的起点,适用于大量请求值只对应少量响应的场景。 |
passthrough | 用请求头的原始字节做缓存匹配,保留大小写、空白、顺序和重复值。如果该头部出现在多行,Cloudflare 会按顺序用逗号合并这些行再做缓存匹配。透传不会改动发出的头部行。当 Respect Strong ETags 关闭时,Cloudflare 仍可能改写 Accept-Encoding。 | 取值范围可控、且具体取值会改变响应的头部。 |
bypass | 当源站在 Vary 中列出该头部时,不存储响应。已有的缓存条目不会被删除,如需清除请手动清除。 | 用于个性化、高基数或不可预期的头部,例如 Cookie 或 User-Agent。 |
我们推荐默认使用 normalize。对于取值个性化或无上限的单个头部,使用 bypass。当具体取值会改变响应时,使用 passthrough。
例如,passthrough 会保留大小写、空白、顺序和重复值上的差异,即使源站认为它们等价。配合 Vary: X-View 和 passthrough,下面三个取值会生成三个不同的缓存键:
X-View: compact,full
X-View: Compact,full
X-View: compact, full
足够多的偶然差异,会让一个本可复用的响应在缓存中变成许多一次性变体。
无论配置了哪些操作,Vary: * 始终绕过缓存。这意味着请求的任何方面——甚至 HTTP 消息之外的信息(比如客户端 IP 地址)——都可能影响源站选择哪个响应。因此,Cloudflare 若不联系源站,就无法把该响应用于后续请求。
响应如何经过缓存
我们跟随上面某个 /catalog 请求走一遍 Cloudflare。
第一次请求时,Cloudflare 没有该资源的 Vary 数据,因此缓存查找未命中。在 Cloudflare 联系源站之前,匹配的 Cache Rule 可以规范化已配置的字段。
这可能发生在 Cloudflare 知道最终响应是否包含 Vary 之前。Cache Rule 定义允许的规范化;响应随后决定这些字段是否成为缓存变体的一部分。
这个顺序很重要。如果 Cloudflare 把多个原始值归入同一个规范化缓存键,而源站仍然收到这些原始值,源站就可能产生不同的响应,而缓存之后会认为它们可以互换。转发规范化后的值,能让源站的选择与缓存保持一致。
源站返回:
Vary: Accept, Accept-Language
Cloudflare 会记录这些标头名称,并将响应存为一个缓存变体。标头值经 Cache Rule 处理后,用于将这个变体与同一资源的其他变体区分开。
当针对 /catalog 的另一个请求到达时,Cloudflare 会从该资源的基础缓存键开始,通常是 URL 加上其他已配置的键字段。随后它会读取已存储的 Vary 字段,并对新请求中的这些标头应用 Cache Rule,以找出匹配的缓存变体。
假设它们归一化后为:
Accept: text/html
Accept-Language: en,fr
Cloudflare 会用这些值直接查找匹配的缓存变体,而不会将请求与每一个已存储的变体逐一比对。
如果存在匹配的变体且尚未过期,该请求即为缓存命中。否则,Cloudflare 会将请求发往源站,并可能将返回的响应存为另一个变体。
源站响应补上了最后一环。对于 Vary 中列出的每个标头,Cloudflare 会采用为该标头配置的动作;若该标头未被单独列出,则采用规则的默认动作:
- 如果不包含
Vary,Cloudflare 正常缓存。 - 如果所有列出的标头都解析为 normalize 或 passthrough,Cloudflare 可以将该响应存为缓存变体。
- 如果任一列出的字段使用 bypass,Cloudflare 不存储该响应。
- 如果响应包含
Vary: *,Cloudflare 不存储。
这就把一项重要责任交给了源站。凡是可能因请求字段不同而不同的可缓存响应,都必须一致地返回相应的 Vary 标头,错误响应和回退响应也不例外。只要有一个响应漏掉了它,Cloudflare 就可能缓存该响应,却缺少将其隔离所需的变体区分。
图中的缓存键是概念性的。后一个请求假定缓存中的响应尚未过期。
针对某个缓存资源的任何清除操作都会覆盖它的所有 Vary 变体。清除自定义缓存键的现有要求仍然适用。
修改 Vary 配置不会自动清除已有内容。新策略可能产生不同的缓存键:请求可能未命中,并在新键下重新填充,而旧条目会一直保留到过期或被清除。
规范化让等价的请求归到一起
还记得前面那些请求英文和法文的请求吗?
Accept-Language: en-US, fr;q=0.8
Accept-Language: fr;q=0.8, en-GB
两个请求都偏好英语,但 passthrough 会把它们视为不同的变体。如果 Cache Rule 允许 en、fr 和 de,normalize 会将两者都归约为 en、fr,从而共享同一份缓存响应。
为此,Cloudflare 会将 Accept、Accept-Language 和 Accept-Encoding 中的值转为小写,然后按质量值排序,最高的在前;质量值相同时,按字母顺序排列。因此,客户端给出的顺序不会影响缓存键。排序后,Cloudflare 会从质量值非零的条目中剥离参数。在缩短语言标签,或按配置的格式和语言进行过滤时,也可能丢失 q=0(“not acceptable”)。例如,en-US;q=0 可能变成 en。如果源站需要看到这些排除项,请对 Accept 或 Accept-Language 使用 passthrough。
你也可以配置规则,让 Accept 和 Accept-Language 中只保留指定的媒体类型或语言。区域性的语言标签(如 en-US)会归约为其基础语言 en,除非配置了完整标签。这样就能让 normalization 与源站实际提供的格式和语言保持一致。
为了让源站选择与缓存匹配保持一致,Cloudflare 会把规范化后的 Accept 和 Accept-Language 值转发给源站。启用 Respect Strong ETags 时,还会转发规范化后的 Accept-Encoding 值。其他标头仅为缓存匹配而规范化。
在 Cache Rules 中配置 Vary
在 Cloudflare 仪表板中,进入 Caching > Cache Rules,创建或编辑一条规则,使响应符合缓存条件,然后添加 Vary 设置。先设置默认行为,再添加源站预期会指定的标头。
同样的配置也可以通过 Rulesets API 在 http_request_cache_settings 阶段完成。默认设置会为源站在 Vary 中指定、但你未单独配置的标头选择一个回退操作。
以下示例将 Accept 和 Accept-Language 规范化为一组配置好的格式和语言。默认的 normalize 操作同样适用于 Vary 中指定的其他标头:
{
"rules": [
{
"ref": "vary_negotiated_content",
"description": "Cache bounded negotiated representations",
"expression": "(http.host eq \"example.com\" and http.request.uri.path eq \"/catalog\")",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"vary": {
"default": {
"action": "normalize"
},
"headers": {
"accept": {
"action": "normalize",
"media_types": ["text/html", "application/json"]
},
"accept-language": {
"action": "normalize",
"languages": ["en", "fr", "de"]
}
}
}
}
}
]
}
这是一个发往 http_request_cache_settings 阶段入口点的 PUT 完整请求体。PUT 会替换该入口点中的所有规则。如果你已经有 Cache Rules,请把它们写进 rules 数组,或者改用相应的单条规则创建或更新操作。
如果源站为每个媒体类型与语言组合各提供一种表示,那么共有六种内容组合。这并不意味着缓存最多只有六个键。偏好顺序、缺失的请求头,以及归一化后为空的值,都可能产生更多键。把支持的范围控制得小一些,并清楚定义规则的边界。上线之后,用不同的请求头值测试同一个 URL,这些值应当归一化为同一个缓存变体。从同一个客户端发出测试请求,确认返回的格式和语言符合预期,并检查 CF-Cache-Status。缓存填充后应能看到命中,如果持续出现 miss 或意料之外的 bypass,就要排查原因。
关于限制、更多示例,以及如何在 Terraform 中配置,参见 Vary 文档。
为什么不直接用自定义缓存键?
到这里,一个自然的问题是:“为什么不把 Accept 和 Accept-Language 加进自定义缓存键?”
如果这些字段始终是资源身份的一部分,这样做没问题。但自定义缓存键会把配置的维度加到规则覆盖的每一个响应上,无论源站是否用到了它们。
Vary 由响应驱动,但同一基础键下的可缓存响应需要一组一致的 Vary 字段。
当某个请求属性始终定义资源时,用自定义缓存键。当源站在各个可缓存响应中声明同一组请求字段时,用 Vary。避免把同一个请求头同时放进两者,除非这种重复是有意为之并经过测试的。
现在就在 Cache Rules 里用 Vary!
Vary 有助于解决一个显而易见的问题:一个 URL 可能有不止一个正确的响应。但它给缓存出了道更难的题——哪些请求差异才真正重要?源站知道自己能提供哪些响应。缓存则需要知道哪些请求可以复用每一个响应。
Vary Cache Rules 将这两种视角连接起来。源站指明哪些请求字段可能影响响应。你可以决定是规范化这些值、用透传保留精确差异,还是让响应不进入缓存。
Vary 从来不算太丑以至于没用。但手动配置支持的格式和语言未必适合每个应用。我们正在评估已过期的 Availability Hints 草案中的思路能否减少这部分工作——让源站直接描述自己提供的表示形式。
Cache Rules中的Vary现已面向 Free、Pro、Business 和 Enterprise 套餐开放,可通过 Cloudflare dashboard、Rulesets API 和 Terraform 使用。