一、引言
「缓存是计算机科学里最难的两件事之一」。在 Web 分发链路中,缓存是性能与成本的双重杠杆:命中一次 CDN 缓存,等于免去一次源站计算、一次跨地域网络传输。但对缓存策略的常见误解——「设置 max-age=3600 就完了」——往往造成两个极端:要么缓存过头,用户看到旧数据;要么处处 no-store,CDN 退化为纯代理,性能与成本双输。
本文不讲 CDN 控制台怎么点,而是讲缓存策略的设计语言:HTTP Cache-Control 的完整语义、CDN 如何决定缓存键、Stale-While-Revalidate 如何把「新鲜度」与「可用性」解耦、动态内容如何进缓存,以及浏览器/CDN/源站三层分层缓存如何协同。参考的实践同时适用于 Cloudflare 与 Vercel,相关平台细节见 Cloudflare CDN 缓存规则详解 与 Vercel ISR 增量静态再生。
二、Cache-Control:HTTP 缓存的协议语言
2.1 全指令语义
Cache-Control 是同时被浏览器、中间代理、CDN 三端读取的协议头。指令决定「谁能缓存」「缓存多久」「如何校验」:
| 指令 | 语义 | 适用端 |
|---|---|---|
public | 允许所有中间缓存存储 | 共享缓存 |
private | 仅允许私有缓存(浏览器) | 共享缓存不可存储 |
max-age=<s> | 自响应生成起的新鲜期(秒) | 所有缓存 |
s-maxage=<s> | 共享缓存(CDN)的新鲜期,覆盖 max-age | 仅共享缓存 |
no-store | 禁止任何存储 | 所有缓存 |
no-cache | 可存储,但每次必须回源校验 | 所有缓存 |
must-revalidate | 过期后必须回源,不得用陈旧响应 | 所有缓存 |
stale-while-revalidate=<s> | 过期后可用陈旧响应,同时后台刷新 | 所有缓存 |
stale-if-error=<s> | 源站出错时用陈旧响应兜底 | 所有缓存 |
immutable | 内容不可变,缓存期间无需校验 | 浏览器 |
组合示例:
# 静态资源:CDN 与浏览器都强缓存,1 年不变
Cache-Control: public, max-age=31536000, immutable
# HTML:浏览器 60s 新鲜,CDN 缓存 600s,过期后台刷新
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=3600
# 个性化页面:只允许浏览器缓存
Cache-Control: private, max-age=300
# 订单提交响应:绝不缓存
Cache-Control: no-store
2.2 最常见的头解析顺序
CDN 决定「能否缓存」看三件事,按优先级:
- 是否存在
Authorization/Set-Cookie等默认不可缓存的条件(多数 CDN 默认尊重)。 Cache-Control是否含no-store/private(明确禁止)。Cache-Control的s-maxage/max-age决定新鲜期。
⚠️
no-cache常被误解为「不缓存」。它其实是「存但不信」——每次请求都要带If-Modified-Since/ETag去源站校验,校验通过返回 304 仍算命中。适合「内容会变但源站校验便宜」的场景。
2.3 启发式缓存:没带头时浏览器也在缓存
HTTP 规定:当响应没有任何显式缓存指令时,缓存可依据 Last-Modified 做启发式缓存,新鲜期约为修改时间到现在的 (Date − Last-Modified) × 10%。这意味着「忘了带头」不等于「不缓存」,而是不可预测地缓存。生产规范:源站每个响应都必须显式声明 Cache-Control,不留启发式的侥幸。
三、CDN 缓存键归一化
3.1 默认缓存键与 Vary
CDN 默认以 URL(scheme + host + path + query) 作为缓存键。但 HTTP 语义不止于此——Vary 头告诉缓存:同一 URL 的响应可能因某请求头不同而不同。
# 响应同时存在 gzip 与 br 两种编码,必须区分
Vary: Accept-Encoding
# 响应依赖 Accept-Language(多语言站点)
Vary: Accept-Language
Vary 是把双刃剑:声明越多,缓存键越多,命中率越低。Cloudflare 对 Accept-Encoding 有特殊处理(不参与键值,会自动按编码缓存多份),但通用 CDN 上滥用 Vary: User-Agent 会把每个 UA 变成一份缓存。
3.2 Query 归一化:忽略无关参数
跟踪参数(?utm_source=...、?ref=...)会让同一内容产生海量缓存碎片。策略是只把影响内容的参数纳入缓存键:
Cloudflare 缓存键配置:
- 包含查询字符串: 仅 u 和 lang # 其余参数一律忽略
- 忽略查询字符串: false
等价于把缓存键从 URL?q=1&utm_source=x 归一化为 URL?u=1&lang=zh。落地时在源站配合,让响应明确声明「这些参数才是键的一部分」,或直接在 CDN 规则里裁剪。
3.3 设备与地域分区:Surrogate 键思维
移动端与桌面端、中国大陆与海外的响应若不同,应显式分区,而不是全塞进一个缓存键靠 Vary: User-Agent 撞运气。Cloudflare 用 Cache Rules 的「自定义缓存键」实现:
{
"cache_key": {
"include": ["header:cf-ipcountry", "cookie:device_type"]
}
}
规范做法是源站主动输出分区信息,让 CDN 机械执行:
// 源站 Node/Edge:按设备与地域返回不同缓存头
res.setHeader('Cache-Control', 'public, s-maxage=300')
// 若用 Cloudflare Workers 做缓存,可在响应上带自定义键头
// (cf.cacheKey) 按国家分区
四、Stale-While-Revalidate:新鲜度与可用性的解耦
4.1 原理
SWR 打破「新鲜期一过就必须回源」的僵局:响应过期后,请求者立即得到陈旧副本,缓存同时在后台向源站拉取新副本。用户端延迟归零,源站压力也被摊平到后台。
时间线:
[新鲜期 600s] [SWR 窗口 3600s]
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ░░░░░░░░░░░░░░░░░░░░░░
命中缓存(快) → 直接返回陈旧副本(快)
→ 后台异步回源刷新(新副本进入缓存)
实现方式取决于 CDN:
# 方案一:源站输出 SWR 头(CDN 原生支持则最省事)
Cache-Control: public, max-age=600, stale-while-revalidate=3600
// 方案二:Cloudflare Workers 手工实现 SWR(Cache API + 后台回源)
export default {
async fetch(req, ctx) {
const url = new URL(req.url)
const cacheKey = new Request(url, { method: 'GET' })
const cached = await caches.default.match(cacheKey)
if (cached) {
// 后台刷新(waitUntil 不阻塞响应)
ctx.waitUntil(refreshAndPut(cacheKey, url))
return cached // 直接返回陈旧副本
}
return refreshAndPut(cacheKey, url)
},
}
async function refreshAndPut(cacheKey, url) {
const fresh = await fetch(url)
if (fresh.ok) {
await caches.default.put(cacheKey, fresh.clone())
}
return fresh
}
// 方案三:Next.js ISR 语义下的 SWR
export const revalidate = 600 // 600s 后触发后台重新验证
// 请求时返回陈旧 HTML,后台生成新版本
Vercel ISR 的「on-demand + 定时 revalidate」本质就是一种服务端 SWR:用户请求打到 CDN,命中则秒回,未命中/过期则触发重新生成,旧版本继续服务直到新的就绪。详见 Vercel ISR 指南。
4.2 SWR 的取舍
| 优点 | 风险 |
|---|---|
| 过期不阻塞用户,响应恒定快 | 用户可能短暂看到陈旧数据 |
| 回源压力被后台摊平,峰值下降 | 需要接受「最终一致」 |
| 天然配合「新鲜度不重要」的内容(新闻列表、价格非实时) | 对实时性敏感(库存、余额)场景不适用 |
适用边界:SWR 适合「新鲜期够用、过期了也无害」的内容。若数据必须在秒级准确,SWR 不是答案,应回到 no-store + 实时源站。
五、动态内容缓存:从「能不能」到「怎么失效」
5.1 动态不意味着不可缓存
「动态」通常指 随用户/时间变化,不等于「每次都要源站重算」。按变化维度拆分:
| 内容 | 变化维度 | 缓存策略 |
|---|---|---|
| 商品详情 | 变化极慢 | CDN 强缓存 + 主动失效 |
| 用户购物车 | 单用户私有 | private 浏览器缓存 / 不缓存 |
| 新闻列表 | 每 5 分钟变 | SWR 600s |
| 个性化推荐 | 按用户 + 时间 | 源站片段级缓存(Fastly/Cloudflare 片段缓存或 KV 兜底) |
关键洞察:动态内容的缓存难点不是「缓存」,而是「失效」。谁能精确失效,谁就能大胆缓存。
5.2 Surrogate Key / 缓存标签
CDN 允许源站给响应贴「标签」,CDN 维护标签→缓存对象的索引,源站可通过 API 一次性按标签失效整批:
# 源站响应头(Fastly/Cloudflare 均支持类似机制)
Surrogate-Key: article:123 blog:456
# 发布新文章后,按标签批量失效
curl -X POST https://api.cloudflare.com/client/v4/.../purge_cache \
-d '{"tags": ["article:123"]}'
这套机制与 Next.js 的 revalidateTag(见 App Router 深度)异曲同工:把失效从「TTR/TTL 等待」变为「事件驱动」,动态内容因此敢缓存更久。
5.3 个性化内容的片段级缓存
全页无法缓存时,退到「片段级」:壳与公共块在 CDN 缓存,用户私有块走边缘服务拼接。
// Cloudflare Workers:缓存公共壳,拼接个性化头部
export default {
async fetch(req, ctx) {
const u = new URL(req.url)
const shell = await caches.default.match(u.pathname)
const user = await getProfile(req) // 私有数据
return new HTMLRewriter()
.on('#user-box', { element: (el) => el.setInnerContent(user.name) })
.transform(shell)
},
}
六、分层缓存架构:浏览器 → CDN → 源站
6.1 四层协同
| 层级 | 角色 | 命中来源 | 特征 |
|---|---|---|---|
| 浏览器缓存 | 单用户最近内容 | 本地磁盘 | 最快(0ms),但仅本机 |
| Service Worker | 离线/预缓存 | 本地 + 后台更新 | 可控性最强 |
| CDN(边缘) | 共享内容多用户 | 就近 PoP | 覆盖全球,命中即免回源 |
| 源站(+ ISR/函数) | 最终计算 | 数据库/API | 每次计算都是成本 |
完整链路设计原则:
- 越接近用户越优先命中:浏览器 > SW > CDN > 源站。
- TTL 沿链路递减或相同,但失效必须向上游穿透:源站更新后,CDN 的
purge与浏览器ETag校验共同保证收敛。 - 每个头都声明:源站为同一资源输出多套
Cache-Control(浏览器/共享缓存分别控制),例如:
# 同一响应,不同端不同策略
Cache-Control: private, max-age=60;
s-maxage=600, stale-while-revalidate=3600
6.2 缓存风暴与惊群
热点内容突然失效时,若 CDN 配置为「同步回源刷新」,成千上万个并发请求会同时打穿源站——即缓存惊群(thundering herd)。缓解手段:
- 锁合并回源:同一缓存键只允许一个请求回源,其余排队等新副本(Cloudflare 的
stale-while-revalidate+ 单飞(single-flight)回源即为此设计)。 - 主动刷新 + 渐进预热:发布前先手动
purge并预热热点,避免流量自然触发。 - 边缘计算兜底:源站不可用时,CDN 返回陈旧副本(
stale-if-error),不让用户看到 5xx。
# 兜底:源站 5xx 时仍返回陈旧副本最多 1 小时
Cache-Control: public, max-age=300, stale-while-revalidate=3600, stale-if-error=3600
七、缓存策略设计清单
| 决策点 | 推荐 | 反例 |
|---|---|---|
| 静态资源(图片/CSS/JS) | public, max-age=31536000, immutable + 内容哈希文件名 | 短 max-age 反复回源 |
| 公共 HTML | public, max-age=60, s-maxage=600, SWR | 每请求都回源 |
| 用户私有页面 | private, max-age=300 或 no-store | 误用 public 泄露隐私 |
| 写操作/敏感响应 | no-store | 被中间层缓存 |
| 多语言/多设备 | 缓存键分区 + 少用 Vary | 全站 Vary: User-Agent |
| 跟踪参数 | 缓存键归一化忽略 | 每来源一个缓存碎片 |
| 内容更新 | Surrogate Key 事件驱动失效 | 只靠 TTL 被动等 |
八、总结
边缘缓存的核心不是「设置多长 TTL」,而是回答三个问题:
- 谁能缓存、能缓存多久 —— 用
Cache-Control显式声明,浏览器与 CDN 各自独立控制。 - 缓存键是什么 —— 通过 Query 归一化与缓存键分区,让「同一内容」收敛为「一份缓存」。
- 过期之后怎么办 —— SWR 把刷新放到后台,Surrogate Key 让失效事件化,惊群用单飞回源兜底。
把这三层设计好,CDN 才会真正成为「性能引擎」而不是「加速代理」。若要在 Cloudflare / Vercel 上落地,可分别参考 Cloudflare 缓存规则 与 Vercel ISR,并与 性能监控 RUM 配合,用现场命中率与 TTFB 验证策略是否生效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。