症状
两个失败方向共用一件仪器。方向一——陈旧的 304:内容变了、部署发生了,ETag 却纹丝不动,因为它在进程启动时计算、与响应体无关,If-None-Match 命中旧验证器,客户端拿到“未修改”的裁决继续用旧内容。方向二——永不清缓存:ETag 嵌入请求时间或随机盐,每次都是新值,If-None-Match 永不匹配,每次都 200。两者都是派生 bug,两跳 curl 即可暴露:内容未变必须 304、内容已变必须 200 加新 ETag。第三个变体是缺 Vary——一个存储的变体满足所有请求,语言或凭证维度的差异被同一个 ETag 抹平。
诊断思路
先用 If-None-Match 直接测验证器两个方向;再查 ETag 的派生源——mtime、启动时常量、随机盐都是错误来源,正确的是响应体或其内容依赖的哈希;最后检查可缓存性阻断项:Set-Cookie 出现在共享响应上、缺 Vary、意外的 no-store,都会让最好的验证器失效。
根因
条件式 304 的正确性完全取决于验证器。从不变量派生的 ETag 在内容变化时不变,服务陈旧 304;不稳定的验证器永不匹配,缓存形同虚设;缺 Vary 让一个存储变体去满足正确响应本会不同的请求。
修复与验证
验证器从响应体的真实内容派生:体哈希加所有 Vary 维度(如 Accept-Language)参与计算,配强验证器(不带 W/)与 must-revalidate;静态资源交给构建——内容哈希文件名(app.a91f.js)让验证器问题变得平凡,immutable 永久缓存;清掉阻断项:中间件往共享响应注入 Set-Cookie 会把它变成不可缓存,no-store 会覆盖一切。验证时未变内容返回 304 且验证器相同、已变内容返回 200 且带新验证器、两个语言变体在正确的 Vary 下各自独立缓存。弱验证器允许语义相等但会破坏字节范围与严格中间件——按字节变化的内容用强验证器。
代码示例
$ curl -sI https://app.example.com/data
ETag: "v1-boot-8a01" <- computed at process start
# content changes, deploy happens:
$ curl -sI https://app.example.com/data
ETag: "v1-boot-8a01" <- unchanged: the validator ignores the body
const hash = crypto.createHash('sha1')
.update(JSON.stringify(data))
.update(acceptLanguage) // every Vary dimension participates
.digest('base64');
res.set({
'ETag': `"${hash}"`, // strong validator
'Vary': 'Accept-Language', // the changing dimension
'Cache-Control': 'public, max-age=0, must-revalidate',
});