9.1 边缘缓存原理
一句话
边缘缓存用「把内容放到离用户一跳以内」换延迟和回源带宽;命中率由缓存键 × 容量 × 请求分布三者相乘决定,节点数量本身不提高命中率,反而会稀释它。
一次请求的完整路径
用户 ──DNS/Anycast──▶ 边缘节点 PoP ──▶ 查缓存
│
命中 ◀────────┴────────▶ 未命中
│ │
直接返回(~1 RTT) 区域父层(shield)
│
仍未命中 → 源站
| 路径 | 延迟构成 | 量级(跨地域) |
|---|---|---|
| 边缘命中 | 用户 ↔ PoP 的 RTT + 边缘处理 | 同城 1–5ms,跨省 20–40ms |
| 父层命中 | 边缘 ↔ 父层 RTT(通常同区域) | +1–5ms |
| 回源命中 | 边缘 → 源站 RTT + 源站处理 + 排队 | 跨省 +30ms,跨洲 +180ms(RTT 口径见 8.1 TCP/IP 与三次握手) |
延迟收益的本质是砍掉长 RTT,不是「边缘机器更快」。一个跨洲请求命中边缘,省掉的是 180ms 的往返,而不是几十毫秒的 CPU。
缓存键:命中率的第一杀手
缓存键决定了「哪些请求算同一个对象」。键越细,命中率越低——而且是乘法式下降。
key = scheme + host + path + 归一化后的 query + Vary 头 + 地域/设备变体
| 键里的维度 | 常见错误 | 后果 | 正确做法 |
|---|---|---|---|
| query | 把 utm_*、t=、from= 全量入键 | 同一页面生成无穷多 URL,命中率趋近 0 | 配置忽略参数白名单,其余排序归一化 |
| Cookie | 有 Cookie 就不缓存,或把 Cookie 入键 | 命中率崩到 0,或用户串号 | 静态资源去 Cookie;动态接口用 private |
| Accept-Encoding | 忘记 Vary | 把 gzip 响应发给不支持压缩的客户端 | 边缘按 UA 能力归一化,或统一 Brotli |
| 语言 / 设备 | 维度随意加 | 3 语言 × 3 设备 × 3 版本 = 27 个变体 | 只保留真正改变响应体的维度 |
| 地域 | 全国一个键,或每城市一个键 | 前者内容不准,后者命中率极低 | 按大区(华东/华北/华南)分键 |
乘法效应算一遍:4 个维度、每个 3 个取值 = 81 个变体。一个原本 10 万 QPS 的热门页面,每个变体只剩 1234 QPS——很可能低于「能在边缘留住」的热度阈值,于是每个变体都反复回源。
🔑 优化命中率的第一动作不是加机器、加容量,是砍键。把键从 81 个变体收敛到 3 个,等效于把缓存容量放大 27 倍。
命中率的数学:Zipf + 容量
真实请求分布高度倾斜,常用 Zipf 近似:
P(排名第 i 的对象) ∝ 1 / i^α α 常见取值 0.7–1.2,需按业务实测
缓存容量为 C 个对象(LRU 近似最优)时,命中率约为:
H(C) = Σ_{i=1..C} 1/i^α / Σ_{i=1..N} 1/i^α
本机计算(N = 100 万对象,α = 1.0):
| 缓存比例 | 对象数 | 命中率 | 回源 QPS(按 100 万 QPS) |
|---|---|---|---|
| 0.1% | 1,000 | 52.01% | 479,913 |
| 0.5% | 5,000 | 63.19% | 368,118 |
| 1% | 10,000 | 68.00% | 319,962 |
| 5% | 50,000 | 79.19% | 208,141 |
| 10% | 100,000 | 84.00% | 159,982 |
| 20% | 200,000 | 88.82% | 111,823 |
读法有三个:
- 极小的缓存就能拿到过半命中率(0.1% 容量 → 52%),这是 Zipf 的礼物;
- 容量翻 10 倍,命中率只涨 16 个百分点(1% → 10%:68% → 84%),但回源腰斩(32% → 16%);
- 回源带宽按未命中比例线性付费,所以「命中率涨 16pt」在成本上是「回源费减半」。
计算代码(可直接跑):
package main
import (
"fmt"
"math"
)
const catalog = 1_000_000 // 可缓存对象总数
// 排名前 k 的对象贡献的请求比例(未归一化)
func zipfTail(k int, alpha float64) float64 {
s := 0.0
for i := 1; i <= k; i++ {
s += 1 / math.Pow(float64(i), alpha)
}
return s
}
func main() {
total := zipfTail(catalog, 1.0)
fmt.Printf("%-10s %-12s %-10s\n", "缓存比例", "对象数", "命中率")
for _, pct := range []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.2} {
k := int(float64(catalog) * pct)
fmt.Printf("%-10.1f%% %-12d %-10.2f%%\n", pct*100, k, zipfTail(k, 1.0)/total*100)
}
}
⚠️ α 必须实测。α 越小(越平),缓存越难做;α 越大(越尖),小容量就能高命中。用别人的 α 做容量规划,偏差可能是一个数量级。
边缘节点越多,命中率越低
这是最反直觉的一条。总缓存容量固定为目录的 10%,拆给 M 个节点(每个节点只服务自己那份流量,缓存自己的热对象):
| 节点数 M | 每节点对象数 | 命中率 | 回源 QPS(100 万 QPS) |
|---|---|---|---|
| 1 | 100,000 | 84.00% | 159,982 |
| 10 | 10,000 | 68.00% | 319,962 |
| 100 | 1,000 | 52.01% | 479,913 |
| 1,000 | 100 | 36.04% | 639,583 |
| 10,000 | 10 | 20.35% | 796,497 |
同样的总容量,节点从 1 个拆到 1000 个,命中率从 84% 掉到 36%,回源翻了 4 倍。原因:
- 每个节点只看到总流量的一部分,热门对象在全局热、在单节点未必热;
- 单节点容量变小,长尾对象来不及积累访问就被淘汰(缓存污染);
- 同一对象在 M 个节点各存一份,总容量被同一批热点重复占用。
工程上的三种解法:
| 解法 | 做法 | 效果 | 代价 |
|---|---|---|---|
| 分层缓存 | 边缘 → 区域父层(shield)→ 源站 | 父层容量大、命中率高,回源集中 | 多一跳;父层成为新瓶颈 |
| 请求路由 | 按 URL 哈希把同一对象固定到固定节点 | 单对象只存一份,容量利用率高 | 失去就近性;节点故障需重路由 |
| 热点识别/预取 | 全局统计热度,主动推到边缘 | 命中率接近理论值 | 需要全局视图与推送通道 |
🔑 目标不是「节点越少越好」,而是让每个对象在它被访问的区域内被缓存。节点少 → 覆盖差、单点带宽压力大;节点多 → 命中率被稀释。分层 + 路由是在两者之间取平衡。
TTL、新鲜度与失效
| 响应头 | 作用 | 代价 |
|---|---|---|
Cache-Control: s-maxage=600 | 边缘缓存 10 分钟 | 陈旧窗口最长 10 分钟 |
stale-while-revalidate=60 | 过期后先返回旧内容,同时后台回源 | 最长多 60 秒陈旧,但用户不等 |
stale-if-error=3600 | 源站 5xx 时用旧内容兜底 | 源站故障期间可能返回旧数据 |
must-revalidate | 过期后必须回源校验 | 命中率下降,延迟上升 |
private / no-store | 不进共享缓存 | 个性化/敏感内容必须如此 |
失效(purge)不是瞬时一致的:purge 请求要传播到所有节点,存在秒级到分钟级的窗口;节点越多、传播越慢。所以:
- 能靠 URL 版本化就不要靠 purge:内容哈希进文件名(
app.3f2a1b.js),发布即新 URL,旧对象自然过期; - 必须 purge 时,接受传播窗口,并给关键页面配短 TTL 兜底;
- purge 要能按前缀/标签批量执行,否则发一次版要发几千条 purge。
回源率与回源放大
回源率 = 1 − 请求命中率
回源带宽 = 边缘总带宽 × (1 − 字节命中率)
两个指标要分开看:小文件命中率高,大文件(视频)字节命中率才是带宽的关键。
回源放大来自两处:
- 多节点同时未命中:100 个边缘节点同时没有某个对象 → 100 个并发回源;
- 热点过期瞬间:热门对象 TTL 到期的同一毫秒,大量请求一起穿透。
对策是回源合并(request collapsing):同一对象的并发回源只放一个到源站,其余等待结果。再叠加 stale-while-revalidate,把「过期瞬间」变成「后台刷新」。
什么该缓存、什么不该缓存
| 内容 | 建议 | 理由 |
|---|---|---|
| 静态资源(JS/CSS/图片/字体) | 长 TTL(30 天+)+ 内容哈希 URL | 发布即换 URL,无需 purge |
| HTML 首页 / 详情页 | 短 TTL(10s–5min)或边缘 SSR | 平衡新鲜度与命中率 |
| 公开 API GET(榜单、配置) | TTL 按业务容忍度 | 用 s-maxage 明确语义 |
| 个性化接口(我的订单) | private / 不缓存 | 串号风险 |
| 鉴权后的内容 | 不缓存,或按用户分键并严格隔离 | 泄露即事故 |
| 支付/写操作 | 不缓存 | 幂等与安全 |
| 302 / 5xx | 默认不缓存,或极短 TTL | 故障被缓存会放大事故 |
合规提醒:缓存的个人信息要能被删除(删除权/被遗忘权)。把用户 ID 放进缓存键时,要能按用户批量失效,否则「删了库里的、缓存里还在」。
怎么测量
| 指标 | 口径 | 用途 |
|---|---|---|
| 请求命中率 | hit / (hit + miss) | 容量与键设计的直接反馈 |
| 字节命中率 | hitBytes / totalBytes | 带宽成本 |
| 回源率 | 1 − 请求命中率 | 源站容量规划 |
| 分路径/地区/状态码命中率 | 分组统计 | 找「被平均值掩盖」的长尾 |
| 边缘 P99 / 回源 P99 | 分开看 | 区分「边缘慢」与「源站慢」 |
只看全局平均命中率会掩盖问题:一个 95% 的全局命中率,可能由 99% 的静态资源和 20% 的 API 平均出来,而 API 才是回源成本的大头。
反模式
| 反模式 | 后果 |
|---|---|
把 utm_*、时间戳、随机数放进缓存键 | 每个请求都是新对象,命中率接近 0 |
对带 Set-Cookie 的响应开共享缓存 | 用户串号,隐私事故 |
| 长 TTL + 不带哈希的 URL | 发版后用户长时间看到旧页面,只能等 TTL 或手工 purge |
| 用 purge 做「实时更新」 | 传播窗口内不一致,且大文件 purge 成本高 |
| 只加边缘节点、不做分层 | 命中率随节点数下降,回源反而更贵 |
| 不合并回源 | 热点过期瞬间打穿源站 |
| 缓存 302/5xx/空响应 | 故障被固化并放大,恢复后仍有旧错误 |
| 用平均值评估命中率 | 长尾地区/长尾接口的问题被掩盖 |
自检
Q1 缓存容量从目录的 1% 加到 10%,命中率 68% → 84%。回源请求少了多少?为什么说「改键」比「加容量」更划算?
▸ 答案:未命中率从 32% 降到 16%,回源请求减半。而「改键」是把 81 个变体收敛成 3 个(27 倍),相当于容量放大 27 倍却不多花存储:一个对象只有一份,而不是 81 份各自积累热度。加容量受存储成本与淘汰策略限制,砍键只受业务语义限制——先砍键,再加容量。
Q2 总缓存容量不变,边缘节点从 10 个增到 100 个,命中率为什么从 68% 掉到 52%?
▸ 答案:每个节点只服务 1/100 的流量,单节点容量只有原来的 1/10;热门对象在全局热、在单节点可能不够热,长尾对象更来不及积累访问就被淘汰;同一批热点还在 100 个节点各存一份,总容量被重复占用。解法是分层(父层用大容量接住未命中)、按对象路由到固定节点、或用全局热度做预取。
Q3 一个商品详情页 QPS 很高,但边缘命中率只有 20%。列出三个最可能的键设计问题和一个非键问题。
▸ 答案:键问题:① query 里带 utm_*/时间戳/推荐位参数且未归一化;② 把 Cookie 或用户 ID 放进键(甚至直接不缓存);③ Vary 维度太多(语言×设备×版本×地域)。非键问题:TTL 太短(例如 1 秒)或源站返回 no-store/private,导致对象根本进不了缓存。排查顺序:先看响应头是否可缓存,再抓 10 个请求的 URL 看 query 差异,最后统计每个键的请求量分布。
Q4 「发版后必须立刻生效」的静态资源,为什么用内容哈希 URL 而不是 purge?
▸ 答案:purge 是传播式的,节点越多窗口越长(秒级到分钟级),且大文件 purge 成本高、易漏;内容哈希 URL 让新版本变成一个新对象,旧 URL 保留旧内容、自然过期,用户拿到的 HTML 引用新哈希,天然即时一致,还能保留旧版本的缓存命中。唯一要求是 HTML 本身不能被长缓存(短 TTL 或 no-cache)。
Q5 stale-while-revalidate 解决了什么问题?代价是什么?
▸ 答案:解决「TTL 到期瞬间大量请求一起回源」的击穿问题:过期后先返回旧副本(用户不等),同时后台只发一个回源刷新,其余请求共享结果。代价是陈旧窗口被拉长——在 revalidate 期间返回的仍是旧数据;对价格、库存这类强一致内容不能用,或者必须配合版本号/短窗口。
跨篇相关:回源策略与回源率的优化见 9.2 回源策略与回源率;缓存键的完整设计见 9.3 缓存键设计;预热、刷新与失效见 9.4 预热、刷新与失效;动态加速见 9.5 动态加速;边缘计算模型见 9.6 边缘计算模型;缓存穿透/击穿/雪崩的通用解法见 41.3 缓存穿透 / 击穿 / 雪崩;多级缓存的分层思路见 41.7 多级缓存;淘汰策略与 TTL 见 41.5 淘汰策略与 TTL;延迟指标口径见 34.1 性能指标;跨地域就近接入见 72.4 多地域部署与就近接入(待补)。