在 Redis 缓存使用中,穿透、击穿、雪崩 是三种典型的缓存异常场景,核心区别在于触发原因、影响范围和发生时机不同。
1.穿透、击穿、雪崩的核心区别
| 特性 | 缓存穿透(Cache Penetration) | 缓存击穿(Cache Breakdown) | 缓存雪崩(Cache Avalanche) |
|---|---|---|---|
| 触发原因 | 访问 不存在的 key(缓存和数据库均无数据),请求直接穿透到数据库 | 访问 热点 key(缓存中存在但已过期),同时大量请求直达数据库 | 大量缓存 key 同时过期 或 Redis 服务宕机,导致所有请求直达数据库 |
| 数据状态 | 缓存无、数据库无 | 缓存无(过期)、数据库有 | 缓存无(批量过期 / 服务挂了)、数据库有 |
| 请求流量 | 单个或少量请求(可能是恶意攻击,如遍历无效 ID) | 高并发请求(集中在同一个热点 key) | 海量请求(覆盖多个 key,甚至全量请求) |
| 影响范围 | 数据库压力较小(但长期恶意请求会耗资源) | 数据库单点压力暴增(热点 key 对应的表可能被打崩) | 数据库整体压力骤增,可能导致数据库宕机(服务雪崩) |
| 典型场景 | 恶意攻击(如查询用户 ID=-1)、业务逻辑错误(查询不存在的资源) | 秒杀商品、热门活动页面(缓存过期后瞬时大量请求) | 缓存集群重启、批量 key 设置相同过期时间(如凌晨 1 点) |
2.具体解决方案(结合 Spring Boot 实践)
2.1 缓存穿透:避免 "不存在的 key" 直达数据库
核心思路:对无效 key 进行缓存占位、校验拦截、限制请求频率。
2.1.1缓存空值(空对象 / 空字符串)
- 逻辑:当数据库查询结果为空时,仍将该 key 对应的空值存入缓存(设置较短过期时间,如 5 分钟),避免后续相同请求重复穿透。
- 注意:需区分 "业务空值"(如用户已删除,返回 null)和 "无效 key"(如 ID 格式错误),避免缓存垃圾数据。
- Spring Boot 代码示例:
java
@Service
public class UserService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserMapper userMapper;
// 空值缓存前缀(避免与真实数据冲突)
private static final String EMPTY_KEY_PREFIX = "empty:";
// 空值过期时间(5分钟)
private static final long EMPTY_EXPIRE = 300;
public User getUserById(Long id) {
// 1. 校验key有效性(基础拦截)
if (id == null || id <= 0) {
return null;
}
String key = "user:" + id;
// 2. 查询缓存
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
// 3. 若为缓存的空值,直接返回null
if (json.equals(EMPTY_KEY_PREFIX)) {
return null;
}
// 4. 真实数据,反序列化返回
return JSON.parseObject(json, User.class);
}
// 5. 缓存未命中,查询数据库
User user = userMapper.selectById(id);
if (user != null) {
// 6. 数据库有数据,缓存真实值(过期时间1小时)
redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
} else {
// 7. 数据库无数据,缓存空值(短期过期)
redisTemplate.opsForValue().set(key, EMPTY_KEY_PREFIX, EMPTY_EXPIRE, TimeUnit.SECONDS);
}
return user;
}
}
2.1.2布隆过滤器(Bloom Filter)拦截无效 key
- 逻辑:在缓存之前增加布隆过滤器,将数据库中所有有效 key(如用户 ID、商品 ID)提前存入过滤器。请求到来时,先通过过滤器判断 key 是否存在,不存在则直接返回,无需查询缓存和数据库。
- 适用场景:数据量极大(如千万级用户 ID),缓存空值会占用大量内存的场景。
- Spring Boot 集成示例(使用 Redisson 实现布隆过滤器):
java
// 1. 引入 Redisson 依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.3</version>
</dependency>
// 2. 配置布隆过滤器(初始化时加载有效key)
@Configuration
public class RedissonConfig {
@Bean
public RBloomFilter<Long> userBloomFilter(RedissonClient redissonClient) {
// 布隆过滤器名称
String filterName = "user:id:filter";
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(filterName);
// 初始化:预计数据量100万,误判率0.01
bloomFilter.tryInit(1000000, 0.01);
// 加载数据库中所有有效用户ID到过滤器(实际应异步批量加载)
List<Long> validUserIds = userMapper.selectAllValidIds();
for (Long id : validUserIds) {
bloomFilter.add(id);
}
return bloomFilter;
}
}
// 3. 业务层使用布隆过滤器拦截
@Service
public class UserService {
@Autowired
private RBloomFilter<Long> userBloomFilter;
public User getUserById(Long id) {
// 1. 布隆过滤器判断:不存在则直接返回(避免穿透)
if (!userBloomFilter.contains(id)) {
return null;
}
// 2. 后续查询缓存、数据库(同方案1)
// ...
}
}
2.1.3接口参数校验 + 限流
- 逻辑:在网关层(如 Spring Cloud Gateway)对请求参数进行校验(如用户 ID 必须为正整数),同时对异常请求进行限流(如使用 Sentinel 限制单个 IP 的无效请求频率),从源头阻断穿透。
2.2缓存击穿:保护 "热点 key" 过期后的数据库
核心思路:避免热点 key 同时过期,或过期后避免并发请求直达数据库。
2.2.1热点 key 永不过期(物理过期 + 逻辑过期)
- 逻辑:
- 物理过期:缓存不设置过期时间,避免自动失效;
- 逻辑过期:在缓存 value 中嵌入过期时间(如
{"data": "...", "expireTime": 1699999999999}),业务层查询时判断是否过期,若过期则异步更新缓存,不影响当前请求。
- 优点:避免并发穿透,用户体验无感知;
- 缺点:需要异步线程维护缓存,可能存在短期数据不一致(可接受)。
- 代码示例:
java
@Service
public class SeckillService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SeckillMapper seckillMapper;
// 线程池(异步更新缓存)
private static final ExecutorService CACHE_UPDATE_POOL = Executors.newFixedThreadPool(5);
public SeckillGoods getSeckillGoods(Long goodsId) {
String key = "seckill:goods:" + goodsId;
String json = redisTemplate.opsForValue().get(key);
if (json == null) {
// 缓存未命中(可能是首次查询),查询数据库并缓存(逻辑过期1小时)
return loadDataToCache(goodsId, key);
}
// 解析缓存数据(包含逻辑过期时间)
JsonNode jsonNode = JSON.parseObject(json);
SeckillGoods goods = JSON.parseObject(jsonNode.get("data").toString(), SeckillGoods.class);
long expireTime = jsonNode.get("expireTime").asLong();
// 逻辑未过期:直接返回数据
if (System.currentTimeMillis() < expireTime) {
return goods;
}
// 逻辑已过期:异步更新缓存,当前请求返回旧数据(避免穿透)
CACHE_UPDATE_POOL.submit(() -> loadDataToCache(goodsId, key));
return goods;
}
// 加载数据到缓存(逻辑过期)
private SeckillGoods loadDataToCache(Long goodsId, String key) {
// 查询数据库(实际应加分布式锁,避免多线程重复查询数据库)
SeckillGoods goods = seckillMapper.selectById(goodsId);
if (goods == null) {
return null;
}
// 逻辑过期时间:当前时间+1小时
long expireTime = System.currentTimeMillis() + 3600 * 1000;
Map<String, Object> cacheValue = new HashMap<>();
cacheValue.put("data", goods);
cacheValue.put("expireTime", expireTime);
// 缓存永不过期(物理)
redisTemplate.opsForValue().set(key, JSON.toJSONString(cacheValue));
return goods;
}
}
2.2.2分布式锁(互斥锁)保护
- 逻辑:当热点 key 过期时,只有一个线程能获取分布式锁,进入数据库查询并更新缓存,其他线程等待锁释放后直接查询缓存,避免并发穿透。
- 适用场景:数据一致性要求高,不允许返回旧数据的场景;
- 注意:锁的过期时间需合理设置(避免死锁),且需处理锁竞争导致的线程等待(可设置超时时间)。
- 代码示例(使用 Redisson 分布式锁):
java
@Service
public class SeckillService {
@Autowired
private RedissonClient redissonClient;
public SeckillGoods getSeckillGoods(Long goodsId) {
String key = "seckill:goods:" + goodsId;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, SeckillGoods.class);
}
// 缓存未命中,获取分布式锁
RLock lock = redissonClient.getLock("lock:seckill:" + goodsId);
try {
// 尝试获取锁(3秒超时,10秒自动释放)
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (locked) {
// 再次查询缓存(避免其他线程已更新)
String newJson = redisTemplate.opsForValue().get(key);
if (newJson != null) {
return JSON.parseObject(newJson, SeckillGoods.class);
}
// 查询数据库并更新缓存
SeckillGoods goods = seckillMapper.selectById(goodsId);
if (goods != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), 3600, TimeUnit.SECONDS);
}
return goods;
} else {
// 未获取到锁,重试(或返回默认值)
Thread.sleep(50);
return getSeckillGoods(goodsId);
}
} catch (InterruptedException e) {
throw new RuntimeException("获取锁失败", e);
} finally {
// 释放锁(只有持有锁的线程才释放)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
2.2.3热点 key 过期时间错开
- 逻辑:对批量热点 key(如秒杀商品)设置过期时间时,增加随机偏移量(如
3600 ± 60秒),避免所有 key 同时过期。 - 代码示例:
java
// 批量缓存热点商品,过期时间错开(1小时±1分钟)
public void batchCacheSeckillGoods(List<Long> goodsIds) {
for (Long id : goodsIds) {
SeckillGoods goods = seckillMapper.selectById(id);
if (goods != null) {
String key = "seckill:goods:" + id;
// 随机过期时间:3600秒 ± 60秒
long expire = 3600 + new Random().nextInt(120) - 60;
redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), expire, TimeUnit.SECONDS);
}
}
}
2.3 缓存雪崩:避免 "批量 key 过期" 或 "Redis 宕机" 导致的服务雪崩
核心思路:分散过期时间、提高缓存可用性、降级熔断保护数据库。
2.3.1缓存过期时间随机化(核心方案)
- 逻辑:对所有缓存 key 的过期时间添加随机偏移量(如
基础过期时间 + 随机数),避免批量 key 同时过期。 - 示例:基础过期时间 1 小时,随机偏移量 0-300 秒,最终过期时间为 3600~4100 秒。
- 代码示例:
java
// 通用缓存工具类(添加随机过期时间)
@Component
public class CacheUtil {
@Autowired
private StringRedisTemplate redisTemplate;
// 最大随机偏移量(5分钟)
private static final int MAX_RANDOM_EXPIRE = 300;
public void setCacheWithRandomExpire(String key, Object value, long baseExpire, TimeUnit timeUnit) {
// 转换基础过期时间为秒
long baseExpireSec = timeUnit.toSeconds(baseExpire);
// 随机偏移量(0~MAX_RANDOM_EXPIRE秒)
int random = new Random().nextInt(MAX_RANDOM_EXPIRE);
// 最终过期时间
long finalExpire = baseExpireSec + random;
redisTemplate.opsForValue().set(key, JSON.toJSONString(value), finalExpire, TimeUnit.SECONDS);
}
}
2.3.2缓存集群高可用(避免 Redis 宕机)
-
逻辑:
- 部署 Redis 主从集群(1 主 N 从),主节点故障时从节点自动切换;
- 开启 Redis 哨兵模式(Sentinel)或使用 Redis Cluster 集群,确保集群可用性;
- 关键业务可跨机房部署,避免单点故障。
-
Spring Boot 配置 Redis 集群示例:
spring:
redis:
cluster:
nodes:
- 192.168.1.101:6379
- 192.168.1.102:6379
- 192.168.1.103:6379
max-redirects: 3 # 最大重定向次数
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
2.3.3 服务降级与熔断(保护数据库)
- 逻辑:当 Redis 宕机或缓存命中率骤降时,通过降级策略限制对数据库的请求,避免数据库雪崩。
- 实现方式:使用 Sentinel 或 Hystrix 进行熔断降级,如:
- 缓存失效时,返回默认数据(如 "系统繁忙,请稍后重试");
- 数据库压力达到阈值时,拒绝部分非核心请求;
- 代码示例(Sentinel 注解降级):
java
@Service
public class UserService {
// 配置熔断规则:当异常比例>50%且每秒请求>10时,熔断5秒,返回默认值
@SentinelResource(
value = "getUserById",
fallback = "getUserFallback",
blockHandler = "getUserBlockHandler"
)
public User getUserById(Long id) {
// 正常查询缓存、数据库逻辑
// ...
}
// 降级 fallback:异常时返回默认用户
public User getUserFallback(Long id, Throwable e) {
return new User(-1L, "默认用户", "系统繁忙,请稍后重试");
}
// 限流 blockHandler:被限流时返回提示
public User getUserBlockHandler(Long id, BlockException e) {
return new User(-1L, "限流提示", "请求过于频繁,请稍后重试");
}
}
2.3.4数据库限流(最后的防线)
- 逻辑:通过数据库连接池限制最大并发连接数(如 HikariCP 配置
maximum-pool-size=20),或使用数据库中间件(如 MyCat)进行限流,避免数据库因瞬间高并发被打崩。
3.总结与最佳实践
3.1 核心区别速记
- 穿透:查 "不存在的 key"(缓存 + 数据库都没有);
- 击穿:查 "过期的热点 key"(缓存没有,数据库有,高并发);
- 雪崩:"大量 key 过期" 或 "Redis 宕机"(缓存整体失效,海量请求)。
3.2 企业级最佳实践组合
| 异常类型 | 推荐方案组合 |
|---|---|
| 缓存穿透 | 布隆过滤器 + 缓存空值 + 接口参数校验 |
| 缓存击穿 | 逻辑过期(异步更新) + 分布式锁 |
| 缓存雪崩 | 随机过期时间 + Redis 集群高可用 + Sentinel 降级 |
3.3 关键注意点
- 缓存空值时需设置短期过期时间,避免占用过多内存;
- 分布式锁需注意锁的粒度(避免过大)和过期时间(避免死锁);
- 布隆过滤器存在误判率,需根据业务场景调整参数(预计数据量、误判率);
- 降级策略需区分核心业务和非核心业务,避免影响关键功能。