Redis缓存穿透、雪崩、击穿的解决方案?
Redis 缓存问题解决方案及Java实现
一、缓存穿透解决方案
(缓存穿透指查询不存在数据,绕过缓存直接访问数据库)
1. 布隆过滤器 + 空值缓存
注意点:
1.布隆过滤器是需要预热数据的,就是需要输入当前数据库已经存在的缓存,这里会有不少的内存消耗。
2.布隆过滤器会出现漏掉的情况,只是通过算法做一个筛选兜底,避免大量数据访问。
3.对于业务方来说,不是所有的缓存都需要添加进入布隆过滤器的,博主认为只有该数据访问有被外攻击的风险,才需要,如果没有的情况下,业务方需要自己兜底防止缓存穿透。
// 使用Guava布隆过滤器(需引入Guava依赖)
// 初始化布隆过滤器(需预热数据)
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8),1000000, // 预期数据量:根据业务历史数据量估算0.001 // 误判率:每1000次查询允许1次误判
);// 预热数据(系统启动时加载)
database.getAllKeys().forEach(key -> {bloomFilter.put(key); // 将数据库已有key存入过滤器redisTemplate.opsForValue().set(key, database.get(key)); // 初始化缓存
});public Object getData(String key) {// 1. 布隆过滤器校验(存在性预判)if (!bloomFilter.mightContain(key)) {// 确定不存在时直接返回(拦截非法请求)return null; }// 2. 查询Redis缓存Object value = redisTemplate.opsForValue().get(key);if (value != null) {return "NULL".equals(value) ? null : value; // 空值处理逻辑}// 3. 查询数据库(通过过滤器的请求才放行)Object dbValue = database.get(key);// 4. 双写机制更新if (dbValue == null) {redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);} else {redisTemplate.opsForValue().set(key, dbValue, 30, TimeUnit.MINUTES);bloomFilter.put(key); // 动态维护布隆过滤器}return dbValue;
}
二、缓存雪崩解决方案
(大量缓存同时过期导致数据库压力激增)
1. 随机过期时间方案
public Object getDataWithRandomExpire(String key) {// 基础过期时间 + 随机偏移量(防止集体失效)int baseExpire = 30; // 基础30分钟int randomOffset = new Random().nextInt(10); // 0-9分钟随机偏移int totalExpire = baseExpire + randomOffset;Object value = redisTemplate.opsForValue().get(key);if (value != null) {return value;}// 查询数据库...Object dbValue = database.get(key);// 设置随机过期时间redisTemplate.opsForValue().set(key, dbValue, totalExpire, TimeUnit.MINUTES);return dbValue;
}
特点:随机会有概率导致时间过期重合(再小概率的事件只要有概率,生产都有可能发生)
2. 永不过期+后台更新方案
// 后台定时更新线程
@Scheduled(fixedDelay = 30 * 60 * 1000) // 每30分钟执行
public void refreshCache() {List<String> hotKeys = getHotKeysFromMonitor(); // 从监控系统获取热点keyhotKeys.parallelStream().forEach(key -> {Object dbValue = database.get(key);redisTemplate.opsForValue().set(key, dbValue); // 不设置过期时间});
}// 数据访问逻辑
public Object getDataWithPersist(String key) {Object value = redisTemplate.opsForValue().get(key);if (value == null) {value = database.get(key);redisTemplate.opsForValue().set(key, value); // 永不过期写入}return value;
}
特点:使用场景必须是很少更新的数据,如果数据库频繁变更,该数据不过期没有特别大的意义。
3. 多级缓存方案
// 本地缓存(使用Caffeine)
Cache<String, Object> localCache = Caffeine.newBuilder().expireAfterWrite(10, TimeUnit.MINUTES).maximumSize(10000).build();public Object getDataWithMultiCache(String key) {// 1. 检查本地缓存Object value = localCache.getIfPresent(key);if (value != null) return value;// 2. 检查Redis缓存value = redisTemplate.opsForValue().get(key);if (value != null) {localCache.put(key, value); // 回填本地缓存return value;}// 3. 数据库查询value = database.get(key);// 4. 双写缓存(设置不同过期时间)redisTemplate.opsForValue().set(key, value, 30 + new Random().nextInt(10), TimeUnit.MINUTES);localCache.put(key, value);return value;
}
特点:实现复杂,维护缓存困难。
4. 熔断降级方案
// 使用Resilience4j熔断器
CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("cacheCB");public Object getDataWithCircuitBreaker(String key) {return CircuitBreaker.decorateSupplier(circuitBreaker, () -> {Object value = redisTemplate.opsForValue().get(key);if (value != null) return value;// 超过阈值时触发熔断,返回兜底数据if (circuitBreaker.tryAcquirePermission()) {Object dbValue = database.get(key);redisTemplate.opsForValue().set(key, dbValue, 30, TimeUnit.MINUTES);return dbValue;} else {return getFallbackData(); // 返回预设默认值}}).get();
}
特点:会导致业务有段时间不可用,兜底数据返回,用户体验差
5. 热点数据预加载方案
// 监控系统集成
public void monitorAndPreload() {// 实时统计热点key(示例使用滑动窗口)ConcurrentHashMap<String, AtomicInteger> counter = new ConcurrentHashMap<>();// 数据访问埋点AspectJ.around("execution(* getData(..))", (joinPoint, result) -> {String key = (String) joinPoint.getArgs()[0];counter.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();});// 定时分析热点数据ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);scheduler.scheduleAtFixedRate(() -> {counter.entrySet().stream().filter(entry -> entry.getValue().get() > 1000) // 阈值判断.forEach(entry -> {String hotKey = entry.getKey();Object value = database.get(hotKey);redisTemplate.opsForValue().set(hotKey, value, 60, TimeUnit.MINUTES);});counter.clear();}, 5, 5, TimeUnit.MINUTES); // 每5分钟执行
}
特点:需要时间缓存热点数据。
所有方案均可组合使用,建议通过监控以下指标进行方案调优:
- 缓存命中率(Redis/Memcached)
- 数据库QPS(Queries Per Second)
- 系统吞吐量(TPS)
- 熔断器状态(Open/Half-Open/Closed)
总结
解决缓存雪崩就是设计缓存的时候,让缓存不要在同一时间大批量过期,部分很少变化的数据进行预热加载。
三、缓存击穿解决方案
(热点key过期后大量并发请求直达数据库)
1. 互斥锁方案
public Object getDataWithLock(String key) {// 1. 缓存存在时直接返回(无锁)Object value = redisTemplate.opsForValue().get(key);if (value != null) return value;// 2. 尝试获取分布式锁(关键控制点)String lockKey = "LOCK:" + key;try {// 原子性操作:setIfAbsent + expireBoolean isLock = redisTemplate.opsForValue().setIfAbsent(lockKey, "LOCKED",10, // 锁持有时间(秒)TimeUnit.SECONDS);if (Boolean.TRUE.equals(isLock)) {// 3. 仅一个线程执行数据库查询(关键保护)Object dbValue = database.get(key);// 4. 双写缓存(重建热点数据)redisTemplate.opsForValue().set(key, dbValue, 30 + new Random().nextInt(10), // 随机过期时间TimeUnit.MINUTES);return dbValue;} else {// 5. 其他线程等待后重试(避免堆积)Thread.sleep(50);return getDataWithLock(key); }} finally {// 6. 释放锁(必须保证)redisTemplate.delete(lockKey); }
}
总结:缓存击穿的方案就是在重建缓存之前,防止接口并发行为,或者让缓存永不过期
四、组合方案建议
- 分级缓存架构:本地缓存(Caffeine)+ Redis集群
- 热点发现:实时监控Key访问频率,自动续期热点数据
- 熔断降级:Hystrix或Sentinel实现数据库保护
- 异步更新:使用消息队列异步更新缓存
实际生产环境中建议根据业务场景组合使用多种方案,并配合监控系统实时观察缓存命中率、数据库QPS等关键指标。