HTTP 304 没有正文,客户端该从哪里拿回内容?
看到 304 Not Modified 时,一个容易忽略的问题是:这一轮响应没有正文,接下来显示的图片、文本或数据从哪里来?若自己写下载脚本,只保存了一个 ETag,再次请求就没有新的输出文件了,这算下载成功还是失败?
答案依赖客户端已经保存了什么。304 让客户端复用此前保存的适用内容;它不会把那份内容藏在响应头里重新传回来。 验证信息、已保存正文和缓存策略是三件有关联、却需要分别处理的东西。
我在 2026 年 10 月 13 日,用 curl 对本博客的一个公开文本文件做了五组只读 GET 对照,记录请求条件、状态码、正文大小及响应头。下面的数字来自这次运行;协议含义另以规范和官方文档为依据。实验没有检查浏览器的磁盘缓存,也没有衡量 CDN、并发负载或整站性能。
先分清“收到新内容”与“允许复用旧内容”
一个具备 HTTP 缓存管理能力的客户端,保存的不只是验证标签,还包括之前收到的响应内容与相关信息。再次使用时,它可能需要询问服务器:我保存的版本现在还能用吗?
在以 ETag 验证的 GET 请求里,客户端通过 If-None-Match 发送保存的标签。若当前选中的表示与标签匹配,服务器可以返回 304,省去再次发送正文;若该条件允许正常获取,这个例子就会得到带正文的 200。MDN:304 的条件请求含义
RFC 9110 明确规定,304 不能包含内容。因此,收到空正文不是“文件已经变成空文件”的证据。只有手里已经有可用的保存内容,才能完成复用这一步。RFC 9110:304 Not Modified
这也解释了它的实际用途:反复读取相同公开文件时,可以确认已有内容继续适用,而不重复传输整个文件。但本轮仍然发生了 HTTP 请求与响应,响应头也需要传输;正文为零不能改写成“完全没有联网”,更不能直接推出延迟或总流量减少了多少。
对一个真实文件,实际请求五次
实验目标是本博客的读图记录。本机环境为 macOS 27.0.1、Python 3.9.6、curl 8.7.1。脚本固定使用 HTTP/1.1,未启用 curl 的压缩解码选项,也没有使用 -I;每次均为 GET。
初次响应的 ETag 为 "6accd07a-b5a",正文是 2,906 字节。这个标签是运行时快照,之后可能随服务更新而改变;复跑应读取当时收到的标签,不要直接复制旧值。
| 请求条件 | 初次下载的正文还在本地吗 | 本次状态 | 本次下载的正文 |
|---|---|---|---|
| 不发送条件,保存正文与 ETag | 否,尚未下载 | 200 | 2,906 字节 |
| 从文件读出匹配的 ETag | 是 | 304 | 0 字节 |
| 故意发送一个不匹配的标签 | 是 | 200 | 2,906 字节 |
将同一标签加上 W/ 前缀 | 是 | 304 | 0 字节 |
| 删除初次正文,保留匹配的标签文件 | 否 | 304 | 0 字节 |
脚本给每次请求使用独立输出文件名。本次三个 304 条件都没有创建新的正文文件;“有没有文件”和“下载多少字节”分别记录在附件中。这是此次 curl 调用的观察,不应把所有工具的文件创建行为都推成同一规则。
不匹配标签那一行,也没有发生内容更新。它与首次下载的正文 SHA-256 相同:我只是提交了一个不对应当前表示的条件,服务器于是重新返回了完整文本。得到 200,并不能单凭状态码断言服务器上的文件刚刚改过。
计数采用 curl 的 size_download,并与输出文件字节数核对,描述的是响应正文,不包含响应头、TLS 和其他网络开销。本次没有采样耗时,所以表格说明传输了什么,不给出速度提升比例。
响应头里还有另一组可复核的信息:初次 200 的 Date 为 12:05:35 GMT,匹配标签的 304 为 12:05:38 GMT,两次 ETag 和 Last-Modified 相同。新的响应日期和不变的验证信息可以同时存在;前者属于这轮响应,不应被解释为正文的修改记录。本文保留每次响应头,正是为了让读者能区分这些信息的来源。RFC 9110:Date 的含义
用 curl 保存标签,不等于装好了缓存系统
在一个新建的空目录中,可以手工复跑前两步:
gumi_demo_url='https://gumi.ink/examples/great-wave-reading-notes.txt'
curl --http1.1 --silent --show-error --fail --max-time 20 \
--dump-header unconditional.headers --output unconditional.body \
--etag-save etag.txt \
--write-out 'status=%{http_code} body=%{size_download}\n' "$gumi_demo_url"
curl --http1.1 --silent --show-error --fail --max-time 20 \
--dump-header matching_tag.headers --output matching_tag.body \
--etag-compare etag.txt \
--write-out 'status=%{http_code} body=%{size_download}\n' "$gumi_demo_url"
本次对应结果为第一步 status=200 body=2906,第二步 status=304 body=0。两条命令分别把正文和 ETag 写入不同用途的文件:前者保存数据,后者保存下次验证时要发送的值。
curl 文档说明,--etag-save 保存响应的 ETag,--etag-compare 从文件读取标签并发送 If-None-Match;这两个选项自 7.68.0 起提供。它们没有替你的应用查找旧正文、管理完整缓存条目或把旧正文输出到新文件。curl:ETag 保存与条件比较
因此,在这个手工例子中,第二步结束后要读取的是之前的 unconditional.body。不要把不存在的新输出文件当成文本被删除,也不要为了“更新下载结果”而用零字节覆盖已有内容。先读状态码,再决定使用哪一份正文,才符合这次交换的含义。
丢了正文,服务器不会从标签得知这件事
第五组对照刻意删除临时目录里的初次正文,只留下 etag.txt,随后再次发送条件请求。结果仍是 304,且没有收到补回正文的数据。服务器处理的是请求里给出的验证条件,没有检查我的本地文件系统。
这个失败案例的关键不是服务器“拒绝下载”,而是客户端拿着有效的验证标签,却已经无法完成复用。一个短字符串无法重建原先的 2,906 字节;把标签持久保存、却任由对应正文丢失,会留下这种不完整状态。
对于自己管理的下载缓存,应把保存内容和验证信息关联起来。能找到适用内容时再发送相应条件;正文缺失时,本例可重新发送不带该条件的 GET 获取内容。若已经拿到 304 才发现缺失,也需要补做完整获取,不能把该响应当作本轮有正文的成功下载。
RFC 9111 要求缓存收到 304 后识别适合更新的已存响应,并更新相关响应头。它描述的是对已有条目的更新与复用,不是用标签生成一份新正文。RFC 9111:验证后更新已存响应
这里给出的关联与分支是实现建议。附件只是这五组请求的实验驱动,不是一个支持全部 HTTP 缓存规则的下载器。尤其不要把“本地有任意同名文件”当成“已找到这次请求可使用的保存响应”。
ETag 的比较,也不是简单的文件指纹比较
ETag 由服务器生成,客户端应保留它的语法,包括双引号与可能存在的 W/。生成方法没有统一规定,不能看到十六进制外观就认定它一定是完整正文的加密散列。MDN:ETag 的生成与语法
本次第四组把当前标签改写为 W/"6accd07a-b5a",仍得到 304。If-None-Match 使用弱比较;标签的强弱前缀并不会把同一个标签值变成这种条件下的“不匹配”。MDN:If-None-Match 的弱比较
这项结果只验证了本次条件比较。它没有拿两份不同字节的正文配上相同弱标签,也没有测试断点续传或写入冲突。本文计算的 SHA-256 是另做的本地内容对照,不能把它与服务器给出的 ETag 混为一种测量。
当我们说“304 确认保存的版本还能用”,应连同选中的表示、比较规则和请求条件一起理解。它给的是当前交换中是否需要再次传输的答案,不是一张独立于请求上下文的、永久有效的内容证书。
是否保存、多久可用、何时验证,需要分开看
ETag 不负责规定缓存寿命。Cache-Control 等缓存指令参与决定保存和复用策略;其中 no-cache 要求在正常复用路径中验证,而 no-store 用于禁止存储。两者不能仅凭名字当成同一开关。MDN:缓存新鲜度与验证
本次初次响应没有 Cache-Control 字段,不能因此就宣布浏览器绝不会保存它:满足条件时可能存在启发式缓存。脚本每次主动提交请求条件,没有测量浏览器何时会直接使用已有内容,也没有测量某一浏览器会把这个文件留多久。
选择正确条目还涉及表示的变化。Vary 告诉缓存,方法与 URL 之外的哪些请求字段影响响应选择,例如编码协商;并非对同一 URL 收到过任意正文,就能将它用于后来所有请求。MDN:Vary 与缓存条目选择
这次还发现一处具体差异:200 响应有 Vary: Accept-Encoding,三个 304 响应均未带 Vary。 RFC 9110 要求,若同一请求的 200 会生成该字段,304 也应生成它。附件保留了实际响应头;实验没有切换编码或检查多变体缓存,不能用基本的 304 对照宣称这个服务已经通过完整缓存合规检查。
把实验用于排查自己的下载过程
可以下载完整实验包,解压后执行:
python3 http304-lab.py http304-output
也可分别取得脚本、运行说明和本次结果。脚本只依赖 Python 标准库与 curl,按顺序发送五次 GET;删除正文的操作只发生在自身临时目录中。JSON 记录原始响应头、状态、输出文件是否存在、字节数与本地散列。
排查时,先确认自己观察的是 GET,而不是 curl -I 产生的 HEAD;HEAD 本来就不传响应内容,即使状态是 200,也不能用“正文为零”验证 304 的效果。RFC 9110:HEAD
再检查发送了什么条件、手里有没有对应的可用正文,以及应用在 304 分支是否保留了它。最后才检查缓存策略与表示选择。这样的顺序,把“网络这次传了什么”和“应用最终使用了什么”分开,避免仅看一个状态码就结束调查。
例如,日志若只记“请求成功”,就无法辨认收到的是新正文还是复用许可;若只记文件大小,又可能忽略请求条件和输出文件名。把这几项关联到同一次请求,再检查本地保存内容,才能解释为什么这次没有创建新文件,以及应用接下来该读取哪一份数据。这是根据本次对照提出的记录建议。
开头的问题由此可以回答:304 之后的内容来自此前保存且适用于本次请求的响应,不来自这一轮空正文。 能正确找到、更新并复用那个条目的客户端,才完成了这次缓存交换;只保留标签的工具,还需要管理正文和两者之间的关系。