缓存穿透、击穿、雪崩治理体系¶
30 秒版(开场)¶
穿透查不存在的数据、击穿热点 Key 过期、雪崩大量 Key 同时失效——三板斧分别是 布隆过滤器/空值缓存、互斥锁/singleflight、随机 TTL/多级缓存。生产关键词:回源率、热点隔离、熔断降级。
3 分钟版(精讲深度)¶
- 是什么:穿透 = miss 打穿到 DB;击穿 = 热点 miss 并发回源;雪崩 = 缓存层集体失效 DB 承压。
- 为什么:恶意扫描随机 ID、热点 Key TTL 到点、批量缓存同一 TTL 重启/过期。
- 怎么做:穿透 → 布隆 + 空值短 TTL;击穿 → singleflight + 逻辑过期;雪崩 → TTL jitter + 永不过期异步更新 + 限流熔断。
10 分钟版(原理 + 图示)¶
flowchart TB
Req[请求] --> Bloom{布隆过滤器}
Bloom -->|不存在| Empty[空值缓存 / 404]
Bloom -->|可能存在| Redis[(Redis)]
Redis -->|miss| SF[singleflight 合并回源]
SF --> DB[(DB)]
DB --> Fill[回填 + 随机 TTL]
Redis -->|大量 miss| CB[熔断 / 限流]
三类问题对照
| 问题 | 成因 | 治理 | 容量参考 |
|---|---|---|---|
| 穿透 | 恶意/业务不存在 ID | 布隆 1% 误判、空值 TTL 60s | 1 亿 ID 布隆 ~120MB |
| 击穿 | 热点 Key 过期 | singleflight、热点永不过期 | 热点 QPS 10 万需本地缓存 |
| 雪崩 | 同时过期/Redis 宕 | TTL ±20% jitter、集群多 AZ | DB 需扛 5% 回源 = 5000 QPS |
容量估算(10 万 QPS 读服务)
- 命中率 95% → Redis 10 万 QPS。
- 若雪崩命中率跌至 50% → DB 5 万 QPS,多数 MySQL 集群扛不住 → 必须熔断。
- 空值缓存:恶意 1 万随机 ID/s × 100B ≈ 1MB/s 内存增长,需限长 LRU。
生产场景¶
- 商品详情:爬虫扫
-1, 999999999等非法 ID → 布隆 + WAF。 - 明星热搜:热点 Key 1s TTL 到期 → 逻辑过期(值带
expire_at,异步刷新)。 - Redis 集群故障:本地缓存 + 降级读 DB 限流 10% QPS。
排查与工具¶
| 工具 | 指标 |
|---|---|
Redis keyspace_hits/misses |
命中率 |
应用 cache_miss_total |
回源 QPS |
| DB 连接数/QPS | 是否被打穿 |
| 布隆误判率监控 | 假阳性导致多余回源 |
路径:DB QPS 突增 → 命中率下降 → 区分穿透(大量不存在 Key)vs 雪崩(时间相关)vs 击穿(单 Key)。
架构取舍¶
| 方案 | 适用 | 不适用 |
|---|---|---|
| 布隆过滤器 | 海量 Key 存在性 | 需要删除/精确成员 |
| 空值缓存 | 低频不存在 ID | 恶意海量随机 Key |
| singleflight | 热点 miss | 已命中路径 |
| 永不过期 + 异步更新 | 超级热点 | 数据实时性要求高 |
深挖问答¶
- 布隆能删元素吗? → 标准不能;用 Counting Bloom 或定期重建。
- singleflight 和 Mutex 区别? → singleflight 合并同 Key 并发请求,只回源一次。
- 逻辑过期怎么实现? → 值内嵌
logical_expire,过期仍返回旧值,后台 goroutine 刷新。 - Redis 挂了怎么办? → 本地缓存 + 熔断 + 限流读 DB;快速恢复 Redis。
- Go 用哪个库? →
golang.org/x/sync/singleflight;布隆bits-and-blooms/bloom/v3。
反模式与事故¶
- 空值缓存 TTL 7 天,攻击者扫满内存。
- 所有 Key 固定 TTL=3600,整点集体过期。
- 无熔断,Redis 故障后 DB 同步宕机(经典雪崩)。
- 布隆未预热,冷启动全穿透。
代码示例¶
type Cache struct {
rdb *redis.Client
db Repo
sf singleflight.Group
}
func (c *Cache) Get(ctx context.Context, id int64) (*Item, error) {
key := fmt.Sprintf("item:%d", id)
if b, err := c.rdb.Get(ctx, key).Bytes(); err == nil {
if string(b) == "NULL" {
return nil, ErrNotFound
}
var item Item
_ = json.Unmarshal(b, &item)
return &item, nil
}
v, err, _ := c.sf.Do(key, func() (any, error) {
item, err := c.db.Find(ctx, id)
if errors.Is(err, ErrNotFound) {
ttl := time.Duration(30+rand.Intn(30)) * time.Second // jitter
_ = c.rdb.Set(ctx, key, "NULL", ttl).Err()
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
b, err := json.Marshal(item)
if err != nil {
return nil, err
}
ttl := 300*time.Second + time.Duration(rand.Intn(60))*time.Second
_ = c.rdb.Set(ctx, key, b, ttl).Err() // 缓存失败不应伪装成 DB 成功失败
return item, nil
})
if err != nil {
return nil, err
}
return v.(*Item), nil
}
singleflight.Group 只合并单进程内同 Key 请求;多 Pod 超热点仍需 L1、分布式租约或让 DB/下游具备明确的回源保护。