只有旧表示确实仍可替代当前表示,304 才有价值。ETag 提供的是源站对版本的声明,不会独立检查这个声明是否诚实。错误的标签会让条件请求高效地保留错误内容。因此在把传输减少当作性能改善之前,既要测试未变化的数据,也要测试有意义的内容变更,不能只追求更多的 304。
校验器标识版本
实体标签是带引号的不透明标识。客户端应按收到的形式带回它,不应擅自当作时间戳解释,也不应移除弱标签标记。生成器可以使用版本编号或抗碰撞摘要。当最终字节已经存在时,摘要比较方便;如果每次为了返回 304,仍要先完整渲染并计算一份昂贵文档的哈希,源站的计算成本可能并没有明显下降。省掉正文传输不等于省掉所有工作。
强与弱是不同的承诺
所选表示的数据变化时,强验证器必须变化。带缩进的 JSON 与紧凑 JSON 即使解析成同一个对象,字节也不同;gzip 与未编码形式同样具有不同表示数据,强标签必须保留这种区别。长度相同,或者底层数据库的粗粒度时间戳相同,都不能证明逐字节一致。因此验证器的设计要贴近真正返回的表示,而不是只观察某个间接指标。
带 W/ 的弱标签可以把源站认为可互换的表示归为一组。若 JSON 接口契约不关心排版,一个语义版本可以同时覆盖紧凑和缩进序列化。实际字段值变化后,如果旧对象已不再是可接受的替代品,就必须更新该版本。弱验证表达的是明确的等价策略,不是任意变化都允许沿用标签,更不是可以永远不维护版本信息。
If-None-Match 使用弱比较完成缓存验证。条件 GET 匹配时返回不带正文的 304,不匹配时通常返回当前表示与 200。If-Match 则使用强比较,这关系到更新前置条件;客户端也不能用弱标签作为 If-Range 的验证器。这几个字段的契约不同,先去掉 W/ 再统一做字符串比较,会把本来不同的语义混成一件事。
坏掉的样子
变化数据搭配固定标签,可能产生错误的 304,让客户端继续使用旧副本。另一个方向的错误,是完全相同的数据每次生成新标签,导致多余正文传输。添加很长的 max-age 无法修复这两种问题。新鲜度决定何时需要验证,验证决定已有响应是否仍然可用;成功的 304 也不保证应用解析、渲染或业务计算自然消失。
比较必须发生在选择表示和正常访问检查之后,匹配的验证器不是授权凭证。协商响应还需要保留对应的 Vary 维度,并正确区分表示数据。标签列表应交给真正的解析器,因为合法的带引号标签内部可以包含逗号;直接按逗号拆字符串,会破坏本来有效的标识。下面的测试会同时检查这一点,而不仅验证最简单的单个标签。
快速验证
准备 Python 3.11 或更新版本和 Werkzeug 3.1.8,创建临时环境后,用 "$etag_lab/venv/bin/python" 执行 Python 代码。测试客户端在进程内执行 WSGI 应用,不监听端口,也不发网络请求;只有安装依赖需要联网。故意写错的端点是反例测试,不能作为实际部署实现。
etag_lab=$(mktemp -d)
python3 -m venv "$etag_lab/venv"
"$etag_lab/venv/bin/python" -m pip install Werkzeug==3.1.8
import hashlib
import json
from werkzeug.http import parse_etags
from werkzeug.test import Client
from werkzeug.wrappers import Request, Response
state = {"value": 1, "revision": 1, "indent": None}
def body():
return json.dumps({"value": state["value"]}, indent=state["indent"]).encode()
@Request.application
def application(request):
if request.path not in {"/strong", "/weak", "/broken"}:
return Response(status=404)
response = Response(body(), mimetype="application/json")
response.headers["Cache-Control"] = "no-cache"
if request.path == "/strong":
response.set_etag(hashlib.sha256(response.data).hexdigest())
elif request.path == "/weak":
response.set_etag(f"record-{state['revision']}", weak=True)
else:
response.set_etag("fixed") # Deliberately incorrect failure fixture.
return response.make_conditional(request)
client = Client(application)
def get(route, tag=None):
response = client.get(route, headers={"If-None-Match": tag} if tag else {})
print(route, response.status_code, "bytes:", len(response.data))
return response
first = get("/strong")
assert first.status_code == 200 and first.json == {"value": 1}
same = get("/strong", first.headers["ETag"])
assert same.status_code == 304 and same.data == b""
state["indent"] = 2
formatted = get("/strong", first.headers["ETag"])
assert formatted.status_code == 200 and formatted.json == first.json
assert formatted.data != first.data and formatted.headers["ETag"] != first.headers["ETag"]
weak = get("/weak")
state["indent"] = None
assert body() != weak.data and json.loads(body()) == weak.json
equivalent = get("/weak", weak.headers["ETag"])
assert equivalent.status_code == 304 and equivalent.data == b""
state.update(value=2, revision=2)
changed = get("/weak", weak.headers["ETag"])
assert changed.status_code == 200 and changed.json == {"value": 2}
assert changed.headers["ETag"] != weak.headers["ETag"]
tags = parse_etags('"unrelated", W/"record-2", "a,b"')
assert tags.contains_weak("record-2") and not tags.contains("record-2")
assert tags.contains("a,b")
print("weak match: True; strong match: False; quoted comma preserved")
broken = get("/broken")
state.update(value=3, revision=3)
assert json.loads(body()) != broken.json
false_confirmation = get("/broken", broken.headers["ETag"])
assert false_confirmation.status_code == 304 and false_confirmation.data == b""
print("broken validator: changed content was incorrectly confirmed")
强标签端点依次返回 200、304、200。排版让正文从十二字节变为十六字节,即使解析后的对象相同,摘要也变化。弱标签端点同样依次返回 200、304、200,但原因不同:仅修改排版仍可等价复用,修改字段值并更新版本则不能。两次正常的 304 都不携带正文,测试没有拿空响应覆盖原有 JSON 对象。
解析器检查允许弱比较匹配该版本,却不允许把它当作强比较匹配,同时保留引号内的逗号。最后的错误端点先返回 200,随后在当前对象已经变化时仍返回 304。这个断言有意记录源站的错误行为:框架相信应用传入的标签,不会发现应用把错误的版本声明附到了正文上。测试通过意味着反例被重现,不意味着这种实现是正确的。
本实验不验证范围拼装、并发写入、授权或共享缓存。生产环境中的版本变更必须和表示数据关联,写操作前置条件还要与更新原子地检查。应保留这个“内容已变”的反例测试,因为很高的 304 比例既可能意味着正确节省,也可能意味着旧答案被错误地持续确认,单看比例无法分辨两者。
延伸阅读
来源
- RFC 9110:验证器,强弱表示声明,已核验 2026-09-07。
- MDN:ETag,不透明标识与验证器用途,已核验 2026-09-07。
- Werkzeug 实体标签集合,强弱成员比较,已核验 2026-09-07。
- Werkzeug 测试客户端,进程内的 WSGI 请求测试,已核验 2026-09-07。