引言
在高并发系统中,Redis 作为缓存层已经不可或缺。但你是否遇到过:某个不存在的 ID 疯狂请求导致数据库压力飙升?热点 Key 过期瞬间大量请求直接打到数据库?大量 Key 同时失效引发连锁反应?这些正是缓存穿透、击穿和雪崩的典型症状。
本文将从实际场景出发,先讲清三者的本质区别,再给出可落地的代码方案,最后分享一些容易忽略的细节与最佳实践。
1. 概念辨析:穿透 vs 击穿 vs 雪崩
| 问题 | 触发条件 | 现象 | 核心解决思路 | |——|———-|——|————–| | 缓存穿透 | 查询不存在的数据 | 缓存和数据库都无数据,每次请求都穿透到 DB | 缓存空值、布隆过滤器 | | 缓存击穿 | 热点 Key 过期 | 大量请求同时访问已过期的热点 Key | 互斥锁、逻辑过期 | | 缓存雪崩 | 大量 Key 同时过期或 Redis 宕机 | 数据库瞬间承受全量请求 | 随机过期时间、高可用集群、熔断降级 |
下面我们逐一拆解,并给出 Spring Boot + Redis 的实战代码。
2. 缓存穿透:布隆过滤器 + 空值缓存
场景复现
假设有一个用户查询接口 GET /user/{id},攻击者用不存在的 ID(如负数和超大数)频繁请求。由于缓存不命中,每次都会查数据库,导致 DB 压力过大。
解决方案一:缓存空值
对于查询结果为空的数据,仍然写入缓存,但设置较短的过期时间(如 60 秒)。
public User getUserById(Long id) {
String cacheKey = "user:" + id;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
// 如果缓存的是空值标记,直接返回 null
if ("NULL".equals(cached)) {
return null;
}
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
if (user == null) {
// 缓存空值,过期时间短一些
redisTemplate.opsForValue().set(cacheKey, "NULL", 60, TimeUnit.SECONDS);
return null;
}
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
return user;
}
> 注意:空值缓存只能应对随机的不存在 ID,如果攻击者每次使用不同的随机 ID,依然会产生大量空值缓存,占用内存。此时需要布隆过滤器。
解决方案二:布隆过滤器
布隆过滤器可以快速判断一个元素是否可能存在。我们将所有合法 ID 预先加载到布隆过滤器中,查询前先过滤。
使用 Redisson 的布隆过滤器实现:
@Configuration
public class BloomFilterConfig {
@Bean
public RBloomFilter<Long> userBloomFilter(RedissonClient redissonClient) {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("user:bloom");
// 初始化:预计元素数量 100 万,误判率 1%
bloomFilter.tryInit(1_000_000L, 0.01);
return bloomFilter;
}
}
@Service
public class UserService {
@Autowired
private RBloomFilter<Long> userBloomFilter;
@PostConstruct
public void initBloomFilter() {
// 启动时加载所有用户 ID(实际项目可分批加载)
List<Long> allUserIds = userMapper.selectAllIds();
allUserIds.forEach(userBloomFilter::add);
}
public User getUserById(Long id) {
// 1. 布隆过滤器拦截
if (!userBloomFilter.contains(id)) {
return null;
}
// 2. 查询缓存(同前)
// ...
}
}
> 💡 最佳实践:布隆过滤器存在误判率(可能把不存在的误判为存在),但不会漏判。对于新增数据,需要同步更新布隆过滤器(如用户注册后立即 add)。
3. 缓存击穿:互斥锁与逻辑过期
场景复现
某个秒杀商品详情页,缓存 30 分钟过期。当缓存过期瞬间,大量并发请求同时到达,全部去查数据库,导致 DB 压力骤增。
解决方案一:互斥锁(分布式锁)
只允许一个线程去重建缓存,其他线程等待并重试。
public Product getProductById(Long id) {
String cacheKey = "product:" + id;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 获取分布式锁
String lockKey = "lock:product:" + id;
String lockValue = UUID.randomUUID().toString();
try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 双重检查:可能其他线程已经重建了缓存
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
} else {
// 未获取到锁,短暂等待后重试
Thread.sleep(50);
return getProductById(id); // 递归重试,注意控制重试次数
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取商品信息失败", e);
} finally {
// 释放锁(使用 Lua 脚本保证原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), lockValue);
}
}
> 注意:递归重试可能导致栈溢出,建议使用循环 + 最大重试次数。另外锁的超时时间要合理,避免业务未完成锁就释放。
解决方案二:逻辑过期(永不过期)
缓存不设置物理过期时间,而是将过期时间存储在 value 中。获取时判断是否逻辑过期,若过期则异步重建,同时返回旧数据。
@Data
public class RedisData {
private Object data;
private LocalDateTime expireTime;
}
public Product getProductWithLogicalExpire(Long id) {
String cacheKey = "product:" + id;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached == null) {
// 缓存不存在,直接查库并写入(可加锁)
return loadFromDbAndCache(id);
}
RedisData redisData = JSON.parseObject(cached, RedisData.class);
Product product = JSON.parseObject(JSON.toJSONString(redisData.getData()), Product.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return product; // 未过期,直接返回
}
// 已过期,尝试获取锁异步重建
String lockKey = "lock:product:" + id;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 开启新线程重建缓存
executorService.submit(() -> {
try {
Product fresh = productMapper.selectById(id);
RedisData newData = new RedisData();
newData.setData(fresh);
newData.setExpireTime(LocalDateTime.now().plusMinutes(30));
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(newData));
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 无论是否获取锁,都返回旧数据
return product;
}
> 💡 适用场景:逻辑过期适合对数据一致性要求不高的热点数据(如商品详情),能保证高可用,但可能返回旧数据。
4. 缓存雪崩:随机过期 + 高可用 + 熔断
场景复现
凌晨批量导入数据时,给大量 Key 设置了相同的过期时间(如 1 小时),导致一小时后所有 Key 同时失效,请求全部涌向数据库。或者 Redis 集群宕机,缓存层完全失效。
解决方案一:随机过期时间
在基础过期时间上增加随机值,避免同时失效。
public void setWithRandomExpire(String key, String value, long baseSeconds) {
// 随机增加 0~300 秒
long expire = baseSeconds + ThreadLocalRandom.current().nextLong(300);
redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
}
解决方案二:Redis 高可用
使用哨兵模式或集群模式,避免单点故障。同时可配置本地缓存(如 Caffeine)作为二级缓存,在 Redis 不可用时降级。
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
}
}
public User getUserWithLocalCache(Long id) {
String cacheKey = "user:" + id;
// 1. 查本地缓存
Object local = localCache.getIfPresent(cacheKey);
if (local != null) {
return (User) local;
}
// 2. 查 Redis
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
User user = JSON.parseObject(cached, User.class);
localCache.put(cacheKey, user);
return user;
}
// 3. 查 DB
User user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
localCache.put(cacheKey, user);
}
return user;
}
解决方案三:熔断降级
当数据库压力过大时,通过熔断器(如 Sentinel)快速失败,返回兜底数据。
@SentinelResource(value = "getUser", blockHandler = "handleBlock", fallback = "fallback")
public User getUser(Long id) {
// 正常业务逻辑
}
public User handleBlock(Long id, BlockException ex) {
// 限流降级处理
return new User(id, "默认用户", "降级返回");
}
public User fallback(Long id, Throwable t) {
// 异常兜底
return new User(id, "默认用户", "异常兜底");
}
5. 综合实战:缓存工具类封装
将上述策略整合成一个通用的缓存工具类,支持穿透、击穿防护。
@Component
public class CacheHelper {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private RedissonClient redissonClient;
private static final String NULL_VALUE = "NULL";
public <T> T getWithCache(String key, Class<T> clazz, Supplier<T> dbLoader, long expireSeconds) {
// 1. 查缓存
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if (NULL_VALUE.equals(cached)) {
return null;
}
return JSON.parseObject(cached, clazz);
}
// 2. 分布式锁防击穿
RLock lock = redissonClient.getLock("lock:" + key);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// 双重检查
cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return NULL_VALUE.equals(cached) ? null : JSON.parseObject(cached, clazz);
}
// 3. 查数据库
T data = dbLoader.get();
if (data == null) {
// 缓存空值防穿透
redisTemplate.opsForValue().set(key, NULL_VALUE, 60, TimeUnit.SECONDS);
return null;
}
// 4. 随机过期时间防雪崩
long expire = expireSeconds + ThreadLocalRandom.current().nextLong(300);
redisTemplate.opsForValue().set(key, JSON.toJSONString(data), expire, TimeUnit.SECONDS);
return data;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
// 获取锁失败,短暂等待后重试(或直接查库,根据业务容忍度)
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getWithCache(key, clazz, dbLoader, expireSeconds);
}
}
6. 常见坑与最佳实践
- 锁的粒度:锁的 key 要精确到具体数据 ID,避免大范围锁影响性能。
- 锁的超时时间:一定要设置,且大于业务最大执行时间,防止死锁。
- 空值缓存时间:不宜过长(建议 30~60 秒),避免数据变更后长时间不一致。
- 布隆过滤器更新:新增数据后要同步添加,删除数据无法从布隆过滤器移除(可定期重建)。
- 逻辑过期实现:异步重建时注意线程池隔离,避免影响主业务。
- 监控与告警:对缓存命中率、数据库 QPS 进行监控,及时发现异常。
总结
缓存穿透、击穿、雪崩是 Redis 使用中的高频问题,理解它们的本质并掌握对应的解决方案,是构建高可用系统的必备技能。本文从原理到代码,给出了完整的实战指南:
- 穿透:布隆过滤器 + 空值缓存
- 击穿:互斥锁 + 逻辑过期
- 雪崩:随机过期 + 高可用 + 熔断降级
建议根据业务场景组合使用这些策略,并在实际项目中通过压测验证效果。下一步可以研究多级缓存架构(本地 + Redis + DB)以及缓存一致性方案(如 Canal 监听 binlog)。
延伸阅读:
- Redisson 官方文档(分布式锁与布隆过滤器)
- Spring Cache 抽象与 Redis 集成
- 阿里巴巴 Sentinel 熔断降级指南