《Redis 缓存三大经典问题深度解析:穿透、击穿、雪崩的原理与实战解决方案》
前言
在高并发系统中,Redis 几乎是标配的缓存中间件。合理使用缓存可以大幅降低数据库压力、提升接口响应速度。但如果缓存使用不当,反而会引发严重的线上事故------缓存穿透、缓存击穿、缓存雪崩,这三个问题被称为 Redis 缓存的"三大杀手"。
很多开发者在日常工作中用过 Redis,但对这三个问题的理解停留在"背八股文"的层面,一旦在生产环境中遇到,往往手足无措。
这篇文章从原理出发,结合实际代码(基于 Spring Boot + Redis),把三大问题的成因、危害、解决方案讲透,帮你建立完整的缓存防护体系。
一、问题全景:三大缓存故障的本质区别
先建立全局认知,三大问题的核心区别如下:
| 问题 | 触发条件 | 本质原因 | 影响范围 |
|---|---|---|---|
| 缓存穿透 | 查询一个根本不存在的数据 | 缓存和数据库都没有该数据,请求直达数据库 | 针对单个 key 持续攻击 |
| 缓存击穿 | 一个热点 key 在过期的瞬间,大量并发请求同时命中 | 热点 key 过期,所有请求同时打到数据库 | 针对单个热点 key |
| 缓存雪崩 | 大量 key 在同一时间集中过期,或 Redis 节点宕机 | 缓存大面积失效,请求全部涌向数据库 | 针对整个缓存系统 |
三者看似相似,但触发条件和解决思路完全不同。下面逐一拆解。
二、缓存穿透:查一个不存在的数据
2.1 问题描述
用户请求查询一条数据,但该数据在缓存和数据库中都不存在。
正常流程:请求 → 查缓存 → 缓存未命中 → 查数据库 → 数据库也没有 → 返回空 → 写入缓存(空值)→ 返回。
问题在于:如果攻击者持续用不存在的 key 发起请求,每次都会穿透缓存直达数据库。因为数据不存在,缓存中永远不会有这个 key,数据库每次都要执行查询。当请求量足够大时,数据库会被打垮。
恶意用户 ──请求 id=-1──▶ 缓存(未命中)──▶ 数据库(无数据)──▶ 返回空
恶意用户 ──请求 id=-1──▶ 缓存(未命中)──▶ 数据库(无数据)──▶ 返回空
恶意用户 ──请求 id=-1──▶ 缓存(未命中)──▶ 数据库(无数据)──▶ 返回空
...(每秒数万次)
2.2 解决方案一:缓存空值
最简单的方案------当数据库也查不到数据时,在缓存中写入一个空值(如 "" 或特殊标记 NULL_PLACEHOLDER),并设置一个较短的过期时间。
java
public User getUserById(Long userId) {
String cacheKey = "user:" + userId;
// 1. 查缓存
String cached = redisTemplate.opsForValue().get(cacheKey);
// 2. 缓存命中(包括空值)
if (cached != null) {
// 判断是否为空值标记
if ("NULL_PLACEHOLDER".equals(cached)) {
return null; // 直接返回,不查数据库
}
return JSON.parseObject(cached, User.class);
}
// 3. 缓存未命中,查数据库
User user = userMapper.selectById(userId);
if (user != null) {
// 数据存在,写入缓存,过期时间 30 分钟
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
} else {
// 数据不存在,缓存空值,过期时间 5 分钟(较短,防止长期占用)
redisTemplate.opsForValue().set(cacheKey, "NULL_PLACEHOLDER", 5, TimeUnit.MINUTES);
}
return user;
}
优点:实现简单,效果立竿见影。
缺点:
- 如果攻击者用大量随机 key 发起攻击,缓存中会堆积大量空值,浪费内存。
- 空值的过期时间需要权衡:太短则防护效果差,太长则浪费内存。
2.3 解决方案二:布隆过滤器(Bloom Filter)
布隆过滤器是一种空间效率极高的概率型数据结构,用于判断"一个元素是否存在于集合中"。它有两个特性:
- 如果布隆过滤器说"不存在",那一定不存在(不会误判)
- 如果布隆过滤器说"存在",可能存在,也可能不存在(有一定误判率)
利用这个特性,在请求到达缓存之前,先用布隆过滤器判断 key 是否可能存在:
java
@Component
public class UserBloomFilter {
private static final int EXPECTED_INSERTIONS = 1000000; // 预计插入100万条
private static final double FPP = 0.01; // 误判率 1%
@Autowired
private RedissonClient redissonClient;
private RBloomFilter<String> bloomFilter;
@PostConstruct
public void init() {
bloomFilter = redissonClient.getBloomFilter("user:bloom");
bloomFilter.tryInit(EXPECTED_INSERTIONS, FPP);
}
/**
* 初始化:将所有有效用户ID加入布隆过滤器
*/
public void loadAllUserIds() {
List<Long> allUserIds = userMapper.selectAllUserIds();
for (Long userId : allUserIds) {
bloomFilter.add(String.valueOf(userId));
}
}
/**
* 判断用户ID是否可能存在
*/
public boolean mightContain(Long userId) {
return bloomFilter.contains(String.valueOf(userId));
}
/**
* 新增用户时,同步加入布隆过滤器
*/
public void addUser(Long userId) {
bloomFilter.add(String.valueOf(userId));
}
}
查询时的完整流程:
java
public User getUserById(Long userId) {
// 1. 布隆过滤器预判
if (!userBloomFilter.mightContain(userId)) {
// 布隆过滤器说不存在 → 一定不存在,直接返回
return null;
}
// 2. 布隆过滤器说可能存在 → 查缓存
String cacheKey = "user:" + userId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 3. 查数据库
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
}
return user;
}
优点:
- 从源头拦截无效请求,数据库完全不受影响
- 内存占用极小(100万条数据,1% 误判率,只需约 1.14MB)
缺点:
- 存在误判率(可配置降低,但不能完全消除)
- 标准布隆过滤器不支持删除操作(可用 Counting Bloom Filter 解决)
- 需要定期重建或增量维护
2.4 方案选型建议
| 场景 | 推荐方案 |
|---|---|
| 攻击量不大,key 空间有限 | 缓存空值即可 |
| key 空间很大,攻击量大 | 布隆过滤器 + 缓存空值(双重防护) |
| 对误判零容忍 | 布隆过滤器 + 缓存空值 + 参数校验 |
三、缓存击穿:热点 key 过期瞬间的并发风暴
3.1 问题描述
某个数据是高频访问的热点数据(如秒杀商品、热搜话题、首页 Banner),其缓存 key 在某一时刻过期。在过期的瞬间,大量并发请求同时到达:
- 所有请求查缓存 → 缓存已过期,全部未命中
- 所有请求同时查数据库 → 数据库瞬间承受巨大压力
- 第一个请求查到数据,重建缓存
- 后续请求查到缓存,恢复正常
问题就出在第 2 步------在缓存重建的短暂窗口期内,所有请求都打到了数据库。如果这个热点 key 的 QPS 是 10 万,那数据库就要瞬间承受 10 万并发查询。
时间线:
T0: 热点key过期
T0: 10万个请求同时到达 → 缓存未命中 → 全部查数据库
T0+50ms: 第一个请求完成数据库查询,重建缓存
T0+50ms之后: 后续请求命中缓存,恢复正常
3.2 解决方案一:互斥锁(分布式锁)
核心思路:只允许一个线程去查数据库并重建缓存,其他线程等待或返回旧数据。
java
public User getUserByIdWithLock(Long userId) {
String cacheKey = "user:" + userId;
// 1. 查缓存
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 2. 缓存未命中,尝试获取分布式锁
String lockKey = "lock:" + cacheKey;
String lockValue = UUID.randomUUID().toString();
// 尝试加锁,等待时间 100ms,锁过期时间 10s
boolean locked = false;
try {
locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (locked) {
// 获取锁成功,再次检查缓存(双重检查)
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 查数据库
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
}
return user;
} else {
// 获取锁失败,短暂休眠后重试(或返回默认值)
Thread.sleep(50);
return getUserByIdWithLock(userId); // 递归重试
}
} finally {
// 释放锁(只释放自己加的锁)
if (locked) {
String currentValue = redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
redisTemplate.delete(lockKey);
}
}
}
}
生产环境中建议使用 Redisson 的
RLock来实现分布式锁,它内部封装了看门狗(Watch Dog)机制,可以自动续期,避免锁提前过期导致的问题。
优点:保证数据一致性,同一时刻只有一个线程查数据库。
缺点:其他线程需要等待,响应时间增加。
3.3 解决方案二:逻辑过期(永不过期 + 后台异步更新)
核心思路:缓存不设置物理过期时间,而是在 value 中存储一个"逻辑过期时间"。读取时检查是否逻辑过期,如果过期则开启一个异步线程去更新缓存,当前请求返回旧数据。
java
@Data
public class CacheWrapper<T> {
private T data;
private long expireTime; // 逻辑过期时间戳(毫秒)
}
public User getUserByIdWithLogicalExpire(Long userId) {
String cacheKey = "user:" + userId;
// 1. 查缓存
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached == null) {
// 缓存不存在,查数据库并初始化缓存
return initCache(userId);
}
CacheWrapper<User> wrapper = JSON.parseObject(cached,
new TypeReference<CacheWrapper<User>>() {});
// 2. 检查是否逻辑过期
if (System.currentTimeMillis() < wrapper.getExpireTime()) {
// 未过期,直接返回
return wrapper.getData();
}
// 3. 已过期,尝试获取锁去更新缓存
String lockKey = "lock:" + cacheKey;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 获取锁成功,开启异步线程更新缓存
executorService.submit(() -> {
try {
User user = userMapper.selectById(userId);
CacheWrapper<User> newWrapper = new CacheWrapper<>();
newWrapper.setData(user);
newWrapper.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000L);
// 不设置物理过期时间(或设置一个很长的过期时间)
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(newWrapper));
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 4. 返回旧数据(无论是否拿到锁)
return wrapper.getData();
}
优点:
- 无需等待,所有请求都能立即返回数据(虽然是旧数据)
- 不会造成数据库压力
缺点:
- 数据存在短暂不一致(逻辑过期到缓存更新完成之间)
- 需要额外的存储空间来保存逻辑过期时间
- 缓存永不过期,如果数据被删除,缓存中会残留脏数据
3.4 方案选型建议
| 场景 | 推荐方案 |
|---|---|
| 对数据一致性要求高(如库存、余额) | 互斥锁 |
| 对实时性要求不高,允许短暂旧数据(如商品详情、文章) | 逻辑过期 |
| 热点 key 数量少且可预判 | 永不过期 + 定时任务主动刷新 |
四、缓存雪崩:大面积缓存同时失效
4.1 问题描述
缓存雪崩有两种典型触发场景:
场景一:大量 key 在同一时间集中过期
比如系统凌晨 2 点启动时批量加载了 10 万个热点数据到缓存,过期时间都设为 2 小时。凌晨 4 点,这 10 万个 key 同时过期,所有请求瞬间打到数据库。
场景二:Redis 节点宕机
Redis 主节点宕机,且主从切换失败或未配置高可用,导致整个缓存系统不可用,所有请求直达数据库。
4.2 解决方案一:过期时间加随机值
最简单也最有效的方案------在基础过期时间上加一个随机偏移量,避免大量 key 同时过期。
java
// 基础过期时间 30 分钟 + 随机 0~10 分钟
int baseExpire = 30; // 分钟
int randomExpire = ThreadLocalRandom.current().nextInt(0, 11); // 0~10 分钟
int totalExpire = baseExpire + randomExpire;
redisTemplate.opsForValue().set(cacheKey, value, totalExpire, TimeUnit.MINUTES);
这样 10 万个 key 的过期时间会分散在 30~40 分钟之间,不会出现集中过期的情况。
4.3 解决方案二:多级缓存架构
引入本地缓存(如 Caffeine)作为第一级缓存,Redis 作为第二级缓存,数据库作为最终兜底:
java
// 使用 Caffeine 作为本地缓存
private final Cache<String, User> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES) // 本地缓存 5 分钟过期
.build();
public User getUserByIdMultiLevel(Long userId) {
String cacheKey = "user:" + userId;
// 1. 查本地缓存(L1)
User user = localCache.getIfPresent(cacheKey);
if (user != null) {
return user;
}
// 2. 查 Redis(L2)
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
user = JSON.parseObject(cached, User.class);
localCache.put(cacheKey, user); // 回填本地缓存
return user;
}
// 3. 查数据库(L3)
user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user),
30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.MINUTES);
localCache.put(cacheKey, user);
}
return user;
}
多级缓存的优势:
- 即使 Redis 大面积失效,本地缓存仍能挡住大部分请求
- 本地缓存的响应时间在纳秒级,远快于 Redis 的毫秒级
- 热点数据在本地缓存中命中率极高,减少网络开销
4.4 解决方案三:Redis 高可用架构
针对 Redis 节点宕机导致的雪崩,需要从架构层面保障:
- 主从复制 + 哨兵模式:主节点宕机后,哨兵自动将从节点提升为主节点,实现自动故障转移
- Redis Cluster 集群模式:数据分片存储在多个节点上,单个节点宕机只影响部分数据
- 多机房部署:极端情况下,单个机房故障不会影响整体服务
4.5 解决方案四:限流降级兜底
当缓存全面失效时,用限流和降级保护数据库不被打垮:
java
// 使用 Sentinel 进行限流
@SentinelResource(value = "getUserById",
blockHandler = "getUserByIdBlock",
fallback = "getUserByIdFallback")
public User getUserById(Long userId) {
// 正常业务逻辑
return getUserByIdMultiLevel(userId);
}
// 限流时的处理
public User getUserByIdBlock(Long userId, BlockException ex) {
log.warn("请求被限流,userId={}", userId);
return null; // 或返回默认值
}
// 降级时的处理(如数据库也查不到时)
public User getUserByIdFallback(Long userId, Throwable ex) {
log.error("服务降级,userId={}", userId, ex);
return User.defaultUser(); // 返回默认值
}
五、三大问题对比总结
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 触发条件 | 查询不存在的数据 | 热点key过期瞬间 | 大量key同时过期/Redis宕机 |
| 影响范围 | 单个key持续被攻击 | 单个热点key | 整个缓存系统 |
| 核心方案 | 布隆过滤器 + 缓存空值 | 互斥锁 / 逻辑过期 | 随机过期时间 + 多级缓存 + 高可用 |
| 额外成本 | 布隆过滤器的内存和维护 | 锁的开销 / 数据短暂不一致 | 多级缓存的内存 / 集群运维成本 |
六、生产环境缓存设计 Checklist
最后给出一份生产环境缓存设计的检查清单:
- 过期时间是否加了随机值,避免集中过期
- 热点数据是否做了特殊处理(永不过期 / 逻辑过期 / 互斥锁)
- 是否部署了布隆过滤器,拦截不存在的 key
- 缓存空值是否设置了较短的过期时间,防止内存泄漏
- 是否配置了 Redis 高可用(哨兵 / Cluster)
- 是否有限流降级兜底方案,防止缓存全面失效时数据库被打垮
- 缓存 key 的命名是否规范,避免不同业务之间 key 冲突
- 是否监控了缓存命中率,命中率过低时及时告警
- 大 key 是否做了拆分,避免单个 key 过大导致阻塞
- 热 key 是否做了本地缓存,减少 Redis 的网络开销
写在最后
缓存穿透、击穿、雪崩,本质上都是缓存与数据库之间的流量控制问题。解决思路可以归纳为三句话:
- 穿透:在请求到达数据库之前,用布隆过滤器把无效请求拦截掉。
- 击穿:在缓存重建的窗口期内,用锁或逻辑过期控制并发,避免所有请求同时打到数据库。
- 雪崩:通过随机过期时间、多级缓存、高可用架构,分散风险、层层兜底。
没有一种方案能解决所有问题,实际生产中往往是多种方案组合使用。理解每种方案的原理和适用场景,才能在不同业务需求下做出合理的技术选型。