HTTP 304 的判定顺序:ETag 与 Last-Modified 谁先说话
HTTP 304 的判定顺序:ETag 与 Last-Modified 谁先说话
一个静态资源改了内容,浏览器却始终显示旧版本;换个场景,资源根本没动,每次刷新服务端都在重新传全量。两种现象看起来相反,根因常常是同一个:条件请求的判定顺序被理解错了。
两个校验器,两套比较规则
条件请求靠两个请求头工作:
If-None-Match,携带上次响应里的ETag,做实体标签比较;If-Modified-Since,携带上次响应里的Last-Modified,做时间比较。
浏览器两个都带是常态,服务端不能"两个都看、任一命中就 304"。RFC 9110 的规定是明确的:If-None-Match 存在时,If-Modified-Since 必须被忽略。只有在没有 If-None-Match 的情况下,才轮到时间比较。
GET /static/app.js HTTP/1.1
If-None-Match: "5f3a-9c1"
If-Modified-Since: Tue, 11 Nov 2026 02:10:04 GMT
这组请求的语义是:先看 "5f3a-9c1" 还算不算当前资源的标签。算,返回 304;不算,直接 200 返回新内容,不管 If-Modified-Since 写的什么。
很多手写的中间件会写成 if (etagMatch || mtimeNotNewer),这一步就把语义反过来了:只要文件时间没变,标签对不上也回 304,前端拿到的永远是缓存里那份旧的。
Last-Modified 只有秒级精度
Last-Modified 的时间戳精确到秒。文件系统里 mtime 可以是毫秒,但 HTTP 头会把它截断到秒。
于是出现一个稳定的失效场景:构建脚本在 02:10:04.200 写入文件,客户端缓存了 Last-Modified: 02:10:04;紧接着 02:10:04.700 又写了一次新内容,服务端仍然报 02:10:04。客户端下次请求带上 If-Modified-Since: 02:10:04,服务端比较 mtime <= 该时间 成立,回 304,新内容永远不会下发。
验证方法很直接,观察响应头与文件时间的差值:
stat -c '%y %s' dist/app.js
curl -sI https://example.com/static/app.js | grep -i -E 'etag|last-modified|content-length'
如果 Last-Modified 的秒值与 stat 输出的秒值相同、而 stat 的毫秒部分非零,就处在风险窗口内。构建流水线连续两次产出同名文件时,这个窗口几乎必然被踩中。
结论很硬:Last-Modified 只能当兜底,真正的校验器必须是 ETag,且 ETag 要由内容派生,不能由 mtime + size 派生。后者在"同尺寸快速覆盖"时同样撞车。
强校验与弱校验
ETag 有两种写法:
ETag: "a1b2c3" # 强校验
ETag: W/"a1b2c3" # 弱校验
强校验表示字节级完全一致;弱校验只承诺语义等价,允许网关做压缩、转码后仍视为同一实体。区别体现在比较算法上:If-None-Match 使用弱比较,两侧都按弱标签处理;而 If-Match、If-Range 使用强比较,弱标签一律不匹配。
这就是断点续传在开了 gzip 之后失效的原因:Accept-Ranges: bytes 存在,但 ETag 是 W/"...",If-Range 强比较失败,服务端退化成 200 全量返回,Content-Range 不再出现。
304 响应本身也要带缓存头
一个容易忽略的点:304 是"没有实体"的响应,但它仍然是完整的一次 HTTP 交互,Cache-Control、ETag、Vary 必须重新带上。如果 304 是反向代理自己生成的、而不是上游返回的,这些头很容易丢。
丢了会怎样?客户端按启发式规则重新估算新鲜期,或者干脆下次不再发条件请求,缓存行为就漂移了。检查方式:
curl -sI -H 'If-None-Match: "a1b2c3"' https://example.com/static/app.js
响应应为 HTTP/1.1 304 Not Modified,且头部包含 ETag、Cache-Control、Vary。缺任何一个,缓存的稳定性都要打问号。
no-cache 与 no-store 不是一回事
排查"改了没生效"时,先确认到底命中了哪条指令:
| 指令 | 是否存缓存 | 是否复用前是否回源校验 |
|---|---|---|
no-cache |
存 | 必须校验,可得到 304 |
no-store |
不存 | 每次都完整下载 |
max-age=0 |
存 | 视为立即过期,需校验 |
no-cache 的语义常被误读成"不缓存"。它其实允许缓存,只是每次使用前必须回源确认。所以资源设了 no-cache 却返回 304,是完全正常的行为——问题在服务端判定,不在客户端缓存策略。
Vary 缺失导致的串味
同一个 URL 因 Accept-Encoding 或 Accept-Language 返回不同实体时,必须声明 Vary。没有它,CDN 会把 gzip 版本喂给不支持压缩的客户端,或者把中文版页面发给英文请求。
一个隐蔽的连带效应:Vary: Accept-Encoding 会让缓存键分裂,Age 与 X-Cache 的表现随之变化。排查时用两个不同的 Accept-Encoding 各请求一次,比对 ETag 是否不同:
curl -sI -H 'Accept-Encoding: gzip' https://example.com/ | grep -i -E 'etag|vary'
curl -sI -H 'Accept-Encoding: identity' https://example.com/ | grep -i -E 'etag|vary'
如果两次 ETag 不同而 Vary 缺失,缓存串味只是时间问题。
落地检查清单
ETag由内容哈希生成,不使用mtime、size或进程启动时间;- 条件请求按
If-None-Match优先、If-Modified-Since兜底的顺序判定,不做逻辑或; - 304 响应带上
ETag、Cache-Control、Vary; - 需要断点续传的资源不使用弱
ETag; - 构建产物内容寻址(文件名带哈希),从根上绕开秒级精度问题。
前四条改动都在服务端几行代码之内,第五条才是真正把问题消掉的做法——内容变了 URL 就变,缓存不需要判断新旧,只需要判断存在与否。
- 点赞
- 收藏
- 关注作者
评论(0)