源站定义存储和新鲜度策略,缓存判断已保存的响应是否能满足下一次请求。无法直接复用时,验证器可以降低检查成本。这是几个相互独立的决定:在一次成功响应中找到 Cache-Control 和 ETag,并不能证明下一次请求会按预期执行。验收必须沿着重复请求继续观察,才能区分直接命中、成功验证和完整替换。
允许谁复用、可复用多久
private 把存储范围限制到单用户缓存,public 在其他条件允许时明确授予缓存许可。max-age 提供新鲜度寿命,s-maxage 则为共享缓存覆盖这个值。no-cache 允许保存,但要求每次复用前成功验证;no-store 禁止 HTTP 缓存存储,并不管住所有应用日志或用户保存的文件。这些指令都不能替代授权检查和传输安全,不能因为缓存头正确就省略访问控制。
对于带内容指纹的资源,如果字节变化一定带来新网址,public, max-age=31536000, immutable 可以是合理策略。这里的时长是一年,不是一个月。稳定网址下会变化的文档,应按更新要求选择策略:短寿命搭配验证器,允许有界的直接复用;no-cache 则要求后续每次 HTTP 缓存复用都先验证。两者的网络请求成本和可见更新延迟并不一样。
验证器回答另一个问题
客户端可以在 If-None-Match 中带回 ETag。条件 GET 的标签匹配时得到不含正文的 304;标签不匹配时,通常得到包含当前表示的 200。If-None-Match 使用弱比较,因此弱标签同样可以用于缓存验证。Last-Modified 支持 If-Modified-Since,但秒级时间戳可能遗漏快速连续变化。请求中只要存在 If-None-Match,就忽略 If-Modified-Since,而不是等标签匹配失败后再用日期兜底。
收到适用的 304 后,缓存保留原有正文,并依照缓存规则更新元数据。对于对应的 200 本来会发送的 Date、ETag、Cache-Control 等字段,源站在 304 中也有相应发送要求。一个只负责下载响应的程序,不会因此自动具备完整缓存能力;如果把 304 的空内容直接当作新的文档覆盖旧正文,反而会丢掉真正要使用的内容。
坏掉的样子
正文已经发生有意义的变化,ETag 却永远不变,可能错误确认旧副本。相反,相同正文每次都生成新标签,会导致本可避免的完整传输。两种缺陷都必须通过重复请求才能识别。强标签需要区分不同表示数据,包括不同内容编码的变体;对实际字节计算哈希是一种实现方法,但客户端应把标签当作不透明值,不必把哈希算法当成公共接口。
修改源站策略不能召回已经新鲜、暂时没有理由再联系源站的缓存副本。同样,个人 JSON 上的短有效期也不能隔离用户,缓存键与账户切换必须另外测试。浏览器历史恢复和应用自行管理的缓存是额外机制,因此 HTTP 测试通过,不代表当前显示的页面一定立即刷新。应把验证结论限定到实际观察过的交互路径。
快速验证
准备 Python 3.11 或更新版本及 POSIX shell。安装 Werkzeug 3.1.8 后,用 "$validation_lab/venv/bin/python" 执行 Python 代码。它在自动分配的回环端口启动开发服务器,结束后关闭服务和线程。安装需要网络,七次请求都只访问本机。这是源站协议测试,不是生产部署方案,也不是浏览器缓存模拟器。
validation_lab=$(mktemp -d)
python3 -m venv "$validation_lab/venv"
"$validation_lab/venv/bin/python" -m pip install Werkzeug==3.1.8
import hashlib
from datetime import datetime, timezone
from http.client import HTTPConnection
from threading import Thread
from werkzeug.serving import make_server
from werkzeug.wrappers import Request, Response
state = {"body": b"release A\n", "modified": datetime(2026, 1, 1, tzinfo=timezone.utc)}
directive = "public, max-age=0, must-revalidate"
@Request.application
def application(request):
if request.path != "/document":
return Response("not found\n", status=404)
if request.method != "GET":
return Response("GET only\n", status=405, headers={"Allow": "GET"})
body = state["body"]
response = Response(body, mimetype="text/plain")
response.headers["Cache-Control"] = directive
response.set_etag(hashlib.sha256(body).hexdigest())
response.last_modified = state["modified"]
return response.make_conditional(request)
server = make_server("127.0.0.1", 0, application)
worker = Thread(target=server.serve_forever)
worker.start()
def get(headers=None):
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
try:
connection.request("GET", "/document", headers=headers or {})
response = connection.getresponse()
body = response.read()
assert response.headers["Cache-Control"] == directive
assert response.headers["ETag"] and response.headers["Date"]
print(response.status, "bytes:", len(body))
return response.status, response.headers, body
finally:
connection.close()
try:
status, first, body = get()
assert status == 200 and body == b"release A\n"
status, headers, body = get({"If-None-Match": first["ETag"]})
assert status == 304 and body == b"" and headers["ETag"] == first["ETag"]
status, _, body = get({"If-None-Match": '"different"',
"If-Modified-Since": first["Last-Modified"]})
assert status == 200 and body == b"release A\n"
status, _, body = get({"If-None-Match": "W/" + first["ETag"]})
assert status == 304 and body == b""
state.update(body=b"release B\n", modified=datetime(2026, 1, 2, tzinfo=timezone.utc))
status, second, body = get({"If-None-Match": first["ETag"]})
assert status == 200 and body == b"release B\n"
assert second["ETag"] != first["ETag"]
status, _, body = get({"If-None-Match": second["ETag"]})
assert status == 304 and body == b""
status, _, body = get({"If-Modified-Since": second["Last-Modified"]})
assert status == 304 and body == b""
finally:
server.shutdown()
server.server_close()
worker.join()
预期状态依次是 200、304、200、304、200、304、304。每次 200 携带十字节正文,每次 304 都没有正文。冲突验证器请求虽然日期匹配,仍返回 200,证明实体标签优先,而不是把两个条件当成任选其一。弱形式的标签仍能验证未变的表示。A 与 B 的正文长度相同,变更测试还排除了只拿长度当验证器的错误实现。
最后两次分别使用 B 的新 ETag 和单独的 Last-Modified 进行验证。程序在全部响应中检查 Cache-Control、Date 和 ETag。max-age 为零并配合 must-revalidate,允许存储但在过期后要求先成功验证;这不会让 Python 的 HTTP 客户端自动缓存。每次请求都是程序明确构造的,条件解析和实际发出响应时的正文抑制则由 Werkzeug 处理。
部署测试应保留正文变更和验证器冲突案例。检查真正的 GET 响应,而不只看 HEAD,还应确认中间层没有破坏所选表示对应的验证器。合格结果既包括未变时避免传输,也包括内容确实改变时完整替换。只统计 304 越多越好,会漏掉错误验证器把旧内容持续确认成有效副本的风险。
延伸阅读
来源
- RFC 9111:缓存验证,存储正文复用与元数据更新,已核验 2026-09-07。
- RFC 9110:条件请求,实体标签比较与前置条件优先级,已核验 2026-09-07。
- MDN:Cache-Control,存储指令和按资源设定新鲜度,已核验 2026-09-07。
- Werkzeug 条件响应,实际测试的响应接口与正文处理,已核验 2026-09-07。