一文搞懂:缓存三大问题(击穿、穿透、雪崩)原理及全套解决方案
缓存的击穿、穿透、雪崩是后端开发中高频出现、极易引发服务雪崩的核心问题,三者看似相似但原理和解决方案完全不同。本文用「原理+场景+落地方案+代码」的形式,一次性讲透,看完就能直接落地到生产环境。
先分清:三大问题核心区别(一张表看懂)
| 问题类型 | 核心定义 | 典型场景 | 直接后果 |
|---|---|---|---|
| 缓存穿透 | 请求根本不存在的key,缓存和DB都查不到 | 恶意攻击(批量查不存在的用户ID)、业务bug(传错参数) | DB被大量无效请求打满,连接耗尽 |
| 缓存击穿 | 热点key突然失效(过期/被删除),大量请求瞬间打到DB | 秒杀商品key过期、热门榜单key被删除 | DB单点压力骤增,瞬间超时/宕机 |
| 缓存雪崩 | 大量缓存key同时失效,或缓存服务整体宕机 | 缓存key集中设置相同过期时间、Redis集群宕机 | DB被海量请求压垮,整个服务不可用 |
一、缓存穿透:查「不存在的key」把DB打崩
1. 原理
请求的key在缓存中不存在,且在数据库中也不存在,导致每次请求都「穿透」缓存直接打在DB上。如果是恶意高频请求(比如每秒上万次查不存在的用户ID),DB很快会被压垮。
2. 全套解决方案(按优先级排序)
方案1:参数校验(最基础,成本最低)
在请求到达缓存/DB前,先做合法性校验,过滤掉明显无效的请求:
- 比如用户ID是正整数,直接拦截负数/0/超长字符串;
- 比如商品ID有固定格式,拦截不符合格式的请求。
代码示例(Java):
public User getUserById(Long userId) {
// 第一步:参数合法性校验,直接拦截无效请求
if (userId == null || userId <= 0 || userId > 10000000) {
return null;
}
// 后续缓存/DB查询逻辑...
}
方案2:空值缓存(核心方案)
对查询结果为空的key,也写入缓存(设置极短的过期时间,比如1-5分钟),避免后续请求重复打DB。
代码示例(Java + Redis):
public User getUserById(Long userId) {
String cacheKey = "user:" + userId;
// 1. 查缓存
String userJson = redisTemplate.opsForValue().get(cacheKey);
// 空值标记(避免JSON解析问题,用特定字符串表示空)
if ("NULL".equals(userJson)) {
return null;
}
if (userJson != null) {
return JSON.parseObject(userJson, User.class);
}
// 2. 缓存未命中,查DB
User user = userMapper.selectById(userId);
if (user == null) {
// 3. 空值写入缓存,设置短过期时间(防止恶意攻击占满缓存)
redisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
return null;
}
// 4. 正常数据写入缓存
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
return user;
}
方案3:布隆过滤器(高并发/海量key场景)
如果无效key的范围极大(比如电商场景查不存在的商品ID),用布隆过滤器提前判断key是否存在,不存在则直接返回,完全拦截无效请求。
核心逻辑:
- 启动时将DB中所有有效key加载到布隆过滤器;
- 请求过来先过布隆过滤器,不存在则直接返回;
- 存在则走正常缓存/DB流程。
代码示例(Guava布隆过滤器):
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
// 初始化布隆过滤器(预计100万key,误判率0.01)
private static BloomFilter<Long> userBloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1000000,
0.01
);
// 启动时加载所有有效用户ID到过滤器
@PostConstruct
public void initBloomFilter() {
List<Long> allUserId = userMapper.selectAllUserId();
for (Long userId : allUserId) {
userBloomFilter.put(userId);
}
}
public User getUserById(Long userId) {
// 第一步:布隆过滤器拦截无效key
if (!userBloomFilter.mightContain(userId)) {
return null;
}
// 后续缓存/DB查询逻辑...
}
方案4:限流/熔断(兜底方案)
用Sentinel/Hystrix对接口做限流,当QPS超过阈值时直接拒绝;或监控DB压力,达到阈值时熔断缓存穿透的请求,避免DB被打垮。
缓存穿透方案总结
- 基础:参数校验
- 核心:空值缓存(中小规模)、布隆过滤器(大规模)
- 兜底:限流/熔断
二、缓存击穿:热点key失效导致DB单点压力暴增
1. 原理
某个高频访问的「热点key」(比如秒杀商品、热门榜单)突然过期/被删除,大量请求瞬间绕过缓存直接访问DB,导致DB单点压力骤增,甚至宕机。
2. 全套解决方案(按优先级排序)
方案1:热点key永不过期(最简单)
对核心热点key,不设置过期时间,由业务代码主动更新/删除,避免被动过期。
注意:需在代码中保证热点key的更新逻辑(比如商品价格修改时,主动更新缓存),防止缓存脏数据。
方案2:互斥锁(分布式锁)(最通用)
当缓存失效时,不是所有请求都去查DB,而是只有一个请求获取锁后去查DB并更新缓存,其他请求等待锁释放后直接查缓存。
代码示例(Redis分布式锁):
public User getHotUserById(Long userId) {
String cacheKey = "hot_user:" + userId;
String lockKey = "lock:hot_user:" + userId;
// 1. 查缓存
String userJson = redisTemplate.opsForValue().get(cacheKey);
if (userJson != null) {
return JSON.parseObject(userJson, User.class);
}
// 2. 缓存失效,尝试获取分布式锁
Boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (lockSuccess) {
try {
// 3. 获取锁成功,查DB并更新缓存
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 60, TimeUnit.MINUTES);
}
return user;
} finally {
// 4. 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 5. 未获取锁,等待50ms后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 递归重试
return getHotUserById(userId);
}
}
方案3:提前预热(主动更新)
在热点key过期前,主动查询DB并更新缓存,避免过期瞬间的请求冲击。
- 比如定时任务:在key过期前10分钟,主动加载最新数据到缓存;
- 比如监控热点key的过期时间,临近过期时触发更新。
方案4:本地缓存(多级缓存)
对极致热点key,增加「本地缓存」(比如Caffeine/Guava Cache),请求先查本地缓存,再查分布式缓存,最后查DB,进一步降低分布式缓存失效的影响。
缓存击穿方案总结
- 简单版:热点key永不过期
- 通用版:分布式互斥锁
- 进阶版:提前预热 + 多级缓存
三、缓存雪崩:大量key同时失效/缓存宕机导致服务雪崩
1. 原理
两种场景会引发缓存雪崩:
- 大量缓存key在同一时间段集中过期,海量请求瞬间打向DB;
- 缓存服务(如Redis集群)整体宕机,所有请求直接穿透到DB。
最终导致DB压力暴增,服务不可用,甚至整个系统雪崩。
2. 全套解决方案(分场景应对)
场景1:大量key集中过期 → 打散过期时间
核心思路:给每个key的过期时间增加随机值,避免所有key同时过期。
代码示例(Java):
// 基础过期时间30分钟,随机增加0-10分钟
int baseExpire = 30;
int randomExpire = new Random().nextInt(10);
redisTemplate.opsForValue().set(cacheKey, value, baseExpire + randomExpire, TimeUnit.MINUTES);
场景2:缓存服务宕机 → 提高缓存可用性
- 缓存集群化:使用Redis主从+哨兵/Redis Cluster,避免单点故障;
- 熔断降级:用Sentinel/Hystrix监控缓存服务状态,当缓存不可用时,暂时熔断请求(返回默认值/提示),避免DB被打垮;
- 限流:对所有请求做限流,即使缓存宕机,DB也只接收可控的请求量;
- 多级缓存:增加本地缓存(Caffeine),即使分布式缓存宕机,本地缓存仍能承接部分请求;
- 缓存降级:缓存宕机时,返回兜底数据(如商品默认价格、空列表),保证服务不挂。
代码示例(熔断降级伪代码):
public User getUserById(Long userId) {
try {
// 先查分布式缓存
String userJson = redisTemplate.opsForValue().get("user:" + userId);
if (userJson != null) {
return JSON.parseObject(userJson, User.class);
}
} catch (Exception e) {
// 缓存异常,触发降级,返回本地缓存/兜底数据
log.error("缓存异常,触发降级", e);
return getLocalCacheUser(userId); // 本地缓存兜底
}
// 缓存异常/未命中,查DB(同时限流)
return userMapper.selectById(userId);
}
场景3:终极兜底 → DB层防护
即使缓存完全不可用,也要保证DB不被打垮:
- DB读写分离/分库分表,提高DB处理能力;
- DB加连接池限流,避免连接数耗尽;
- 对DB查询做缓存(比如MyBatis一级/二级缓存)。
缓存雪崩方案总结
- 预防集中过期:过期时间加随机值
- 提高缓存可用性:集群化 + 熔断降级 + 多级缓存
- 终极兜底:DB层限流 + 读写分离
四、三大问题核心方案对比表
| 问题类型 | 核心原因 | 首选方案 | 兜底方案 |
|---|---|---|---|
| 缓存穿透 | 查不存在的key | 空值缓存(中小规模)、布隆过滤器(大规模) | 参数校验 + 限流 |
| 缓存击穿 | 热点key失效 | 分布式互斥锁 | 热点key永不过期 |
| 缓存雪崩 | 大量key过期/缓存宕机 | 过期时间随机化 + 缓存集群化 | 熔断降级 + DB限流 |
总结
- 缓存穿透:核心是拦截无效请求,用空值缓存/布隆过滤器挡住不存在的key;
- 缓存击穿:核心是保护热点key,用分布式锁避免并发查DB,或直接设置永不过期;
- 缓存雪崩:核心是提高可用性 + 打散风险,过期时间随机化防集中失效,集群化+熔断降级防缓存宕机;
- 所有方案都需配合限流/熔断作为兜底,确保极端场景下服务不雪崩。
如果需要某类方案的完整生产级代码(比如Redis分布式锁、布隆过滤器落地、Sentinel限流配置),可以告诉我,我会补充对应的可直接运行的代码。

浙公网安备 33010602011771号