给 API 加 HTTP 缓存,真正需要定下来的是三件事:ETag 到底代表什么、条件请求在读路径和写路径上分别返回什么状态码、以及你写出去的 Cache-Control 会被链路上哪一层真正读到。第一件决定并发写会不会互相覆盖,第二件决定客户端能不能省掉一次传输,第三件决定你的用户私有数据会不会被共享缓存发给别人。规范这边其实早就稳了——RFC 9110(STD 97) 和 RFC 9111(STD 98) 都在 2022 年 6 月发布,把 RFC 723x 那一批全部废弃了。所以真正咬人的地方不在规范,而在规范与中间层实现之间的缝里。唯一算得上「新」的是 RFC 9875《HTTP Cache Groups》,2025 年 10 月才发布。

强 ETag 和弱 ETag,选错了后面全错

RFC 9110 §8.8.3.2 定义了两个比较函数。强比较要求两个 tag 都不带 W/ 前缀,且 opaque-tag 逐字符相同;弱比较只要 opaque-tag 逐字符相同,不管带不带 W/。规范给的对照表很直白:W/"1""1",强比较不匹配,弱比较匹配;W/"1"W/"1",强比较也不匹配。

关键在于哪个请求头用哪个函数,这是 MUST 级别的约束:

  • If-None-Match MUST 用弱比较(§13.1.2)
  • If-Match MUST 用强比较(§13.1.1)

直接推论:一个 W/"..." 形式的 ETag 可以用来省流量,但不能用来做乐观并发。服务端拿 If-Match: W/"abc" 去做强比较永远不匹配,结果是所有条件写全部 412,而且这个 bug 在只测 GET 的集成测试里完全看不出来。

API 里生成 ETag 的三种常见做法:

  1. 哈希序列化后的响应体——强 ETag,但要求序列化完全稳定:字段顺序、浮点格式、时间戳精度任何一处抖动,ETag 就变,客户端白拉一次全量。
  2. 用行版本号——updated_at 加主键,或者自己维护的单调递增 version 整数。强 ETag,稳定,便宜。
  3. 对语义等价的表示打弱 ETag——只有当你确实在做内容协商(同一资源的不同压缩编码、不同投影视图)时才需要。

我的建议很明确:API 资源用强 ETag,值来自版本号,不要哈希响应体。哈希响应体的问题不是慢,是次序错了——你必须先把响应体造出来才能回答 304,而 304 存在的全部意义就是别造、别传。

ETag: "42" # 强验证器,来自 version 列,可用于 If-Match
ETag: W/"42" # 弱验证器,只能用于 GET 复用

前置条件的求值顺序是规范写死的

RFC 9110 §13.2.2 用 MUST 规定了六步顺序,理由是「丢更新」类前置条件的要求比缓存验证更严格,而实体标签被认为比日期验证器更准确:

  1. 接收方是源服务器且有 If-Match → 为真进入第 3 步;为假返回 412,除非能确定该状态变更请求已经成功过
  2. 源服务器、没有 If-Match 但有 If-Unmodified-Since → 同上
  3. If-None-Match → 为真进入第 5 步;为假时,GET/HEAD 返回 304其他方法返回 412
  4. 方法是 GET/HEAD、没有 If-None-Match 且有 If-Modified-Since → 为假返回 304
  5. GET 且同时有 RangeIf-Range → 判断是否 206
  6. 否则执行方法

三个容易踩的点。第一,PUT 上的 If-None-Match 失败必须返回 412 而不是 304。手写的中间件很容易统一返回 304,而客户端库通常把 304 当成「没变化,成功了」,结果写没落地却报成功。第二,只要 If-None-Match 存在,If-Modified-Since 就被完全跳过,别指望两个都生效。第三,第 1 步和第 2 步都限定「接收方是源服务器」——中间缓存不评估 If-Match,它只会把请求透传回源。

乐观并发:四种写法,各有各的位置

  • 无条件 PUT:后写覆盖先写,典型的丢更新。
  • If-Match: "42":标准做法。客户端把 GET 拿到的 ETag 原样回传。
  • If-Match: *:只断言「资源当前存在」,用于「必须已存在才允许更新」。
  • If-None-Match: *:断言「资源当前不存在」,用于 PUT 创建。§13.1.2 明确说这是为了防止多个客户端同时创建初始表示,是丢更新问题的一个变体。

服务端这边有两处细节。§13.1.1 说 If-Match 为假时源服务器 MAY 用 412 表示失败——MAY 是给幂等重放留的口子(能证明这个请求之前已经成功,可以直接返回成功;这种重放识别怎么落地见Idempotency-Key 实现指南)。但默认实现就该返回 412,并且响应里带上当前 ETag,让客户端能立刻重试而不必再 GET 一次。

另一处:如果你的写接口要求必须带 If-Match,缺失时应该返回 428 Precondition RequiredRFC 6585,2012 年 4 月,标准跟踪;同一份 RFC 里还定义了 429,那条路上的响应头约定见API 限流实战),不是 400 也不是 412。428 的语义就是「源服务器要求该请求必须是条件请求」,规范还建议响应体里说明怎么重新提交。

304 里必须带什么

RFC 9110 §15.4.5 规定,生成 304 时 MUST 带上同一请求下 200 响应本会带的这些字段:Content-LocationDateETagVary,以及 Cache-ControlExpires。同时 304 不能有 body、不能有 trailer,响应在头部结束时终止。

漏掉 ETag 是最常见的错误,明面上的代价是客户端下一次没 tag 可发,直接退化成全量。更隐蔽的代价在下游:RFC 9111 §4.3.4 规定缓存收到 304 后要挑出可更新的已存响应,筛选规则的第一条是——如果新响应含有强验证器,则只更新带相同强验证器的已存响应;如果一个都对不上,缓存 MUST NOT 用这个 304 更新任何已存响应。也就是说 ETag 缺失或抖动时,你的 304 对共享缓存是完全无效的,缓存条目会一直陈旧下去。

顺带一提 §4.3.5 的姊妹规则:缓存发出 HEAD 拿到 200 后,若验证器或 Content-Length 与已存的 GET 响应对不上,SHOULD 把该已存响应视为陈旧。HEAD 是真实的失效通道,不只是元数据探针。

Cache-Control:API 场景真正该写什么

坑一:no-cache 不是不缓存。 §5.2.2.4:无参数形式的语义是「不转发验证并收到成功响应,就 MUST NOT 用它满足其他请求」。存是可以存的。真正的不存是 no-store。这两个搞反是写 API 的人最高频的错误。

坑二:private 不是访问控制。 它只约束共享缓存,浏览器该写磁盘还是写,落到磁盘上就是明文。真正敏感的数据要 no-store

坑三:带参数的限定形式很少人用,但很好用。 no-cache="Set-Cookie" 表示只要排除或成功验证列出的字段,缓存 MAY 用它满足后续请求;private="Set-Cookie" 表示共享缓存 MUST NOT 存储列出的头,其余部分仍可存(§5.2.2.4 / §5.2.2.7)。对于「一份基本可共享的载荷上挂了一个按用户变化的头」这种场景,这就是对的工具。

坑四:Authorization 的三个后门指令。 RFC 9111 §3.5 说,共享缓存 MUST NOT 用「带 Authorization 头的请求」的缓存响应去满足后续请求,除非响应里含有允许共享缓存存储它的指令。规范点名了三个:must-revalidatepublics-maxage

must-revalidate 在这个列表里非常反直觉。有人给带 Bearer token 的响应加上 must-revalidate,以为这样能保证每次都验证——实际效果是打开了共享缓存存储并跨用户复用它的开关must-revalidate 只在响应变陈旧之后才强制验证;在新鲜期内它允许共享缓存把这份响应发给别的用户,而新鲜期可能来自 Expires,甚至来自启发式。要私有就写 privateno-store,永远不要用 must-revalidate 表达隐私。

坑五:没有显式过期时间就会走启发式。 §4.2.2:有显式过期时间时 MUST NOT 用启发式;没有时缓存可以基于 Last-Modified 估算,规范建议取「距上次修改的间隔」的一个比例,典型设置是 10%。一个只带 Last-Modified: 一年前 而没有 Cache-Control 的 API 响应,可能被合规的中间缓存认为新鲜约 36 天。API 的每个响应都要有显式 Cache-Control,包括错误响应。

两个常被误记为 RFC 9111 的指令:immutable 出自 RFC 8246(2017 年 9 月,Proposed Standard),面向版本化 URL 的静态资源,不适用于可变的 API 资源;stale-while-revalidatestale-if-error 出自 RFC 5861(2010 年 5 月),状态是 Informational,不是标准跟踪。这不代表不能用——浏览器和 CDN 都实现得很广——但任何一层都可以合法忽略它,所以它是优化,不是保证。对 API 来说 stale-if-error 尤其值得配:源站 5xx 时返回略旧的数据,通常比返回硬错误好。

Vary 概念是对的,落地未必

RFC 9111 §4.1 定义二级缓存键:除非 Vary 点名的所有请求头都与产生该已存响应的那次请求匹配,否则缓存 MUST NOT 不经验证就复用它。匹配规则允许空白规整、同名字段行合并,以及「已知语义相同」的规范化(大小写折叠、顺序无关时的值重排)。两条硬规则:某个字段在一边缺失,只能匹配另一边也缺失Vary* 的已存响应永远不匹配

落到 API 上:

  • Vary: Accept-Encoding —— 必须有,而且几乎所有 CDN 都对它特殊处理。
  • Vary: Accept —— 只在你真的做表示协商时才写。
  • Vary: Authorization / Vary: Cookie —— 看起来安全,实际无用。等于给每个 token 开一个缓存条目,命中率归零,内存无界增长。要么标 private/no-store,要么在边缘做鉴权,别拿 Vary 当安全边界。
  • Vary: Accept-Language —— 浏览器发的值是长尾分布,zh-CN,zh;q=0.9,en;q=0.8 这类组合数量惊人,不做归一化基本等于不缓存。

真正的隐患是 CDN 长期没有完整实现 VaryCloudflare 的文档写得很直白:默认情况下它的 CDN 只用请求 URL 加少数几个特定头构造缓存键,Vary 必须显式配置才会生效——在 Cache Rules 里配,或者对 Workers 子请求用 cf.vary;「Vary for images」是另一套独立机制。2026 年 7 月 2 日,Cloudflare 上线了 Cache Rules 的 Vary 支持,全套餐可用,提供三种动作:normalize(把语义等价的头值折叠成一个版本)、passthrough(用原始值分出不同版本)、bypass(响应的 Vary 里出现指定头就不缓存),而 Vary: * 仍按 RFC 9110 绕过缓存。

这条变更的诚实读法是:2026 年 7 月之前、依赖 Cloudflare 尊重 Vary 的 API 设计一直是错的,只是你未必发现——错误表现是「某些用户拿到了别人语言或编码的响应」,这种 bug 几乎不会在监控面板上冒头。上线前用调试头在真实链路上实测一遍,不要假设。

可以推广到所有厂商的一句话:只要响应内容取决于某个请求头,而链路上存在共享缓存,你就必须要么让那层缓存真的按该头分键、要么让响应对共享缓存不可存储。中间态没有安全选项。

分层控制:CDN-Cache-Control

RFC 9213(2022 年 6 月,标准跟踪,作者分别来自 Akamai、Fastly、Cloudflare)定义了「定向缓存控制头」的约定和 CDN-Cache-Control 这个具体字段。

最容易被忽略的一点:定向字段是 Dictionary 结构化字段RFC 9651,2024 年 9 月,废弃 RFC 8941),不是 Cache-Control 那套语法。简单情形下二者看起来一样,但错误处理不同,规范原文明确警告:用 Cache-Control 解析器而不是结构化字段解析器会引入互操作问题。无值指令映射为 Boolean true,quoted-string 映射为 String,token 映射为 Token、Integer 或 Decimal。

行为规则:缓存维护一个按优先级排序的 target list,收到响应时 MUST 选取列表顺序中第一个有效非空的字段来决定缓存策略,并且 MUST 忽略该响应里的 Cache-ControlExpires——除非列表里没有任何有效非空值。不在自己 target list 上的定向字段 MUST NOT 改变行为,且 MUST 透传。字段为空或解析出错时 MUST 被忽略,回落到其他机制。使用定向字段的缓存 MUST 实现 max-agemust-revalidateno-storeno-cacheprivate 的语义。

对 API 有用的形态:

Cache-Control: private, no-store
CDN-Cache-Control: max-age=60, stale-if-error=86400

浏览器什么都不存,CDN 存 60 秒。这是给「公开但计算昂贵」的端点用的模式。但要留意 §2.3 的提醒:CDN 现在拥有比下游任何一层都长的新鲜期,它发出去的响应对其他缓存可能一出生就是陈旧的,反而拉低整体缓存效率。要么接受,要么在边缘出口重写 Cache-Control

批量失效终于有了标准

「改了一个订单,把这个用户所有列表页的缓存清掉」长期只能靠厂商私有机制,最常见的是 Fastly 的 Surrogate-Key 加 purge API。RFC 9875,2025 年 10 月发布,标准跟踪,作者 Mark Nottingham,把这套形态标准化了。两个字段,都是 List of String:

HTTP/1.1 200 OK
Cache-Control: max-age=3600
Cache-Groups: "user-42-orders", "orders"
HTTP/1.1 200 OK
Cache-Group-Invalidation: "user-42-orders"

设计之前先读清楚约束:

  • 同组的判定要求两个响应的 Cache-Groups逐字符相同、大小写敏感的字符串,且共享同一 URI origin。跨源分组不存在。
  • Cache-Group-Invalidation 在安全方法(如 GET)的响应上 MUST 被忽略,它只在 POST/PUT/DELETE 这类响应上有意义。
  • 失效行为是 MAY 而不是 MUST——合规的缓存可以完全忽略它。RFC 9213 那类定向头可以把它加强成强制,但需要另外的规范来定义。
  • 不级联:一次分组失效不会再触发新的分组失效。
  • 实现 MUST 至少支持 32 个组、每组至少 32 字符。别设计出上百个组的方案。
  • 规范明确说这是单个缓存内部的机制,不解决多个缓存之间的状态同步。

现阶段的务实立场:Cache-Groups 当作前向兼容的路径发出去(成本几十字节),但不要把正确性押在它上面。真需要保证失效时,调厂商的 purge API。

排障:先看 Cache-Status,再谈假设

RFC 9211(2022 年 6 月,标准跟踪)定义了 Cache-Status。对 API 调试最有用的参数是 fwd,它直接给出转发原因:bypassmethoduri-missvary-missmissrequeststalepartial。看到 vary-miss 就不用再猜了——二级键没对上。另外 collapsed 告诉你请求有没有被合并,ttl 给剩余新鲜秒数,stored 说明响应有没有被存下来,key 给出缓存键的表示。

配套的一条:RFC 9111 §4 要求缓存在不经验证直接用已存响应满足请求时 MUST 生成 Age 头,值等于该响应的当前年龄。所以看到 Age 就说明这个响应不是本次由源站生成或验证的。反过来不成立——规范明确说没有 Age 不代表源站被访问过。

可以直接抄的默认值

端点类型 Cache-Control 说明
用户私有数据(/me、订单详情) private, no-store 强 ETag + 写路径 If-Match
需鉴权但可跨用户共享的只读数据 private, max-age=0, must-revalidate 慎用;共享缓存场景改用边缘鉴权
公开、变化不频繁 public, max-age=60, stale-if-error=86400 CDN-Cache-Control 做分层
公开但计算昂贵 private, no-store + CDN-Cache-Control: max-age=300 浏览器不存,CDN 吸收负载
写接口(PUT/PATCH/DELETE) no-store If-Match 返 428,冲突返 412

每一行都默认带上 Vary: Accept-Encoding,以及一个来自版本号而非响应体哈希的强 ETag

值得记住的部分

这套东西里唯一真正难的是一个观念:ETag 服务于两个互不相干的目的——省字节和防丢更新——而这两个目的用不同的比较函数、不同的请求头、不同的失败状态码。把这个分裂想清楚,剩下的都是查表。部署侧只有一条铁律值得记:任何依赖 Vary 的正确性,都必须在真实链路上验证过再上线。