Files
autumn-recruitment/03.Redis/strategies/缓存穿透击穿雪崩解决方案.md
T

202 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [redis, cache-penetration, cache-breakdown, cache-avalanche, bloom-filter]
create time: 2026-08-08 18:43
update time: 2026-08-08 18:43
---
# 缓存穿透、击穿、雪崩解决方案
## 概述
缓存三大问题——穿透(Penetration)、击穿(Breakdown)和雪崩(Avalanche)是面试中必问的经典题目。它们看似相似,但本质完全不同:穿透是不存在的数据反复请求,击穿是热点 key 过期时并发重建缓存,雪崩是大量 key 同时过期导致流量全部涌向 DB。理解各自的成因并对症下药,才能构建健壮的缓存系统。
## 详解
### 穿透 — 布隆过滤器与空值缓存
**成因**:恶意用户或异常查询不断访问数据库中不存在的 key(如 id = -1 或随机 ID),每次缓存都 miss 导致请求直接打到数据库,可能压垮 DB。
#### 方案一:布隆过滤器(Bloom Filter)
布隆过滤器的核心是用一个极小的位数组来近似判断元素是否属于某个集合。它的优势是空间效率极高用几个 MB 的内存就能判断数十亿级别的数据是否存在。
**工作原理**:
1. 初始化一个 m 位的位数组(全 0)和 k 个独立哈希函数
2. 存入数据时,用 k 个哈希函数算出 k 个位置,置为 1
3. 查询时,同样计算 k 个位置,如果任一位置为 0,则必定不存在;全部为 1 则可能存在
**误判率计算**:
```
p ≈ (1 - e^(-kn/m))^k
```
其中 m 是位数组大小,n 是元素数量,k 是哈希函数个数。最优哈希函数数:`k = (m/n) * ln(2)`,此时误判率最低。
> [!TIP]
> 面试常考:当 m/n = 10、k = 7 时,误判率约 0.8%。实际工程中可以通过增大 m/n 进一步降低误判率(如 m/n = 20 时误判率降至 0.02%)。
Go 中使用典型 Bloom Filter 结构:
```go
type BloomFilter struct {
bits []bool
hash func([]byte) uint64
}
func (b *BloomFilter) Add(key string) {
for i := 0; i < k; i++ {
pos := b.hash([]byte(key+strconv.Itoa(i))) % uint64(len(b.bits))
b.bits[pos] = true
}
}
func (b *BloomFilter) MightContain(key string) bool {
for i := 0; i < k; i++ {
pos := b.hash([]byte(key+strconv.Itoa(i))) % uint64(len(b.bits))
if !b.bits[pos] {
return false // 一定不在
}
}
return true // 可能在
}
```
**缺点**:无法删除(除非用 Counting Bloom Filter,每个 bit 换成 counter)、不支持跨节点共享(需要 Redisson 分布式布隆过滤器)。
#### 方案二:空值缓存
对查询结果为空的 key,仍然写入缓存并设置短 TTL(30s~2min):
| 策略 | 优点 | 缺点 |
|------|-----|------|
| 布隆过滤器 | 空间利用率极高,拦截效果好 | 有 False Positive,不可删减元素 |
| 空值缓存 | 实现简单,精确匹配 | 占用额外存储空间,大 key 集合时浪费内存 |
| 组合使用 | 布隆做粗筛 + 空值做兜底 | 复杂度略增 |
### 击穿 — 分布式锁保证单点重建
**成因**:一个超级热点 key(如首页配置、爆款商品详情)刚好过期,此时大量并发请求同时发现缓存 miss,纷纷去查 DB 并重写缓存,瞬间将 DB 流量放大数倍甚至数十倍。
**方案一:互斥锁(Mutex Lock)**
只有一个线程去查 DB 并回填缓存,其余线程等待(重试读缓存)。
```go
import "github.com/go-redis/redismutex"
func GetHotKey(ctx context.Context, key string) ([]byte, error) {
val, err := redis.Get(ctx, key).Bytes()
if err == nil {
return val, nil
}
mutex := redismutex.New(key, redisClient)
locked, err := mutex.Lock(5*time.Second, 100*time.Millisecond)
if err != nil || !locked {
time.Sleep(50 * time.Millisecond)
return redis.Get(ctx, key).Bytes() // 等别人重建好再读
}
defer mutex.Unlock()
val, err = redis.Get(ctx, key).Bytes() // 双重检查
if err == nil {
return val, nil
}
val = queryDBAndSerialize(key) // 真正查 DB
redis.SetEX(ctx, key, val, randomTTL(10*time.Minute, 30))
return val, nil
}
```
**方案二:永不过期(逻辑 TTL)**
物理上不设置过期时间,而是在内存中维护一个逻辑过期时间。发现逻辑过期后,异步触发重建,读取到的仍是旧值(无脑 hit),直到新缓存写入成功。
> [!WARNING]
> 互斥锁方案的死锁风险:如果重建过程抛出异常没有释放锁,其他线程永远阻塞。务必使用 defer unlock 或超时机制。
| 维度 | 互斥锁方案 | 逻辑 TTL 方案 |
|------|----------|-------------|
| 一致性 | 强(新值写入后才可读到) | 最终一致(有短暂旧值窗口) |
| 性能开销 | 锁等待增加延迟 | 无锁开销 |
| 实现复杂度 | 中等 | 较低 |
| 适用场景 | 强一致性要求 | 高可用优先 |
### 雪崩 — 过期时间加随机偏移量
**成因**:大批量 key 设置相同的过期时间,到期时集中失效,请求洪水般涌向下流,超出系统承载能力。
**方案一:过期时间随机化**(最推荐)
在基础 TTL 上加加减号 30% 的随机波动:
```go
func RandomJitter(base time.Duration, jitterRatio int) time.Duration {
range_ := int(base) * jitterRatio / 100
delta := rand.Intn(2*range_) - range_
return base + time.Duration(delta)
}
// base=10min, ratio=30 -> [7min, 13min] 均匀分布
```
**方案二:多实例部署 + 读写隔离**
将缓存拆分为多个独立实例(按业务线分或按 hash slot 分),单个实例的雪崩不会波及全局。配合读写分离,读操作走从库减轻主库压力。
**方案三:服务降级 + 限流熔断**
当缓存大面积失效时,通过 Sentinel/Hystrix 快速降级,返回默认值或走本地缓存,避免级联故障。
| 策略 | 优点 | 缺点 |
|------|-----|------|
| TTL 随机化 | 零成本,效果明显 | 不能解决极端热点同时过期的问题 |
| 多实例部署 | 故障域隔离 | 增加运维成本 |
| 熔断降级 | 保护系统不被打垮 | 用户体验下降(返回降级数据) |
## 代码示例
综合防御的完整缓存获取方法:
```go
func SafeGet(ctx context.Context, key string, fn QueryFn) ([]byte, error) {
// 第1道防线:布隆过滤器拦截不存在的 key
if !bloom.MightContain(key) {
return nil, ErrNotExist
}
// 第2道防线:读缓存
val, err := redis.Get(ctx, key).Bytes()
if err == nil {
return val, nil
}
// 第3道防线:分布式锁防止击穿
mutex := lock.GetOrNew(key, 5*time.Second)
if locked, _ := mutex.TryLock(); locked {
defer mutex.Unlock()
val = doQuery(ctx, key, fn)
return val, nil
}
time.Sleep(50 * time.Millisecond)
return redis.Get(ctx, key).Bytes()
}
```
## 实践场景
1. **电商 SKU 查询**:透穿防护(布隆过滤器预加载所有有效 SKU ID)+ 击穿防护(互斥锁)+ 雪崩防护(TTL 随机化 10加减号 3 min)。三层防御覆盖所有攻击面。
2. **社交媒体点赞数**:几乎不可能透穿(key 都是有效的),重点防击穿。可以用永不过期加后台异步刷新的逻辑 TTL 方案,保证 QPS 峰值时 DB 不受影响。
3. **活动秒杀页面**:超热点 key,建议预热到 L1 缓存(应用本地),L2 用互斥锁保护,L3 用 CDN 静态化。不要把所有压力都放在 Redis 上。
4. **日志聚合统计**:适合放宽一致性要求,逻辑 TTL 方案更合适用户看到的统计数据延迟几分钟完全可以接受,但系统稳定性远比数据实时性重要。
## 关联笔记
- [[03.Redis/strategies/多级缓存架构设计]]
- [[03.Redis/strategies/旁路缓存与读写策略]]
- [[03.Redis/strategies/HeavyKeeper 热点探测算法]]