前言
由于本文讲述的主要是缓存穿透,缓存雪崩和缓存击穿这三个常见的问题,这三个问题是在学习黑马点评时遇到的问题,所以说业务逻辑也是在黑马点评中就已经前提说好了。在这之前,我们肯定首先需要知道什么是缓存,缓存主要用来干什么,那么废话不多说,我们就开始我们今天的解答。
什么是缓存
由于我个人对于计算机的理解也不能说太深,所以我只好通过我个人较为直白的的理解通过mysql和redis这两个数据库去讲述一下什么是缓存,我么知道,当我们需要从"我的电脑"当中读取数据时,是从C盘,D盘这样的磁盘当中去读取的,这就涉及到了IO操作,Mysql中的数据也是基于去存储的,所以说当我们的应用层需要去访问mysql中的数据的时候,就涉及到了大量的IO操作。而Redis就提供了一个缓存,缓存当中也可以对数据进行一系类操作,但是缓存他是基于电脑的运行内存去实现的,我们购买电脑时也会去看电脑的配置信息,其中就包含了运行内存这一个信息,这也就是我刚才所说的运行内存
为什么需要缓存
就像刚才所说的,对于Mysql的一系类操作涉及到了磁盘IO操作,而对于Redis的操作则是涉及到了电脑的运行内存,我们知道基于内存实现内容交互肯定是比磁盘要快很多的,因此如果当有大量的请求直接去我们的数据库查询,那么会很容易的将数据库直接干崩溃。这个时候缓存的作用就出现了。当第一次请求请求过来时,我们可以先去查询缓存,如果命中缓存了,那么直接将缓存中的数据返回即可,但是如果缓存未命中,就说明我们先前没有查询过,那么我们就需要先去查询数据库,再将数据库的信息写入缓存即可
缓存穿透
定义
在进行讨论解决,我们肯定首先需要知道什么是缓存穿透,缓存穿透指的是当一个请求到发送到我们的服务端时,我们需要去查找对应的数据并将这个数据返回给他,可是存在着一种情况,就是需要查询的数据在我们的数据库和缓存都不存在,这样缓存永远都不会生效,这些请求最终都会打到数据库上,造成数据库的崩溃。例如:前端发送请求查询小明的信息,可是小明的信息在我们的缓存和数据库不存在,前端第一次查询发现没有数据返回,那么就会一直持续查询,这样是很不好的
解决方案
缓存空对象
缓存空对象,就是他的字面意思,当请求发送过来时,我们查询缓存未命中,就会去查找我们的数据库,但是如果我们发现数据库中也没有对应的信息,我们可以将一个空对象直接写入缓存,这样以后如果有类似的请求,就会直接从缓存中获取空值,但是我们一般会为这个缓存设置一个有效期
优点:实现简单,维护方便
缺点:额外的内存消耗,可能造成短期的不一致
布隆过滤
当客户端发送请求时,会首先通过我们的布隆过滤器,由布隆过滤器进行判断,只有过滤器允许放行请求才可以继续执行
优点:内存占用较少,没有多余的key
缺点:实现复杂,存在误判功能
缓存雪崩
定义
缓存雪崩指的是同一时间内大量的缓存key同时失效或者是Redis服务宕机,导致客户端的大量请求直接请求到数据库,带来巨大压力
解决方案
1.给不同的key的TTL添加随机值
2.利用Redis集群提高服务的可用性
3.给缓存业务添加降级限流策略
4.给业务添加多级缓存
缓存击穿
定义
缓存击穿问题也叫做热点key问题,就是一个被高并发访问并且缓存重建业务较为复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击,常常出现在我们的秒杀场景中
解决方案
互斥锁
当一个线程去查询数据的时候,缓存没有命中,此时需要给这个缓存加上锁,之后再进行查询数据库进行缓存重建的操作,当这个操作执行完成之后再将锁给释放,因此当其他线程在释放锁之前发现查询缓存没有命中的情况,就让当前线程休眠一会,然后再进行操作,直到上一个线程将锁释放之后才会继续执行操作。
加上锁之后,当一个线程出发了缓存未命中的状态时,在他进行缓存重写的过程中,如果有其他并发的请求也响应过来,此时其他请求由于没有拿到互斥锁会造成线程的休眠,一直等到拿到互斥锁为止
/*
解决缓存击穿的代码实现
*/
public Shop getShopWithCacheBreakDown(Long id){
//1.先根据商品id去缓存当中查询
String shopJSON = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id); //得到shop对象的序列化数据
//2.1 如果缓存中存在且为非空,则直接将这个信息返回
if(StrUtil.isNotBlank(shopJSON)){
//将对象序列化后返回给客户端
return JSON.parseObject(shopJSON,Shop.class);
}
//执行到这就说明shopJSON肯定不为空,所以此时只要shopJSON也不为null就符合""的情况
//2.2 如果缓存当中存在并且为""的,那么需要返回错误
if("".equals(shopJSON)){
return null;
}
Shop shop = null;
try {
//3.缓存当中不存在,判断是否拿到了互斥锁
if(!lockShop(id)){
//4.未拿到互斥锁,则让当前线程休眠
Thread.sleep(200);
//休眠后重新执行当前逻辑
return getShopWithCacheBreakDown(id);
}
//5.拿到互斥锁了之后,去数据库当中查找数据
shop = getById(id);
Thread.sleep(200);
//6.数据库当中的数据不存在,将一个空值存入到redis当中
if( shop == null){
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id ,"", RedisConstants.SHOP_NULL_TTL , TimeUnit.MINUTES);
return null;
}
//5.数据库当中的数据存在,则将这个对象添加到缓存当中
//5.1将对象序列化为JSON类型的数据
String shopMysqlJSON = JSON.toJSONString(shop);
//5.2将序列化后的数据添加到redis中
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id ,shopMysqlJSON , RedisConstants.SHOP_TTL , TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlockShop(id);
}
设置逻辑过期时间
如果缓存不存在,就说明当前热点的key已经不存在了,因为热点key只是物理层面有expiretime表示过期时间,他在redis中不会真的消失,如果命中缓存了,才需要对expireTime判断是否过期了,如果未过期,则直接将当前获取到的信息返回就好了,但是如果过期了,则需要开启一个新的线程执行缓存重建的操作,当前线程直接返回这个旧数据就好了
/*
利用逻辑过期解决缓存击穿的问题
*/
public <R,T> R queryWithLogicalExpire(String keyPrefix, T id, Class<R> type, Function<T,R> dbFallBack,Long ttl, TimeUnit timeUnit){
//1.在缓存中查找数据
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//2.如果缓存未命中,返回null
if(StrUtil.isBlank(json)){
return null;
}
//3.如果缓存命中,判断逻辑时间是否过期
RedisData redisData = JSON.parseObject(json, RedisData.class); //先转为RedisData对象
LocalDateTime expireTime = redisData.getExpireTime();
R result = JSON.parseObject(redisData.getData().toString(), type);
//4.如果逻辑没有超时,则直接将旧数据返回
if(expireTime.isAfter(LocalDateTime.now())){
return result;
}
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
//5.如果逻辑没有过期,则去更新逻辑失效时间,首先需要获取互斥锁
if(lock(lockKey)){
//6.获取到锁,进行缓存重建
R apply = dbFallBack.apply(id);
try {
//6.2缓存中没有数据再执行缓存重建的操作,开启独立线程,在独立线程中执行缓存重建的操作
CACHE_REBUILD_EXECUTOR.submit(()->{
this.setWithLogicalExpire(key,apply,ttl,timeUnit);
});
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//6.3释放锁
unlockShop(key);
}
}
//7.直接返回旧数据
return result;
}