Skip to content
Published on

把 HTTP 缓存用对 — Cache-Control、ETag 与 stale-while-revalidate 的准确含义

分享
Authors

引言 — 发布完了,用户看到的还是旧包

发布结束了,服务器上是新文件,无痕窗口里出现的是新页面。可有一部分用户看到的仍然是旧页面。指导他们强制刷新能解决,但当这条指导成为必需的那一刻,就说明缓存设计已经错了。

HTTP 缓存不是背几个指令的问题,而是搞清楚哪一层持有什么、持有多久,以及你能怎样把它收回来的问题。而其中有一层是收不回来的:用户的浏览器。

Cache-Control 各指令的准确含义

先从最容易被误解的指令开始。

指令准确含义常见误解
max-age=N自响应生成起 N 秒内新鲜。新鲜期间不去问源站以为浏览器每 N 秒去确认一次
s-maxage=N只作用于共享缓存,并在那里覆盖 max-age以为对浏览器也生效
no-cache可以存,但复用之前必须到源站验证理解成「不要缓存」
no-store任何缓存都禁止存储。也不许落到磁盘理解成和 no-cache 一样
must-revalidate新鲜期结束后不验证就禁止提供。出错时也禁止提供 stale理解成「总是要验证」
private只能存在浏览器这类单用户缓存里理解成加密或访问控制
public标记那些平时不会被存的响应也可以存进共享缓存理解成打开缓存的开关
immutable新鲜期间即便刷新也不做再验证理解成永远被缓存
stale-while-revalidate=N过期后 N 秒内立刻返回 stale 响应,同时在后台更新理解成等同于把 max-age 调大

no-cacheno-store的差别是决定性的。给已登录用户的账户页面加no-cache,响应会被写进磁盘。在公用电脑上按后退,或者去翻缓存目录,内容还在那里。要阻止存储本身,就必须是no-store

反过来,经常见到的是这么一组。

Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0

有了no-store就不会存,所以其余的都没有意义。must-revalidate是针对已存储响应的规则,而Pragma是 HTTP/1.0 时代的请求头,挂在响应上现代缓存也不看。它像惯例一样被到处复制,其实Cache-Control: no-store一行就够了。

另外还要知道一点:什么缓存头都不发,并不意味着禁止缓存。没有明确的新鲜度信息时,缓存可以用启发式方式推测寿命,惯例上取自 Last-Modified 起已过去时间的 10% 左右。也就是说,一个月前修改过的文件可能被缓存三天上下。「我没配缓存,所以不会被缓存」这个假设是不成立的。

条件请求与 304 — 省下的和省不下的

新鲜期结束的响应,缓存并不会丢掉。它会带着验证器去问源站。

GET /assets/logo.svg HTTP/1.1
Host: cdn.example.com
If-None-Match: "8f3c1a20-4e2"
If-Modified-Since: Wed, 09 Jul 2026 11:20:14 GMT
HTTP/1.1 304 Not Modified
ETag: "8f3c1a20-4e2"
Cache-Control: max-age=600
Date: Sun, 26 Jul 2026 04:20:11 GMT

没有响应体。那个 12KB 的 SVG 没有重新下载,所以省了带宽。

关键的地方就在这里。304 省下的是带宽,往返时间照样要花。在往返延迟 180 毫秒的移动网络上验证 30 个静态资源,明明什么都没变,也要花掉相当可观的时间。即便在 HTTP/2 上被多路复用、并行处理,至少一次往返仍然跑不掉。

所以条件请求的用途是另一回事。它适合在频繁变化的资源上减少传输量,却不足以缩短静态资源的加载时间。静态资源要做的是把请求本身消掉。

用 ETag 时在实务里还会撞上一些问题。多台服务器提供同一个文件,而 ETag 每台都不一样,那么用户每被路由到不同节点一次,拿到的就是 200 而不是 304。把文件的 inode 号纳入 ETag 计算的默认配置会引发这个问题。要么改成基于内容哈希,要么把这个因素去掉。

压缩也有影响。响应经 gzip 或 brotli 编码后字节就变了,所以强 ETag 不能原样沿用。要用弱 ETag,或者按编码分开取值,同时还需要Vary: Accept-Encoding

Last-Modified 有一个秒级分辨率的限制。同一秒内改了两次就分辨不出来。能两个都发就都发,但需要准确性时以 ETag 为准。

带哈希的文件名与 immutable — 前端发布的标准组合

打包器之所以生成app.4f2a1c9e.js这样的文件名,原因就在这里。内容变了哈希就变,哈希变了 URL 就变。URL 不同,缓存键就不同,因此不可能和旧缓存冲突。

那么这个文件永远缓存也是安全的。

# 名字里带哈希的静态资源
Cache-Control: public, max-age=31536000, immutable

# 入口 HTML
Cache-Control: no-cache

没有immutable的话,用户按刷新时,浏览器连仍然新鲜的资源也会发条件请求。资源有 40 个,每次刷新就有 40 次 304 往返。immutable把这层再验证也一并省掉。

这个组合的核心,是把需要失效的对象收窄到恰好一个。只有 HTML 每次都验证,而当 HTML 引用的资源 URL 变了,新资源就会被自动取到。它把「缓存失效很难」这个问题,替换成了「压根不需要失效」的设计。

有两点要注意。给 HTML 加长 max-age 会让整个结构垮掉,因为拿着旧 HTML 的用户会一直引用旧的资源 URL。另外,发布时如果立刻把旧资源文件删掉,拿着旧 HTML 的用户会撞上 404。前面几个版本的资源要保留一段时间。

三个缓存层与彼此不同的失效方法

同一个响应会被同时存到好几个地方。每一层的控制手段都不一样。

浏览器缓存在用户的设备上。这里没有任何办法可以下发失效命令。一旦按一年的 max-age 发出去的 URL,在那位用户自己清缓存之前是收不回来的。所以发给浏览器的 TTL,只有在你确信永远不需要收回时才设长。带哈希的文件名正是造出这份确信的东西。

CDN 缓存在我们的掌控之下。可以通过 purge API 按 URL 或按标签清除,大多数 CDN 支持 surrogate key 或缓存标签。一个商品变了,就把带该商品标签的所有页面一次清掉。不过要传播到全球边缘节点需要几秒到几十秒。

反向代理缓存在我们自己的基础设施里。用 nginx 的缓存 purge、Varnish 的 ban 或 purge 来清。可以用正则指定范围因而很灵活,但一次清掉很大范围,请求就会涌向源站。

还有第四层,却经常被忘掉:Service Worker 缓存。它不遵守 Cache-Control,只按我们写的代码所定的方式行事。如果发布之后只有特定用户一直看到旧页面,应该先怀疑 Service Worker。

经常会想给不同层不同的 TTL。给浏览器短、给 CDN 长,这样发布时用 purge 就能立刻生效,同时把源站负载压得很低。

Cache-Control: public, max-age=60
CDN-Cache-Control: public, max-age=86400

CDN-Cache-Control只有 CDN 会解释,浏览器会忽略。用s-maxage也能做出类似效果,但那个值对所有共享缓存都生效,因此也会一并作用到公司代理之类的中间缓存上,这是差别所在。

Vary 与缓存键爆炸

缓存键基本上就是方法加 URL。同一个 URL 却因请求头不同而返回不同响应时,就必须把这件事告诉缓存。

Vary: Accept-Encoding, Origin

这一条是必需的。把 gzip 响应发给不支持压缩的客户端,或者把为别的源存下的 CORS 头拿来复用,这类事故都源于漏了它。

问题在于 Vary 里放什么,因为所列头部的每一种取值组合都会生成一条独立的缓存条目。

加上Vary: User-Agent基本等于把缓存关掉。浏览器版本和操作系统的组合有多少就会生成多少条目,而真实流量里这种类别有几万种。命中率趋近于零。如果确实需要按设备类型返回不同响应,就该在边缘把 UA 归一化成移动端、桌面端这样少数几个值,再按那个值来分。

Vary: Cookie更危险。只要有一个分析工具埋下的 Cookie,每个用户的取值就都不同,于是条目数等于用户数。缓存被关掉还算好的,更糟的是存储空间爆掉、把其他内容也挤出去。大多数 CDN 都有只把白名单里的 Cookie 纳入缓存键的功能,请使用它。

Vary: Accept-Language也因为语言协商头的组合很多样,产生的条目比想象中多。把语言放进路径、用 URL 来区分,对缓存更友好。

stale-while-revalidate 与缓存 API 响应的陷阱

撞上过期缓存时,用户就得等源站的响应。还有一个问题是:热门页面的缓存过期的那一瞬间,同时涌入的请求会全部打到源站。

Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400

60 秒内是新鲜的。之后的 600 秒里,把缓存的响应立刻返回,同时在后台取回新响应并存下。在用户看来始终是缓存命中的速度,而内容最多也就旧了 60 秒出头。stale-if-error则在源站返回 5xx 或不可达时,一整天都可以返回旧响应。这是把故障时间对用户藏起来的廉价办法。

这个组合很适合列表页、首页、公开 API 的读取响应这类不需要秒级新鲜度的地方。反过来,像库存数量或余额这种旧值本身就是错误的数据,绝不能用。

把已认证的响应泄进共享缓存的事故

缓存 API 响应最大的事故不是性能,而是数据外泄。

原则上,带Authorization头的请求,其响应共享缓存不会存储。除非你显式加上publics-maxage。但是用 Cookie 认证的请求不适用这条保护。基于 Cookie 登录的站点如果给按用户生成的 HTML 加上Cache-Control: max-age=300,CDN 就会把用户 A 的页面存下来,再原样返给用户 B。这在多个服务里真实发生过,而且属于原因往往被发现得太晚的那类。

防御很简单。按用户生成的响应一定要加private,敏感的直接上no-store

# 按用户生成的响应
Cache-Control: private, no-store

# 公开的列表响应
Cache-Control: public, max-age=30, stale-while-revalidate=300

更好的办法是一开始就把路径分开。把公开数据和按用户的数据拆到不同端点,公开的那一侧就能放心上 CDN,按用户的那一侧把缓存关掉即可。把两者混在同一个响应里,整体就只能迁就最保守的策略。

给 API 加 ETag 来降低客户端轮询成本也是有用的,但条件请求一来,服务器仍然得算出 ETag。只有在有更新时间列或版本计数器这类能廉价拿到的依据时才划算。把整个响应造出来再取哈希、然后返回 304,省下的只有带宽,服务器负载一点没减。

结语 — 失效很难,那就设计成不需要失效

「缓存失效很难」这句话并不夸张。浏览器缓存下发不了命令,CDN purge 传播需要时间,每一层方法都不同,Service Worker 还不守规矩。所以做得好的系统不是把失效做得更精巧,而是减少需要失效的场合。

让内容一变 URL 就变,而那个 URL 永远缓存。会变的东西集中到一个短周期验证的入口上。不需要秒级新鲜度的东西用stale-while-revalidate做到零延迟地提供。按用户的数据从路径上就分开,别让缓存策略混在一起。

缓存设计里最先要定的不是 TTL 的数字,而是这个响应以后有没有可能需要收回来。只要造出一个你能确信永远不必收回的结构,其余的设置自然就跟上了。