基于 RFC 9111 (2022) 的 HTTP 缓存标准解读
1. 概述
HTTP 缓存是 Web 性能优化的基石,通过复用已获取的响应来减少网络请求、降低延迟、节省带宽。
1.1 缓存链架构
┌─────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │ ←→ │ Shared Cache│ ←→ │ Shared Cache│ ←→ │ Origin │
│ Private │ │ (CDN/Proxy) │ │ (CDN/Proxy) │ │ Server │
│ Cache │ │ │ │ │ │ │
└─────────┘ └─────────────┘ └─────────────┘ └─────────────┘
↑
│ Age header from shared caches
↓
Browser 计算: age = time_in_browser + Age:xxx header value关键概念:
- 私有缓存(Private Cache):浏览器本地缓存,仅服务于单个用户
- 共享缓存(Shared Cache):CDN、代理服务器等,可服务多个用户
- Age 头:中间缓存告知客户端响应已在缓存中存放的秒数
1.2 Cache-Control 机制
Cache-Control 是控制缓存行为的主要 HTTP 头,支持多种指令:
指令分类:
| 目标 | 指令类型 |
|---|---|
| 私有缓存 | private、no-store、immutable |
| 共享缓存 | s-maxage、proxy-revalidate、public |
| 两者皆可 | max-age、no-cache、must-revalidate、stale-while-revalidate |
2. 新鲜度判定:缓存何时可用?
当缓存收到请求时,需要判断缓存响应是新鲜(fresh)还是过期(stale)。
2.1 缓存决策流程
┌─────────────────┐
│ 收到HTTP请求 │
└────────┬────────┘
↓
┌─────────────────┐
│ 查找缓存响应 │
└────────┬────────┘
↓
┌──────────────┴──────────────┐
↓ ↓ ↓
┌─────────┐ ┌──────────┐ ┌──────────┐
│ 找到 │ │ 未找到 │ │ 找到但 │
│ 匹配响应│ │ │ │ 不匹配 │
└────┬────┘ └────┬─────┘ └────┬─────┘
↓ ↓ ↓
┌─────────┴─────────┐ ↓ ┌────────┴────────┐
↓ ↓ ↓ ↓
┌────────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐
│ 计算age │ │ 向服务器 │ │ 重新查找 │ │ 转发请求 │
│ vs freshness│ │ 发起请求 │ │ (Vary等) │ │ 到服务器 │
└─────┬──────┘ └──────────┘ └─────┬─────┘ └──────────┘
↓ │
↓ ┌───────────────────────────┘
↓ ↓
┌─────────────┐
│ age <= │←── max-age / s-maxage / Expires
│ freshness? │
└──────┬──────┘
↓ NO
┌─────────────┐
│ 检查是否可验证│
│ (must-rev.) │
└──────┬──────┘
↓
┌─────────────┐ ┌──────────────────┐
│ 发起条件请求 │───→│ If-None-Match │
│ (验证) │ │ If-Modified-Since│
└──────┬──────┘ └──────────────────┘
↓
┌──────────────┐
│ 304? │←── ETag / Last-Modified 匹配
└──────┬───────┘
NO ↓ YES ↓
┌────────────┐ ┌─────────────┐
│ 200 + Body │ │ 304 无 Body │
└────────────┘ │ 复用缓存 │
└─────────────┘2.2 新鲜度时间线判定顺序
┌─────────────────────────────────────────────────────────────────┐
│ 新鲜度时间线判定顺序 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Cache-Control: max-age=N │
│ └─→ 直接使用 max-age 作为新鲜度秒数 │
│ │
│ ↓ 不存在 │
│ │
│ 2. Expires: <date> 与 Date: <date> 的差值 │
│ └─→ 新鲜度 = Expires - Date │
│ │
│ ↓ 不存在 │
│ │
│ 3. Last-Modified 启发式估算 │
│ └─→ 新鲜度 ≈ (Date - Last-Modified) / 10 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 共享缓存优先规则: s-maxage 优先于所有其他指令 │
└─────────────────────────────────────────────────────────────────┘2.3 新鲜度 vs 过期
| 状态 | 条件 | 行为 |
|---|---|---|
| Fresh | age <= freshness | 直接使用缓存,不发起请求 |
| Stale | age > freshness | 必须验证后才能使用 |
3. 过期后处理:条件请求验证
即使响应过期,也不应直接丢弃,而应通过条件请求验证是否仍可使用。
3.1 条件请求验证流程
┌─────────────────────┐ ┌─────────────────────┐
│ 客户端缓存的响应 │ │ 源服务器 │
├─────────────────────┤ ├─────────────────────┤
│ HTTP/1.1 200 OK │ │ │
│ ETag: "abc123" │ │ │
│ Last-Modified: ... │ │ │
│ Content: {...} │ │ │
└─────────┬───────────┘ └──────────┬──────────┘
│ │
│ 缓存过期,发送条件请求 │
├───────────────────────────→│
│ GET /api/data │
│ If-None-Match: "abc123" │
│ If-Modified-Since: ... │
│ │
│ ┌──────────────────┴───┐
│ ↓ ↓
│ ┌──────────┐ ┌──────────┐
│ │ 内容未变 │ │ 内容已变 │
│ │ ETag匹配 │ │ │
│ └────┬─────┘ └────┬─────┘
│ ↓ ↓
│ ┌──────────┐ ┌──────────┐
│ │ 304 Not │ │ 200 OK │
│ │ Modified │ │ 新内容 │
│ └────┬─────┘ └────┬─────┘
│ │ │
│ │ 复用原缓存 │ 使用新内容
│ │ 更新headers │
│ ↓ │
│ ┌──────────┐ │
│ │ 缓存有效 │ │
│ │ 无需下载 │ │
│ │ 节省带宽 │ │
│ └──────────┘ │
│ │
└─────────────────────────────────┘3.2 验证标签与条件头对应关系
┌────────────────────┬─────────────────────────────┐
│ 响应头 (服务器) │ 条件请求头 (客户端) │
├────────────────────┼─────────────────────────────┤
│ ETag: "abc123" │ If-None-Match: "abc123" │
│ Last-Modified: ... │ If-Modified-Since: ... │
└────────────────────┴─────────────────────────────┘
↑
│ 优先级规则
↓
If-None-Match 优先于 If-Modified-Since
(两者都存在时,只评估 If-None-Match)验证结果:
| 服务器响应 | 含义 | 处理 |
|---|---|---|
| 304 Not Modified | 内容未变 | 复用缓存,更新 headers |
| 200 OK | 内容已更新 | 使用新响应 |
4. 缓存存储机制
4.1 缓存键(Cache Key)
HTTP 缓存使用缓存键来存储和检索响应:
┌─────────────────────────────────────────────────────────┐
│ 缓存键组成 │
├─────────────────────────────────────────────────────────┤
│ │
│ 基本缓存键 = URL + HTTP Method │
│ │
│ 示例: │
│ ├── GET https://api.example.com/data → cache key 1 │
│ ├── POST https://api.example.com/data → cache key 2 │
│ └── GET https://api.example.com/data → cache key 1 │
│ │
├─────────────────────────────────────────────────────────┤
│ 浏览器额外分区 (隐私保护,双键机制): │
│ │
│ 缓存键 += top-level-site + frame-site + flags │
│ │
│ 目的: 防止 iframe 跨站追踪用户 │
└─────────────────────────────────────────────────────────┘4.2 Vary 响应头:处理可变响应
同一 URL 可能根据请求头返回不同内容,Vary 头指示哪些请求头影响响应。
请求: GET /api/resource
Accept-Language: zh-CN
┌─────────────────────────────────────────────────────┐
│ 共享缓存 │
│ ┌─────────────────────────────────────────────────┐│
│ │ Cache Key = "GET" + "/api/resource" + "zh-CN" ││
│ │ ││
│ │ ┌─────────────────────────────────────────────┐ ││
│ │ │ 响应1: zh-CN → {"greeting": "你好"} │ ││
│ │ │ Vary: Accept-Language │ ││
│ │ └─────────────────────────────────────────────┘ ││
│ │ ┌─────────────────────────────────────────────┐ ││
│ │ │ 响应2: en-US → {"greeting": "Hello"} │ ││
│ │ │ Vary: Accept-Language │ ││
│ │ └─────────────────────────────────────────────┘ ││
│ │ ┌─────────────────────────────────────────────┐ ││
│ │ │ 响应3: fr-FR → {"greeting": "Bonjour"} │ ││
│ │ │ Vary: Accept-Language │ ││
│ │ └─────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────┘
请求: GET /api/resource
Accept-Language: en-US ← 不同的Vary值 → 命中响应2Vary 头的问题:
| 问题 | 影响 |
|---|---|
| 高变异性 | 大量不可复用的缓存变体 |
| CDN 行为差异 | 部分 CDN 忽略或不正确处理 Vary |
5. Cache-Control 响应指令详解
┌──────────────────────────────────────────────────────────────┐
│ 响应指令速查 │
├──────────────────────────────────────────────────────────────┤
│ 新鲜度控制 │
├──────────────────────────────────────────────────────────────┤
│ max-age=<seconds> → 定义新鲜度持续时间 │
│ s-maxage=<seconds> → 仅共享缓存使用,优先级最高 │
│ immutable → 期内不会更新,跳过软刷新验证 │
├──────────────────────────────────────────────────────────────┤
│ 验证控制 │
├──────────────────────────────────────────────────────────────┤
│ no-cache → 每次使用前必须验证 │
│ must-revalidate → 过期后必须验证,不能使用过期缓存 │
│ proxy-revalidate → 仅共享缓存需验证 │
├──────────────────────────────────────────────────────────────┤
│ 存储控制 │
├──────────────────────────────────────────────────────────────┤
│ no-store → 禁止任何缓存存储 │
│ private → 仅浏览器私有缓存可用 │
│ public → 允许共享缓存存储认证请求的响应 │
├──────────────────────────────────────────────────────────────┤
│ 其他控制 │
├──────────────────────────────────────────────────────────────┤
│ stale-while-revalidate=<N> → 后台验证期间可返回过期内容N秒 │
│ stale-if-error=<N> → 验证失败时可返回过期内容N秒 │
│ no-transform → 禁止中间件转换内容 │
│ must-understand → 只缓存已知状态码 │
└──────────────────────────────────────────────────────────────┘5.1 新鲜度指令
max-age=<number>
┌─────────────────────────────────────────┐
│ max-age=3600 │
│ ↓ │
│ ┌─────────────────────────────────┐ │
│ │ 0s 1800s 3600s 5400s │ │
│ │ │ │ │ │ │ │
│ │ Fresh←─┼─────────→Stale←───┼──→ │ │
│ │ │ │ │ │
│ └────────┴──────────────────┴────┘ │
│ ↑ │
│ 响应在3600秒内为Fresh │
└─────────────────────────────────────────┘immutable
┌─────────────────────────────────────────────┐
│ Cache-Control: max-age=31536000, immutable │
│ │
│ 用户操作: 软刷新 (Ctrl+R) │
│ ┌───────────────────────────────────────┐ │
│ │ 直接使用缓存,不发起验证请求 │ │
│ │ (浏览器知道内容期内不会更新) │ │
│ └───────────────────────────────────────┘ │
│ │
│ 用户操作: 硬刷新 (Ctrl+Shift+R) │
│ ┌───────────────────────────────────────┐ │
│ │ 强制发起请求,忽略缓存 │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘5.2 验证指令
must-revalidate vs no-cache
┌────────────────┬─────────────────────────────┐
│ 指令 │ 行为 │
├────────────────┼─────────────────────────────┤
│ no-cache │ 始终验证后才能使用 │
│ │ (相当于 max-age=0, │
│ │ must-revalidate) │
├────────────────┼─────────────────────────────┤
│ must-revalidate│ 仅过期后必须验证 │
│ │ 未过期时可直接使用 │
└────────────────┴─────────────────────────────┘must-revalidate 边界行为:
过期缓存 + must-revalidate:
场景1: 验证成功 (304) → 复用缓存
场景2: 验证成功 (200) → 使用新内容
场景3: 验证失败 (5xx) → 返回错误,不能使用过期缓存
场景4: 离线 → 返回 504 Gateway Timeout5.3 存储指令
no-store
┌─────────────────────────────────────────────────────────┐
│ Cache-Control: no-store │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 缓存处理流程 │ │
│ │ │ │
│ │ 收到响应 ──→ 不存储 ──→ 转发给客户端 │ │
│ │ ↓ │ │
│ │ 立即从内存清除 │ │
│ │ │ │
│ │ ⚠️ 隐私保护不依赖于 no-store │ │
│ │ ⚠️ 恶意缓存可能不遵守此指令 │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘private vs public
┌────────────────┬─────────────────────────────┐
│ 指令 │ 缓存允许 │
├────────────────┼─────────────────────────────┤
│ private │ ✓ 浏览器私有缓存 │
│ │ ✗ 共享缓存 (CDN/Proxy) │
├────────────────┼─────────────────────────────┤
│ public │ ✓ 浏览器私有缓存 │
│ │ ✓ 共享缓存 │
│ │ ✓ 认证请求的响应 │
└────────────────┴─────────────────────────────┘5.4 过期内容复用指令
stale-while-revalidate
┌─────────────────────────────────────────────────────────┐
│ Cache-Control: max-age=60, stale-while-revalidate=120 │
│ │
│ 时间线: │
│ │
│ 0s 60s 180s 240s 300s │
│ │ │ │ │ │ │
│ │ Fresh Stale │ │ │
│ │ │ │ │ │ │
│ │ │ └───────┴───────┘ │
│ │ │ 后台验证中 │
│ │ │ 返回过期内容 │
│ │ │ │
│ └──────┴─────────────────────────────────→ │
│ 立即返回 │
│ 过期内容 │
│ 触发后台验证 │
└─────────────────────────────────────────────────────────┘6. Cache-Control 请求指令
浏览器在刷新时使用请求指令表达缓存偏好:
┌──────────────────────────────────────────────────────────────┐
│ 请求指令速查 │
├──────────────────────────────────────────────────────────────┤
│ max-age=<seconds> → 期望响应age <= 指定秒数 │
│ max-stale=<seconds> → 接受过期时间在此范围内的响应 │
│ min-fresh=<seconds> → 期望响应至少有指定秒数的新鲜度 │
│ no-cache → 必须验证后才能使用缓存 │
│ no-store → 禁止缓存存储此请求和响应 │
│ only-if-cached → 只接受缓存,不向服务器请求 │
│ no-transform → 禁止转换响应内容 │
└──────────────────────────────────────────────────────────────┘7. 浏览器刷新机制
7.1 软刷新 vs 硬刷新行为对比
┌────────────────────────────────────────────────────────────────┐
│ 软刷新 (Ctrl+R / Cmd+R) │
├────────────────────────────────────────────────────────────────┤
│ │
│ Firefox/Chrome: │
│ ├─────────────────────────────────────────────────────→ │
│ │ 主资源: 带 If-None-Match/If-Modified-Since 验证 │
│ │ 子资源: 按各自 Cache-Control 策略处理 │
│ │ (带 immutable 的资源不验证) │
│ │
│ Safari: │
│ ├─────────────────────────────────────────────────────→ │
│ │ 主资源: 非条件请求 (不带验证头) │
│ │ 子资源: 按策略处理 │
│ │
├────────────────────────────────────────────────────────────────┤
│ 硬刷新 (Ctrl+Shift+R / Cmd+Shift+R) │
├────────────────────────────────────────────────────────────────┤
│ │
│ 所有浏览器 (除 Safari 快捷键不同): │
│ ├─────────────────────────────────────────────────────→ │
│ │ 所有资源: Cache-Control: no-cache │
│ │ 完全跳过缓存,强制从服务器获取 │
│ │ │
│ 快捷键差异: │
│ ├ Windows/Linux: Ctrl+Shift+R │
│ ├ macOS Chrome/Firefox: Cmd+Shift+R │
│ └ macOS Safari: Cmd+Option+R (Reader Mode冲突) │
│ │
└────────────────────────────────────────────────────────────────┘7.2 immutable 指令的诞生背景
问题 (2015年):
用户刷新页面 → 所有子资源都被重新验证 → 大量 304 响应
┌─────────────────────────────────────────────────────────┐
│ 场景: Facebook 用户刷新 Feed 页面 │
│ │
│ 1 次页面请求 │
│ ├─ 10+ 次脚本请求 (长期缓存的 JS) │
│ ├─ 5+ 次样式请求 (长期缓存的 CSS) │
│ └─ 20+ 次图片请求 │
│ │
│ 结果: 大量不必要的 304 响应,浪费带宽和服务器资源 │
└─────────────────────────────────────────────────────────┘
解决方案:
1. immutable 指令 (RFC 8246)
└─→ 告诉浏览器: 期内不会更新,无需验证
2. 改进的软刷新策略 (Chrome 2017, Safari, Firefox 100+)
└─→ 仅验证主资源,子资源按策略处理
现在: 两者并存,但改进的刷新策略已解决大部分问题8. 认证请求响应缓存
8.1 基本规则
┌──────────────────────────────────────────────────────────────┐
│ 默认规则 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 请求带 Authorization header? │
│ ↓ YES │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 共享缓存默认禁止存储此响应 │ │
│ │ (因为响应是用户特定的) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘8.2 允许共享缓存存储的指令
┌──────────────────────────────────────────────────────────────┐
│ 以下任一指令可允许共享缓存存储认证请求的响应: │
├──────────────────────────────────────────────────────────────┤
│ │
│ ✓ public │
│ └─→ 显式允许共享缓存存储 │
│ │
│ ✓ s-maxage=<seconds> │
│ └─→ 共享缓存专用,同时允许存储认证响应 │
│ │
│ ✓ must-revalidate │
│ └─↓ 包含 s-maxage 语义 │
│ │
│ ✗ private │
│ └─→ 阻止任何共享缓存存储 │
│ │
└──────────────────────────────────────────────────────────────┘
⚠️ 部署 public/s-maxage/must-revalidate 前必须评估安全性!9. 总结
9.1 缓存策略选择决策树
┌──────────────────────────────────────────────────────────────┐
│ 缓存策略选择 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 是否需要缓存? │
│ │ │
│ ├─ NO ──→ Cache-Control: no-store │
│ │ │
│ └─ YES ──→ 是否包含敏感信息? │
│ │ │
│ YES ──→ Cache-Control: private, no-store │
│ │ │
│ NO ──→ 是否需要共享缓存 (CDN)? │
│ │ │
│ YES ──→ 是否频繁更新? │
│ │ │
│ YES ──→ s-maxage=0, must-revalidate │
│ NO ──→ s-maxage=<seconds>, immutable? │
│ │
└──────────────────────────────────────────────────────────────┘9.2 推荐缓存策略
| 资源类型 | 缓存策略 | 说明 |
|---|---|---|
| HTML | no-cache, must-revalidate | 每次验证,确保内容最新 |
| 静态资源 (JS/CSS) | max-age=31536000, immutable | 长期缓存,文件名含 hash |
| API 响应 | no-cache 或 s-maxage | 根据更新频率选择 |
| 用户特定数据 | private, no-store | 不缓存或仅私有缓存 |
| 图片/字体 | max-age=<long>, immutable | 长期缓存 |