今天学习到了Redis的缓存穿透、缓存雪崩、缓存击穿,其中缓存雪崩还没有做具体的案例,所以就不做介绍了。具体案例代码为黑马点评项目。
基本概念:
- 缓存穿透:指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会请求到数据库中。
- 缓存击穿:也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,大量的请求会在瞬间给数据库带来巨大的压力。
解决方案:
- 缓存穿透:
- 缓存null值。
- 布隆过滤。
- 增强id的复杂度,避免被猜测id规律。
- 做好数据的基础数据校验。
- 加强用户权限校验。
- 做好热点参数的限流。
其中前两点是比较被动的方案,后面的几点是比较主动的方案,这里主要来实现缓存null值来解决缓存穿透。
- 缓存击穿:
- 互斥锁。
- 逻辑过期。
缓存穿透的解决方案:
先来看使用缓存null值来解决缓存穿透的代码:
private Shop queryWithPassThrough(Long id){
String key= RedisConstants.CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson)){
//不为空则说明有redis中缓存命中,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//如果命中的是空值
//这儿是在解决缓存穿透
if (shopJson!=null){
//这里需要理解一下,如果shopJson不是null,说明其只能是空字符串,说明其就是“”,已经
//是解决过的缓存穿透的了
return null;
}
Shop shop = getById(id);//mybatisplus 根据id去查询商铺信息
if(shop==null){
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return shop;
}//这是抽取出来解决缓存穿透的方法,在调用时,可对返回值做出具体的判断,如果为null,则可以给出对应的提示
对StrUtil包中的isNotBlank()的介绍:
StringUtils.isNotBlank(str) 等价于:
str != null && str.length > 0 && str.trim().length > 0
关于缓存穿透的解决方案:
缓存穿透利用缓存null值解决还是很简单的,基本的逻辑就是,当查询的信息在缓存和数据库中都没有时,为了避免缓存穿透,就把该信息在第一次查询数据库确定没有时,返回给redis空值,以便在下次查询时可以在redis中命中。
缓存击穿的解决方案:
运用互斥锁来实现具体代码:
private Shop queryWithMutex(Long id){
String key= RedisConstants.CACHE_SHOP_KEY+id;
//从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if(StrUtil.isNotBlank(shopJson)){
return JSONUtil.toBean(shopJson, Shop.class);
}
//如果命中的是空值
//这儿是在解决缓存穿透
if (shopJson!=null){
return null;
}
//1实现缓存重建,解决缓存击穿
//1.1获取互斥锁
String lockKey=RedisConstants.LOCK_SHOP_KEY+id;
Shop shop=null;
try {
boolean isLock = tryLock(lockKey);
//1.2判断是否获取锁成功
if (!isLock){
//1.3失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//获取锁 成功 也应该再次检测redis缓存是否存在,做doubleCheck的判断,这里我觉得主要是为了,有可能一个线程刚释放锁,另一个线程正好去获取锁,
//此时获取到了锁,但此时redis的缓存已经更新,就没必要了
//这里可考虑把这一段代码抽取为一个方法
shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if(StrUtil.isNotBlank(shopJson)){
return JSONUtil.toBean(shopJson, Shop.class);
}
//1.3成功,根据id查询数据库
shop = getById(id);
if(shop==null){
//这里在解决缓存穿透
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL,TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放互斥锁
unlock(lockKey);
}
return shop;
}
//解决缓存击穿,获取锁
private boolean tryLock(String key){
Boolean flag=stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.MINUTES);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
利用互斥锁来解决缓存击穿也是很好理解,当热点key失效时,大量的请求涌入,此时利用了互斥锁,只有一个线程会得到锁,得到锁以后该线程就会去查询数据库,然后将数据缓存到redis中,在这个过程中,其他的线程只能一直尝试从redis中获取对应的缓存数据再获取锁然后休眠,再递归的调用这个方法(这里用递归来解决循环是否不太合理?),直到从redis中获取到缓存数据。同时当一个线程获取锁成功时,应该进行再次的检测redis中有没有对应的缓存数据,这里考虑主要因为是,当得到锁的线程释放锁后,有其他线程在获取锁,那该线程就会获取到锁,又去mysql查询数据,此时已经没有意义了。这里也就不难看出利用互斥锁的缺点:线程需要等待,性能受影响,可能有死锁风险。优点:没有额外的内存消耗,保证一致性,实现简单。
这里主要理解来理解关于锁的实现:
SETNX:向Redis中添加一个key,只用当key不存在的时候才添加并返回1,存在则不添加返回0。并且这个命令是原子性的。添加成功表示获取到锁,添加失败表示未获取到锁。至于添加的value值无所谓可以是任意值(根据业务需求),只要保证多个线程使用的是同一个key,所以多个线程添加时只会有一个线程添加成功,就只会有一个线程能够获取到锁。而释放锁锁只需要将锁删除即可。对应到这里的java代码就是stringRedisTemplate.opsForValue().setIfAbsent()。
利用逻辑过期解决缓存击穿的代码:
缓存击穿是发生在热点key上的问题,会提前将这些热点key缓存到redis中,并且设置的是逻辑过期,所以就不存在查询不到的情况,如果真的没从redis命中,那就直接返回null就可以了。以下为提前给redis加载热点key的代码:
//提前将热点数据缓存到redis中
public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
//查询店铺数据
Shop shop = getById(id);
Thread.sleep(50000);
//封装逻辑过期时间
RedisData redisData=new RedisData();
redisData.setData(shop);
//设置逻辑过期时间
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//写入redis中
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
以下代码为具体实现用逻辑过期解决缓存击穿:
private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
//逻辑过期 解决缓存击穿
private Shop queryWithLogicalExpire(Long id){
String key= RedisConstants.CACHE_SHOP_KEY+id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isBlank(shopJson)){
//如果为空,则说明没有这个key,因为这里已经预热处理了,所以直接返回null
return null;
}
//命中,需要把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
//Shop data = (Shop) redisData.getData();
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//没有过期 直接返回店铺信息
return shop;
}
//已过期 需要重建缓存
//缓存重建
//获取互斥锁
String lockKey=RedisConstants.LOCK_SHOP_KEY+id;
boolean isLock = tryLock(lockKey);
//判断是否获取锁成功
if (isLock){
//进行双重检测,防止获取锁成功已经有其他线程实现了缓存重建
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//没有过期 直接返回店铺信息
return shop;
}
//成功开启独立线程 实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
//返回过期的商铺信息
return shop;
}
逻辑过期解决缓存击穿也很好理解,就是给对应的缓存增加逻辑过期时间,当在查询时,与当前的时间进行对比,如果发现没有过期,则直接返回数据就可以了,如果发现过期,则另开一个线程去获取锁然后去实现缓存重建,而它会直接返回旧的数据,此时即使有其他线程来,发现缓存过期,但是也已经获取不到互斥锁,也会直接返回旧的数据。这就不难看出逻辑过期的的优点:线程无需等待,性能较好。缺点:不保证一致性,有额外的内存消耗,实现相对复杂。