缓存常见问题与解决方案
缓存常见问题与解决方案
文章目录
- 缓存常见问题与解决方案
- 1、缓存穿透
- 1.1、 概述
- 1.2 、非注解缓存解决方案
- 1.3 、注解缓存解决方案
- 代码示例:
- 2 、缓存雪崩
- 2.1、概述
- 2.2 、非注解缓存解决方案
- 代码示例:
- 2.3 、注解缓存解决方案
- 代码示例:
- 3、缓存击穿
- 3.1、概述
- 3.2、非注解缓存解决方案
- 代码示例:
- 优化备注:
- 3.3 注解缓存解决方案
- 代码示例:
- 优化备注:
- 4、总结
1、缓存穿透
1.1、 概述
缓存穿透是指查询一个一定不存在
的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
@Overridepublic SkuInfo findBySkuInfoId(Integer skuInfoId) {/*** 首先去redis中根据key查询是否缓存了key的对应相关信息。* 1. 如果没有,说明是第一次访问这个key,那么就查询数据库,再把相关数据存入redis*2. 如果有,说明之前缓存过这个key,那么就从redis中取数据,不再查数据库*///1.查询缓存String key = "sku:" + skuInfoId + ":info";Object value = redisUtils.get(key);SkuInfo skuInfo;if (value != null) {//说明缓存中缓存这个sku,直接取出缓存数据skuInfo = new Gson().fromJson(value.toString(), SkuInfo.class);} else {//说明缓存中没有缓存这个sku,查数据并缓存//1.查询sku info表skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());if (skuInfo != null) {//2.查询sku_image表:sku对应的图片SkuImageExample skuImageExample = new SkuImageExample();skuImageExample.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());List<SkuImage> skuImages = skuImageMapper.selectByExample(skuImageExample);//3.将查询到的结果封装到sku对象中skuInfo.setSkuImages(skuImages);//4.将sku对象序列化String json = new Gson().toJson(skuInfo);//5.将序列化后的数据存入缓存中redisUtils.set(key, json);}}return skuInfo;}
我们分析一下上述代码,如果有人恶意的拿一个不存在的key去查询数据,此时redis中没有相应的缓存数据,这就会绕过redis频繁的去调用数据库查询,这样就会给数据库造成压力。
有很多种方法可以有效地解决缓存穿透问题,我们选择一种,如果一个查询返回的数据为空 (不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
1.2 、非注解缓存解决方案
@Overridepublic SkuInfo findBySkuInfoId(Integer skuInfoId) {/*** 首先去redis中根据key查询是否缓存了key的对应相关信息。* 1. 如果没有,说明是第一次访问这个key,那么就查询数据库,再把相关数据存入redis*2. 如果有,说明之前缓存过这个key,那么就从redis中取数据,不再查数据库*///1.查询缓存String key = "sku:" + skuInfoId + ":info";Object value = redisUtils.get(key);SkuInfo skuInfo;if (value != null) {//说明缓存中缓存这个sku,直接取出缓存数据skuInfo = new Gson().fromJson(value.toString(), SkuInfo.class);} else {//说明缓存中没有缓存这个sku,查数据并缓存//1.查询sku info表skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());if (skuInfo != null) {//2.查询sku_image表:sku对应的图片SkuImageExample skuImageExample = new SkuImageExample();skuImageExample.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());List<SkuImage> skuImages = skuImageMapper.selectByExample(skuImageExample);//3.将查询到的结果封装到sku对象中skuInfo.setSkuImages(skuImages);//4.将sku对象序列化String json = new Gson().toJson(skuInfo);//5.将序列化后的数据存入缓存中redisUtils.set(key, json);} else {//说明数据库中没有这个sku,此时也将这个null数据进行缓存,并且设置过期时间为5minredisUtils.set(key, null, 5, TimeUnit.MINUTES);}}return skuInfo;}
1.3 、注解缓存解决方案
基于 Spring Cache 注解式缓存解决缓存穿透,核心思路与非注解式一致:缓存空值并设置较短过期时间。需要通过配置CacheManager
和注解属性配合实现。
代码示例:
// 1. 配置Redis缓存管理器(设置默认过期时间及空值处理)
@Configuration
@EnableCaching
public class RedisCacheConfig {@Beanpublic RedisCacheManager cacheManager(RedisConnectionFactory factory) {// 默认配置(非空值缓存)RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(2)) // 非空值默认过期时间2小时.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));// 空值缓存配置(单独设置较短过期时间)RedisCacheConfiguration nullValueCacheConfig = RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(5)) // 空值缓存5分钟.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))// 允许缓存null值.disableCachingNullValues(false);// 针对不同缓存名称设置不同配置(这里对skuInfo缓存单独配置空值策略)Map<String, RedisCacheConfiguration> configMap = new HashMap<>();configMap.put("skuInfo", nullValueCacheConfig);return RedisCacheManager.builder(factory).cacheDefaults(defaultCacheConfig) // 默认配置.withInitialCacheConfigurations(configMap) // 特殊缓存配置.build();}
}// 2. 业务层使用注解
@Service
public class SkuInfoService {@Autowiredprivate SkuInfoMapper skuInfoMapper;@Autowiredprivate SkuImageMapper skuImageMapper;/*** @Cacheable:查询缓存,不存在则执行方法并缓存结果* key:缓存key* cacheNames:缓存名称(对应上面配置的skuInfo)* unless:结果为null时不缓存?这里设置为false,允许缓存null*/@Cacheable(key = "'sku:'+#skuInfoId+':info'", cacheNames = "skuInfo", unless = "#result == null ? false : false")public SkuInfo findBySkuInfoId(Integer skuInfoId) {// 1.查询数据库SkuInfo skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());if (skuInfo != null) {// 2.查询关联图片SkuImageExample example = new SkuImageExample();example.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());skuInfo.setSkuImages(skuImageMapper.selectByExample(example));}// 注意:这里会返回null,而注解配置会缓存null值(5分钟过期)return skuInfo;}
}
- 注解式缓存需通过
RedisCacheConfiguration
显式开启disableCachingNullValues(false)
允许缓存 null- 空值缓存必须设置较短过期时间(5 分钟内),避免长期占用内存
unless
属性用于控制是否缓存,这里配置为始终缓存(包括 null)- 优势:代码更简洁,无需手动编写缓存逻辑;劣势:空值过期时间配置较固定,灵活性略低
2 、缓存雪崩
2.1、概述
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
一个简单方案就时讲缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
2.2 、非注解缓存解决方案
核心方案:给缓存过期时间添加随机偏移量,避免大量缓存同时失效。
代码示例:
@Override
public SkuInfo findBySkuInfoId(Integer skuInfoId) {String key = "sku:" + skuInfoId + ":info";Object value = redisUtils.get(key);SkuInfo skuInfo;if (value != null) {skuInfo = new Gson().fromJson(value.toString(), SkuInfo.class);} else {// 查询数据库skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());if (skuInfo != null) {// 补充关联数据SkuImageExample example = new SkuImageExample();example.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());skuInfo.setSkuImages(skuImageMapper.selectByExample(example));// 缓存逻辑:基础过期时间+随机偏移量String json = new Gson().toJson(skuInfo);long baseExpire = 30; // 基础30分钟long random = new Random().nextInt(5); // 0-5分钟随机值redisUtils.set(key, json, baseExpire + random, TimeUnit.MINUTES);} else {// 空值缓存(同样加随机偏移,避免空值缓存同时失效)long nullExpire = 5 + new Random().nextInt(2); // 5-7分钟redisUtils.set(key, null, nullExpire, TimeUnit.MINUTES);}}return skuInfo;
}
2.3 、注解缓存解决方案
一般可以采用多级缓存,不同级别的缓存设置不同的超时时间,尽量避免集体失效,由于注解式的灵活度很低(高度封装),建议使用非注解式解决方案
注解式通过自定义缓存过期时间生成器,为不同 key 分配随机过期时间。
代码示例:
// 1. 自定义缓存过期时间生成器
public class RandomTtlRedisCacheWriter extends DefaultRedisCacheWriter {private final Duration baseTtl;private final int randomRange; // 随机范围(分钟)public RandomTtlRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration baseTtl, int randomRange) {super(connectionFactory);this.baseTtl = baseTtl;this.randomRange = randomRange;}@Overridepublic void put(String name, byte[] key, byte[] value, Duration ttl) {// 覆盖默认ttl,使用基础时间+随机值Duration actualTtl = baseTtl.plusMinutes(new Random().nextInt(randomRange));super.put(name, key, value, actualTtl);}
}// 2. 配置缓存管理器
@Configuration
@EnableCaching
public class RedisCacheConfig {@Beanpublic RedisCacheManager cacheManager(RedisConnectionFactory factory) {// 创建带随机过期时间的缓存写入器RandomTtlRedisCacheWriter writer = new RandomTtlRedisCacheWriter(factory, Duration.ofHours(2), // 基础2小时30 // 随机0-30分钟);RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig().serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));return RedisCacheManager.builder(writer).cacheDefaults(config).build();}
}// 3. 业务层使用(与普通注解一致)
@Service
public class SkuInfoService {@Cacheable(key = "'sku:'+#skuInfoId+':info'", cacheNames = "skuInfo")public SkuInfo findBySkuInfoId(Integer skuInfoId) {// 数据库查询逻辑(同上)}
}
3、缓存击穿
3.1、概述
在高并发的情况下,大量的请求同时查询同一个key时,此时这个key正好失效了,就会导致同一时间,这些请求都会去查询数据库,这样的现象我们称为缓存击穿
3.2、非注解缓存解决方案
核心方案:分布式锁 + 双重检查,确保同一时间只有一个请求查询数据库。
代码示例:
@Override
public SkuInfo findBySkuInfoId(Integer skuInfoId) {String key = "sku:" + skuInfoId + ":info";String lockKey = "lock:sku:" + skuInfoId; // 分布式锁keyObject value = redisUtils.get(key);SkuInfo skuInfo;if (value != null) {// 缓存命中skuInfo = new Gson().fromJson(value.toString(), SkuInfo.class);return skuInfo;}// 缓存未命中,尝试获取分布式锁boolean locked = false;try {// 获取锁(设置3秒过期,避免死锁)locked = redisUtils.tryLock(lockKey, 3, TimeUnit.SECONDS);if (locked) {// 双重检查:获取锁后再次检查缓存(防止锁等待期间已被其他请求更新)Object doubleCheck = redisUtils.get(key);if (doubleCheck != null) {return new Gson().fromJson(doubleCheck.toString(), SkuInfo.class);}// 查询数据库skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());if (skuInfo != null) {// 补充关联数据SkuImageExample example = new SkuImageExample();example.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());skuInfo.setSkuImages(skuImageMapper.selectByExample(example));// 缓存数据(带随机过期时间)String json = new Gson().toJson(skuInfo);long expire = 30 + new Random().nextInt(5);redisUtils.set(key, json, expire, TimeUnit.MINUTES);} else {// 缓存空值redisUtils.set(key, null, 5, TimeUnit.MINUTES);}return skuInfo;} else {// 未获取到锁,等待50ms后重试Thread.sleep(50);return findBySkuInfoId(skuInfoId); // 递归重试}} catch (InterruptedException e) {Thread.currentThread().interrupt();return null;} finally {// 释放锁if (locked) {redisUtils.unlock(lockKey);}}
}
优化备注:
- 分布式锁必须设置过期时间,防止锁持有者宕机导致死锁
- 双重检查机制:获取锁后再次查询缓存,避免重复查询数据库
- 未获取到锁时应重试(而非直接返回),重试间隔建议 50-100ms
- 推荐使用 Redisson 等成熟框架实现分布式锁,而非自行实现
tryLock
3.3 注解缓存解决方案
基于 Spring AOP 和分布式锁实现,通过自定义注解封装锁逻辑。
代码示例:
// 1. 自定义防击穿注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheBreakdownProtection {String lockKeyPrefix() default "lock:"; // 锁key前缀long lockExpire() default 3; // 锁过期时间(秒)long retryInterval() default 50; // 重试间隔(毫秒)
}// 2. AOP切面实现
@Aspect
@Component
public class CacheBreakdownAspect {@Autowiredprivate RedisUtils redisUtils;@Around("@annotation(protection)")public Object around(ProceedingJoinPoint joinPoint, CacheBreakdownProtection protection) throws Throwable {// 获取方法参数(假设第一个参数为ID)Object[] args = joinPoint.getArgs();String id = args[0].toString();String lockKey = protection.lockKeyPrefix() + id;try {// 尝试获取锁boolean locked = redisUtils.tryLock(lockKey, protection.lockExpire(), TimeUnit.SECONDS);if (locked) {// 获取锁成功,执行原方法return joinPoint.proceed();} else {// 未获取到锁,重试Thread.sleep(protection.retryInterval());return around(joinPoint, protection); // 递归重试}} finally {// 释放锁(需判断当前线程是否持有锁,避免误释放)if (redisUtils.isLocked(lockKey)) {redisUtils.unlock(lockKey);}}}
}// 3. 业务层使用
@Service
public class SkuInfoService {@Cacheable(key = "'sku:'+#skuInfoId+':info'", cacheNames = "skuInfo")@CacheBreakdownProtection(lockKeyPrefix = "lock:sku:") // 应用防击穿注解public SkuInfo findBySkuInfoId(Integer skuInfoId) {// 数据库查询逻辑(同上)}
}
优化备注:
- 注解式通过 AOP 封装锁逻辑,业务代码更简洁
- 需注意锁的粒度:建议按 ID 维度加锁(如
lock:sku:1001
),避免全局锁影响性能 - 重试次数需有限制(可在注解中增加
maxRetry
属性),防止无限重试导致栈溢出 - 适用于高并发读、低并发写的场景,如商品详情查询
4、总结
问题 | 核心解决方案 | 非注解式优势 | 注解式优势 |
---|---|---|---|
缓存穿透 | 缓存空值 + 短期过期 | 灵活性高 | 代码简洁 |
缓存雪崩 | 随机过期时间 + 多级缓存 | 易定制 | 全局管理方便 |
缓存击穿 | 分布式锁 + 双重检查 | 控制粒度细 | 无侵入性 |