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

读法有三个:

  1. 极小的缓存就能拿到过半命中率(0.1% 容量 → 52%),这是 Zipf 的礼物;
  2. 容量翻 10 倍,命中率只涨 16 个百分点(1% → 10%:68% → 84%),但回源腰斩(32% → 16%);
  3. 回源带宽按未命中比例线性付费,所以「命中率涨 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 − 字节命中率)

两个指标要分开看:小文件命中率高,大文件(视频)字节命中率才是带宽的关键。

回源放大来自两处:

  1. 多节点同时未命中:100 个边缘节点同时没有某个对象 → 100 个并发回源;
  2. 热点过期瞬间:热门对象 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 多地域部署与就近接入(待补)。