4. 缓存穿透、击穿、雪崩:问题定义、产生原因、经典解决方案
缓存是后端性能优化的第一道防线,但防线本身也存在三大经典故障模式:缓存穿透、缓存击穿和缓存雪崩。这三者虽然名字相似,但成因、影响范围和解决方案截然不同。本节将逐一拆解其定义、产生原因,并给出经过生产验证的应对策略。
4.1 三大故障模式对比总览
| 故障模式 | 核心问题 | 影响范围 | 典型触发场景 | 解决思路 |
|---|---|---|---|---|
| 缓存穿透 | 请求的数据在缓存和数据库中都不存在 | 单个无效请求穿透至DB | 恶意攻击、查询不存在的ID | 布隆过滤器、空值缓存 |
| 缓存击穿 | 热点key在缓存失效的瞬间,大量并发请求直接打到DB | 单个热点key失效 | 秒杀商品、热门新闻 | 互斥锁、永不过期+异步更新 |
| 缓存雪崩 | 大量key同时失效或缓存节点宕机,导致DB瞬间被压垮 | 大面积缓存失效 | 批量过期时间相同、缓存集群故障 | 过期时间随机化、多级缓存、高可用集群 |
4.2 缓存穿透:问题定义与解决方案
4.2.1 定义与产生原因
缓存穿透指客户端请求的数据在缓存中不存在,在数据库中也不存在,导致每次请求都直接穿透缓存层访问数据库。攻击者可以利用这个漏洞,构造大量不存在的key(如 user_id=-1 或随机字符串)发起请求,使数据库负载飙升甚至宕机。
4.2.2 经典解决方案
布隆过滤器核心原理:
- 使用一个很长的二进制位数组和多个哈希函数。
- 添加元素时,通过多个哈希函数将元素映射到位数组的多个位置,并将这些位置置为1。
- 查询元素时,检查所有映射位置是否都为1;只要有一个为0,则元素一定不存在。
- 特点: 空间效率极高,但存在一定的误判率(可能把不存在的元素判为存在),且无法删除元素。
方案二:缓存空值(Null Cache) —— 当查询数据库发现数据不存在时,仍然将该key写入缓存,但value设置为一个特殊标记(如 null 或 EMPTY),并设置较短的过期时间(如30~60秒)。后续相同key的请求直接命中缓存空值,不再穿透到数据库。
4.3 缓存击穿:问题定义与解决方案
4.3.1 定义与产生原因
缓存击穿指一个热点key在缓存失效的瞬间,大量并发请求同时涌入,由于缓存中没有数据,所有请求几乎同时去数据库查询,导致数据库压力瞬间激增。与穿透不同,击穿针对的是确实存在的热点数据,只是恰好在其失效时刻发生了并发访问。
4.3.2 经典解决方案
互斥锁流程:
- 请求到达,检查缓存中是否存在目标key。
- 如果缓存不存在,尝试获取分布式锁(如
SETNX lock_key)。 - 获取锁成功的线程去数据库查询数据,写入缓存,释放锁。
- 获取锁失败的线程等待一段时间后重试,从缓存获取数据。
// 伪代码示例:Redis分布式锁防止缓存击穿
public String getData(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
// 尝试获取锁,锁的过期时间要大于数据库查询时间
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) {
try {
// 双重检查:防止在等待锁的过程中其他线程已经写入缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 查询数据库
value = queryFromDB(key);
redis.set(key, value, 60, TimeUnit.SECONDS);
return value;
} finally {
redis.del(lockKey);
}
} else {
// 未获取到锁,等待后重试
Thread.sleep(50);
return getData(key); // 递归重试
}
}
方案二:永不过期策略(逻辑过期) —— 缓存中的数据物理上永不过期,但为每个缓存数据增加一个逻辑过期时间字段。后台启动一个定时任务或异步线程,在检测到数据逻辑过期时,主动去数据库加载最新数据并更新缓存。
- 优点: 不会出现缓存击穿,所有请求都能直接从缓存获取数据(可能是旧数据)。
- 缺点: 数据存在短暂的不一致窗口,且需要额外的后台线程维护缓存更新。
- 适用场景: 对数据实时性要求不高,但要求高可用、高并发的场景(如首页推荐、配置信息)。
4.4 缓存雪崩:问题定义与解决方案
4.4.1 定义与产生原因
缓存雪崩指缓存层中大量key同时失效,或者缓存节点整体宕机,导致所有请求直接落到数据库上,数据库因无法承受瞬时压力而崩溃。雪崩是三种故障中影响范围最广、破坏力最强的。
常见原因:
- 大量key设置了相同的过期时间(如统一设置为凌晨0点过期)。
- 缓存集群发生故障(如Redis主节点宕机、网络分区)。
- 缓存服务重启后,缓存中数据为空,大量请求直接穿透。
4.4.2 经典解决方案
| 解决方案 | 实现方式 | 适用场景 |
|---|---|---|
| 过期时间随机化 | 在基础过期时间上增加一个随机偏移量(如 ±30%),避免大量key同时过期 | 批量写入缓存时 |
| 多级缓存 | 引入本地缓存(如Caffeine、Guava Cache)作为第一级,Redis作为第二级,数据库作为第三级 | 对性能要求极高的场景 |
| 缓存高可用 | 使用Redis Sentinel或Redis Cluster,避免单点故障 | 生产环境必备 |
| 限流降级 | 在数据库访问层增加限流(如Sentinel、Hystrix),超出阈值时直接返回默认值或错误提示 | 防止数据库被完全打垮 |
| 提前预热 | 在缓存即将过期前,通过后台任务提前刷新缓存 | 可预知的批量过期场景 |
4.5 三种故障模式与解决方案的SVG架构图
下图从请求入口到数据库的完整链路出发,展示了缓存穿透、击穿、雪崩三种故障的发生位置,以及对应的解决方案在架构中的部署层次。
4.6 总结与选型建议
在实际项目中,三种故障模式往往同时存在,需要根据业务场景组合使用解决方案:
- 缓存穿透: 优先使用布隆过滤器(key集合固定)或空值缓存(key动态变化)。
- 缓存击穿: 热点key数量少时用互斥锁,对一致性要求不高时用永不过期策略。
- 缓存雪崩: 过期时间随机化是基础手段,多级缓存和限流降级是保底措施,缓存高可用是运维保障。