HTTP 缓存最棘手的一环:用 Cache Rules 驯服 Vary

2026-09-22 26 预计阅读时间: 1 分钟
来源: blog.cloudflare.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

Vary 可能是 HTTP 缓存里最容易被低估的响应头。它只占一行,却会直接决定缓存是否命中、不同用户会不会拿到错误内容,以及缓存对象数量是否突然膨胀。现在,Cache Rules 已在所有套餐中支持 Vary,开发者可以针对协商请求头选择三种策略:归一化已知值、保留精确值并传给源站,或者在变化不可控时绕过缓存。

Vary 到底改变了什么

假设源站返回:

HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Vary: Accept-Language
Content-Language: zh-CN

这表示响应不仅由 URL 决定,也由请求中的 Accept-Language 决定。共享缓存不能简单地把 /docs 当成一个对象,而要区分不同语言版本。

问题在于,真实请求头往往并不整齐:

Accept-Language: zh-CN
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Accept-Language: zh-cn
Accept-Language: zh-Hans-CN;q=1.0

如果源站最终都返回同一份中文内容,但缓存按照原始字符串建立变体,就可能产生四个缓存对象。请求头越自由,缓存基数越高,命中率也越低。

更危险的情况则相反:源站确实根据请求头返回不同内容,但缓存没有把该请求头纳入变体判断。这样,一个用户请求到的响应可能被错误地复用给另一个用户。

因此,Vary 不是简单的“开启或关闭缓存”,而是在定义缓存对象的身份。

三种策略,对应三类请求头

1. 归一化:适合可枚举的内容协商

Accept-LanguageAccept-Encoding 这类请求头通常包含权重、大小写和多个候选值。业务真正支持的结果却可能只有少数几种,例如:

zh-CN
 en

这时可以把大量输入映射到有限集合:

  • 所有中文偏好映射为 zh-CN
  • 其他语言统一映射为 en
  • 只保留平台实际支持的压缩编码

归一化可以显著减少缓存碎片,但有一个关键约束:缓存和源站必须对归一化结果达成一致。如果边缘把两个请求映射到同一个缓存键,源站却仍根据原始请求头返回不同正文,就会发生内容串用。

2. 保留精确值:适合差异小而且受控的场景

如果一个请求头的取值集合有限,并且细微差异确实会改变响应,可以保留精确值并传给源站。例如:

X-Theme: light
X-Theme: dark

这种方式忠实于源站逻辑,但要警惕大小写、空白、随机标识符或用户可控值。一个看似普通的请求头如果拥有成千上万个值,就会制造同样数量的缓存变体。

适合精确传递的请求头通常应满足:

  • 值来自有限白名单;
  • 每个值确实对应不同响应;
  • 客户端不能随意生成高基数值;
  • 源站与缓存使用同一套比较规则。

3. 绕过缓存:变化不可预测时更安全

如果响应受 Cookie、实验分组、用户身份或任意客户端输入影响,绕过缓存往往比构造复杂缓存键更可靠。

尤其需要注意:Vary 不是权限隔离机制。涉及 Authorization、会话 Cookie 或个人数据时,不能仅依赖 Vary 防止泄漏,还要正确设置缓存策略,并在必要时直接禁止共享缓存:

Cache-Control: private, no-store

Vary: * 也通常意味着该响应不适合被共享缓存复用。面对无法稳定描述的变化维度,不缓存比低命中率和错误复用更可控。

一个可以运行的语言协商示例

下面的 Flask 服务把任意中文偏好归一化为 zh-CN,其他请求统一返回英文。它同时声明 Vary: Accept-Language,让共享缓存知道响应会随该请求头变化。

先安装依赖:

python -m pip install flask

保存为 app.py

from flask import Flask, make_response, request

app = Flask(__name__)


def normalize_language(value: str) -> str:
    first_choice = value.split(",", 1)[0].strip().lower()
    if first_choice.startswith("zh"):
        return "zh-CN"
    return "en"


@app.get("/docs")
def docs():
    language = normalize_language(
        request.headers.get("Accept-Language", "en")
    )

    if language == "zh-CN":
        body = "缓存应只保存有限数量的语言变体。\n"
    else:
        body = "The cache should store only a bounded set of variants.\n"

    response = make_response(body)
    response.headers["Content-Type"] = "text/plain; charset=utf-8"
    response.headers["Content-Language"] = language
    response.headers["Cache-Control"] = "public, max-age=60"
    response.headers["Vary"] = "Accept-Language"
    return response


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=8000)

启动服务:

python app.py

在另一个终端测试:

curl -i -H 'Accept-Language: zh-CN,zh;q=0.9' http://127.0.0.1:8000/docs
curl -i -H 'Accept-Language: zh-Hans-CN;q=1.0' http://127.0.0.1:8000/docs
curl -i -H 'Accept-Language: en-US,en;q=0.8' http://127.0.0.1:8000/docs

前两个请求都会得到 Content-Language: zh-CN,第三个会得到 Content-Language: en。需要注意,本地 Flask 服务没有实现共享缓存;这个示例用于验证源站的归一化逻辑和响应头。部署到缓存层后,还应检查重复请求的缓存状态与缓存键行为。

如果要在 Cache Rules 中配置同类策略,可以把需求先写成下面这样的伪配置,再映射到实际规则界面。以下 YAML 只是设计草图,不代表特定厂商的 API 格式:

cache_policy:
  path: /docs*
  vary_by:
    header: Accept-Language
    strategy: normalize
    values:
      zh-CN:
        - zh
        - zh-CN
        - zh-Hans
      en:
        - en
        - en-US
    fallback: en

如果不能保证边缘归一化规则与源站一致,应改为精确传递;如果值集合根本无法约束,则应直接绕过缓存。

上线前不要只看一次命中

验证 Vary 策略时,至少覆盖以下检查项:

  • 相同 URL、相同归一化结果是否命中同一缓存对象;
  • 相同 URL、不同有效变体是否返回正确内容;
  • 大小写、空白和权重参数是否制造无意义的变体;
  • 源站看到的请求头是否与缓存键使用的值一致;
  • 未知值是否落入明确的默认分支;
  • Cookie、身份信息和个性化响应是否绕过共享缓存;
  • 规则变更后是否需要清理已有缓存对象。

最稳妥的采用顺序是:先统计线上请求头的实际取值,再定义有限的规范集合,随后灰度启用规则并观察缓存命中率、对象数量和错误响应。Vary 的目标不是让缓存键包含更多信息,而是只包含那些真正会改变响应的信息。


相关推荐