41.7 多级缓存
一句话
多级缓存是「用命中率的乘法换延迟、用失效通道的加法换一致性」:外层买延迟,内层买 QPS,而全链路的陈旧上界由陈旧最久的那一层决定——加层之前先答出它的容量、命中率和失效通道,否则层数越多,脏数据活得越久。
两级只是起点,三级以上才是「多级」
两级结构(进程内 + Redis)、L1 与 L2 的六个差异、失效广播的三种通道,见 41.2 本地缓存与分布式缓存。本节只讲层数 ≥ 3 之后新增的四个问题:
- 每层各该放什么、容量多大、脏多久?
- 陈旧上界由哪一层决定?
- 一次写要打几个洞、漏一个会怎样?
- 回源放大与容量分配怎么算?
五个位置,五种「旧」
假设用户到边缘节点 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 | 又一套失效通道 | 不值得 |
判据两条,同时满足才加:
- 端到端命中率提升 ≥ 10%(或延迟下降 ≥ 5ms,或源站 QPS 下降 ≥ 30%);
- 这一层的失效通道可控(能答出最长陈旧时间,且有 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%+,新层最多贡献几个百分点,不值得多一套失效与监控。三个问题里有一个答不上来,就不加——层数是成本,不是成绩。