缓存常见问题与解决方案
文章目录
1、缓存穿透
1.1、 概述
缓存穿透是指查询一个一定不存在
的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
@Override
public 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 、非注解缓存解决方案
@Override
public 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数据进行缓存,并且设置过期时间为5min
redisUtils.set(key, null, 5, TimeUnit.MINUTES);
}
}
return skuInfo;
}
1.3 、注解缓存解决方案
基于 Spring Cache 注解式缓存解决缓存穿透,核心思路与非注解式一致:缓存空值并设置较短过期时间。需要通过配置CacheManager
和注解属性配合实现。
代码示例:
// 1. 配置Redis缓存管理器(设置默认过期时间及空值处理)
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public 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 {
@Autowired
private SkuInfoMapper skuInfoMapper;
@Autowired
private 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;
}
@Override
public 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 {
@Bean
public 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; // 分布式锁key
Object 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 {
@Autowired
private 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、总结
问题 | 核心解决方案 | 非注解式优势 | 注解式优势 |
---|---|---|---|
缓存穿透 | 缓存空值 + 短期过期 | 灵活性高 | 代码简洁 |
缓存雪崩 | 随机过期时间 + 多级缓存 | 易定制 | 全局管理方便 |
缓存击穿 | 分布式锁 + 双重检查 | 控制粒度细 | 无侵入性 |