Redis缓存穿透、击穿、雪崩解决方案
前言
在高并发的互联网应用中,Redis作为缓存层扮演着至关重要的角色。然而,缓存系统也面临着三大经典问题:缓存穿透、缓存击穿、缓存雪崩。这三个问题如果处理不当,可能会导致数据库瞬间崩溃,整个系统不可用。
本文将深入剖析这三个问题的本质,并提供切实可行的解决方案。
一、缓存穿透
1.1 什么是缓存穿透
缓存穿透是指查询一个缓存和数据库中都不存在的数据。
正常流程:
1
| 请求 → 查询缓存(未命中) → 查询数据库(有数据) → 写入缓存 → 返回
|
穿透情况:
1 2
| 请求 → 查询缓存(未命中) → 查询数据库(无数据) → 返回空 每次请求都会打到数据库!
|
1.2 危害分析
假设有恶意用户不断请求不存在的数据:
1 2 3 4 5
| for (int i = 0; i < 100000; i++) { String userId = "user_" + UUID.randomUUID(); getUserInfo(userId); }
|
后果:
- 缓存完全失效
- 每个请求都打到数据库
- 数据库连接池耗尽
- 系统崩溃
1.3 解决方案一:布隆过滤器
布隆过滤器可以快速判断一个元素是否存在于集合中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels;
@Service public class UserService { private BloomFilter<Long> bloomFilter = BloomFilter.create( Funnels.longFunnel(), 1000000, 0.01 ); @PostConstruct public void init() { List<Long> userIds = userMapper.selectAllUserIds(); userIds.forEach(bloomFilter::put); } public User getUserById(Long userId) { if (!bloomFilter.mightContain(userId)) { return null; } String key = "user:" + userId; User user = redisTemplate.opsForValue().get(key); if (user != null) { return user; } user = userMapper.selectById(userId); if (user != null) { redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES); } return user; } }
|
优点:
- 内存占用小(100万数据只需约1.2MB)
- 查询速度快(O(k),k是hash函数个数)
缺点:
- 存在误判(可能判断存在但实际不存在)
- 无法删除元素
1.3 解决方案二:缓存空值
如果数据库查询结果为空,也将空值写入缓存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| public User getUserById(Long userId) { String key = "user:" + userId; Object value = redisTemplate.opsForValue().get(key); if (value != null) { if (value instanceof User) { return (User) value; } else { return null; } } User user = userMapper.selectById(userId); if (user != null) { redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES); } return user; }
|
优点:
缺点:
1.4 解决方案三:接口限流
在接口层面做限流和参数校验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @RestController public class UserController { @GetMapping("/user/{userId}") @RateLimiter(limit = 10, timeout = 1000) public Result<User> getUser(@PathVariable Long userId) { if (userId == null || userId <= 0) { return Result.error("参数错误"); } if (userId > 10000000) { return Result.error("用户不存在"); } User user = userService.getUserById(userId); return Result.success(user); } }
|
二、缓存击穿
2.1 什么是缓存击穿
缓存击穿是指一个热点key在缓存过期的瞬间,大量并发请求同时打到数据库。
场景:
1 2 3 4
| 某个热门商品的缓存在12:00:00过期 12:00:00.001 - 10000个并发请求同时到达 所有请求都发现缓存不存在 10000个请求同时查询数据库!
|
2.2 解决方案一:互斥锁
使用Redis的SETNX实现分布式锁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| public Product getProductById(Long productId) { String key = "product:" + productId; String lockKey = "lock:product:" + productId; Product product = redisTemplate.opsForValue().get(key); if (product != null) { return product; } Boolean locked = redisTemplate.opsForValue().setIfAbsent( lockKey, "1", 10, TimeUnit.SECONDS ); try { if (Boolean.TRUE.equals(locked)) { product = productMapper.selectById(productId); if (product != null) { redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES); } return product; } else { Thread.sleep(50); return getProductById(productId); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("获取数据失败", e); } finally { if (Boolean.TRUE.equals(locked)) { redisTemplate.delete(lockKey); } } }
|
优点:
- 确保只有一个线程查询数据库
- 其他线程等待,减轻数据库压力
缺点:
2.3 解决方案二:热点数据永不过期
逻辑过期,而不是物理过期。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| @Data public class CacheData { private Object data; private Long expireTime; }
public Product getProductById(Long productId) { String key = "product:" + productId; CacheData cacheData = redisTemplate.opsForValue().get(key); if (cacheData == null) { Product product = productMapper.selectById(productId); if (product != null) { CacheData newCache = new CacheData(); newCache.setData(product); newCache.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000); redisTemplate.opsForValue().set(key, newCache); } return product; } if (cacheData.getExpireTime() < System.currentTimeMillis()) { String lockKey = "lock:product:" + productId; Boolean locked = redisTemplate.opsForValue().setIfAbsent( lockKey, "1", 10, TimeUnit.SECONDS ); if (Boolean.TRUE.equals(locked)) { CompletableFuture.runAsync(() -> { try { Product product = productMapper.selectById(productId); if (product != null) { CacheData newCache = new CacheData(); newCache.setData(product); newCache.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000); redisTemplate.opsForValue().set(key, newCache); } } finally { redisTemplate.delete(lockKey); } }); } } return (Product) cacheData.getData(); }
|
优点:
- 高可用性(即使数据过期也能返回)
- 性能好(异步更新)
缺点:
三、缓存雪崩
3.1 什么是缓存雪崩
缓存雪崩是指大量缓存key在同一时间过期,导致所有请求都打到数据库。
场景:
1 2 3 4
| 凌晨2点,系统预热了10000个热点数据,过期时间都是30分钟 凌晨2:30,10000个key同时过期 凌晨2:30:01,大量请求涌入 数据库瞬间崩溃!
|
3.2 解决方案一:过期时间打散
给每个key的过期时间加上随机值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public void setCache(String key, Object value, long baseExpireMinutes) { long randomMinutes = ThreadLocalRandom.current().nextInt(5); long expireMinutes = baseExpireMinutes + randomMinutes; redisTemplate.opsForValue().set( key, value, expireMinutes, TimeUnit.MINUTES ); }
setCache("product:1001", product, 30);
|
3.3 解决方案二:多级缓存
构建多级缓存架构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| @Service public class ProductService { @Autowired private RedisTemplate<String, Object> redisTemplate; private Cache<String, Product> localCache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); public Product getProductById(Long productId) { String key = "product:" + productId; Product product = localCache.getIfPresent(key); if (product != null) { return product; } product = (Product) redisTemplate.opsForValue().get(key); if (product != null) { localCache.put(key, product); return product; } product = productMapper.selectById(productId); if (product != null) { redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES); localCache.put(key, product); } return product; } }
|
架构图:
1 2
| 请求 → 本地缓存(Caffeine) → Redis → 数据库 ↓ 5分钟 ↓ 30分钟
|
3.4 解决方案三:限流降级
使用Sentinel进行流量控制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @Service public class ProductService { @SentinelResource( value = "getProductById", blockHandler = "handleBlock", fallback = "handleFallback" ) public Product getProductById(Long productId) { return productMapper.selectById(productId); } public Product handleBlock(Long productId, BlockException e) { log.warn("触发限流: productId={}", productId); return getProductFromCache(productId); } public Product handleFallback(Long productId, Throwable e) { log.error("查询异常: productId={}", productId, e); return getDefaultProduct(); } }
|
3.5 解决方案四:Redis集群
使用Redis主从+哨兵模式或Redis Cluster,提高可用性。
1 2 3 4 5 6 7 8 9 10 11 12
| spring: redis: cluster: nodes: - 192.168.1.101:6379 - 192.168.1.102:6379 - 192.168.1.103:6379 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 5
|
四、综合实战方案
结合以上方案,提供一个生产级的缓存工具类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
| @Component public class CacheService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private BloomFilter<String> bloomFilter;
public <T> T getWithSafety(String key, Class<T> type, Function<String, T> dbFallback, long expireMinutes) { if (!bloomFilter.mightContain(key)) { return null; } T value = (T) redisTemplate.opsForValue().get(key); if (value != null) { return value; } String lockKey = "lock:" + key; Boolean locked = redisTemplate.opsForValue().setIfAbsent( lockKey, "1", 10, TimeUnit.SECONDS ); try { if (Boolean.TRUE.equals(locked)) { value = dbFallback.apply(key); if (value != null) { long randomMinutes = ThreadLocalRandom.current().nextInt(5); redisTemplate.opsForValue().set( key, value, expireMinutes + randomMinutes, TimeUnit.MINUTES ); } else { redisTemplate.opsForValue().set( key, "NULL", 5, TimeUnit.MINUTES ); } return value; } else { Thread.sleep(100); return getWithSafety(key, type, dbFallback, expireMinutes); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("缓存查询失败", e); } finally { if (Boolean.TRUE.equals(locked)) { redisTemplate.delete(lockKey); } } } }
@Service public class ProductService { @Autowired private CacheService cacheService; @Autowired private ProductMapper productMapper; public Product getProductById(Long productId) { String key = "product:" + productId; return cacheService.getWithSafety( key, Product.class, k -> productMapper.selectById(productId), 30L ); } }
|
五、监控与告警
5.1 关键指标监控
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| @Aspect @Component public class CacheMonitorAspect { private final AtomicLong hitCount = new AtomicLong(0); private final AtomicLong missCount = new AtomicLong(0); @Around("@annotation(Cacheable)") public Object monitor(ProceedingJoinPoint pjp) throws Throwable { Object result = pjp.proceed(); if (result != null) { hitCount.incrementAndGet(); } else { missCount.incrementAndGet(); } long total = hitCount.get() + missCount.get(); double hitRate = total > 0 ? (double) hitCount.get() / total : 0; if (hitRate < 0.8) { log.warn("缓存命中率过低: {}", hitRate); } return result; } }
|
5.2 Prometheus指标暴露
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Component public class CacheMetrics { private final Counter cacheHits = Counter.build() .name("cache_hits_total") .help("Total cache hits") .register(); private final Counter cacheMisses = Counter.build() .name("cache_misses_total") .help("Total cache misses") .register(); public void recordHit() { cacheHits.inc(); } public void recordMiss() { cacheMisses.inc(); } }
|
六、总结
| 问题 |
原因 |
解决方案 |
| 缓存穿透 |
查询不存在的数据 |
布隆过滤器、缓存空值、接口限流 |
| 缓存击穿 |
热点key过期 |
互斥锁、逻辑过期 |
| 缓存雪崩 |
大量key同时过期 |
过期时间打散、多级缓存、限流降级 |
最佳实践:
- 永远不要完全依赖缓存
- 设计合理的降级方案
- 监控缓存命中率
- 压测验证方案有效性
Redis缓存优化是一个系统工程,需要结合业务场景选择合适的方案。希望本文能帮助你更好地应对生产环境中的缓存问题。