Redis缓存三大经典问题:穿透、击穿、雪崩的深度解析与解决方案
三大缓存问题概述
在使用Redis作为缓存时,会遇到三个经典问题。很多人容易混淆这三个概念,让我们先明确它们的本质区别:
缓存穿透:缓存没有,数据库也没有
缓存击穿:缓存没有,数据库有
缓存雪崩:大量缓存同时失效
缓存穿透(Cache Penetration)
问题本质
缓存穿透是指查询一个根本不存在的数据,缓存层和持久层都没有命中,但出于容错考虑,如果从持久层查不到数据则不写入缓存层。
用户请求 → 缓存查询(miss) → 数据库查询(miss) → 返回空
典型场景
恶意攻击场景:
// 攻击者故意查询不存在的数据
for (int i = 0; i < 10000; i++) {
getUserById(-i); // 负数ID不存在,每次都查数据库
}
业务场景:
// 用户输入错误的商品ID
getProductById(999999999L); // 不存在的商品ID
问题危害
- 数据库压力:每次查询都会打到数据库
- 响应延迟:无效查询增加系统延迟
- 资源浪费:CPU、内存、网络资源被无效请求占用
- 系统风险:可能被恶意利用进行DDoS攻击
解决方案
方案一:缓存空值
@Service
public class UserService {
private static final String NULL_VALUE = "NULL";
public User getUserById(Long userId) {
String cacheKey = "user:" + userId;
String userJson = redisTemplate.opsForValue().get(cacheKey);
// 检查是否是缓存的空值
if (NULL_VALUE.equals(userJson)) {
return null;
}
if (userJson != null) {
return JSON.parseObject(userJson, User.class);
}
// 查询数据库
User user = userRepository.findById(userId);
if (user != null) {
// 缓存正常数据,长时间过期
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user),
Duration.ofMinutes(30));
} else {
// 缓存空值,短时间过期
redisTemplate.opsForValue().set(cacheKey, NULL_VALUE,
Duration.ofMinutes(2));
}
return user;
}
}
优点:实现简单,能有效防止穿透 缺点:会占用缓存空间,可能缓存大量无用数据
方案二:布隆过滤器
@Component
public class BloomFilterService {
private final BloomFilter<Long> userBloomFilter;
public BloomFilterService() {
// 创建布隆过滤器:预期100万元素,1%误判率
this.userBloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1000000, 0.01);
// 初始化:将所有存在的用户ID加入过滤器
initUserBloomFilter();
}
public User getUserById(Long userId) {
// 先检查布隆过滤器
if (!userBloomFilter.mightContain(userId)) {
// 肯定不存在,直接返回null
log.info("布隆过滤器拦截不存在的用户ID: {}", userId);
return null;
}
// 可能存在,继续正常查询流程
return queryUserWithCache(userId);
}
private void initUserBloomFilter() {
// 从数据库加载所有用户ID
List<Long> allUserIds = userRepository.findAllUserIds();
allUserIds.forEach(userBloomFilter::put);
log.info("布隆过滤器初始化完成,加载用户数量: {}", allUserIds.size());
}
// 新增用户时,同步更新布隆过滤器
public void addUser(User user) {
userRepository.save(user);
userBloomFilter.put(user.getId());
}
}
优点:内存占用小,查询效率高 缺点:有一定误判率,无法删除元素
方案三:参数校验
@RestController
public class UserController {
@GetMapping("/user/{userId}")
public ResponseEntity<User> getUser(@PathVariable Long userId) {
// 参数校验
if (userId == null || userId <= 0) {
return ResponseEntity.badRequest().build();
}
// 业务范围校验
if (userId > 1000000000L) {
return ResponseEntity.badRequest().build();
}
User user = userService.getUserById(userId);
return ResponseEntity.ok(user);
}
}
缓存击穿(Cache Breakdown)
问题本质
缓存击穿是指某个热点数据的缓存过期,在缓存重建期间,大量请求同时访问这个数据,都会打到数据库。
大量并发请求 → 缓存查询(miss) → 同时查询数据库 → 数据库压力激增
典型场景
热点商品场景:
// 爆款商品缓存过期
// 瞬间1000个用户同时访问商品详情
// 所有请求都打到数据库查询同一个商品
getProductById(12345L); // 热门商品ID
明星用户场景:
// 网红用户资料缓存过期
// 大量粉丝同时查看用户信息
getUserProfile(888888L); // 明星用户ID
问题危害
- 数据库瞬时压力:短时间内大量相同查询
- 响应时间增加:数据库查询比缓存慢很多
- 系统不稳定:可能导致数据库连接池耗尽
- 雪花效应:一个热点数据影响整个系统
解决方案
方案一:分布式锁
@Service
public class ProductService {
public Product getProductById(Long productId) {
String cacheKey = "product:" + productId;
String lockKey = "lock:product:" + productId;
// 1. 查询缓存
String productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 2. 缓存未命中,尝试获取分布式锁
String lockValue = UUID.randomUUID().toString();
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10));
if (lockAcquired) {
try {
// 3. 获得锁,双重检查缓存
productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 4. 查询数据库并缓存
Product product = productRepository.findById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(product), Duration.ofMinutes(30));
}
return product;
} finally {
// 5. 释放锁
releaseLock(lockKey, lockValue);
}
} else {
// 6. 未获得锁,等待后重试
try {
Thread.sleep(50);
return getProductById(productId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
}
private void releaseLock(String lockKey, String lockValue) {
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);
}
}
方案二:永不过期
@Service
public class HotDataService {
// 热点数据设置永不过期,但记录过期时间
public Product getHotProduct(Long productId) {
String cacheKey = "hot:product:" + productId;
String productData = redisTemplate.opsForValue().get(cacheKey);
if (productData != null) {
ProductCache cache = JSON.parseObject(productData, ProductCache.class);
// 检查逻辑过期时间
if (cache.getExpireTime() < System.currentTimeMillis()) {
// 异步更新缓存
CompletableFuture.runAsync(() -> refreshCache(cacheKey, productId));
}
return cache.getProduct();
}
// 缓存不存在,同步加载
return loadAndCacheProduct(productId);
}
private void refreshCache(String cacheKey, Long productId) {
Product product = productRepository.findById(productId);
if (product != null) {
ProductCache cache = new ProductCache();
cache.setProduct(product);
cache.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000); // 30分钟后逻辑过期
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));
}
}
// 缓存包装类
@Data
static class ProductCache {
private Product product;
private Long expireTime;
}
}
方案三:预热机制
@Component
public class CacheWarmupService {
// 定时预热热点数据
@Scheduled(fixedRate = 600000) // 每10分钟执行一次
public void warmupHotData() {
List<Long> hotProductIds = getHotProductIds();
hotProductIds.parallelStream().forEach(productId -> {
try {
String cacheKey = "product:" + productId;
Long ttl = redisTemplate.getExpire(cacheKey);
// 如果缓存即将过期(剩余时间小于5分钟),提前刷新
if (ttl != null && ttl < 300) {
Product product = productRepository.findById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(product), Duration.ofMinutes(30));
log.debug("预热缓存: {}", cacheKey);
}
}
} catch (Exception e) {
log.error("预热缓存失败: {}", productId, e);
}
});
}
private List<Long> getHotProductIds() {
// 从统计数据获取热点商品ID
return analyticsService.getTopViewedProducts(100);
}
}
缓存雪崩(Cache Avalanche)
问题本质
缓存雪崩是指大量缓存在同一时间失效,导致大量请求直接打到数据库。
大量缓存同时失效 → 大量请求打到数据库 → 数据库压力激增 → 系统崩溃
典型场景
集中过期场景:
// 系统重启后,批量设置缓存,都是30分钟过期
for (Product product : products) {
redisTemplate.opsForValue().set("product:" + product.getId(),
JSON.toJSONString(product), Duration.ofMinutes(30));
}
// 30分钟后,所有缓存同时过期
缓存服务宕机场景:
// Redis服务器宕机
// 所有缓存请求都失效
// 全部请求打到数据库
问题危害
- 系统崩溃风险:数据库可能被压垮
- 服务不可用:整个系统可能瘫痪
- 恢复困难:系统压力大,难以快速恢复
- 连锁反应:可能引发其他服务的连锁故障
解决方案
方案一:随机过期时间
@Service
public class RandomExpireService {
private final Random random = new Random();
public void cacheProductWithRandomExpire(Product product) {
String cacheKey = "product:" + product.getId();
// 基础过期时间30分钟,随机增加0-10分钟
int baseExpireMinutes = 30;
int randomMinutes = random.nextInt(10);
int totalExpireMinutes = baseExpireMinutes + randomMinutes;
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product),
Duration.ofMinutes(totalExpireMinutes));
log.debug("缓存商品,过期时间: {}分钟", totalExpireMinutes);
}
// 批量缓存时,每个都有不同的过期时间
public void batchCacheProducts(List<Product> products) {
products.forEach(this::cacheProductWithRandomExpire);
}
}
方案二:多级缓存
@Component
public class MultiLevelCacheService {
// L1缓存:本地缓存(Caffeine)
private final LoadingCache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofMinutes(5))
.build(key -> null);
// L2缓存:Redis
@Autowired
private RedisTemplate<String, String> redisTemplate;
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// 1. 查询本地缓存
String productJson = localCache.getIfPresent(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 2. 查询Redis缓存
productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
// 写入本地缓存
localCache.put(cacheKey, productJson);
return JSON.parseObject(productJson, Product.class);
}
// 3. 查询数据库
Product product = productRepository.findById(productId);
if (product != null) {
productJson = JSON.toJSONString(product);
// 写入两级缓存
redisTemplate.opsForValue().set(cacheKey, productJson,
Duration.ofMinutes(30));
localCache.put(cacheKey, productJson);
}
return product;
}
}
方案三:缓存预热 + 降级策略
@Component
public class CacheAvalancheProtection {
// 系统启动时预热缓存
@EventListener(ContextRefreshedEvent.class)
public void warmupCache() {
log.info("开始缓存预热...");
try {
// 预热核心数据
warmupCoreProducts();
warmupHotUsers();
warmupSystemConfig();
log.info("缓存预热完成");
} catch (Exception e) {
log.error("缓存预热失败", e);
}
}
private void warmupCoreProducts() {
List<Product> coreProducts = productService.getCoreProducts();
coreProducts.parallelStream().forEach(product -> {
String cacheKey = "product:" + product.getId();
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product),
Duration.ofMinutes(30 + new Random().nextInt(10)));
});
}
// 降级策略:当缓存不可用时的处理
@Service
public static class FallbackService {
// 使用断路器模式
@HystrixCommand(fallbackMethod = "getProductFallback")
public Product getProduct(Long productId) {
return productCacheService.getProduct(productId);
}
// 降级方法:返回基础数据或默认数据
public Product getProductFallback(Long productId) {
log.warn("缓存服务降级,商品ID: {}", productId);
// 返回基础商品信息(从本地缓存或数据库)
return productRepository.findBasicInfoById(productId);
}
}
}
方案四:集群和哨兵
# Redis集群配置
spring:
redis:
cluster:
nodes:
- 192.168.1.10:7000
- 192.168.1.10:7001
- 192.168.1.11:7000
- 192.168.1.11:7001
- 192.168.1.12:7000
- 192.168.1.12:7001
max-redirects: 3
timeout: 3000
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
三大问题对比总结
问题类型 | 缓存状态 | 数据库状态 | 主要原因 | 核心解决方案 |
---|---|---|---|---|
缓存穿透 | 无数据 | 无数据 | 查询不存在的数据 | 布隆过滤器、缓存空值 |
缓存击穿 | 无数据 | 有数据 | 热点数据过期 | 分布式锁、永不过期 |
缓存雪崩 | 大量无数据 | 有数据 | 大量缓存同时失效 | 随机过期、多级缓存 |
综合防护策略
在实际项目中,通常需要综合运用多种策略:
@Service
public class ComprehensiveCacheService {
// 综合防护的缓存查询
public <T> T getCacheData(String key, Class<T> clazz, Supplier<T> dataLoader) {
// 1. 布隆过滤器检查(防穿透)
if (!bloomFilterService.mightContain(key)) {
return null;
}
// 2. 多级缓存查询(防雪崩)
T data = multiLevelCache.get(key, clazz);
if (data != null) {
return data;
}
// 3. 分布式锁防击穿
String lockKey = "lock:" + key;
return distributedLock.executeWithLock(lockKey, () -> {
// 双重检查
T cachedData = multiLevelCache.get(key, clazz);
if (cachedData != null) {
return cachedData;
}
// 加载数据
T loadedData = dataLoader.get();
if (loadedData != null) {
// 随机过期时间(防雪崩)
int expireMinutes = 30 + new Random().nextInt(10);
multiLevelCache.put(key, loadedData, Duration.ofMinutes(expireMinutes));
} else {
// 缓存空值(防穿透)
multiLevelCache.put(key, createNullObject(clazz), Duration.ofMinutes(2));
}
return loadedData;
});
}
}
总结
理解和解决Redis缓存的三大经典问题是构建高可用系统的关键:
核心要点:
- 缓存穿透:防止查询不存在的数据,用布隆过滤器和参数校验
- 缓存击穿:防止热点数据过期时的并发冲击,用分布式锁和预热
- 缓存雪崩:防止大量缓存同时失效,用随机过期和多级缓存
最佳实践:
- 建立多层防护体系,不依赖单一解决方案
- 监控缓存命中率和异常情况
- 设计合理的降级和熔断机制
- 定期评估和优化缓存策略
通过深入理解这三个问题的本质和解决方案,可以构建出更加稳定和高效的缓存系统。