全部笔记

ETag 与条件请求:304 不是没有发生请求

用实体标签避免重复正文传输,同时理解验证、编码与缓存一致性。

浏览器再次打开文章时,收到 304 Not Modified,仍然发生了网络往返。它省下的是正文传输,不是所有延迟。要决定使用长缓存还是重新验证,必须分清“对象仍然新鲜”与“服务器确认对象未变”。

本文假设公开 GET 资源,客户端支持 HTTP 缓存。写操作的乐观并发也可以利用实体标签,但不能简单套用读取时的 If-None-Match 处理。

用版本描述响应,而不是描述文件名

HTTP/1.1 200 OK
ETag: "article-v7"
Cache-Control: public, max-age=0, must-revalidate
Content-Type: text/html; charset=utf-8

...

下一次请求可以携带 If-None-Match: "article-v7"。如果选定表示未变化,服务器返回 304,让客户端复用本地正文。实体标签必须随真实响应变化,不能只用 Markdown 的文件名或修改日期:布局、导航、转换器升级都可能改变最终 HTML。

使用内容哈希是一种可行策略,但需要确认哈希对象是最终表示还是某个中间产物。如果部署系统已经提供实体标签,应用不应再随意覆盖。强标签要求字节级一致性,弱标签以 W/ 前缀表达语义上的等价,适用比较规则也不同。

首先根据请求选择表示:路径、语言、内容编码及其他合法变体共同决定当前资源。然后计算或读取该表示的验证器,再按规范比较条件头,最后生成二百或三百零四响应。若先比较一个全站共用标签,再决定语言,就可能让中文版缓存被用于英文请求。验证器并不能代替正确的缓存键,它是在选定表示之后工作的。

其次区分对象新鲜度和内容版本。响应带有长新鲜期时,浏览器可以不访问服务器,即使服务器的 ETag 已改变;对象需要验证时,客户端才发送条件。要让紧急更新可见,就不能给固定文章地址配置无法接受的长新鲜期。服务器也无法直接命令已经离线的客户端删除副本,公开内容应按可能被长期保存来设计。

最后考虑响应来源。浏览器发送条件请求,中间 CDN 可能直接验证并返回结果,原站根本没有执行;也可能 CDN 重新向原站验证。看到三百零四并不能推导原站负载已经减少。记录原站请求量、边缘命中与实际传输字节,才能知道节约发生在哪一层。性能目标若是减少网络往返,应该优化新鲜期或资源身份,而不只增加条件请求比例。

不要手写一个仅支持单值的解析器

条件头可能包含多个标签,也可能是 *。字符串直接相等的判断只覆盖最简单情况。压缩后的响应、范围请求以及弱标签比较进一步增加复杂度。优先使用成熟 HTTP 服务器或平台的实现;自定义时必须按规范解析并测试。

HEAD 应返回与 GET 相符的元数据而没有正文。304 也没有正文,但应保留协议要求或缓存更新需要的相关头。状态码正确但丢失关键缓存头,会让中间层无法正确更新对象。

两段 HTML 的空白不同,语义相同,弱标签可以表达这种等价;强标签则需要满足字节一致的约束。弱标签不是“质量差的强标签”,它表达了不同保证。读取验证与范围拼接对这些保证的需求不同,选择时应根据用途,而不是把前缀随手去掉以方便比较。

压缩让表示问题更明显。同一正文经过不同编码形成不同字节,平台可能生成不同强标签、改写为弱标签或移除标签,具体行为必须实测。如果应用用未压缩正文的哈希作为强标签,却在之后改变压缩字节,声明可能不成立。不要让边缘转换、内容压缩与应用标签分别由互不了解的组件控制。

范围下载尤其需要谨慎。客户端拿旧资源的前半段,再请求新资源的后半段,可能拼出损坏文件。If-Range 用于表达条件满足时继续范围传输,否则获取完整资源,但它有自己的验证规则。若当前需求只是技术文章,不必手写完整范围实现;交给成熟服务器能避免把读取优化扩展成复杂协议工程。

新鲜缓存和验证缓存各有代价

对于带内容哈希的 JS 或 CSS,可以使用较长 max-age 与 immutable,因为新内容使用新 URL。HTML 通常需要更及时更新,可以采用较短新鲜期与验证。不能给固定 URL 的 HTML 设置无限长缓存,再希望改 ETag 能立即生效:缓存仍新鲜时客户端不需要向服务器询问。

no-cache 并不是“不允许存储”,而是复用前需要验证;真正禁止存储的是 no-store。把两者混用,会导致隐私数据残留或公开内容重复传输。缓存控制应该根据内容敏感性与更新要求决定,而不是复制一份固定头。

不把修改时间当成万能内容身份

Last-Modified 对一些资源是有用验证器,但时间精度与更新来源需要明确。多个依赖在同一秒变化、部署保留源文件时间或生成器恢复旧时间,都可能让最终正文改变而时间不变。ETag 与修改时间同时存在时,条件处理有规定的优先关系,应由协议实现处理,不能任选一个相等就返回未修改。

静态构建可以给最终产物生成可靠标签,避免在每次请求时重新计算。动态组合页面则要评估计算代价:若为了判断不变而执行全部数据库查询与渲染,三百零四仅减少输出字节,没有减少后端工作。可以在数据层维护可靠版本,但版本必须随所有输出依赖变化。没有可靠版本时宁可返回正确二百,不生成一个容易碰撞的“近似”标签。

客户端也可能发送已经很旧的标签,服务端不能假设它来自最近部署。标签比较针对当前资源,不需要保留全部历史正文才能判断不相同。对于资源被删除或权限改变的情况,应先执行资源与权限判断,再考虑验证;不能因为标签曾经正确就继续允许复用受限正文。

这也是测试身份变化的理由:同一 URL 对匿名、用户甲和用户乙的响应若不同,公共缓存策略应先隔离,标签再分别表达各自版本。否则一个巧合相同的标签可能让客户端收到错误验证结论。条件请求优化必须建立在资源选择与授权正确之上,不能反过来用标签命中绕过业务判断。

文章 Markdown 没变,页头导航更新了,最终 HTML 就已经改变。以源文件修改时间作为唯一验证器可能让浏览器继续显示旧导航。内容哈希若针对最终输出,就可以覆盖这类变化,但需要在压缩或变换边界明确它代表什么。大型动态页面每次重新渲染再计算哈希,也可能消耗比传输节约更多的计算。

另一种方案是组合可靠版本:文章版本、布局版本和公开数据版本共同形成表示版本。它避免每次读取全部字节,但要求任何影响输出的依赖都参加版本变更。时间戳只有在精度、时区和更新过程可靠时才适合充当一部分依据,不能假设文件系统修改时间永远唯一。相同秒内两次更新就是需要测试的反例。

错误处理同样重要。验证器生成失败时可以退回正常二百响应,而不是伪造一个固定标签;固定标签可能让不同内容永远被视为未变化。缓存优化失败不应制造错误正文复用。对敏感资源按照隐私策略决定是否存储,不因为它拥有 ETag 就自动允许公共缓存。

条件请求也可以防止写覆盖

假设两位编辑者都读取版本七。第一位保存后得到版本八,第二位若无条件保存,就覆盖了前者修改。写接口可以要求 If-Match 与当前版本匹配,不匹配时返回前置条件失败,再让客户端刷新和合并。这与读取中的 If-None-Match 方向不同:读取时比较相同可以复用,写入时比较相同才允许继续。

服务端必须在检查版本与执行更新之间保持原子性。先读取数据库版本、在应用中比较、随后普通更新,两个并发请求仍可能都通过。应使用带版本条件的更新或事务机制,根据受影响行数判断是否成功。ETag 是协议层携带版本的方式,不提供数据库并发控制本身。

对于创建资源,条件头可以表达只在目标不存在时创建,但仍需遵循具体接口与规范。错误响应要告诉用户需要刷新或处理冲突,不自动重试覆盖。这个例子说明条件请求不仅是省带宽,也能表达状态前提;前提验证的正确性依赖后端原子操作,而不是某个头是否出现。

用一个资源生命周期检查前置条件顺序

客户端保存版本七的文章,随后文章被撤下。它再次携带版本七的标签请求时,服务端应该先确认目标资源当前是否存在及是否允许访问,而不是见到标签就返回未修改。三百零四授权客户端继续复用已有正文,所以错误的顺序可能让删除或权限变化无法被正确表达。资源状态、表示选择与条件评估是不同阶段,不能压缩成一个缓存命中分支。

再考虑资源迁移。旧路径现在重定向到新路径,客户端给旧路径发送旧正文的标签,服务端不应把历史正文验证器当作当前重定向表示的身份。应由真实协议实现处理当前响应语义,并检查缓存的重定向有效期。旧正文地址与新正文地址在应用层指向同一文章,不表示所有 HTTP 元数据都可以原样沿用。

权限变化的样本应先以允许身份获得响应,再改变服务端权限并复用旧条件头。断言结果反映当前授权,而不是旧标签相同。公共文章转为私有时,同时检查共享缓存策略与已存副本,不能保证客户端主动遗忘已经下载的文字。条件请求只约束服务器如何回答当前请求,不提供撤回历史副本的能力。

最后让内容在计算验证器与发出响应之间改变。动态系统需要确保返回正文与标签属于同一读取版本;如果先读取版本号,再独立读取正文,可能给版本八正文附上版本七标签。可采用一致的数据快照或已经完成的不可变产物。静态部署更容易把标签和字节一起生成,运行时组合则必须说明读一致性边界。

实现写前置条件时不隐藏冲突

编辑者乙的旧版本保存失败以后,客户端不能自动获取新标签再重复旧正文提交,那相当于绕过并发保护。正确恢复是保留乙的未保存输入,显示当前服务端版本,并让用户比较或合并。服务端返回冲突信息也要避免披露对方无权访问的字段;版本检查发生在授权之后,不能拿前置条件失败作为查询私有状态的通道。

数据库条件更新的结果要区分记录不存在、当前用户无权修改和版本不匹配,如何对外表达由接口的防披露策略决定。对内诊断可以保留分类,客户端不一定需要看到全部详情。更新失败后不应记录成功事件或发送通知。事务提交之后才确认写入成功,外部通知若无法与事务原子完成,应采用明确的后续交付机制,而不是将标签比较视为整个业务流程的保证。

重复请求也涉及另一种问题。版本七提交已经成功但响应丢失,客户端重试相同写入时旧版本会失败;这并不证明第一次失败。版本前置条件避免覆盖,幂等标识帮助识别同一次操作,两者职责不同。恢复界面可以查询操作结果或读取当前状态,但不能从一次前置条件失败推断是否需要重新执行外部副作用。

创建接口中的通配条件也需要与资源身份一致。如果路径由客户端提供,先校验目标与权限;如果资源 ID 由服务端生成,是否支持条件创建取决于具体契约。不能将“目标不存在”误解为“同一业务对象不存在”,数据库仍然可能需要业务唯一约束。HTTP 条件提供请求前提,跨资源业务不变量仍然由后端实现维护。

为验证器成本建立两个对照实验

第一组使用已经构建的静态 HTML,比较完整二百、三百零四与新鲜缓存三条路径,分别统计正文传输、请求次数与往返时间。第二组使用动态生成响应,保持相同正文规模,观察验证器判断是否仍执行数据查询与渲染。两个实验回答不同问题:前者关心传输路径,后者关心后端工作。不能用静态资源的收益推导动态接口也减少计算。

实验中的标签必须来自真实响应,不固定写成 article-v7。记录是否发生 CDN 命中、是否启用压缩与浏览器缓存,避免把一个平台层的优化归功于应用条件处理。测试无变化与变化输入,变化不仅是正文,也包括布局与语言。报告所有失败和超时,不把异常样本删除后只比较成功请求的平均值。

内容哈希也有成本。如果每次请求都完整读取大文件再计算,省下的网络正文可能换成额外 CPU 与内存;预生成标签可以把成本移到构建阶段。动态组合版本可以减少计算,却依赖所有输出来源可靠参与版本更新。把这两种实现分别记录为字节验证与依赖版本验证,维护者才能知道新增导航数据时应该修改哪里。

检查三百零四后的客户端实际合并

三百零四不是一个空白新页面,而是通知缓存使用既有正文并更新适用元数据。验收应该先获得二百响应建立对象,再发条件请求观察缓存是否保留正确正文和更新头。直接对一个没有缓存正文的工具发送条件头,只能验证服务器状态码,无法模拟读者真实复用。命令行请求与浏览器实验因此互补,而非相互替代。

将响应头策略修改但正文保持不变,检查验证响应是否带上需要更新的缓存字段。再比较 HEAD 与 GET 的元数据,确认它们没有独立生成随机标签。对于协议实现不支持的边界,交给平台或成熟服务器处理,不在中间件中补一个看似足够的字符串判断。条件请求的正确性来自完整表示与规范顺序,省掉正文只是其中可见的结果。

验证性能收益,也验证正确性

curl -i https://example.dev/writing/a/
curl -i -H 'If-None-Match: "article-v7"' https://example.dev/writing/a/

先读取当前真实 ETag,再用它发条件请求,不要盲目使用示例值。修改正文后,旧标签应收到新正文和新标签;保持内容不变时应按规则验证。分别测试 gzip 与未压缩表示,观察 CDN 是否改写或移除标签。

还应检查身份页面是否禁止公共共享,避免对不同用户生成相同标签后错误复用。浏览器面板中的“磁盘缓存”与 304 是不同路径,需要结合请求头判断。最终优化目标是减少不必要传输,同时保证更新可见;304 数量越多,不一定代表整体体验越快。

比较规则也要成为独立反例。当前表示使用强标签 "v7",读取请求带 If-None-Match: W/"v7",与写请求带 If-Match: W/"v7" 不能沿用同一个相等函数。前者使用弱比较,后者使用强比较;把 W/ 去掉再比较会错误允许依赖字节身份的写前提。这里说的是规范比较,不是建议把所有业务版本都换成弱标签。

另一个样本使用 If-None-Match: *。它检查目标是否已有当前表示,并不是匹配正文恰好为星号的版本。已有资源的读取与条件创建,会得到不同的前置条件处理结果。测试记录请求方法和当前资源状态,再核对所用服务器的响应;不能只给解析器一个字符串,忽略方法语义后宣称通配条件已支持。

最后检查代理没有改写这些条件。旧客户端可能携带包含多个值的头,代理若截断到第一个值,就让服务器接收到另一份前提。使用两个不同标签的合成列表,分别保存入口与后端可见的安全测试头,并比较协议结果。真实用户的条件头不需要全量进入长期日志;这项验收关注中间层保真,不是收集浏览历史。

参考:HTTP 条件请求、HTTP 语义 RFC 9110。