41.3 缓存穿透 / 击穿 / 雪崩
一句话
三种「缓存没挡住数据库」的故障,区别只在失效的是什么:穿透是数据根本不存在、击穿是一个热 key 刚失效、雪崩是一批 key 或整个缓存层同时失效;修法不能互换,用错药会把小故障治成事故。
三张脸:先分清,再谈修法
| 维度 | 穿透 (penetration) | 击穿 (breakdown) | 雪崩 (avalanche) |
|---|---|---|---|
| 数据在 DB 里吗 | 不存在 | 存在 | 存在 |
| 失效对象 | 每一次查询都 miss | 一个热 key 刚过期 | 一批 key 或整个缓存层 |
| DB 看到的流量 | 与恶意 / 无效请求量成正比 | 并发集中在同一行 | 分散到很多行,先打满连接池 |
| 典型触发 | 爬虫扫 ID、负数 / 非法参数、被删数据 | 热点商品缓存到期、发版主动删缓存 | 同一秒批量预热、Redis 主从切换、集群故障 |
| 先坏在哪 | DB 的 QPS | DB 的单行锁 + 连接数 | 连接池 → 线程池 → 下游,逐级雪崩 |
| 主力修法 | 空值缓存 + 布隆过滤器 + 参数校验 | 互斥重建 / 逻辑过期 | TTL 抖动 + 多级缓存 + 限流降级 |
| 辅助修法 | 限流、WAF | 热点 key 不过期 + 后台刷新 | 缓存高可用、预热、熔断 |
🔑 一句话记牢:穿透看「数据在不在」,击穿看「几个 key」,雪崩看「多少 key / 整层可用性」。
穿透:数据根本不存在,缓存永远给不出答案
请求 id=-1 / id=999999999 → 缓存 miss → 查 DB → 没有 → 不缓存 → 下次再来一遍
↑ 死循环
缓存的前提是「第二次能命中」,而查不到的数据没有东西可缓存,于是每一次请求都是首次请求。
先算杀伤力:一个爬虫按自增 ID 扫,5000 QPS,每次回源 5ms,DB 侧就是 5000 QPS + 25 个并发——单看不大;但业务正常读 QPS 可能只有 2000。一个不存在的 ID 能把 DB 的读流量打到原来的 3.5 倍,而且它绕过缓存,所有扩容手段(加从库、加缓存)都无效。
修法一:空值缓存(最便宜,但有两个坑)
把「查过了,确实没有」这件事本身缓存起来,用一个不会与真实值冲突的占位符(\x00、NULL、独立标记)表示,TTL 设短(30–120s)。
| 坑 | 后果 | 处理 |
|---|---|---|
| 空值 TTL 太长 | 数据后来被创建了,用户最长 TTL 内一直看到「不存在」 | TTL 压到 30–120s;创建 / 更新时主动删这个 key |
| 空值 key 不设上限 | 攻击者用随机 ID 把缓存塞满,挤掉真实数据,自己造成一次雪崩 | 只缓存格式合法的 key、限制空值 key 的总量、空值 TTL 短于正常值 |
「创建后主动删」还有竞态:读线程查 DB 得到「无」→ 写线程插入数据 → 读线程把空值回填 → 空值活到 TTL 结束。这段竞态的通用处理见 41.4 缓存一致性策略,本节只给结论:空值 TTL 要短,写路径要删 key,创建成功的请求写后读主库。
修法二:布隆过滤器(挡在缓存之前)
在查缓存之前先问布隆过滤器「这个 key 可能存在吗」:答否 → 一定不存在,直接返回;答是 → 才继续查。它只做否决、不做确认,参数计算与误判率实测见 41.8。
修法三:参数校验与白名单(零成本)
id <= 0、长度超限、非数字、不在合法 ID 段(如本系统 ID 从 100000 起)的请求,在入口直接拒掉。这一步能挡掉扫号脚本里 90% 的低成本请求,而且不需要任何存储。
击穿:一个热 key 失效,几千个请求同时回源
单个 key 过期本身不是问题,问题是过期瞬间所有并发请求同时 miss。
用利特尔法则算并发:
回源并发 ≈ 该 key 的 QPS × 回源耗时
- 热点商品详情:5 万 QPS,回源 20ms → 1000 个并发请求同时压在同一行上;
- DB 连接池通常 100–200 → 排队 5–10 倍 → 超时 → 客户端重试 2–3 次 → 实际流量再放大 3 倍;
- 行锁 + 慢查询叠加,这一行会拖住整个连接池,其他接口跟着超时。
⚠️ 击穿的杀伤力不在「DB 总量」,在「同一瞬间、同一行」。QPS 监控上可能只看到一条细尖峰,但业务已经 503。
三种修法,选的是「让用户等」还是「让用户看旧值」
| 方案 | 用户等多久 | 拿到的是 | DB 回源 | 代价 | 适用 |
|---|---|---|---|---|---|
| 互斥重建(singleflight / 分布式锁) | 一次回源耗时(20ms) | 新值 | 1 次 | 锁的粒度、锁超时、持锁进程挂掉 | 回源快、能接受一次慢请求 |
| 逻辑过期(stale-while-revalidate) | ≈0 | 旧值,最坏一个刷新周期 | 1 次(后台) | value 多存 8B 时间戳;key 不物理过期,靠淘汰兜底 | 极热 key、回源慢、读多写少 |
| 热点不过期 + 定时刷新 | ≈0 | 旧值,最坏一个刷新间隔 | 每周期 1 次 | 需要维护「热点集合」,集合变了要重算 | Top 1 万这类稳定热点 |
互斥重建的最小实现(singleflight 版本)见 41.2 本地缓存与分布式缓存——那里的第 ④ 个实验就是「50 并发同时穿透,实际只回源 1 次」。
可运行对比:互斥重建 vs 逻辑过期
一个热点 key 刚过期,200 个并发请求同时到达(回源 20ms):
package main
import (
"fmt"
"math/rand"
"sort"
"sync"
"sync/atomic"
"time"
)
// ==================== 模拟 DB ====================
type db struct {
mu sync.Mutex
data map[string]string
calls int64
cost time.Duration
}
func newDB(cost time.Duration) *db { return &db{data: map[string]string{}, cost: cost} }
func (d *db) get(k string) (string, bool) {
atomic.AddInt64(&d.calls, 1)
if d.cost > 0 {
time.Sleep(d.cost) // 模拟一次索引查询
}
d.mu.Lock()
defer d.mu.Unlock()
v, ok := d.data[k]
return v, ok
}
func (d *db) set(k, v string) {
d.mu.Lock()
d.data[k] = v
d.mu.Unlock()
}
func (d *db) callCount() int64 { return atomic.LoadInt64(&d.calls) }
// ==================== ① 穿透:空值缓存 ====================
// 把「查过、确实不存在」也缓存起来,用一个不会与真实值冲突的占位符表示空。
type nullGuard struct {
mu sync.Mutex
vals map[string]string
expires map[string]time.Time
db *db
ttl time.Duration // 空值的 TTL 必须远短于正常值:数据可能随时被创建
}
func (g *nullGuard) get(k string) (string, bool) {
g.mu.Lock()
if v, ok := g.vals[k]; ok && time.Now().Before(g.expires[k]) {
g.mu.Unlock()
return v, v != "\x00"
}
g.mu.Unlock()
v, ok := g.db.get(k)
if !ok {
v = "\x00" // 空值占位
}
g.mu.Lock()
g.vals[k], g.expires[k] = v, time.Now().Add(g.ttl)
g.mu.Unlock()
return v, ok
}
func experimentPenetration() {
const keys, repeats = 1000, 100 // 1000 个不存在的 ID,各被请求 100 次 = 10 万次
ids := make([]string, keys)
for i := range ids {
ids[i] = fmt.Sprintf("user:%d", 900000+i)
}
// 无任何保护
d1 := newDB(0)
for _, id := range ids {
for i := 0; i < repeats; i++ {
d1.get(id)
}
}
fmt.Printf(" 无保护:10 万次请求 → DB 被调用 %d 次\n", d1.callCount())
// 空值缓存
d2 := newDB(0)
g := &nullGuard{vals: map[string]string{}, expires: map[string]time.Time{}, db: d2, ttl: 60 * time.Second}
for _, id := range ids {
for i := 0; i < repeats; i++ {
g.get(id)
}
}
fmt.Printf(" 空值缓存:10 万次请求 → DB 被调用 %d 次(每个 key 只回源一次)\n", d2.callCount())
}
// ==================== ② 雪崩:TTL 抖动 ====================
func experimentAvalanche() {
const keys = 1000
const ttl = 600 * time.Second
r := rand.New(rand.NewSource(42))
// 把「过期时刻」按 10 秒分桶,看最忙的一个桶里有多少个 key 同时失效
peak := func(offsets []time.Duration) (int, int) {
m := map[int]int{}
for _, off := range offsets {
m[int(off/(10*time.Second))]++
}
max, nonEmpty := 0, len(m)
for _, c := range m {
if c > max {
max = c
}
}
return max, nonEmpty
}
base := make([]time.Duration, keys)
jitter := make([]time.Duration, keys)
for i := 0; i < keys; i++ {
base[i] = ttl
// 抖动:TTL 名义值不变,只在 [0.9×TTL, 1.1×TTL] 之间随机,避免同一秒集体失效
jitter[i] = time.Duration(float64(ttl) * (1 + 0.1*(2*r.Float64()-1)))
}
p1, s1 := peak(base)
p2, s2 := peak(jitter)
fmt.Printf(" 无抖动:过期限于 %d 个 10 秒桶,最忙一桶 %d 个 key 同时失效\n", s1, p1)
fmt.Printf(" 抖动 ±10%%:摊到 %d 个 10 秒桶,最忙一桶 %d 个 key 同时失效\n", s2, p2)
}
// ==================== ③ 击穿:互斥重建 vs 逻辑过期 ====================
type hotKey struct {
mu sync.Mutex
cond *sync.Cond
val string
expireAt time.Time
refreshing bool
db *db
ttl time.Duration
}
func newHotKey(d *db, ttl time.Duration) *hotKey {
h := &hotKey{db: d, ttl: ttl, val: "v1", expireAt: time.Now().Add(-time.Second)} // 刚过期
h.cond = sync.NewCond(&h.mu)
return h
}
// 互斥重建:第一个请求回源,其余请求排队等它,拿到的是新值,但要一起等。
func (h *hotKey) getWithLock() (string, time.Duration) {
start := time.Now()
h.mu.Lock()
for h.refreshing { // 别人正在回源,等它回填
h.cond.Wait()
}
if time.Now().Before(h.expireAt) {
v := h.val
h.mu.Unlock()
return v, time.Since(start)
}
h.refreshing = true
h.mu.Unlock()
v, _ := h.db.get("hot")
h.mu.Lock()
h.val, h.expireAt, h.refreshing = v, time.Now().Add(h.ttl), false
h.cond.Broadcast()
h.mu.Unlock()
return v, time.Since(start)
}
// 逻辑过期:value 里带 expireAt、key 本身不过期;
// 发现逻辑过期就「先返回旧值 + 后台刷新」,用户不等。
func (h *hotKey) getStaleOK() (string, time.Duration) {
start := time.Now()
h.mu.Lock()
v := h.val
if time.Now().After(h.expireAt) && !h.refreshing {
h.refreshing = true // 只放一个 goroutine 去刷新
go func() {
nv, _ := h.db.get("hot")
h.mu.Lock()
h.val, h.expireAt, h.refreshing = nv, time.Now().Add(h.ttl), false
h.mu.Unlock()
}()
}
h.mu.Unlock()
return v, time.Since(start)
}
func pct(ds []time.Duration, p float64) time.Duration {
sort.Slice(ds, func(i, j int) bool { return ds[i] < ds[j] })
return ds[int(float64(len(ds)-1)*p)]
}
func experimentBreakdown() {
const concurrency = 200
run := func(name string, get func(*hotKey) (string, time.Duration)) {
d := newDB(20 * time.Millisecond) // 一次回源 20ms
h := newHotKey(d, time.Minute) // 缓存已处于「刚过期」状态
d.set("hot", "v2") // DB 里是新值
lats := make([]time.Duration, concurrency)
var stale int64 // 返回旧值(v1)的次数
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
v, d := get(h)
lats[i] = d
if v == "v1" {
atomic.AddInt64(&stale, 1)
}
}(i)
}
wg.Wait()
fmt.Printf(" %s:DB 回源 %d 次;返回旧值 %d 次 / 新值 %d 次;P50 %v,P99 %v\n",
name, d.callCount(), stale, concurrency-int(stale),
pct(lats, 0.50).Round(time.Microsecond), pct(lats, 0.99).Round(time.Microsecond))
}
run("互斥重建(等新值)", func(h *hotKey) (string, time.Duration) { return h.getWithLock() })
run("逻辑过期(先返回旧值)", func(h *hotKey) (string, time.Duration) { return h.getStaleOK() })
}
func main() {
fmt.Println("① 穿透:1000 个不存在的 ID,各请求 100 次")
experimentPenetration()
fmt.Println("\n② 雪崩:1000 个 key 同一时刻预热,TTL = 600s")
experimentAvalanche()
fmt.Println("\n③ 击穿:一个热点 key 刚过期,200 个并发请求同时到达")
experimentBreakdown()
}
输出:
① 穿透:1000 个不存在的 ID,各请求 100 次
无保护:10 万次请求 → DB 被调用 100000 次
空值缓存:10 万次请求 → DB 被调用 1000 次(每个 key 只回源一次)
② 雪崩:1000 个 key 同一时刻预热,TTL = 600s
无抖动:过期限于 1 个 10 秒桶,最忙一桶 1000 个 key 同时失效
抖动 ±10%:摊到 12 个 10 秒桶,最忙一桶 101 个 key 同时失效
③ 击穿:一个热点 key 刚过期,200 个并发请求同时到达
互斥重建(等新值):DB 回源 1 次;返回旧值 0 次 / 新值 200 次;P50 20.916ms,P99 21.177ms
逻辑过期(先返回旧值):DB 回源 1 次;返回旧值 200 次 / 新值 0 次;P50 0s,P99 29µs
💡 延迟数字每次运行会波动(微秒级抖动),关键是数量级:互斥重建 21ms vs 逻辑过期几十微秒。
三行结论:
- 空值缓存把 10 万次回源压到 1000 次(100 倍),代价是空值占的内存与一段会「说谎」的窗口;
- 抖动把同时失效的 key 从 1000 个摊到峰值 101 个(约 10 倍),它治的是雪崩,不是击穿;
- 两种击穿修法的 DB 回源都是 1 次,差别全在延迟:互斥让 200 个请求都等 21ms,逻辑过期让它们 29µs 拿到旧值——你换来的是「旧」。
雪崩:一批 key 同时失效,或者缓存整层没了
三种来源,量级完全不同:
| 来源 | 机制 | 放大倍数 |
|---|---|---|
| 同 TTL 批量预热 | 发版 / 活动前批量灌缓存,全部 TTL=1800s,半小时后同一分钟集体到期 | 峰值 = 这批 key 的回源并发之和 |
| 缓存层故障 | Redis 主从切换、节点宕机、网络分区 | 全部读流量瞬间回源(20 倍以上) |
| 冷启动 | 缓存被清空 / 新集群上线,命中率 0 | 全部读流量回源,且持续到缓存重建完成 |
抖动:把「同一秒」变成「同一段」
ttl = 1800 * (1 + 0.1*(2*rand.Float64()-1)) // 1800s ±10% → 1620–1980s,摊到 6 分钟
抖动只做一件事:把并发从「一个点」摊成「一条带」。带子宽度 = 2 × 抖动比例 × TTL;1000 个 key、TTL 600s、±10% → 摊到 120 秒,峰值从 1000 降到约 100(实测 101)。
⚠️ 抖动治不了单个热 key。热 key 的 QPS 是 5 万,TTL 抖到哪一秒,那一秒还是 5 万 QPS —— 击穿得用互斥重建或逻辑过期。
缓存整层故障:这是雪崩真正的事故形态
算一笔账:读 QPS 3 万、缓存命中率 95%,正常时回源 1500 QPS。
- Redis 主从切换 40 秒(判定 + 选主 + 客户端重连)→ 120 万次请求全部回源;
- DB 能接 5000 QPS → 即使不再有新请求,也要 240 秒才能消化完;
- 这 240 秒里连接池满、接口超时、上游重试,故障从「缓存不可用」升级为「全站不可用」。
所以缓存故障的修法不是「让缓存更快恢复」,而是给 DB 留出活路:
| 手段 | 做法 | 效果 |
|---|---|---|
| 本地缓存兜底 | 进程内缓存命中 50%–80%,把回源从 3 万降到 0.6–1.5 万(做法见 41.7 多级缓存) | 回源降 50%–80% |
| 限流 | 回源接口按 DB 容量限流(35.7 限流) | 把「打死 DB」变成「部分请求快速失败」 |
| 熔断降级 | 回源失败率超阈值就返回兜底数据(35.6 熔断与降级) | 保住核心链路 |
| 预热 | 发布 / 扩容后先灌热点,再放流量 | 避免冷启动期的 100% 回源 |
| 缓存高可用 | 哨兵 / Cluster、多可用区、客户端重连退避 | 缩短不可用窗口(40s → 10s 量级) |
🔑 雪崩的判据不是「缓存挂了」,而是「缓存挂的这段时间,DB 有没有活下来」。缓存本身要按「随时会挂」设计——它已经不是一个组件,而是 DB 的前置保护层。
Redis 的具体操作:三种病,三条命令链
生产上不需要自己写缓存:L2 默认 Redis(本节以 Redis 8.10 实测),可替代的只有 Memcached(纯 KV、没有 Lua)与进程内缓存(只适合 L1,见 41.2 本地缓存与分布式缓存)。要写清楚的是每种故障在 Redis 里到底敲什么命令。下面以商品 product:42、TTL 300s 为例;<token> 用 UUID。
① 穿透:空值缓存 + 布隆过滤器
# 回源后确认“确实没有”:写一个短 TTL 的空值占位
SET product:42:null 1 EX 60
# 创建成功后主动删空值键,避免“已创建仍报不存在”
DEL product:42:null
# 布隆(需要 RedisBloom 模块;本机未装,以下三条未实测、标「未验证」)
BF.RESERVE product:bloom 0.01 1000000 # 误判率 1%、容量 100 万
BF.ADD product:bloom 42
BF.EXISTS product:bloom 42 # 0 = 一定不存在,直接返回
② 击穿:互斥重建
# 只有一个请求能拿到重建锁;PX 防死锁
SET lock:rebuild:product:42 <token> NX PX 5000
# 没拿到锁的请求:sleep 20ms 后重读缓存,最多重试 200ms
# 拿到锁的请求:回源、写缓存、再用 Lua 释放(只能删自己的锁)
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:rebuild:product:42 <token>
实测(Redis 8.10,粘终端可复现):
$ redis-cli SET product:42:null 1 EX 60
OK
$ redis-cli TTL product:42:null
60
$ redis-cli SET lock:rebuild:product:42 tok-a NX PX 5000
OK
$ redis-cli SET lock:rebuild:product:42 tok-b NX PX 5000
(nil)
$ redis-cli EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:rebuild:product:42 tok-a
1
A 抢锁 OK、B 抢锁 (nil)、A 释放 1——和 48.3 分布式锁 是同一套原语。
③ 雪崩:TTL 抖动 + 批量预热
# TTL 在应用侧加 ±10% 抖动:300s → 270–330s,别让一批 key 同一秒失效
SET product:42 '{"id":42}' EX 287
# 批量预热用 pipe,别发 N 次 RTT
redis-cli --pipe < warmup.txt
客户端配置同样要按“缓存可失败”来设:
| 参数 | 建议值 | 理由 |
|---|---|---|
| dial / read / write timeout | 50ms / 100ms / 100ms | 缓存慢就回源降级,不能拖死接口 |
| 连接池大小 | 50–100 | 按并发配;池满会排队,比回源还慢 |
| 最大重试 | 1 | 缓存重试会放大故障,不值得 |
| Cluster 多 key | hash tag {product:42}:* | 同一 slot 才能 MGET / 事务 |
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses' # 命中率
redis-cli TTL product:42 # 抖动是否生效
redis-cli SLOWLOG GET 10 # 大 key / 慢命令
两条边界:空值键必须能被写路径主动删除(否则数据创建后仍报不存在);布隆过滤器需要 RedisBloom 模块,没装模块就别在文档里假装能跑——用 SET 空值 + 参数校验先兜住。
降级开关(缓存出问题时按顺序用):① 缓存读写失败直接回源 + 回源限流(35.7 限流);② 关闭空值缓存/布隆(回源量上升,但不会脏读);③ 热点 key 改逻辑过期、后台刷新。
什么时候别用:余额、库存这类强一致数据不要缓存;QPS 低、单实例时 L1 就够,引入 Redis 只是多一个故障点。
三种故障的误判:现象很像,修法相反
| 现象 | 最可能是 | 怎么验证 |
|---|---|---|
| miss 率高、DB 查询大量返回空 | 穿透 | 统计 miss key 的「查无此物」比例;看 Top key 是否是同一批非法 ID |
| miss 集中在少数 key、DB 慢查询指向同一行 | 击穿 | 看回源 key 的集中度(Top 10 占比);看是否恰好是某个热点到期时刻 |
| 回源 QPS 阶跃式上涨、key 很分散、时间成批 | 雪崩 | 看过期时间分布是否集中在 1–2 分钟;看缓存节点 / 主从切换事件 |
| 命中率 99% 但 DB 仍吃力 | 不是缓存故障 | 99% × 10 万 QPS = 1000 QPS 回源,先看总量与单次回源成本(34.4 优化手段) |
修法组合与优先级
| 你的情况 | 先做什么 | 再做什么 |
|---|---|---|
| 新系统,还没出事 | 参数校验 + 空值缓存(TTL 60s) | TTL 抖动;上布隆过滤器(41.8) |
| 已在被扫号 | 入口校验 + 限流 | 布隆过滤器;空值缓存兜底 |
| 热点 key 频繁到期抖动 | 互斥重建 | 逻辑过期 / 热点不过期 + 后台刷新 |
| 缓存命中率 95%+、DB 仍紧张 | 查回源总量与慢查询 | 本地缓存兜底;回源限流 |
| 缓存层故障过一次 | 限流 + 熔断降级 + 预热 | 本地缓存兜底;缓存高可用 |
反模式
| 反模式 | 后果 |
|---|---|
| 用布隆过滤器修击穿 | 完全不解决问题:热 key 依然并发回源,还多了一次哈希开销 |
| 用 TTL 抖动修击穿 | 单 key 的 QPS 不因过期时刻改变,尖峰照旧 |
| 空值缓存不设 TTL | 数据后来创建了,用户永久看到「不存在」 |
| 空值缓存不限 key 数量 | 攻击流量把缓存内存打满,真实数据被淘汰,自己制造雪崩 |
| 全局共用一把重建锁 | 一个慢 key 的重建阻塞所有 key,缓存层退化成串行队列 |
| 逻辑过期但不监控陈旧度 | 后台刷新一直失败,用户看了几小时旧价格,无人知晓 |
| 缓存失效后自动全量重建、不限速 | 重连风暴:所有实例同时回源,DB 二次被打穿 |
| 只给缓存加机器,不给回源加限流 | 缓存再稳也挡不住一次网络分区 |
| 把「命中率」当唯一指标 | 命中 99% 但 1% 的回源打满 DB,监控全绿、业务已挂 |
| 发版前批量预热且用同一 TTL | 给半小时后的自己埋一颗定时炸弹 |
自检
Q1 监控显示缓存命中率 99.5%,DB 的读 QPS 却涨了 3 倍,接口开始超时。命中率没掉,为什么 DB 会涨?
▸ 答案:命中率是比例,DB 压力是绝对值。若入口 QPS 从 2 万涨到 6 万,99.5% 的命中率对应回源从 100 涨到 300,正好 3 倍。先查「总 QPS × 未命中率」的乘积,而不是命中率本身。另一种可能是回源成本变高(慢查询、索引失效、单次回源从 5ms 变 50ms),并发 = QPS × 耗时,同样 300 QPS 会从 1.5 个并发变成 15 个。两种原因都跟「穿透/击穿/雪崩」无关,别急着加缓存。
Q2 同事说「把热点 key 的 TTL 从 30 分钟改成 30 分钟 ±10%,就不会击穿了」。这句话哪里错了?
▸ 答案:抖动改变的是过期时刻的分布,不是并发量。击穿的本质是「同一 key 过期的瞬间,QPS × 回源耗时 个请求同时回源」;把过期时刻随机到 27–33 分钟,那一秒该 key 还是 5 万 QPS,依然会打出 1000 个并发。抖动是雪崩(一批 key 同时过期)的药,治击穿要用互斥重建(让并发合并成 1 次回源)或逻辑过期(让并发不等)。判断口诀:key 的数量少而热 → 击穿;key 的数量多而同刻 → 雪崩。
Q3 空值缓存和布隆过滤器都防穿透,为什么生产上常常两个都上?各自漏什么?
▸ 答案:它们挡的是漏斗的两级。布隆挡「格式合法但一定不存在」的 key,但它有假阳性——约 1% 的不存在请求会被放过去;布隆还有「尚未建立 / 正在重建」的窗口。空值缓存兜住这批漏网之鱼,也兜住「布隆重建期间」的流量。反过来,空值缓存挡不住海量不同的 key(每个都要占内存、都要回源一次),这正好是布隆的强项(每 key 约 10 bit,且不存原值)。只上布隆:1% 假阳性 × 高 QPS 仍能打疼 DB;只上空值缓存:扫号用随机 ID 就能把内存和 DB 一起打满。
Q4 缓存集群主从切换需要 40 秒,你的读 QPS 是 3 万、命中率 95%。这 40 秒你打算怎么过?
▸ 答案:先算,再选。正常回源 1500 QPS;切换后 3 万 QPS 全部回源,是 20 倍,DB 必死,所以不能「扛过去」。三层动作:① 本地缓存兜底——L1 命中 60% 就能把回源压到 1.2 万,仍超 DB 容量,所以必须叠加;② 回源限流 + 熔断降级——按 DB 能承受的 5000 QPS 限流,超出的快速失败或返回兜底数据,把「全站雪崩」降级为「部分商品不可见」;③ 客户端重连退避 + 预热——避免重连风暴和冷启动的回源叠加。事后才谈优化:把切换时间从 40s 压到 10s 级(哨兵 quorum、Cluster、多可用区),以及给最热的 1% key 做逻辑过期,让它们在缓存层不可用时也不回源。