Redis缓存三大核心问题(穿透、失效、雪崩)
一、缓存穿透(Cache Penetration)
1. 产生原因
缓存穿透是指 客户端请求查询的数据,在缓存(Redis)和数据库(MySQL)中均不存在,导致所有请求直接穿透缓存,全部落到数据库上,引发数据库压力骤增,甚至宕机。
常见触发场景:
- 恶意攻击:攻击者故意构造不存在的 key(如随机生成无效用户ID、商品ID),高频发起请求,模拟高并发穿透;
- 业务误操作:查询不存在的业务数据(如已删除的用户、未上架的商品),且未做缓存兜底;
- 缓存与数据库数据不一致:数据库数据已删除,但缓存未清理,后续查询该 key 时,缓存命中空值,仍会穿透到数据库(非典型穿透,属于缓存与数据库同步问题衍生)。
2. 核心危害
- 数据库承受高并发请求,CPU、IO 负载飙升,严重时导致数据库宕机,影响整个业务链路;
- 缓存失去作用,所有请求直接打在数据库,违背“缓存减轻数据库压力”的核心目的;
- 可能引发连锁反应,导致依赖该数据库的其他服务不可用。
3. 解决办法(按落地优先级排序)
(1)缓存空对象(最简单、易落地)
核心思路:查询数据库发现数据不存在时,不直接返回空,而是在缓存中存入一个“空对象”(如 ""、null),并设置较短的过期时间(避免占用过多内存),后续相同请求直接命中缓存空对象,无需穿透到数据库。
代码示例(Java + StringRedisTemplate):
/**
* 缓存空对象示例(查询用户信息)
*/
public User getUserById(Long userId) {
String key = "user:" + userId;
// 1. 先查缓存
String userJson = stringRedisTemplate.opsForValue().get(key);
if (userJson != null) {
// 缓存命中(包括空对象),直接返回
return userJson.equals("null") ? null : JSON.parseObject(userJson, User.class);
}
// 2. 缓存未命中,查数据库
User user = userMapper.selectById(userId);
// 3. 数据库无数据,缓存空对象(过期时间设为5分钟,避免长期占用内存)
if (user == null) {
stringRedisTemplate.opsForValue().set(key, "null", 5, TimeUnit.MINUTES);
return null;
}
// 4. 数据库有数据,缓存真实数据(过期时间设为30分钟)
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
return user;
}
(2)使用布隆过滤器(高效、适合大数据量场景)
核心思路:在缓存之前增加一层布隆过滤器,将数据库中所有存在的 key(如所有用户ID、商品ID)提前存入布隆过滤器,请求过来时,先通过布隆过滤器判断 key 是否存在——不存在则直接返回,存在再走“缓存→数据库”流程,从源头拦截无效请求。
关键说明:
- 布隆过滤器特点:占用内存极小(1亿条数据仅需十几MB)、查询速度快(O(1)),但存在极小的误判率(可通过调整哈希函数数量和位数降低);
- 适配场景:数据量极大(如亿级用户ID)、恶意攻击频繁的场景;
- 实现方式:可基于 Redis Bitmap 手动实现,或使用 Redisson 封装的布隆过滤器(推荐,无需手动处理哈希逻辑)。
Redisson 布隆过滤器代码示例:
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.List;
@Component
public class BloomFilterConfig {
@Resource
private RedissonClient redissonClient;
@Resource
private UserMapper userMapper;
// 布隆过滤器key
private static final String USER_BLOOM_KEY = "bloom:user:ids";
// 预计数据量(如1亿用户)
private static final long EXPECTED_COUNT = 100000000L;
// 误判率(0.01 = 1%,误判率越低,占用内存越大)
private static final double FALSE_POSITIVE_RATE = 0.01;
/**
* 项目启动时,初始化布隆过滤器(将所有用户ID存入)
*/
@PostConstruct
public void initBloomFilter() {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(USER_BLOOM_KEY);
// 初始化布隆过滤器(预计数据量、误判率)
bloomFilter.tryInit(EXPECTED_COUNT, FALSE_POSITIVE_RATE);
// 从数据库查询所有用户ID,存入布隆过滤器(可分批查询,避免内存溢出)
List<Long> allUserIds = userMapper.selectAllUserIds();
for (Long userId : allUserIds) {
bloomFilter.add(userId);
}
}
/**
* 校验用户ID是否存在(用于拦截缓存穿透)
*/
public boolean isUserIdExists(Long userId) {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(USER_BLOOM_KEY);
return bloomFilter.contains(userId);
}
}
(3)其他补充优化
- 接口层限流:对高频无效请求(如短时间内大量相同无效 key)进行限流,避免恶意攻击;
- 数据校验:接口层先校验请求参数(如用户ID格式、商品ID范围),无效参数直接返回,不进入缓存和数据库流程;
- 缓存预热:提前将热点数据、常用数据存入缓存,减少缓存未命中的概率。
二、缓存失效(Cache Invalidation)
1. 产生原因
缓存失效(也叫“缓存击穿”)是指 某一个热点 key 过期失效时,恰好有大量并发请求访问该 key,导致所有请求同时穿透缓存,打到数据库上,引发数据库瞬时压力激增(单点热点 key 引发的“小雪崩”)。
常见触发场景:
- 热点数据集中过期:如电商秒杀商品、热门活动页面,所有请求都访问同一个热点 key,且该 key 过期时间设置相同;
- 缓存主动删除:业务操作中手动删除热点 key(如商品下架时删除缓存),删除后瞬间有大量请求访问;
- 缓存被动失效:Redis 内存满时,根据淘汰策略(如 LRU)淘汰热点 key,淘汰后有大量请求访问该 key。
2. 核心危害
- 数据库瞬时承受高并发请求,可能导致数据库连接池耗尽、响应变慢,甚至短暂宕机;
- 热点业务(如秒杀、热门商品)响应延迟,影响用户体验;
- 若数据库处理不过来,可能引发请求堆积,导致整个业务链路卡顿。
3. 解决办法(按落地优先级排序)
(1)热点 key 过期时间“错开”(最简单、无侵入)
核心思路:对热点 key 的过期时间增加随机偏移量,避免多个热点 key 在同一时间同时过期,分散数据库压力。
代码示例:
// 热点 key 过期时间:基础30分钟 + 随机0~5分钟,避免集中过期
long baseExpire = 30 * 60; // 基础过期时间(秒)
long randomExpire = new Random().nextInt(5 * 60); // 随机偏移量(0~300秒)
long totalExpire = baseExpire + randomExpire;
// 缓存热点商品数据(key:product:1001,过期时间带随机偏移)
stringRedisTemplate.opsForValue().set("product:1001", JSON.toJSONString(product), totalExpire, TimeUnit.SECONDS);
(2)互斥锁(防止并发穿透,适合高一致性场景)
核心思路:当热点 key 过期、缓存未命中时,不是所有请求都去查数据库,而是只有一个请求获取互斥锁,去数据库查询并更新缓存,其他请求等待锁释放后,直接从缓存获取数据,避免并发打库。
代码示例(Redis 分布式锁 + 互斥逻辑):
/**
* 互斥锁解决缓存失效(查询热门商品)
*/
public Product getHotProduct(Long productId) {
String key = "product:" + productId;
String lockKey = "lock:product:" + productId;
// 1. 先查缓存
String productJson = stringRedisTemplate.opsForValue().get(key);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 2. 缓存未命中,获取互斥锁(最多等待1秒,锁10秒后自动释放)
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 3. 拿到锁,查数据库
Product product = productMapper.selectById(productId);
if (product == null) {
// 数据库无数据,缓存空对象(避免再次穿透)
stringRedisTemplate.opsForValue().set(key, "null", 5, TimeUnit.MINUTES);
return null;
}
// 4. 更新缓存(过期时间带随机偏移)
long expire = 30 * 60 + new Random().nextInt(5 * 60);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(product), expire, TimeUnit.SECONDS);
return product;
} finally {
// 5. 释放锁(无论成功失败,都要释放锁)
stringRedisTemplate.delete(lockKey);
}
} else {
// 6. 未拿到锁,等待50毫秒后重试(避免频繁请求)
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getHotProduct(productId); // 递归重试
}
}
(3)热点 key 永不过期(适合非实时数据)
核心思路:对热点 key 不设置过期时间,避免过期失效;同时通过后台线程定期更新缓存数据,保证缓存与数据库数据一致。
适用场景:数据更新频率低、实时性要求不高的热点数据(如首页热门推荐、固定配置数据)。
实现思路:
- 热点 key 存入缓存时,不设置过期时间;
- 开启一个定时任务(如每10分钟),从数据库查询最新数据,更新缓存;
- 若数据发生紧急更新(如商品价格调整),手动触发缓存更新,避免数据不一致。
(4)其他补充优化
- 缓存预热:提前将热点数据存入缓存,并设置合理的过期时间,避免缓存未命中;
- 扩大缓存粒度:将热点数据与关联数据合并缓存(如商品信息+库存信息),减少缓存查询次数;
- Redis 淘汰策略优化:避免使用 LRU 淘汰策略(可能淘汰热点 key),可使用 LFU 策略(淘汰访问频率低的 key),或调整 maxmemory-policy 配置。
三、缓存雪崩(Cache Avalanche)
1. 产生原因
缓存雪崩是指 缓存服务器(Redis)整体不可用(宕机),或大量 key 在同一时间段集中过期失效,导致所有请求全部穿透缓存,直接打在数据库上,引发数据库崩溃,甚至整个业务系统瘫痪(比缓存失效更严重,是“批量失效”或“缓存整体宕机”导致的系统性问题)。
常见触发场景:
- 缓存集群宕机:Redis 主从架构中,主节点宕机且从节点未及时切换;或 Redis 集群整体故障(如网络中断、服务器宕机);
- 大量 key 集中过期:如电商大促前,批量缓存了大量商品数据,且设置了相同的过期时间(如24小时),到期后批量失效;
- 缓存容量满:Redis 内存达到上限,触发淘汰策略,大量热点 key 被批量淘汰,导致请求穿透。
2. 核心危害
- 数据库瞬间承受全量请求,CPU、IO 负载直接拉满,大概率导致数据库宕机;
- 缓存服务完全失效,整个业务链路依赖缓存的服务全部不可用(如商品查询、用户登录);
- 引发连锁反应,导致依赖该业务的其他系统崩溃,造成严重的生产事故。
3. 解决办法(按“预防+兜底”思路分类)
(一)预防措施(核心:避免缓存整体不可用、避免大量 key 集中过期)
1. 搭建高可用缓存架构(解决缓存宕机问题)
- 主从复制 + 哨兵模式:部署 Redis 主从架构,搭配哨兵监控,主节点宕机时,哨兵自动将从节点切换为主节点,保证缓存服务连续性;
- Redis 集群模式:部署 Redis Cluster 集群(至少3主3从),单个节点宕机时,其他节点正常提供服务,避免单点故障;
- 多机房部署:跨机房部署 Redis 集群,避免单个机房故障导致缓存整体不可用。
2. 避免大量 key 集中过期(解决批量失效问题)
- 过期时间加随机偏移:与“缓存失效”的解决办法一致,对所有 key 的过期时间增加随机偏移(如基础30分钟 + 0~10分钟随机),分散过期时间;
- 分批设置过期时间:对批量缓存的 key,分批次设置不同的过期时间(如第一批1小时、第二批1小时10分、第三批1小时20分),避免集中过期;
- 热点 key 永不过期:对核心热点 key,不设置过期时间,通过后台线程定期更新,避免过期失效。
3. 缓存容量优化(避免批量淘汰)
- 合理设置 Redis 内存上限(maxmemory),预留足够的冗余空间,避免内存满触发批量淘汰;
- 优化缓存淘汰策略:生产环境推荐使用 LFU(least frequently used,最少访问频率)策略,优先淘汰访问频率低的 key,减少热点 key 被淘汰的概率;
- 定期清理无效缓存:后台线程定期清理过期、无效的缓存 key(如已删除数据的缓存),释放内存。
(二)兜底措施(核心:缓存失效后,减少数据库压力)
1. 服务降级与限流
- 服务降级:缓存宕机时,对非核心业务(如商品推荐、历史订单查询)进行降级,直接返回默认数据(如“服务暂时不可用”),避免请求全部打在数据库;
- 接口限流:通过网关(如 Nginx、Gateway)对请求进行限流,限制单位时间内访问数据库的请求数量,避免数据库被压垮。
2. 本地缓存兜底
- 在应用层增加本地缓存(如 Caffeine、Guava Cache),缓存核心热点数据,当 Redis 缓存宕机时,请求先访问本地缓存,减少数据库压力;
- 注意:本地缓存需设置较短的过期时间,避免与 Redis 缓存数据不一致,且本地缓存容量不宜过大,避免占用过多应用内存。
3. 数据库保护
- 数据库读写分离:主库负责写操作,从库负责读操作,缓存宕机时,读请求打在从库上,分散主库压力;
- 数据库限流:通过数据库连接池限制最大连接数,避免请求过多导致连接池耗尽;
- 熔断机制:当数据库压力达到阈值(如 CPU 使用率超过80%),触发熔断,暂时拒绝部分请求,避免数据库崩溃。
(三)监控与应急
- 实时监控:监控 Redis 集群状态(节点存活、内存使用率、QPS)、数据库状态(连接数、CPU、IO),设置告警阈值(如 Redis 节点宕机、数据库连接数超过阈值时,及时告警);
- 应急方案:提前制定缓存雪崩应急方案,如 Redis 集群宕机时,手动切换备用集群、临时启用本地缓存、降级非核心业务,快速恢复服务。
四、三大问题对比总结
| 问题类型 | 核心原因 | 核心危害 | 核心解决思路 |
|---|---|---|---|
| 缓存穿透 | 请求不存在的数据,缓存和数据库均无 | 数据库压力激增,可能宕机 | 缓存空对象、布隆过滤器、接口限流 |
| 缓存失效 | 单个热点 key 过期,并发请求穿透 | 数据库瞬时压力激增,热点业务卡顿 | 过期时间随机偏移、互斥锁、热点 key 永不过期 |
| 缓存雪崩 | 缓存整体宕机/大量 key 集中过期 | 数据库崩溃,业务系统瘫痪 | 高可用架构、分散过期时间、服务降级、本地缓存兜底 |
核心总结
- 缓存穿透:重点“拦截无效请求”,从源头减少数据库压力;
- 缓存失效:重点“避免热点 key 集中过期”,防止并发穿透;
- 缓存雪崩:重点“保证缓存高可用”,同时做好兜底措施,避免系统性崩溃。
生产环境中,需结合业务场景(数据量、并发量、实时性要求),组合使用上述解决办法,既保证缓存的高效性,也能应对各类异常场景,确保业务稳定运行。