HTTP 304 的判定顺序:ETag 与 Last-Modified 谁先说话

举报
yd_228692151 发表于 2026/11/11 06:48:03 2026/11/11
【摘要】 HTTP 304 的判定顺序:ETag 与 Last-Modified 谁先说话一个静态资源改了内容,浏览器却始终显示旧版本;换个场景,资源根本没动,每次刷新服务端都在重新传全量。两种现象看起来相反,根因常常是同一个:条件请求的判定顺序被理解错了。 两个校验器,两套比较规则条件请求靠两个请求头工作:If-None-Match,携带上次响应里的 ETag,做实体标签比较;If-Modifie...

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 缺失,缓存串味只是时间问题。

落地检查清单

  1. ETag 由内容哈希生成,不使用 mtime、size 或进程启动时间;
  2. 条件请求按 If-None-Match 优先、If-Modified-Since 兜底的顺序判定,不做逻辑或;
  3. 304 响应带上 ETag、Cache-Control、Vary;
  4. 需要断点续传的资源不使用弱 ETag;
  5. 构建产物内容寻址(文件名带哈希),从根上绕开秒级精度问题。

前四条改动都在服务端几行代码之内,第五条才是真正把问题消掉的做法——内容变了 URL 就变,缓存不需要判断新旧,只需要判断存在与否。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。