入门指南

文件改了、页面没变:ETag 是不透明校验器,304 只证明你手里那份还能用

部署成功、线上也是新的,用户却还看到旧页面:ETag 与 Last-Modified 是缓存用来判断「你手里那份还算不算数」的两个校验器,304 只说明校验器相等,不证明内容相等。讲清强校验与弱校验的边界、304 必须带哪几个头,以及「校验器没变」和「304 少了头」为什么是两类不同的事故。

304 只证明校验器相等,不证明内容相等。

作者
AOPC 编辑部
发布
AOPC 编辑部
发布时间
更新
2026/11/1
核查
已复核
一个人在白板上把「文件已改」与「缓存仍说没改」写成两列,中间连着一个写着 ETag 的校验器
经营者责任与 AI 执行层的关系

先看答案

适合谁
  • 改完前端资源或页面就上线、靠「刷新一下」交付的人
  • 线上 curl 是新内容、用户截图却是旧版本的人
  • 被 CDN 控制台里的 304 命中率绕晕、不知道该查哪一层的人
你会获得
先分清是内容没上线,还是校验器没跟着变。
发布时间
实质更新
发布
AOPC 编辑部

60 秒结论

一分钟看懂这篇文章

304 只证明校验器相等,不证明内容相等。

人负责

  • 判断这次改动该不该换校验器
  • 分清是内容没上线还是校验器没变

AI 负责

  • 读 200 与 304 的头部差异
  • 用条件请求复现 304 并比对字节
  1. 对齐校验器
  2. 发条件请求
  3. 读 304 的头
  4. 回源比对字节

直接答案

「我文件明明改了,浏览器还说没改。」

这句话里有两个判断被混在一起:服务端认为内容变了没有,和缓存认为它手里那份还能不能用。前者由你的发布流程决定,后者由校验器(validator)与 304 决定。两者不是同一件事。

响应 谁发的 字面意思 实际发生了什么
200 + 完整内容 源站 这是当前选中的表示 缓存用新内容替换旧条目
304 源站或中间缓存 条件为假,不必再传 缓存继续用它存着的那份内容,只更新头部
412 源站 前置条件失败 非 GET/HEAD 的条件请求被拒绝

304 的原文定义是:

The 304 (Not Modified) status code indicates that a conditional GET or HEAD request has been received and would have resulted in a 200 (OK) response if it were not for the fact that the condition evaluated to false.

紧接着一句更关键:

the server is therefore redirecting the client to make use of that stored representation as if it were the content of a 200 (OK) response.

也就是说:304 里没有内容,因为内容在客户端手里。服务端做的是一次背书,不是一次传输;背书的对象是校验器,不是文件的字节。

三句话记住这条链路:

  1. 校验器相等 → 服务端回 304 → 缓存继续用旧副本;
  2. 校验器不等 → 服务端回 200 加新内容 → 缓存替换;
  3. 校验器没跟着内容变 → 第 1 条会一直成立,而这是服务端的责任。

下面按这三点拆开:校验器是什么、谁有资格发 304、304 里必须带什么,以及出事时怎么分清是哪一类。

一、ETag 是「不透明校验器」,不是内容哈希

RFC 9110 对 ETag 的第一句定义就排除了「哈希」这个直觉:

An entity tag is an opaque validator for differentiating between multiple representations of the same resource, regardless of whether those multiple representations are due to resource state changes over time, content negotiation resulting in multiple representations being valid at the same time, or both.

opaque 是关键:客户端只拿它做相等比较,不需要、也不应该知道它怎么生成。规范把这一点写得很直白:

Since the value is opaque, there is no need for the client to be aware of how each entity tag is constructed.

语法上它只是一个带引号的不透明字符串,可以带一个弱标记前缀:

ETag: "xyzzy"
ETag: W/"xyzzy"
ETag: ""

生成方式规范给了四种例子,全是实现自由:内部修订号、内容哈希、若干文件属性的组合、带亚秒精度的修改时间戳。所以下面这些推论都不能从 ETag 的值本身读出来:

  • 不能反推内容。看到 "a1b2c3" 不代表内容是某个 MD5。
  • 不能跨资源比较。规范明确 there is no implication of uniqueness across representations of different resources:两个不同 URL 上出现同样的 ETag,不代表内容相同。
  • 不能当成版本号。你可以用它判断「是不是同一份」,不能用它判断「哪一份更新」。

反过来,这也解释了一个常见事故:ETag 变了不等于内容该变,ETag 没变也不等于内容没变。它只保证一件事——按照你的生成逻辑,这两个表示该不该被当成同一份。

二、W/ 是服务端对自己校验器的降级声明

规范把校验器分成强、弱两类,判据不是位数,而是「内容变了它会不会跟着变」:

A “strong validator” is representation metadata that changes value whenever a change occurs to the representation data that would be observable in the content of a 200 (OK) response to GET.

弱校验器的定义正好是它的反面:

a “weak validator” is representation metadata that might not change for every change to the representation data.

强校验器有一个容易被忽略的附加条款:它可以因为表示元数据变化而变(原文举的例子是 Content-Type),但规范同时说,为了不白白让远端缓存失效,最好只在必要时才变。这意味着「同样的字节、不同的 Content-Type」到底该不该共用 ETag,是一个实现判断,不是规范强制。

而弱校验器不是「随便写写」,它是必须声明的:

If an origin server provides an entity tag for a representation and the generation of that entity tag does not satisfy all of the characteristics of a strong validator, then the origin server MUST mark the entity tag as weak by prefixing its opaque value with “W/” (case-sensitive).

还有一条特别容易踩的:同一个校验器同时用在两种表示上,它就是弱的。规范的原例是 gzip 压缩版与非压缩版共用同一个 ETag——除非两份内容本来就完全一致。这解释了为什么「源站 ETag 和边缘 ETag 不一样」有时不是配置错误,而是压缩层在替你做决定。

两者的可用范围也不同:

强校验器 弱校验器
内容变了会不会变 会(可观察的变化一定变) 可能不变
缓存校验(If-None-Match) 可用 可用(规范要求按弱比较)
范围请求(Range / If-Range) 可用 不可用
防止丢更新(If-Match) 可用 不可用

原文的总结是 Strong validators are usable for all conditional requests,而 Weak validators are only usable when the client does not require exact equality with previously obtained representation data。缓存校验恰好属于「不需要精确相等」的那一类——这正是下一节要讲的、最常被误读的一条。

三、Last-Modified 的默认身份是「弱」

很多人以为 Last-Modified 是比 ETag 更「实在」的时间戳。规范给的默认结论相反:

A Last-Modified time, when used as a validator in a request, is implicitly weak unless it is possible to deduce that it is strong.

原因是分辨率。HTTP-date 只到秒,而文件可以在同一秒内被改两次:

Likewise, a representation’s modification time, if defined with only one-second resolution, might be a weak validator if it is possible for the representation to be modified twice during a single second and retrieved between those modifications.

规范也正是拿这一点解释 ETag 为什么更可靠:

An entity tag can be more reliable for validation than a modification date in situations where it is inconvenient to store modification dates, where the one-second resolution of HTTP-date values is not sufficient, or where modification dates are not consistently maintained.

两条硬约束值得记下来:

  • 不能给出未来的修改时间。原文是 MUST NOT generate a Last-Modified date that is later than the server's time of message origination;如果实现里的时间戳落在未来,必须用响应的 Date 顶替。服务器时钟被校正、或文件从备份恢复时,这一条很容易被违反。
  • If-Modified-Since 会被忽略的四种情况:请求里同时有 If-None-Match(MUST ignore,因为后者更准确)、方法是 GET/HEAD 之外、字段值不是合法 HTTP-date 或有多个成员、资源没有可用的修改时间。换句话说,只要客户端发了 If-None-Match,你精心维护的 Last-Modified 根本不参与判定。

这也是一人公司最常见的校验器事故来源:构建脚本把文件 mtime 当校验器,而复制、打包、镜像层缓存都可能把时间戳原样带过去;同一秒内连续两次构建,第二次的时间戳和第一次一模一样。内容换了,校验器没换,304 就会一直发下去。

四、304 只在 GET/HEAD 上出现

条件请求头的判定是有固定顺序的,规范写死为 If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since。和缓存校验相关的是第三步:

  • if false for GET/HEAD, respond 304 (Not Modified)
  • if false for other methods, respond 412 (Precondition Failed)

所以:

  • 一条 PUT 或 POST 收到 304,不是正常语义,要么是中间层自作主张,要么是日志读错了行。
  • If-None-Match 的比较函数是弱比较,这是 MUST,不是建议:

A recipient MUST use the weak comparison function when comparing entity tags for If-None-Match, since weak entity tags can be used for cache validation even if there have been changes to the representation data.

也就是说 "abc" 与 W/"abc" 在 If-None-Match 里算匹配。这正是「弱 ETag 能不能用于缓存校验」的答案:能,而且是规范要求的用法。

If-None-Match: * 是另一种形态:只要目标资源当前存在任何表示,条件即为假。它常被用在「我确信这个资源还不存在」的创建型请求上,用来避免覆盖别人刚写进去的内容。

五、304 里必须带哪几个头

这是最容易被「反正没内容」糊弄过去的一节。规范用的是 MUST generate:

The server generating a 304 response MUST generate any of the following header fields that would have been sent in a 200 (OK) response to the same request:

  • Content-Location, Date, ETag, and Vary
  • Cache-Control and Expires (see [CACHING])

注意限定语是「如果 200 里会发」。200 里没有 Content-Location,304 里也不需要凭空造一个;但 200 里有 ETag,304 就必须原样带上。

反过来,规范也不鼓励多带:

a sender SHOULD NOT generate representation metadata other than the above listed fields unless said metadata exists for the purpose of guiding cache updates (e.g., Last-Modified might be useful if the response does not have an ETag field).

以及一条常被忽略的形态约束:

A 304 response is terminated by the end of the header section; it cannot contain content or trailers.

逐个头看它为什么非带不可:

头 在 304 里起什么作用 漏了会怎样
ETag 让缓存认出这条 304 对应哪一份存着的响应 缓存无法匹配,这条 304 白跑(见下一节)
Date 刷新条目时间;新鲜度是按 Date 起算的 条目一直按旧时间算,可能永远「不新鲜」
Vary 决定这份缓存能被哪些后续请求复用 变体选择失去依据,容易串内容
Cache-Control / Expires 更新新鲜度策略 旧值被保留,策略改了也不生效
Content-Location 200 里有就必须带 缓存不知道这条响应代表哪个 URI

最后一个细节来自缓存侧:更新头部时,Content-Length 是被排除在外的字段之一。所以不要拿 304 的 Content-Length 去推断新内容有多大——它根本不参与更新。

六、缓存拿到 304 之后,更新的是哪一条

这一步决定了「304 到底有没有用」。规范给了一个三段的筛选顺序:

  • If the new response contains one or more “strong validators” … then each of those strong validators identifies a selected representation for update. … If none of the initial set contains at least one of the same strong validators, then the cache MUST NOT use the new response to update any stored responses.
  • If the new response contains no strong validators but does contain one or more “weak validators” … then the most recent of those matching stored responses is identified for update.
  • If the new response does not include any form of validator … and there is only one stored response in the initial set, and that stored response also lacks a validator, then that stored response is identified for update.

把它翻译成操作语言:

  1. 304 里有强校验器 → 按强校验器精确匹配存着的那几条;一条都对不上,就一条都不许更新。
  2. 304 里没有强校验器、但有弱校验器 → 匹配上「最近的那一条」。
  3. 304 里什么校验器都没有 → 只有当缓存里恰好只存了一条、且那条也没有校验器时,才更新。

而「更新」这个动作本身也有限定:

the cache MUST add each header field in the provided response to the stored response, replacing field values that are already present

是「加进去、覆盖已有值」,不是「用 304 整体替换」。没出现在 304 里的头,旧值会留着。

由此可以推出一条本文最重要的机制(属编辑推论,依据是上面两段原文的合成):如果 304 漏了 ETag,或 ETag 与缓存里那条对不上,这条 304 就无法更新任何存储响应——包括 Date。 Date 不刷新,新鲜度就一直从旧时间起算,条目永远是「不新鲜」,于是每次请求都再发一次条件请求、每次都拿到 304。现象是「状态码一直 304、内容一直是旧的」,而两边的日志看上去都很正常。

七、两类不同的事故

同样是「用户看到旧内容」,成因分两类,处理方式完全不同。

A 类:内容变了,校验器没变

服务端每次都诚实地回答「你手里那份还有效」,因为它手里的校验器确实没变。常见来源:

  • 校验器取错了对象。用文件 mtime、文件大小或某个不随内容变化的属性生成 ETag;这次改动没动到那个属性。
  • 同一秒内两次构建。mtime 是秒级,第二次构建的时间戳与第一次相同。
  • 时间戳被沿途保留。打包、复制、镜像层缓存把 mtime 原样带过去,构建产物换了、时间戳没换。
  • 压缩层共用校验器。同一 ETag 同时给 gzip 与 identity 两份表示用;按规范它只能是弱校验器,强比较时对不上、弱比较时又分不开。
  • 发布产物根本没换。构建缓存复用了旧文件,线上的字节确实没变——这不是校验器问题,但表现一模一样。

B 类:内容变了,304 没把新信息带回来

校验器换了,但 304 里的头不全或对不上:

  • 304 缺 ETag → 缓存匹配不到任何存储响应 → 不更新。
  • 边缘把 ETag 改写成了自己的一套 → 客户端拿的是边缘的、回源验证用的是源站的,两边对不上。
  • 304 缺 Cache-Control / Expires → 旧策略留着,你以为改过的缓存时长没生效。
  • 304 带正文或 Transfer-Encoding → 不符合「不能有内容或 trailer」的形态约束,多半是中间层拼出来的。

分诊表:

现象 先查什么 判据
每次都是 304,内容一直旧 200 与 304 的 ETag 是否相同 相同 → A 类;不同或缺 → B 类
自己 curl 是新的,用户是旧的 用户那条请求的响应头 用户侧 304 而 curl 200 → 校验器分叉
换了 CDN 节点就正常 各节点返回的 ETag 节点间不一致 → 边缘改写
改了 Cache-Control 不生效 304 里有没有这个头 没有 → 旧值仍在生效
PUT 收到 304 中间层 规范只为 GET/HEAD 定义 304

八、十分钟核对

下面五组命令按顺序跑,足以把 A 类与 B 类分开。示例域名与 ETag 都是占位值,换成你自己的。

# 1. 先看服务端到底发了什么
curl -sSI https://www.example.com/app.js \
  | grep -iE '^(HTTP/|etag|last-modified|cache-control|vary|age|date|content-length)'

# 2. 带上 ETag 再问一次:应当 304,且 ETag 原样返回
curl -sS -o /dev/null -D - \
  -H 'If-None-Match: "abc123"' https://www.example.com/app.js

# 3. 内容是否真的换了:把线上字节和本地产物比一次
curl -sS https://www.example.com/app.js | shasum -a 256
shasum -a 256 dist/app.js

# 4. 两种编码下的 ETag 是否相同(相同即说明它只能当弱校验器用)
curl -sSI -H 'Accept-Encoding: identity' https://www.example.com/app.js | grep -i '^etag'
curl -sSI --compressed https://www.example.com/app.js | grep -i '^etag'

# 5. 连发两次,看 Age 与 Date 是不是在动
curl -sSI https://www.example.com/app.js | grep -iE '^(age|date|etag)'
curl -sSI https://www.example.com/app.js | grep -iE '^(age|date|etag)'

判读表:

看到 说明 下一步
带 If-None-Match 仍回 200 校验器不匹配 比对两次响应的 ETag 是否一致
304 但不含 ETag 服务端或边缘漏发 源站与边缘各测一次
304 的 ETag 与 200 不同 中间层改写了 绕过边缘直连源站复测
ETag 相同但字节不同 校验器没跟着内容变 换生成方式(见下节清单)
304 带正文或 Transfer-Encoding 不符合规范形态 查中间层与框架
Last-Modified 在未来 时钟或时间戳有问题 按 Date 顶替并修正来源

九、上线前十二项清单

  1. 每个可缓存的静态资源都发 ETag,且它随内容变。
  2. ETag 的生成不依赖秒级 mtime 作为唯一输入。
  3. 生成方式不满足强校验特征时,明确加 W/ 前缀。
  4. gzip 与 identity 两份表示不要共用同一个强校验器。
  5. 200 里发 Vary 的,304 里也发同一个值。
  6. 304 原样回带当前 ETag,不要重新算一个。
  7. 304 里带 Cache-Control / Expires(如果 200 里会发)。
  8. 304 不带正文、不带 trailer。
  9. Last-Modified 不晚于 Date。
  10. 边缘不改写、不剥离源站的 ETag;改了就要能说清为什么。
  11. 发布核对同时比「状态码」和「字节哈希」,不只比状态码。
  12. 把「这条 URL 的校验器由什么生成」写进仓库,换人也能查。

十、六种误读,与本周能做的五件事

误读一:「ETag 就是内容哈希。」 原文是 opaque validator,生成方式有四种以上的合法选择。哈希只是其中一种,而且不要求客户端知道。

误读二:「304 说明内容没变。」 304 说明的是校验器相等。校验器本身没跟着内容变时,304 会持续发出,而内容早就是新的(或者反过来,客户端手里是旧的)。

误读三:「弱 ETag 不能用于缓存校验。」 规范要求 If-None-Match 必须用弱比较。弱校验器不能用于范围请求和 If-Match,但缓存校验正是它的主场。

误读四:「改了文件,ETag 必然变。」 取决于生成方式。用 mtime、文件属性或复用构建缓存时,内容变了而校验器不变是常态。

误读五:「304 里不用带 ETag,反正没内容。」 不带 ETag 的 304 在缓存侧匹配不到任何存储响应,按规范一条都不许更新——这条 304 等于空转。

误读六:「清一下 CDN 缓存就好了。」 purge 清的是边缘那一层。客户端手里那份仍按自己的校验器判断,源站的校验器没修好,下一次还是 304。两件事要分开做。

本周能做的五件事:

  1. 打开一次 200 响应,确认 ETag、Last-Modified、Cache-Control、Vary 四个头里哪些真的在。
  2. 带上 ETag 发一次条件请求,确认回的是 304、且 ETag 原样返回。
  3. 把线上字节与本地产物各算一次哈希,确认「内容是新的」这件事本身成立。
  4. 找出 ETag 的生成代码,写一行注释说明它随什么变;找不到就当作 A 类风险处理。
  5. 在发布清单里加一条:不只核状态码,还核 ETag 与字节哈希。

如果只能记住一件事:304 是服务端替客户端手里那份副本做的背书,背书凭据是校验器。校验器错了,304 会把错误的内容一直背书下去,而且全程没有任何一个状态码看起来是错的。


边界说明:本文关于校验器、条件请求与 304 的陈述全部来自 RFC 9110 与 RFC 9111,两份来源均于 2026-11-01 访问并验证为 HTTP 200(字节数见「来源」)。不写「304 一定更快或一定省流量」——规范只定义条件请求的语义,没有性能承诺;不写「ETag 就是内容哈希」——原文是 opaque validator,且 §8.8.3.1 列了四种合法生成方式;不写「弱 ETag 不能用于缓存校验」——§13.1.2 要求 If-None-Match 用弱比较;不写「改了文件 ETag 必然变」——生成方式决定,须回源实测;不写「Last-Modified 一秒内改两次必然撞车」为规范结论——原文只说这是弱校验器的一种可能;不把 304 的头部要求写成可选——§15.4.5 用的是 MUST generate,但只覆盖列出的字段。文中未给任何 CDN、框架或语言运行时的默认 ETag 生成算法,也未给命中率、流量占比与事故时长数字。标注为编辑判断的有:第六节「Date 不刷新导致条目永远不新鲜」的机制推论、第七节两类事故的归类与分诊表、第八节的命令组合与判读口径、第九节清单的顺序。示例域名与 ETag 均为占位值,不对应真实站点。本文只讲「缓存凭什么判断副本是否仍有效」,不清边缘缓存(见《purge 返回 200 只说明请求收到了》一篇),也不重复重定向本身的缓存默认值(见《301 的「永久」没有期限》一篇)。

来源

以下页面均于 2026-11-01 逐条访问并验证为 HTTP 200;字节数为当日抓取的实际大小。

  1. RFC 9110: HTTP Semantics核查于 2026/11/1(200,502 941 B):§8.8.1 强校验器与弱校验器的定义、强校验器可因表示元数据变化而变、跨资源不保证唯一、gzip 与非压缩共用校验器即为弱、Strong validators are usable for all conditional requests 与弱校验器的适用范围;§8.8.2 的 Last-Modified 生成要求、MUST NOT generate a Last-Modified date that is later than the server's time of message origination、§8.8.2.2 的 implicitly weak 与三条强校验判定;§8.8.3 的 opaque validator、语法与三种示例、Since the value is opaque, there is no need for the client to be aware of how each entity tag is constructed.、the one-second resolution of HTTP-date values is not sufficient、不符合强校验特征时 MUST mark the entity tag as weak by prefixing its opaque value with "W/";§8.8.3.1 的四种生成方式与 SHOULD send an ETag;§8.8.3.2 强比较与弱比较;§13.1.2 A recipient MUST use the weak comparison function when comparing entity tags for If-None-Match、* 的条件、条件为假时 GET/HEAD 回 304 其余回 412;§13.1.3 A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field 与另外三种被忽略的情形;§13.2.2 前置条件判定顺序;§15.4.5 的 304 定义、would have resulted in a 200 (OK) response、redirecting the client to make use of that stored representation as if it were the content of a 200 (OK) response、MUST generate 的头部清单、SHOULD NOT generate representation metadata other than the above listed fields、A 304 response is terminated by the end of the header section; it cannot contain content or trailers.
  2. RFC 9111: HTTP Caching核查于 2026/11/1(200,84 477 B):§3.2 更新存储头部时 MUST add each header field in the provided response to the stored response, replacing field values that are already present、Content-Length 等例外、浏览器可能存储解码后内容因而可跳过某些头的说明;§4.3.1 缓存发起校验时 MUST send the relevant entity tags、SHOULD send the Last-Modified value;§4.3.2 If-None-Match takes precedence over If-Modified-Since;§4.3.4 收到 304 后的三段筛选——强校验器精确匹配、If none of the initial set contains at least one of the same strong validators, then the cache MUST NOT use the new response to update any stored responses、无强校验器时取最近一条弱校验器匹配、无校验器时的单条目条件。

唯一主行动

把下一步变成可执行动作

进入发布、维护与回滚工作流