# Redis缓存穿透、击穿、雪崩终极解决方案:从底层原理到生产高可用架构落地
> 版权:凡尘版权
## 导读
做后端开发,Redis几乎是中大型系统的标配组件。很多同学本地调试、单元测试全部跑通,上线压测之后就爆出各类缓存故障。数据库压力飙升、接口大量超时、CPU打满,追查半天,根源往往就是缓存穿透、缓存击穿、缓存雪崩这三类经典问题。
网上关于这三个问题的教程很多,但大部分只讲概念,缺少生产环境可直接落地的代码,也很少讲真实线上踩过的坑。本文结合线上项目实战,从现象、底层原理、错误示范、代码实现、性能取舍完整梳理,看完可以直接用到业务开发当中。
## 一、缓存穿透:查询不存在的数据,直接打穿缓存直达数据库
### 故障现象
大量请求查询数据库根本不存在的key,缓存层拦截失效,所有流量全部落到MySQL。比如传入ID=-1、ID=99999999这类不存在业务数据,Redis查不到,每次请求都访问数据库。高并发场景下数据库CPU、连接数直接打满。
### 错误代码示例
```java
// 错误写法:没有空值处理,不存在的数据直接穿透到DB
public User getUserById(Long userId){
String cacheKey = "user:" + userId;
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if(StringUtils.isNotEmpty(cacheValue)){
return JSON.parseObject(cacheValue,User.class);
}
//缓存没命中,直接查数据库
User user = userMapper.selectById(userId);
if(user != null){
redisTemplate.opsForValue().set(cacheKey,JSON.toJSONString(user),30, TimeUnit.MINUTES);
}
return user;
}
```
**问题分析**:当userId为不存在的id,数据库返回null,程序不会往Redis写入任何缓存。下一次相同请求过来,依旧绕过缓存查询数据库。恶意接口遍历、非法参数传入,就会造成缓存穿透攻击。
### 方案1:缓存空对象(业务简单场景首选)
查询数据库返回null,依然向Redis存入空标记,设置较短过期时间,避免相同请求反复访问数据库。
```java
public User getUserById(Long userId){
String cacheKey = "user:" + userId;
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if(StringUtils.isNotEmpty(cacheValue)){
// 判断缓存空标记
if("NULL".equals(cacheValue)){
return null;
}
return JSON.parseObject(cacheValue,User.class);
}
User user = userMapper.selectById(userId);
if(user != null){
redisTemplate.opsForValue().set(cacheKey,JSON.toJSONString(user),30, TimeUnit.MINUTES);
}else{
// 存入空标识,短过期,避免大量无效key占满内存
redisTemplate.opsForValue().set(cacheKey,"NULL",5, TimeUnit.MINUTES);
}
return user;
}
```
> 缺点:会产生大量无效key,需要控制过期时间不能设置过长;适合并发量中等业务。
### 方案2:布隆过滤器(高并发大流量系统)
把所有合法业务ID预加载进布隆过滤器,请求进来先经过布隆过滤器拦截。如果布隆过滤器判定这个ID不存在,直接返回,不再查询Redis和数据库。
> 注意:布隆过滤器存在误判,只能过滤“一定不存在”的数据,不能100%精准匹配;不支持删除操作,业务数据大量删除场景需要额外处理。
**生产落地注意点**
1. 项目启动的时候批量把有效业务id载入布隆过滤器;
2. 新增业务数据同步写入布隆过滤器;
3. 预估业务数据总量,合理设置误判率。
## 二、缓存击穿:热点key失效,大量并发直接打数据库
### 故障现象
某一个热点高频访问的缓存key,到期瞬间,成千上万个并发请求同时来到,缓存失效,全部请求打到数据库,瞬间压垮DB。和穿透不同,**击穿针对的是某一个存在的热点数据**,比如秒杀商品、首页热门资讯。
### 错误代码
上面缓存查询示例就是典型错误,热点key过期瞬间,大量线程同时进入查询数据库逻辑。很多开发以为设置过期时间就万事大吉,线上一压测直接出事故。
### 解决方案一:互斥锁(分布式锁)
缓存失效时,只放行一个线程去查询数据库并且更新缓存,其余线程等待重试。
```java
public User getUserWithLock(Long userId){
String cacheKey = "user:" + userId;
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if(StringUtils.isNotEmpty(cacheValue)){
return JSON.parseObject(cacheValue,User.class);
}
String lockKey = "lock:user:" + userId;
// 获取分布式锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);
if(!lock){
// 获取锁失败,短暂休眠后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getUserWithLock(userId);
}
try{
//拿到锁之后,再次二次校验缓存,防止其它线程已经更新缓存
cacheValue = redisTemplate.opsForValue().get(cacheKey);
if(StringUtils.isNotEmpty(cacheValue)){
return JSON.parseObject(cacheValue,User.class);
}
User user = userMapper.selectById(userId);
if(user != null){
redisTemplate.opsForValue().set(cacheKey,JSON.toJSONString(user),30, TimeUnit.MINUTES);
}
return user;
}finally {
//释放锁
redisTemplate.delete(lockKey);
}
}
```
> 优缺点:实现简单,保证数据一致性;并发极高场景会带来少量等待,注意锁超时时间,避免死锁。
### 方案二:热点key永不过期(业务允许优先选择)
关闭Redis层面过期时间,后台开启异步线程定时刷新缓存。完全杜绝key过期带来的击穿风险。
> 适用场景:热点数据,允许短暂数据不一致;不适合强实时更新业务。
## 三、缓存雪崩:大量缓存key集中失效,Redis宕机,大规模数据库压力
### 故障现象
大批量缓存key同一时间过期,大量请求全部落到数据库;或者Redis服务整体宕机,所有缓存全部失效,数据库瞬间承载全量流量,系统整体雪崩。
⚠️区分概念:
- 击穿:**单个热点key失效**
- 雪崩:**大批量key同时失效 / Redis整体故障**
### 诱因盘点
1. 批量设置缓存过期时间完全相同,到时间集体失效;
2. Redis实例宕机、断电、网络故障;
3. Redis内存满,大量key被LRU淘汰。
### 解决方案
1. **过期时间打散,增加随机偏移量**
不要统一设置30分钟过期,在基础过期时间上叠加随机值,错开失效窗口。
```java
//基础30分钟,叠加0‑5分钟随机偏移
long expireTime = 30*60 + new Random().nextInt(5*60);
redisTemplate.opsForValue().set(key,value,expireTime,TimeUnit.SECONDS);
```
2. **Redis高可用架构**
主从 + 哨兵或者Redis‑Cluster集群,避免单点故障,Redis挂掉能自动切换。
3. **服务层限流、熔断降级**
即使缓存全部失效,通过Sentinel或者Resilience4j做接口熔断限流,数据库压力到达阈值直接返回降级提示,不让数据库被打挂。
4. **多级缓存设计**
本地Caffeine一级缓存 + Redis二级缓存,即使Redis故障,本地缓存依然可以承接一部分流量。
## 四、线上踩坑复盘,容易被忽略的细节
### 坑1:只解决穿透击穿雪崩,忽略缓存与数据库双写一致性
更新业务数据,只更新数据库,忘记更新/删除缓存,造成缓存长期脏数据。
> 最佳实践:更新数据库之后删除缓存,不要直接更新缓存;延迟双删策略处理并发读写带来的脏数据。
### 坑2:分布式锁过期时间评估错误
业务查询数据库耗时大于锁过期时间,锁提前释放,多个线程同时执行业务逻辑,锁失效。锁超时时间要大于业务预估最大执行时间。
### 坑3:布隆过滤器误判引发业务bug
业务上不要把布隆过滤器当成100%判断依据,布隆过滤器说存在,实际有可能不存在;布隆过滤器说不存在,一定不存在。
### 坑4:缓存空对象无限堆积
穿透方案存入大量空key,没有控制过期时间,Redis内存持续上涨。空对象过期时间必须设置很短,5‑10分钟区间。
## 五、生产选型参考表
|问题类型|推荐方案|适用场景|局限|
|---|---|---|---|
|缓存穿透|缓存空值|中小并发业务|产生无效key|
|缓存穿透|布隆过滤器|大流量高并发系统|无法删除、存在误判|
|缓存击穿|分布式锁|追求数据一致性|存在少量等待开销|
|缓存击穿|热点key永不过期|允许短暂数据不一致|需要定时刷新任务|
|缓存雪崩|过期时间随机打散|绝大多数业务|无法解决Redis宕机|
|缓存雪崩|集群+限流熔断|核心生产环境|架构复杂度上升|
## 六、开发编码规范总结
1. 所有带缓存的查询接口,必须思考:如果缓存全部失效,数据库能不能扛住流量;提前做好降级兜底。
2. 热点业务key,优先考虑永不过期,后台异步刷新。
3. 批量写入缓存,一定要增加随机过期偏移,杜绝集体过期。
4. 分布式锁必须设置超时时间,finally代码块释放锁,防止死锁。
5. 线上上线前,做缓存失效场景压测,模拟key大批量过期,观察数据库指标。
很多时候线上故障不是代码逻辑写错,而是没有考虑缓存失效、并发竞争这种边界场景。本地单线程调试完全正常,一旦放到高并发生产环境,各种隐藏问题全部暴露出来。理解底层原理,结合业务场景选型,不要网上复制一段代码直接就上线。
> 版权:凡尘版权
热门跟贴