同一个 URL 可以向 API 客户端返回 JSON,也可以向另一个读者返回纯文本。Content-Type 说明这次选中了什么表示,Vary 则说明哪些请求字段参与了选择。如果缓存不知道应该怎样匹配后续请求,即使正文的类型标签完全正确,也可能把属于另一种偏好的响应送给当前读者。
分清格式、选择与复用
如果服务器根据请求的 Accept 字段进行协商,就应在实际响应类型之外返回 Vary: Accept。写成 Vary: Content-Type 指定的是请求里的类型字段,并不是响应的媒体类型;它通常不能表达客户端希望收到哪一种格式。缓存无法仅通过查看已存响应的类型,倒推出服务器选择该表示时使用的规则。目标 URI 与相关方法语义本来就参与普通缓存匹配,Vary 额外约束的是请求头字段。
压缩还涉及另一组容易混淆的概念。经过 gzip 的 JSON 仍然是 JSON 媒体类型,Content-Encoding: gzip 描述对表示施加的内容编码,不是传输编码或消息分帧。如果源站依据 Accept-Encoding 选择是否压缩,这个请求字段就是有关的 Vary 维度。只有真正参与选择的维度才需要纳入;浏览器的实际视口宽度不会自动变成一个请求头,也不能仅凭“这是移动端页面”就推导出缓存键应该包含什么。
Vary 不授予缓存资格,也不让一个过期响应重新变新。存储和复用还必须满足状态码、方法、认证以及缓存指令等独立条件。它更不是访问控制边界:增加 Vary: Cookie 并不能证明个人账户数据适合进入共享缓存。应先确定谁有资格接收响应、哪些缓存允许保存,再描述这些可复用表示之间的差异,不能把权限问题缩减成几个字段名。
坏掉的样子
当响应本身可以缓存且仍然新鲜时,漏写 Vary 可能让第一个请求决定后续读者拿到哪一种内容。直接访问源站一切正常,但走缓存时,后来的客户端不再回源,直接收到不兼容的表示。状态码仍是成功,Content-Type 也如实描述正文,却没有告诉读者这份响应属于另一种请求偏好。诊断时需要同时对照请求 Accept、响应类型和缓存复用证据,不能只看接口是否返回成功。
另一个常见错误是仅给非默认表示发送 Vary,默认响应则漏掉了这个字段。这样默认副本仍可能匹配本不适合它的请求。每一种表示都应该带上相互一致的选择元数据;中间件补充另一个维度时,也应保留已有维度,不能直接覆盖。相反,把所有请求头都放进去会造成不必要的缓存碎片。正确做法是核对真实选择逻辑,而不是通过扩大字段列表制造正确的错觉。
快速验证
准备 Python 3.11 或更新版本以及 POSIX shell。下面命令建立临时虚拟环境,安装本次验证使用的 CacheControl 与 Requests 版本。只有安装依赖需要外网;实验请求全部发送到回环地址,并禁用环境代理与 netrc 设置,避免本机网络配置或认证文件影响结果。这些包只是实验工具,不是网站运行依赖。
vary_lab=$(mktemp -d)
python3 -m venv "$vary_lab/venv"
"$vary_lab/venv/bin/python" -m pip install CacheControl==0.14.4 requests==2.34.2
在同一 shell 中用 "$vary_lab/venv/bin/python" 运行以下程序。服务端只识别两个固定的 Accept 值,不实现通配符、质量权重等通用协商规则;真实缓存行为由 CacheControl 提供,而不是用手写字典模拟 HTTP 缓存。
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import requests
from cachecontrol import CacheControl
representations = {
"application/json": b'{"format":"json"}\n',
"text/plain": b"format=plain\n",
}
hits = {"/vary": 0, "/no-vary": 0, "/star": 0}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
media = self.headers.get("Accept")
if self.path not in hits or media not in representations:
self.send_error(400)
return
hits[self.path] += 1
body = representations[media]
self.send_response(200)
self.send_header("Content-Type", media)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "public, max-age=300")
if self.path != "/no-vary":
self.send_header("Vary", "*" if self.path == "/star" else "Accept")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = Thread(target=server.serve_forever)
worker.start()
session = CacheControl(requests.Session())
session.trust_env = False
try:
base = f"http://127.0.0.1:{server.server_port}"
for route in hits:
for index, accept in enumerate([
"application/json", "application/json", "text/plain", "text/plain"
]):
response = session.get(base + route, headers={"Accept": accept}, timeout=3)
response.raise_for_status()
expected = "application/json" if route == "/no-vary" else accept
assert response.headers["Content-Type"] == expected
assert response.content == representations[expected]
cached = index > 0 if route == "/no-vary" else index in (1, 3)
if route == "/star":
cached = False
assert response.from_cache == cached
print(route, accept, "->", expected, "cached=", response.from_cache)
assert hits == {"/vary": 2, "/no-vary": 1, "/star": 4}
print("origin hits:", hits)
finally:
session.close()
server.shutdown()
server.server_close()
worker.join()
/vary 的命中标记依次为假、真、假、真:每一种偏好先回源一次,紧随其后的重复请求使用缓存。/no-vary 则依次为假、真、真、真,两个纯文本请求都错误地拿到了 JSON。程序特意断言这个故障确实出现,并不是把错误内容认定为业务可接受结果。两条路径的源站计数分别为二和一,独立证明哪些请求真正到达了服务端,而不只是相信客户端的命中标签。
/star 的四次请求全部回源。标准要求包含 * 的 Vary 值总是匹配失败,但不能把这句话改写成所有缓存都绝对禁止存储此类响应。本次使用的 CacheControl 版本选择不存储它们。实验也没有要求缓存必须同时保留同一地址的多种变体:实现可以替换一个条目,只要继续遵守请求匹配规则即可。不能从正确复用两种表示,额外推导出缓存内部采用了哪种数据结构。
验收实际部署的缓存时,应调换两种偏好的请求顺序,并使用隔离测试地址或经过批准的失效流程。源站刚刚补齐响应头,不代表已经缓存的错误条目会立即修复。还要针对真实代理或 CDN 单独测试,因为这里证明的是客户端缓存中的机制,不是生产共享缓存的配置、授权隔离或隐私保障。
延伸阅读
来源
- RFC 9110:Vary,选择字段与表示元数据,已核验 2026-09-07。
- RFC 9111:Vary 缓存匹配,匹配要求、星号行为与存储规则,已核验 2026-09-07。
- CacheControl 0.14.4,实验使用的 Requests 缓存封装,已核验 2026-09-07。