边缘缓存策略:Cache-Control 语义、Stale-While-Revalidate 与 CDN 缓存键归一化

系统讲解边缘缓存架构:Cache-Control 全指令语义与启发式缓存、CDN 缓存键归一化(Vary、Query 规整、Device/Locale 分区)、Stale-While-Revalidate 原理与落地、动态内容缓存与 Surrogate Key 失效、以及浏览器/CDN/源站三层分层缓存设计。

一、引言

「缓存是计算机科学里最难的两件事之一」。在 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 决定「能否缓存」看三件事,按优先级:

  1. 是否存在 Authorization / Set-Cookie 等默认不可缓存的条件(多数 CDN 默认尊重)。
  2. Cache-Control 是否含 no-store / private(明确禁止)。
  3. 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每次计算都是成本

完整链路设计原则:

  1. 越接近用户越优先命中:浏览器 > SW > CDN > 源站。
  2. TTL 沿链路递减或相同,但失效必须向上游穿透:源站更新后,CDN 的 purge 与浏览器 ETag 校验共同保证收敛。
  3. 每个头都声明:源站为同一资源输出多套 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 反复回源
公共 HTMLpublic, max-age=60, s-maxage=600, SWR每请求都回源
用户私有页面private, max-age=300 或 no-store误用 public 泄露隐私
写操作/敏感响应no-store被中间层缓存
多语言/多设备缓存键分区 + 少用 Vary全站 Vary: User-Agent
跟踪参数缓存键归一化忽略每来源一个缓存碎片
内容更新Surrogate Key 事件驱动失效只靠 TTL 被动等

八、总结

边缘缓存的核心不是「设置多长 TTL」,而是回答三个问题:

  1. 谁能缓存、能缓存多久 —— 用 Cache-Control 显式声明,浏览器与 CDN 各自独立控制。
  2. 缓存键是什么 —— 通过 Query 归一化与缓存键分区,让「同一内容」收敛为「一份缓存」。
  3. 过期之后怎么办 —— SWR 把刷新放到后台,Surrogate Key 让失效事件化,惊群用单飞回源兜底。

把这三层设计好,CDN 才会真正成为「性能引擎」而不是「加速代理」。若要在 Cloudflare / Vercel 上落地,可分别参考 Cloudflare 缓存规则 与 Vercel ISR,并与 性能监控 RUM 配合,用现场命中率与 TTFB 验证策略是否生效。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「tools」更多文章

  1. 部署与回滚策略:蓝绿、金丝雀与不可变部署实战
  2. 边缘认证与会话管理:JWT、Cookie 与 Serverless 登录实战
  3. 可观测性与错误追踪:日志、Trace 与告警闭环