缓存三大问题深度解析:穿透、击穿与雪崩
缓存三大问题深度解析:穿透、击穿与雪崩
缓存技术是高并发系统中提升性能的关键手段,但在实际应用中,我们经常会遇到缓存雪崩、缓存穿透和缓存击穿这三大问题。下面将详细分析它们的产生原因、解决方案以及方案背后的原理和优缺点。
一、缓存穿透
1. 定义
缓存穿透是指查询一个根本不存在的数据,由于缓存中没有该数据,每次都会查询数据库,导致数据库压力过大的情况。这种情况可能是误操作,也可能是恶意攻击。
2. 产生原因
- 查询不存在的数据:正常业务中用户误操作查询了不存在的数据
- 恶意攻击:黑客通过大量请求不存在的数据,消耗系统资源
- 缓存设计缺陷:没有对空值进行缓存处理
3. 解决方案及原理分析
方案1:缓存空对象
public Object getFromCache(String key) {// 1. 查询缓存Object value = redisTemplate.opsForValue().get(key);if (value != null) {return value;}// 2. 查询数据库value = database.query(key);if (value != null) {// 数据库中有数据,正常缓存redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);} else {// 数据库中没有数据,缓存空对象redisTemplate.opsForValue().set(key, NULL_VALUE, 60, TimeUnit.SECONDS); // 空值过期时间较短}return value;
}
原理:当数据库中不存在某条记录时,我们将空值也缓存起来,但设置较短的过期时间
优点:实现简单,有效防止缓存穿透
缺点:占用额外的缓存空间,可能会有短期的数据不一致问题
方案2:布隆过滤器
// 布隆过滤器实现缓存穿透防护
public class CacheService {private BloomFilter<String> bloomFilter;@PostConstructpublic void initBloomFilter() {// 初始化布隆过滤器,预计元素数量为1000000,期望误判率为0.01bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01);// 将数据库中所有可能存在的key加载到布隆过滤器中List<String> allKeys = database.queryAllPossibleKeys();for (String key : allKeys) {bloomFilter.put(key);}}public Object getFromCache(String key) {// 先通过布隆过滤器快速判断key是否可能存在if (!bloomFilter.mightContain(key)) {return null; // 布隆过滤器说不存在,肯定不存在}// 布隆过滤器说可能存在,继续查询缓存和数据库Object value = redisTemplate.opsForValue().get(key);if (value == null) {value = database.query(key);if (value != null) {redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);}}return value;}
}
原理:布隆过滤器是一种空间效率很高的概率性数据结构,用于判断一个元素是否在集合中。它可能会误判(说存在的元素可能不存在),但不会漏判(说不存在的元素一定不存在)。
优点:空间效率高,适用于大数据量场景,误判率可控
缺点:有一定的误判率,需要额外维护布隆过滤器,不支持删除操作
方案3:接口层参数校验
// 在Controller层进行参数校验
@RestController
public class CacheController {@GetMapping("/data/{id}")public ResponseEntity<?> getData(@PathVariable String id) {// 参数合法性校验if (!isValidId(id)) {return ResponseEntity.badRequest().body("Invalid parameter");}// 正常业务逻辑return ResponseEntity.ok(cacheService.getFromCache(id));}private boolean isValidId(String id) {// 实现参数校验逻辑// 例如:检查ID格式、范围等return id != null && id.matches("^[A-Za-z0-9]{1,32}$");}
}
原理:在API接口层对请求参数进行严格校验,过滤掉明显不合理的请求,从源头避免恶意请求到达后端
优点:实现简单,直接拦截无效请求,保护后端系统
缺点:只能拦截部分明显的非法请求,对于伪装成合法格式的恶意请求无法有效拦截
二、缓存击穿
1. 定义
缓存击穿是指一个热点数据的缓存突然失效(例如过期),此时大量并发请求同时访问这个热点数据,直接打在数据库上,造成数据库瞬时压力激增的现象。
2. 产生原因
- 热点数据过期:某个被大量访问的热点数据缓存过期
- 高并发场景:系统存在大量并发请求访问同一数据
- 缓存重建耗时:从数据库查询并重建缓存的过程耗时较长
3. 解决方案及原理分析
方案1:互斥锁(分布式锁)
public Object getFromCacheWithLock(String key) {// 1. 尝试从缓存获取Object value = redisTemplate.opsForValue().get(key);if (value != null) {return value;}// 2. 缓存不存在,获取分布式锁String lockKey = "lock:" + key;if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS)) {try {// 3. 再次检查缓存,防止其他线程已更新value = redisTemplate.opsForValue().get(key);if (value == null) {// 4. 查询数据库并更新缓存value = database.query(key);if (value != null) {redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);}}} finally {// 5. 释放锁redisTemplate.delete(lockKey);}} else {// 6. 获取锁失败,短暂休眠后重试try {Thread.sleep(50);} catch (InterruptedException e) {Thread.currentThread().interrupt();}return getFromCacheWithLock(key); // 重试}return value;
}
原理:当缓存失效时,只有获取到锁的线程才能去查询数据库并重建缓存,其他线程等待或重试
优点:确保只有一个线程去查询数据库,有效防止缓存击穿
缺点:增加了系统响应时间,在高并发下可能导致线程阻塞
方案2:热点数据永不过期
// 热点数据永不过期,通过定时任务更新
@Scheduled(fixedRate = 1800000) // 每30分钟更新一次热点数据
public void updateHotData() {List<HotItem> hotItems = database.queryHotItems();for (HotItem item : hotItems) {redisTemplate.opsForValue().set(item.getKey(), item.getValue());}
}
原理:对于热点数据,不设置过期时间,而是通过后台定时任务主动更新缓存
优点:避免了缓存过期带来的问题,保证热点数据始终可用
缺点:需要识别热点数据,可能占用较多缓存空间
方案3:预热缓存+定时续期
// 系统启动时预热缓存
@PostConstruct
public void warmupCache() {List<HotItem> hotItems = database.queryHotItems();for (HotItem item : hotItems) {// 设置稍长的过期时间redisTemplate.opsForValue().set(item.getKey(), item.getValue(), 7200, TimeUnit.SECONDS);}
}// 定时任务检查并续期即将过期的热点数据
@Scheduled(fixedRate = 300000) // 每5分钟检查一次
public void renewExpiringCache() {List<HotItem> hotItems = database.queryHotItems();for (HotItem item : hotItems) {// 使用EXPIRE命令的返回值判断剩余过期时间Long ttl = redisTemplate.getExpire(item.getKey(), TimeUnit.SECONDS);// 如果剩余时间小于阈值(如1小时),则续期if (ttl != null && ttl > 0 && ttl < 3600) {redisTemplate.expire(item.getKey(), 7200, TimeUnit.SECONDS);// 同时更新缓存值redisTemplate.opsForValue().set(item.getKey(), item.getValue(), 7200, TimeUnit.SECONDS);}}
}
原理:系统启动时预热热点数据,并通过定时任务为即将过期的热点数据续期,避免缓存失效
优点:在保持数据相对新鲜的同时,避免了热点数据过期导致的缓存击穿
缺点:实现复杂度增加,需要维护预热和续期逻辑
三、缓存雪崩
1. 定义
缓存雪崩是指在某一时间段内,缓存中的大量数据同时失效或Redis服务器宕机,导致所有请求直接打在数据库上,造成数据库压力骤增,甚至导致数据库宕机的现象。
2. 产生原因
- 大量缓存同时过期:缓存设置了相同的过期时间,导致在同一时间点大量缓存失效
- 缓存服务器宕机:Redis等缓存服务不可用,所有请求直接访问后端数据库
- 缓存预热不充分:系统重启或新服务上线时,缓存未及时加载数据
3. 解决方案及原理分析
方案1:设置随机过期时间
// 避免缓存雪崩:设置随机过期时间
int baseExpireTime = 3600; // 基础过期时间1小时
int randomExpireTime = new Random().nextInt(1800); // 随机增加0-30分钟
redisTemplate.opsForValue().set("key", value, baseExpireTime + randomExpireTime, TimeUnit.SECONDS);
原理:通过为不同缓存项设置不同的过期时间,避免大量缓存同时失效,将缓存失效时间分散到不同时间段
优点:实现简单,效果明显,无需额外组件
缺点:无法完全避免缓存失效的情况,只是降低了集中失效的概率
方案2:多级缓存架构
// 多级缓存架构示例(伪代码)
public Object getFromCache(String key) {// 1. 先查本地缓存(Caffeine)Object value = localCache.getIfPresent(key);if (value != null) return value;// 2. 再查分布式缓存(Redis)value = redisCache.get(key);if (value != null) {// 回写本地缓存localCache.put(key, value);return value;}// 3. 最后查数据库value = database.query(key);// 更新缓存redisCache.set(key, value, getRandomExpireTime());localCache.put(key, value);return value;
}
原理:构建本地缓存(L1)和分布式缓存(L2)的多级架构,当L2缓存失效时,L1缓存可以作为兜底方案
优点:提高了系统可用性,即使分布式缓存失效,本地缓存仍可提供服务
缺点:实现复杂度增加,本地缓存可能导致数据一致性问题
方案3:设置热点数据永不过期
// 热点数据永不过期,通过后台定时更新
@Scheduled(fixedRate = 3600000) // 每小时更新一次
public void updateHotCache() {List<HotData> hotDataList = database.queryHotData();for (HotData data : hotDataList) {redisTemplate.opsForValue().set(data.getKey(), data.getValue());}
}
原理:对于核心业务的热点数据,不设置过期时间,通过后台定时任务定期更新缓存数据
优点:确保热点数据始终可用,避免缓存过期带来的问题
缺点:占用缓存空间较大,需要额外的定时任务维护机制
方案4:限流降级熔断
// 伪代码:使用Sentinel实现限流降级
@SentinelResource(value = "cacheService", blockHandler = "fallbackMethod")
public Object getFromCacheWithSentinel(String key) {// 正常缓存查询逻辑return doGetFromCache(key);
}// 降级方法
public Object fallbackMethod(String key, BlockException ex) {log.warn("Cache service is blocked, using fallback");// 返回默认值或错误提示return getDefaultValue();
}
原理:通过限流保护数据库,当缓存失效导致流量激增时,限制进入系统的请求数量
优点:保护系统在极端情况下不会崩溃,提高系统稳定性
缺点:会导致部分用户请求被拒绝,影响用户体验
