41.7 多级缓存

一句话

多级缓存是「用命中率的乘法换延迟、用失效通道的加法换一致性」:外层买延迟,内层买 QPS,而全链路的陈旧上界由陈旧最久的那一层决定——加层之前先答出它的容量、命中率和失效通道,否则层数越多,脏数据活得越久。

两级只是起点,三级以上才是「多级」

两级结构(进程内 + Redis)、L1 与 L2 的六个差异、失效广播的三种通道,见 41.2 本地缓存与分布式缓存。本节只讲层数 ≥ 3 之后新增的四个问题:

  1. 每层各该放什么、容量多大、脏多久?
  2. 陈旧上界由哪一层决定?
  3. 一次写要打几个洞、漏一个会怎样?
  4. 回源放大与容量分配怎么算?

五个位置,五种「旧」

假设用户到边缘节点 RTT 20ms、到源站 RTT 50ms,一次 DB 查询 20ms:

层 存什么 命中耗时(含网络) 新鲜度由谁控制 典型 TTL
浏览器 HTTP 缓存 静态资源、可缓存的 GET 0.5ms(本地磁盘) 响应头 Cache-Control + 用户刷新 分钟–天
CDN / 边缘 图片、视频、静态页、少量 API 20ms max-age + purge API 10 分钟–几小时
接入层 Nginx proxy_cache 热点 HTML、接口响应 50.2ms purge 模块 / reload 10s–5min
进程内 L1 配置、字典、Top key 50.1ms 失效广播 + TTL 1–10s
分布式 L2 Redis 全量热点 50.5ms 删 key 分钟–小时
DB 权威数据 70ms — —

边缘节点的回源与命中率模型见 9.1 边缘缓存原理;跨层缓存键的统一规则见 9.3 缓存键设计。

⚠️ 注意「命中耗时」这一列:L1 与 L2 只差 0.4ms,但它们都要先付 50ms 的用户到源站 RTT。这就是为什么「加 L1」几乎不改善平均延迟,而「加 CDN / 浏览器缓存」改善巨大。

命中率是乘法,延迟由最外层决定

入口 10 万 QPS,各层对「到达本层的请求」命中率:CDN 70%、L1 60%、L2 90%。到达 DB 的比例:

0.30(CDN 未命中)× 0.40(L1 未命中)× 0.10(L2 未命中)= 0.012 → 1.2%
端到端命中率 = 1 − 0.012 = 98.8%
配置 端到端命中率 平均延迟 源站 QPS(入口 10 万)
只有源站 L2(90%) 90% 52.5ms 10,000
+ L1(60%) 96% 51.0ms 4,000
+ CDN(70%) 98.8% 29.3ms 1,200
+ 浏览器缓存(30%) 99.16% 20.7ms 840

三件事同时发生:

  • L1 把源站 QPS 从 10,000 砍到 4,000(−60%),平均延迟只降 1.4ms——它挡掉的请求本来也只多花 0.4ms(L2 那一跳);
  • CDN 把平均延迟砍掉 21.7ms,因为它挡掉的是最贵的 50ms RTT;
  • 浏览器缓存再挡 30%,延迟又降 8.6ms,但源站 QPS 只从 1200 降到 840——外层买延迟,内层买 QPS。

命中率到平均延迟的边际收益推导见 34.4 优化手段。

🔑 判据:延迟问题往外层找,容量 / QPS 问题往内层找。 在源站内部加十层缓存,也救不了「用户到源站 50ms」这个物理距离。

多级缓存的每一层,都要回答三个问题

层 容量 失效通道 最长陈旧 故障影响
浏览器 用户设备(MB–GB) 不可控,只能等 TTL 或换 URL TTL(分钟–天) 只影响该用户
CDN TB 级 purge API(秒–分钟,可能排队失败) TTL 或 purge 延迟 回源放大(全部回源)
Nginx GB(单机) purge / reload(秒) 秒级 单机房回源
L1 进程内存的 10%–20% 广播(1–10ms,可能丢) 秒级 单实例回源
L2 TB 级 删 key(同步生效) 0 全站回源(见 41.3 缓存穿透 / 击穿 / 雪崩)

答不出「最长陈旧」的那一层,就不该加。 这是多级缓存唯一的前置条件:每一层都要能说清「最坏情况下,用户看到的是几秒前的数据」。

陈旧上界 = 陈旧最久的那一层

用户的一次读只走一条路径,路径上任意一层返回旧值,用户就看到旧值。所以:

端到端陈旧 ≤ max(每一层的 TTL, 每一层的失效延迟)

不是 min,也不是内层的 TTL。看一个模拟:写入 v2,删 L2、广播 L1 只到达 8/10 个实例、purge CDN 时边缘 B 失败:

package main

import "fmt"

// clock 是「相对写入时刻的秒数」:整个模拟用逻辑时间,不真的 sleep 600 秒。
type clock int

type l1 struct {
	val     string
	expires clock
}

type edge struct {
	name    string
	val     string
	expires clock
}

type origin struct {
	dbVal    string
	l2       string
	l2Expire clock
	l1s      [10]l1
	edges    [2]edge
	dbLoads  int
	l2Hits   int
}

// readAll 模拟「同一瞬间到达一批请求」:先统一判断,再统一回填。
// 若逐个回填,第一个请求就把 L2 填好,后面的会假命中,看不出并发穿透。
func (o *origin) readAll(now clock) (stale int) {
	var miss []int
	for i := range o.l1s {
		if o.l1s[i].val != "" && now < o.l1s[i].expires {
			if o.l1s[i].val != o.dbVal {
				stale++
			}
			continue
		}
		miss = append(miss, i)
	}
	l2Has := o.l2 != "" && now < o.l2Expire // 本瞬间开始时的 L2 状态
	switch {
	case !l2Has && len(miss) > 0:
		o.dbLoads += len(miss) // 并发穿透:每个请求都打到 DB
		o.l2, o.l2Expire = o.dbVal, now+3600
	case l2Has:
		o.l2Hits += len(miss)
	}
	v := o.l2
	for _, i := range miss {
		o.l1s[i] = l1{val: v, expires: now + 5} // 回填 L1,TTL 5s
	}
	return stale
}

// readOne 是「一个请求」的路径:L1 -> L2 -> DB。
func (o *origin) readOne(i int, now clock) string {
	if o.l1s[i].val != "" && now < o.l1s[i].expires {
		return o.l1s[i].val
	}
	if o.l2 != "" && now < o.l2Expire {
		o.l2Hits++
		o.l1s[i] = l1{val: o.l2, expires: now + 5}
		return o.l2
	}
	o.dbLoads++
	o.l2, o.l2Expire = o.dbVal, now+3600
	o.l1s[i] = l1{val: o.l2, expires: now + 5}
	return o.l2
}

func (o *origin) readEdge(e int, now clock, via int) string {
	if o.edges[e].val != "" && now < o.edges[e].expires {
		return o.edges[e].val
	}
	v := o.readOne(via, now) // CDN 未命中 -> 回源
	o.edges[e] = edge{name: o.edges[e].name, val: v, expires: now + 600}
	return v
}

func main() {
	o := &origin{dbVal: "v2", l2: "v1", l2Expire: 300}
	for i := range o.l1s {
		o.l1s[i] = l1{val: "v1", expires: 5}
	}
	o.edges[0] = edge{name: "边缘A", val: "v1", expires: 600}
	o.edges[1] = edge{name: "边缘B", val: "v1", expires: 600}

	// ===== t=0 写入 v2 =====
	// ① 更新 DB(dbVal 已是 v2)② 删 L2 ③ 广播删 L1(10 个实例只到达 8 个)
	// ④ purge CDN:边缘A 成功,边缘B 失败(val 还是 v1,expires 还是 600)
	o.l2 = ""
	for i := 0; i < 8; i++ {
		o.l1s[i] = l1{}
	}
	o.edges[0] = edge{name: "边缘A"}

	fmt.Println("t=0 写入 v2:删 L2、广播删 L1(10 个实例只收到 8 个)、purge CDN(边缘B 失败)")
	for _, t := range []clock{1, 6, 601} {
		o.dbLoads, o.l2Hits = 0, 0
		stale := o.readAll(t) // 10 个实例各来一个请求
		edgeStale := 0
		for e := 0; e < 2; e++ {
			if o.readEdge(e, t, e) == "v1" {
				edgeStale++
			}
		}
		fmt.Printf("  %-7s L1 旧值 %d/10 实例   CDN 旧值 %d/2 边缘   DB 回源 %d 次   L2 命中 %d 次\n",
			fmt.Sprintf("t=%ds", int(t)), stale, edgeStale, o.dbLoads, o.l2Hits)
	}
	fmt.Println("\nL1 的 TTL 只有 5s:t=6s 时所有实例都已拿到 v2;")
	fmt.Println("边缘B 的 purge 失败、TTL 600s:它后面的用户 10 分钟内读到的还是 v1。")
}

输出:

t=0 写入 v2:删 L2、广播删 L1(10 个实例只收到 8 个)、purge CDN(边缘B 失败)
  t=1s    L1 旧值 2/10 实例   CDN 旧值 1/2 边缘   DB 回源 8 次   L2 命中 0 次
  t=6s    L1 旧值 0/10 实例   CDN 旧值 1/2 边缘   DB 回源 0 次   L2 命中 10 次
  t=601s  L1 旧值 0/10 实例   CDN 旧值 0/2 边缘   DB 回源 0 次   L2 命中 10 次

L1 的 TTL 只有 5s:t=6s 时所有实例都已拿到 v2;
边缘B 的 purge 失败、TTL 600s:它后面的用户 10 分钟内读到的还是 v1。

三行读完:

  • t=1s:2 个实例 L1 没收到广播,继续发 v1;8 个实例同时穿透到 DB(8 次回源,这就是回源放大);
  • t=6s:L1 的 5s TTL 兜底生效,所有实例都从 L2 拿到 v2——内层只脏了 6 秒;
  • t=601s:边缘 B 的 600s TTL 才到期,它后面的用户整整 10 分钟看到的都是 v1。

🔑 决定新鲜度的是最外层,决定源站压力的是最内层。 给 L1 配 1s TTL、给 CDN 配 1h,等于「内层很勤快,外层很顽固」,用户感知的仍是 1 小时。

多级缓存的失效传播:一次写要打几个洞

写 DB ──▶ 删 L2 ──▶ 广播删 L1 × N 个实例 ──▶ purge CDN × M 个边缘 ──▶ 浏览器(只能等 TTL / 换 URL)
            1 个洞          N 个洞                  M 个洞                 不可控

四条顺序与可靠性规则:

规则 原因
先删 L2,再广播 L1 反过来:L1 删了,下一次读会从还没删的 L2 回填旧值
每次删除都要「同步 + TTL 兜底」 广播是尽力而为,漏一个实例就会脏到 TTL 结束
CDN purge 当异步、可能失败 purge API 会排队、限流;失败要有重试 + 版本号兜底
浏览器不靠 purge,靠 URL 版本 app.3f2a1c.js、?v=20261025 换 URL 就是最可靠的失效

一次写要打 1 + N + M 个洞,漏任何一个,那一层的用户就看到旧值。层数的成本是加法:每层多一个容量、一个失效通道、一个监控项、一个故障面。

💡 删缓存与「读回填旧值」的竞态(读线程查 DB 拿到旧值、写线程删完缓存、读线程再把旧值写回去)与延迟双删、版本号方案,通用解法见 41.4 缓存一致性策略。本节只给结论:删缓存的顺序解决不了竞态,能解决竞态的是「版本号 / 写后读主 / 延迟双删」。

多级缓存的回源放大:N 个实例 × 1 次 = N 次

上面的模拟里,删一个 key 引来了 8 次 DB 查询(10 个实例里 8 个穿透)。放大系数就是「实例数」:

规模 一个 key 失效 1 万个热 key 同时失效
10 个实例 10 次回源 10 万次
100 个实例 100 次回源 100 万次

100 万次回源 ÷ DB 5000 QPS = 200 秒才能重建完,期间正常业务的查询全被挤在后面。四种收敛手段:

手段 作用 边界
进程内 singleflight 同实例内同 key 的并发合并成 1 次 只能压到「每实例 1 次」
分布式锁 / 单实例重建 全集群只放一个实例回源,其余等 锁的可用性与超时是新的故障点
预热任务分片 每个实例只灌 1/N 的 key,再互相通过 L2 共享 需要任务编排
回源抖动 + 限流 把回源摊开,按 DB 容量放行 治峰值,不治总量

热 key 的识别、拆分与打散见 41.6 热点 key 与分片。

⚠️ 回源放大最容易被忽略的场景是发布:滚动发布时每个新 Pod 都是冷缓存,如果启动即接满流量,等于每 30 秒制造一次「N 个实例同时回源」。

三层的时间预算怎么配

产品落点:L1 用 Caffeine(Java)/ BigCache、ristretto(Go)/ 语言自带 LRU;L2 用 Redis 8.10(Memcached 只有纯 KV,没有 Lua/持久化);L3 用 CDN(Cloudflare / CloudFront)或 Nginx proxy_cache。

# cache.yaml:每层只回答“多快、能旧多久、装多少”
l1:                      # 进程内
  max_entries: 20000
  ttl: 5s                # 最短:跨实例失效广播有延迟,靠它兜底
  stale_ttl: 30s         # 回源失败时允许的陈旧上限(可选)
l2:                      # Redis
  addr: redis-cache:6379
  ttl: 5m
  maxmemory: 8gb         # 见 41.2 的纯缓存基线
l3:                      # CDN
  ttl: 60s

陈旧上界 = 最长的那层 TTL,不是最短的:上面的配置里,用户最多可能看到 5 分钟前的数据(L2),L1 的 5s 并不能把上界拉低。要压上界就压最长的那层,或者写路径主动失效(见上一节的广播通道)。

验证要看“每层各自”的命中率,而不是一个总数:

# 应用指标:L1/L2 分开打点
curl -s localhost:9100/metrics | grep -E 'cache_(l1|l2)_(hits|misses)'
# 延迟:命中 L1 的 P99 应 < 1ms;命中 L2 的 P99 约等于一次 Redis RTT

总命中率 95% 可能意味着“L1 只占 5%、L2 占 90%”——延迟预算完全不同,别用总数掩盖分层。

Redis 侧最小验证(Redis 8.10 实测):

$ redis-cli SET product:42 '{"id":42}' EX 287
OK
$ redis-cli TTL product:42
287
$ redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses'
keyspace_hits:2
keyspace_misses:1

降级开关:L2 故障直接回源 + 回源限流;L1 有独立开关(关掉只影响延迟);L3 用 purge / TTL 回退。 什么时候别用:写多读少、强一致(余额/库存)、单实例低 QPS——加层只是多加一个故障点。

容量分配:Zipf 说前 1% 的 key 值 68% 的请求

真实访问分布接近 Zipf(s≈1):1000 万个 key 里,前 1%(10 万个)承担约 68% 的请求,前 10% 承担约 88%。

于是容量可以这样分:

层 放多少 key 内存量级 命中率贡献
L1 前 1%(10 万) 约 30MB/实例(每条 ~300B) 60%–68%
L2 全量热点(1000 万) 3–6GB(含 Redis 对象开销) 到 90% 左右
CDN 静态与可缓存接口 TB 级 70% 上下

淘汰策略(LRU / LFU / TTL)与容量上限的关系见 41.5 淘汰策略与 TTL。

两条容量规则:

  • L1 不超过进程内存的 10%–20%,否则 GC 扫描成本把延迟优势吃回去(算法见 41.2 的内存段);
  • 内层 TTL ≤ 外层 TTL。若 L1 是 60s、L2 是 10s,L2 每 10 秒刷新一次,但用户最多仍看到 60 秒前的值——付了 L2 的回源成本,没拿到新鲜度。

加第四层值不值:一个判据

加的层 端到端命中率增量 平均延迟变化 新增运维面 结论
浏览器缓存 +0.36%(98.8→99.16) −8.6ms 响应头 / 版本号 静态与可缓存接口值得
CDN +2.8%(96→98.8) −21.7ms purge / 回源 / 证书 有跨地域用户就值得
L1 +6%(90→96) −1.4ms 广播 / 内存上限 源站 QPS 高时值得
再加一层「L0 静态 map」 < +1% ≈0 又一套失效通道 不值得

判据两条,同时满足才加:

  1. 端到端命中率提升 ≥ 10%(或延迟下降 ≥ 5ms,或源站 QPS 下降 ≥ 30%);
  2. 这一层的失效通道可控(能答出最长陈旧时间,且有 TTL 兜底)。

💡 层数不是架构成熟度的指标。三层(CDN + L1 + L2)能覆盖绝大多数业务;第四层的收益通常小于它带来的一次「purge 漏了」的事故。

反模式

反模式 后果
只加层,不加失效通道 脏窗口无上界,用户看到的旧值取决于「那台机器什么时候重启」
用 L2 的 TTL 估算用户可见新鲜度 实际由 CDN / 浏览器决定,估算偏小一个数量级
各层缓存键不一致(L1 带租户前缀,L2 没带) 命中率虚低,或跨租户串数据
TTL 倒挂(L1 比 L2 长) L2 的回源成本白付,新鲜度还更差
预热任务不分片、启动即接满流量 发布引发回源风暴,DB 被打穿
CDN 缓存了带 Set-Cookie / Authorization 的响应 用户 A 的数据发给用户 B(重大事故)
用多级缓存给库存、权限「续命」 超卖、越权;层数越多越难追
只监控 L2 命中率 看不到 L1 的真实贡献,也看不到各层的陈旧度
每层用相同的 TTL 同时失效,层层一起回源
失效广播用 Pub/Sub 却无 TTL 兜底 丢一条消息 = 一台实例永久脏数据

自检

Q1 你们在源站内部把 L1 命中率从 60% 提到 90%,P99 只降了 0.4ms。老板问「这活白干了吗」?怎么回答?

▸ 答案:没白干,但收益不在延迟,在源站 QPS。L1 与 L2 的耗时只差 0.4ms,所以延迟几乎不动;但到达 DB 的比例从 4% 降到 1%(以 CDN 后 1.2 万 QPS 计,回源从 480 降到 120),DB 与 L2 的容量、连接数、故障面全都下降。用「回源 QPS 下降 75%、DB CPU 从 70% 降到 35%」汇报,而不是用延迟。内层买 QPS,外层买延迟——这是评估多级缓存收益时必须先分清的口径。

Q2 你们的配置是 L1 TTL 5s、L2 TTL 5min、CDN TTL 1h。一次写操作删了 L2、成功广播了所有 L1,但 CDN purge 接口超时。用户最长多久能看到新值?谁决定的?

▸ 答案:最长 1 小时,由 CDN 的 TTL 决定,与 L1 的 5s、L2 的即时删除无关。原因是用户请求可能根本不经过源站:CDN 命中就直接返回它手里的旧副本,源站内部再实时也传不出去。修法不是把 L1 调更小,而是:① purge 失败要重试 + 告警;② 给 CDN 的 key 加版本号(发版时 URL 变化,天然失效);③ 对时效敏感的接口把 CDN 的 max-age 降到分钟级,或用 stale-while-revalidate 配合短 TTL。先问「谁的最长陈旧时间最大」,再谈优化。

Q3 100 个实例、1 万个热 key,L2 因故障被清空。恢复瞬间会发生什么?singleflight 能救吗?

▸ 答案:每个实例对每个 key 最多回源一次,最坏 100 × 1 万 = 100 万次 DB 查询;DB 5000 QPS 需要 200 秒才能重建,这段时间业务查询被挤爆。singleflight 只能把「同实例同 key 的并发」合并成 1 次,救不了「100 个实例各来一次」——它的上限就是实例数。真正的解法是分布式单飞(全集群只有一个实例回源,其余等 L2 回填)或分片预热(每实例只负责 1/N 的 key),再叠加回源限流把 DB 保护在容量内。

Q4 团队想再加一层「进程内静态 map」缓存配置,说「反正内存够」。你会问哪三个问题?

▸ 答案:① 它的失效通道是什么?配置改了,怎么通知到这个 map——如果没有通道、只靠 TTL,那最长陈旧是多少?② 它和现有 L1 的职责重叠吗?如果 L1 已经缓存配置(TTL 5s + 广播),再加一层只是把「最长陈旧」从 5s 拉长到新层的 TTL,收益为零、故障面 +1。③ 端到端命中率能提升 ≥ 10% 吗?如果现有 L1 对配置的命中率已经是 95%+,新层最多贡献几个百分点,不值得多一套失效与监控。三个问题里有一个答不上来,就不加——层数是成本,不是成绩。

关联