每次请求一个资源,都要经过一次网络往返。如果同一个资源被反复使用,这些往返大多是冗余的。HTTP 缓存的作用就是让客户端或中间层持有一份可复用的响应副本,在副本仍然可用时直接使用,在不可用时再向服务端确认。
缓存的决策完全由响应头驱动,参与决策的每一层——浏览器私有缓存、代理缓存、CDN 节点——各自独立判断。因此同一组响应头在不同层级的实际效果可能并不一致。
缓存键与 Vary
缓存条目的查找依赖缓存键。多数实现以一个「方法 + URL」组合作为主键,因此同一个 URL 的不同表示需要额外机制区分。Vary 响应头用于把若干请求头纳入缓存键:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Encoding: br
Vary: Accept-Encoding, Accept-Language
Cache-Control: max-age=600
上例中,Accept-Encoding 或 Accept-Language 不同的请求会分别形成独立的缓存条目。Vary: * 表示该响应不可被复用。把过多请求头写进 Vary 会降低命中率,因为它把一个 URL 拆成了多个条目。
强缓存:Cache-Control 指令
强缓存指在不联系服务端的前提下直接使用副本。判定依据集中在 Cache-Control:
| 指令 | 语义 |
|---|---|
max-age=N | 副本在 N 秒内视为新鲜,可直接使用 |
s-maxage=N | 覆盖 max-age,只对共享缓存生效 |
no-cache | 副本可以存储,但每次使用前必须向服务端校验 |
no-store | 不允许以任何形式存储该响应 |
private | 只允许私有缓存(如浏览器)存储 |
public | 允许共享缓存存储,即使响应带认证信息 |
must-revalidate | 副本过期后不允许在断网等情况下继续使用 |
immutable | 声明资源在有效期内不会变化,跳过校验 |
需要区分 no-cache 与 no-store:前者仍会保存副本,只是每次使用都要求确认;后者则不写入任何存储。must-revalidate 针对的是副本过期后的行为,它禁止以「陈旧内容继续展示」的方式容错。
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
ETag: "a1b2c3"
Expires 的局限
Expires 是 HTTP/1.0 引入的过期字段,取值为一个固定的日历时间:
Expires: Wed, 21 Oct 2026 07:28:00 GMT
它的判断依赖客户端的本地时钟。客户端时间若与服务器时间偏差较大,过期时间就会提前或推迟生效,实际行为变得不可预期。当 Cache-Control 中出现 max-age 时,max-age 优先于 Expires,后者通常只作为兼容旧客户端的兜底。
协商缓存
副本过期后,并非立刻要重新下载整个响应,而是可以带上条件请求头向服务端确认副本是否仍然有效。若服务端判定没有变化,返回不带响应体的 304,客户端继续使用本地副本。这套机制涉及两组字段。
ETag 与 If-None-Match
服务端为响应计算一个标识符,客户端在后续请求中通过 If-None-Match 回传。标识符变化即视为内容变化。标识符可以带 W/ 前缀表示弱校验,弱校验只要求语义等价。
Last-Modified 与 If-Modified-Since
服务端给出资源的修改时间,客户端通过 If-Modified-Since 回传。它的精度通常只到秒,因此一秒内的多次修改可能无法区分;若资源由多台机器生成,修改时间还可能不一致。
GET /style.css HTTP/1.1
If-None-Match: "a1b2c3"
HTTP/1.1 304 Not Modified
ETag: "a1b2c3"
Cache-Control: max-age=60
两组字段同时存在时,ETag 系列优先参与判定。各实现对 ETag 的生成策略不同,有的基于内容摘要,有的把 inode 与修改时间组合,因此同一资源在不同服务器上得到的标识符并不通用。
判定顺序与刷新行为
一个请求到达缓存层后,判断过程可以概括为:
- 查找缓存键对应的副本,若无则发起完整请求
- 若副本仍在有效期内,直接使用,不产生网络请求(强缓存)
- 若副本已过期,带上条件请求头发起校验请求(协商缓存)
- 收到
304则复用副本并刷新其有效期;否则用新的响应替换副本
| 触发方式 | 是否使用未过期的副本 | 是否发起校验请求 |
|---|---|---|
| 地址栏输入并回车 | 通常使用 | 副本过期时才发起 |
| 页面内跳转 / 前进后退 | 通常使用 | 副本过期时才发起 |
| 重新加载(刷新) | 不使用 | 通常会发起 |
| 强制重新加载 | 不使用 | 不发起,直接取新响应 |
上表的差异来自浏览器对「重新加载」的处理:重新加载时,浏览器会给请求附加等价于 max-age=0 的条件,使副本被视为立即过期,从而强制走一次校验;强制重新加载则进一步跳过校验,并让新响应覆盖已有副本。这两条规则也是排查「改了文件却不生效」类问题的关键,可与 HTTP/2 多路复用 中对连接复用的描述结合起来看。
小结
强缓存的判定只看有效期,不产生网络往返;协商缓存的判定需要一次轻量校验,但能避免重传响应体。两者串联成一条顺序固定的判定链,而链路是否生效取决于响应头是否正确、缓存键是否被 Vary 切割、以及请求由哪种方式触发。