黑马点评学习笔记2

1.缓存穿透

1.1 缓存穿透是什么?

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

1.2缓存穿透的解决方案

1.2.1 缓存空对象

缓存空对象是一种简单暴力的做法
思路就是:

当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库。而数据库能够承载的并发不如redis这么高,所以为了请求不访问到数据库,我们就把空对象存储到redis里去。这样,下次用户过来访问这个不存在的数据,在redis中也能找到这个数据,尽管命中的是null,但请求不会再到数据库

优点:实现简单
缺点:有额外的内存消耗

例如随便传一个id(不存在的数据)就将我以空对象的形式缓存起来,那么这也就是说随便传各种各样的id都会被缓存起来,因此redis里面就会缓存了很多这样的垃圾。
但是这个问题可以被解决
解决方法就是我们在缓存null的时候添加一个ttl,ttl就是有效期,我们可以给它设置一个比较短的ttl,比如五分钟或者是两分钟。
在两分钟内缓存是有效的,所以当有恶意的用户来访问的时候也可以起一定的保护作用,同时它的有效期也不长所以说这些垃圾数据过段时间也会被清除掉,也不会带来特别大的内存消耗
但是可能造成短期的不一致(假设用户请求了一个ID,这个id刚好不存在,我们给他设置了null,就在此时我们真的给这个id插入了一条数据,那这个时候等于数据库里已经有了,但是我们却缓存了一个null,这个时候用户来查询,查询到的是null,而实际上是存在的,这就出现了不一致,只有当这个ttl过期以后,用户才能查到最新的一个数据
针对这个问题,只要我们在一定程度上控制ttl的时间,在一定程度上是可以缓解的,那么这个不一致的时间只要足够短其实也是可以接受的。
如果实在是无法接受这个不一致性,我们可以在新增一条数据的时候,我们主动的把这条数据插入到缓存中覆盖之前的null

1.2.2 布隆过滤

布隆过滤准确来讲是一种算法
思路就是:

它的原理是在客户端和redis之间又加入了一层拦截,当用户请求来了之后不是上来就查redis,而是先去找布隆过滤问一问这个数据是存在还是不存在,如果这个数据不存在会直接拒绝就不给机会继续往下走,如果告诉你存在,那么被允许去访问redis
布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中

1.2.3 其他解决方案

以上两种方案都属于被动方案(即已经发生了缓存穿透然后想办法弥补)
但是我们也可以主动地采取一些措施去解决缓存穿透

  • 增强id的复杂度,避免被猜测id规律

这样就可以尽可能避免让别人猜到我们的id规律而输入自己编的一些id

  • 做好数据的基础格式校验
  • 加强用户权限校验

即加强用户权限的管理,比如说什么的用户能够访问我们,这样的一个用户访问我们的时候有一个什么频率的限制

  • 做好热点参数的限流

2.缓存雪崩

2.1 缓存雪崩是什么?

缓存雪崩是指在同一时段大量的缓存key同时失效或者redis服务宕机,导致大量请求到达数据库,带来巨大压力

2.2 缓存雪崩的解决方案

  • 给不同的Key的TTL添加随机值

我们平时在做缓存的时候,为了做缓存的预热我们可能提前把数据库中的数据导入到我们的缓存当中,这个导入一般是批量导入的。在批量导入的过程中,因为是同一时刻导入的,他们的ttl设置的是一样的值,很有可能将来时间一到,所有的都一起过期了,那么就会出现雪崩
为了解决这个问题我们在做缓存预设批量的数据导入的时候,我们可以给这个ttl后边跟上一个随机数,比说我们的时间是30分钟有效期,然后我们在后面跟上一个随机的1-5之间的一个随机数,那么它的一个有效期就会在30-35分钟之间波动,当然我们也可以扩大这个随机数,这样的话就可以让这些key过期的时间分散在一个时间段内而不是一起失效

  • 利用Redis集群提高服务的可用性

redis宕机导致的缓存雪崩是最严重的,针对这个问题我们要尽可能避免redis的宕机,那也就是提高整个redis的高可用性,要想提高redis的高可用性我们就必须借助redis集群
例如rediis的哨兵机制,redis哨兵可以实现服务的监控
如果说主机宕机了,哨兵可以自动地从从机里面选出来一个去替代原来的主机,这样就可以确保redis已知能够正常地对外提供服务,而且我们的主从还可以实现一种数据的同步,如果说主机宕机了,从机上面还会有数据,这样就可以在很大程度上保证redis的高可用性

  • 给缓存业务添加降级限流策略

比方说出现了人们无法抗拒的一些超级严重的事故:整个服务器挂了、整个机房挂了,这些导致的就是整个redis都挂了,整个集群都完蛋了
那这个时候的可用性如何保证呢?
此时我们就可以给服务添加一些降级限流的策略
那这是什么意思呢?
也就是提前做好一些容错处理
当我们发现redis出现故障时我们应该及时地去做服务降级
比如说快速失败拒绝服务而不是把这个请求继续发到我们的数据库上,这样子做的话就可以保护我们的数据库了,牺牲部分服务但是最终保护了我们整个数据库的一个健康
那这个降级如何实现的呢?
还是与sprringclound相关内容有关

  • 给业务添加多级缓存

何为多级缓存?
缓存的使用场景是多种多样的,不仅可以在应用层添加还可以在多个层面建立缓存,这样redis这一环崩溃了那么还会有很多的缓存可以去弥补
通俗的说就是防弹衣穿了五层,打了一层还有一层
具体来说请求从浏览器发出,那么浏览器是可以添加缓存的,但是浏览器的缓存一般缓存的是静态的数据,而对于一些需要从数据库查询的动态数据是无法做缓存的,对于这一部份我们可以在反向代理服务器nginx
层面去做缓存,nginx未命中再去找redis,redis未命中再到达jvm(我们还可以在jum内部建立本地缓存),最后就是数据库.
所以说通过多级缓存的方式就可以有效地避免雪崩导致的问题了

3.缓存击穿

3.1缓存击穿是什么?

缓存击穿问题也叫热点key问题,就是一个被高并发访问并缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大冲击

如何理解高并发访问?
这个key被访问的非常非常多,可能是正在做活动的某一件商品,它的缓存同一时刻可能就会有无数的请求来访问这个key

如何理解缓存重建业务比较复杂?

何为缓存重建呢?
就是我们的缓存在redis中存着,它到了一定的时间就会被清除,缓存就会失效,失效了之后我们需要重新从数据库里查询写redis

但是在实际的开发中,从数据库里查询并且构建这个数据并不一定是在数据库查到什么就直接往redis里面存什么,有时候一些业务比较复杂,我们那需要从多个数据库的表中进行查询后要去做各种各样的表关联的运算,最终得到的这个结果把它缓存起来
那这样一来一个业务它的耗时可能就会比较长达到几十毫秒上百毫秒甚至数百毫秒,那这样一来,在这么长的一个时间段内,我们的redis等于一直没有缓存,此时就会有无数请求来都无法命中了并将请求打到数据库,这就有可能把数据库都整垮了

3.2缓存击穿的解决方案

3.2.1互斥锁

互斥锁比较好理解,就是现在有无数的请求都进来尝试去做重建了,我们用加锁的方式让这些请求都不要来创建,只要有一个来创建就可以了

具体来说就是假如现在有一个线程,它来查询的时候未命中,那么它就会去做缓存重建,但是为了避免无数的线程都来重建,我们会加锁,也就是线程发现未命中之后,必须先获取锁,只有获取锁成功的人才可以去重建这个缓存,重建完之后把这个数据写入缓存,紧接着就可以释放锁了。
线程获取锁失败它就会去重试,但是不可以无限重试,我们可以让它休眠一下再去重试(重新查询是否命中),如果说命中,就不用再获取了

这个解决方案最大的缺点就是互相等待

比如说现在同一时刻有1000个线程来了,其实只有一个线程在做重建,其他的线程都在等待。那如果说这个重建的时间比较久达到比如说200ms甚至500ms,那么在这一段时间内涌入的所有线程都只能等待,因此就会导致性能比较差,所以才有了第二种解决方案

3.2.2 逻辑过期

逻辑过期不是真的过期 ,可以认为是永不过期
这个方案就是当我们再向redis存储数据的时候我们不设置ttl了

缓存击穿就是因为设置了ttl,缓存突然失效,导致未命中然后需要重建

1.但是不设置ttl我们如何知道这个缓存是否过期?
逻辑过期就是我们在存储一个数据的时候(以前是存储一个k-v就结束了)在valve里面加一个字段
比如说expire过期时间
这里的过期时间并不是ttl而是我们再去添加缓存的时候在当前时间基础上加上一个过期时间(比如说30分钟)得到的一个时间存储进去
也就是说我们逻辑上维护了这个时间
2.这个key没有ttl过期时间是不是意味着将来这个key一旦存储到redis里面就永远不会过期?
加上我们配了合适的一些内存淘汰策略的话,理论上可以认为只要这个key写到redis以后永远都能够查询的到,不会出现未命中的情况。
一些热点key往往都是在做活动的时候我们去添加进去的,那我们在做活动的同时直接给它设知道redis中添加上逻辑过期时间,活动结束了我们再把它移除就可以了。

那因此任何的线程来查询这样的热点商品的时候理论上来讲都是可以命中的的,唯一需要判断的就是逻辑上有没有过期,如果说逻辑上已经过期了,那可能说明这个key已经是一些旧的数据了需要更新

3.那接下来我们需要去做什么呢?
就是要去重建这个缓存,但是这个重建为了避免有多个线程都来重建他也要去获取锁,那这里做的就跟前面的一样了,也是要等待
但是为了避免获取锁以后等待时间过长,那它拿到锁之后就会做一件事,他不是自己选择构建而是开启一个独立的新的线程,有这个线程去做查询数据重建写入缓存(写入缓存以后要重置这个逻辑过期时间),这些都做完之后就要去释放锁,那也就是说这个耗时比较久的任务不再是线程一自己做了而是交给另一个线程去做,该线程做完以后释放锁

4.那么在它写入缓存的这段时间内,等于缓存的都是旧的数据,那这个时候线程一开启新线程做这件事,线程一干什么?
直接返回旧的数据即可。那么其他线程来查询获取锁失败的时候也不会是等待而是返回旧数据(因为他知道有人去帮我更新数据了,那就可以直接把查到的旧数据返回就可以了)

3.2.3两种方案的优缺点对比

1.互斥锁的优点

互斥锁这种方案没有额外的内存消耗

因为相对于逻辑过期来说,逻辑过期会在原有的数据的基础上要多维护一个过期时间的字段,这就会有额外的内存消耗。而互斥锁不需要去保存逻辑过期,所以它这块内存占用是比较小的

互斥锁可以保证强的一致性

因为当一个线程来取缓存的时候,如果发现未命中会尝试去更新,而如果它发现已经有人拿到锁正在更新
,他不会说去那点旧数据,因为没有旧数据,很多都是空的(因为失效了就会删除,而逻辑过期是永不删除,所以这就是为什么逻辑过期可以拿大旧的数据而不是空的数据),那么他就会等待缓存中有了数据,那时候缓存中的一定是最新的数据,所以说只要它拿到数据一定拿的是最新的,他可以保证缓存和数据库之间的这种强的一致性

2.互斥锁的缺点

等待会降低性能
除此之外还有会有死锁的风险

假设说我们这个业务里有对多个缓存的查询需求,而在同样一个业务里也有,这个时候有可能你拿到了一把锁,你要获取另外一个缓存的锁的时候结果发现是在其他业务里于是就会产生这种互相等待的死锁情况

3.逻辑过期的优点

因为逻辑过期不用等待所以它的性能就很好,并发能力不会受到影响。

4.逻辑过期的缺点

但是也会有缺点,他会返回旧数据,当我看到是过期了,但我也先用着这个数据,所以这就造成数据不一致性。
代码实现比较复杂是要维护逻辑过期以及各种逻辑判断

5.总结

总的来说这两个方案都是在解决我们缓存重建的这一段时间内产生的并发问题
互斥锁的解决方案就是在缓存重建的这一段时间内让这些并发的线程串行执行或者互相等待从而确保安全,那这种方案确保了数据的一致性但却牺牲了服务的可用性,性能是有很大下降的,而且在阻塞过程中可能甚至于不可用
而逻辑过期这种方案是在缓存重建的这段时间内保证了可用性,大家来了都可以访问,只不过访问得到的可能是旧数据与数据库不一致,这牺牲了不一致性

也就是说一个选择了一致性一个选择了可用性
没有谁好谁不好 看我们需要什么就选择什么

3.2.4 互斥锁解决缓存击穿问题的实现所要注意的细节

这个锁不是我们平常用的那个锁,平常用的是synchronize或者是lock,这个锁我们要实现拿到了锁可以执行,没拿到锁就要一直等待。但是这个执行逻辑是要我们自己去定义的,所以说我们不能够使用前面的那种锁的方式了,1.我们要采用一个自定义的锁

2.那要用什么方式来实现这种自定义的互斥锁呢?

所谓的这种互斥锁就是说在多个线程并发执行的时候只能有一个人成功其他人失败

在我们学习的redis的string数据类型的时候,它里面就有一个命令跟这个效果是非常接近的setnx
(set the value of a key,only if the key does not exist就是说给key赋值当且仅当这个key不存在的时候去执行,如果key存在就不执行了)

3.它为什么会做到互斥呢?
我们先用一个setnx 给一个key赋值
假设这个key是lock,代表的就是一把锁
现在有这么一个对象一他要去获取这把锁setnx lock 1
这个返回值为1,这个返回值代表的就是成功get lock 检查是否成功
这时候又有一个对象二想要获取这把锁setnx lock 2
这个返回值为0
再来一个对象执行同样的操作还是0
现在我们通过get lock看看lock的值有没有发生改变
没有改变,依然是最开始设置的1

这就说明setnx是在key不存在的时候才能往里面写,key如果已经存在了是无法写的

如果说现在有数百上千并发的线程一起来执行setnx操作,只有第一个人会成功,它成功写入了之后其他线程再来执行setnx得到的一定是一个0也就是失败的结果,这就类似于我们讲的互斥(只有一个人成功其他都失败)

这就是自定义锁的一个方案 其实这也是分布式锁的基本原理,当然真正的分布式锁会比这个要复杂得多

4.获取锁用setnx就可以了那释放锁呢?
获取锁是给它赋值,那释放锁其实非常简单就是把这个锁删除掉就可以了(del lock)(删除掉了之后再有其他人来执行这个setnx操作的时候就能成功了)

但是会有意外的情况,比如说setnx设置了一把锁设置完了之后由于某种原因程序出问题了最后迟迟没有人去执行这个删除或者释放的动作,那将来就很有可能这把锁就永远不会释放
所以我们在利用setnx设置锁的时候往往会给它加一个有效期,比如说设置有效期为十秒钟,一般我们的业务执行在1秒钟以内,那么在这个业务执行的过程中如果正常释放就行,万一因为某种异常导致服务出现了故障锁永远不释放了,将来十秒钟到了之后锁还能够自动释放
所以说我们再去设置这把锁的时候也会给它设置一个有效期来做兜底,避免因为某种原因得不到释放产生死锁

5.在代码如何实现?
在实现业务之前先去声明两个方法代表 获取锁和释放锁
setnx的方法名是setIfAbsent

注意我们不能直接返回setIfAbsent的执行结果(这个结果在命令行中是0和1,但是spring帮我们转换成了Boolean),我们需要把这个结果转为基本类型之后才能返回,如果直接把结果返回是会做拆箱的,那么在这个拆箱过程中是有可能出现空指针的(拆箱底层调用booleanValue()方法,如果flag为null的话就会空指针异常),所以这里可以使用一个工具类BooleanUtil里的isTrue方法,这个方法的意思是只有true的时候返回true,flase和null都返回false
(为什么会有包装类,因为不包装就是基本类型,包装了就是一个类,很多操作需要类才能进行

6.注意:

  • 注意在获取锁成功的时候应该再次检测redis缓存是否存在,做DoubleCheck。如果存在则无需重建缓存
  • 获取锁失败的时候递归会有溢栈的风险
  • 这里还要处理异常,不管有没有异常都要释放锁,所以释放锁这块要放到finally里面去写

3.2.5 逻辑过期解决缓存击穿问题的实现所要注意的细节

逻辑过期不是真正的过期,它要求我们在存储数据到redis的时候额外地要添加一个过期时间的字段,这个key本身是不需要去设置ttl的,所以它的过期时间不是由redis控制的,而是有我们程序员自己去判断它是否过期的,那这样我们的业务上就会复杂很多

理论上讲只要设置了逻辑过期就不会出现未命中的情况
首先key是不会过期的所以我们可以认为一旦这个key添加到了缓存里面它应该是会永久存在的除非活动结束我们再人工删除,而像这种热点key往往是一些参加活动的一些商品,我们会提前给他们加入缓存,在那个时候就会给他设置一下逻辑过期时间,所以说理论上讲所有的热点key都会提前添加好并且一直存在直到活动结束。因此我们去查询的时候其实可以不用去判断它有没有命中,如果说真的查到了这个缓存不存在那只能说明一个问题就是说这个商品它不在活动当中,不属于一个热点key

因此我们的核心逻辑就是默认缓存命中

在命中的情况下,我们需要判断它有没有过期,也就是它的逻辑过期时间
如果未过期则直接把这个数据返回给前端
但是如果过期了则说明需要做缓存重建,不过不是任何人来了都可以做缓存重建,所以这里也需要先尝试去获取这个互斥锁然后判断一下是否获取成功,如果获取失败则返回旧数据,获取成功则开启独立线程去做缓存重建(查询数据库,将数据写入缓存并设置逻辑过期时间 最后释放互斥锁)自己返回旧数据

开启独立线程去做重建建议是使用一个线程池,不要自己去写一个线程,自己写一个线程性能不太好需要经常去创建和销毁,所以我们就创建一个线程池

那这个逻辑过期时间怎么添加到数据里面呢?
虽然我们可以直接在实体类中添加逻辑过期字段 这样在提交实体类数据到缓存的时候逻辑过期时间的字段也在缓存中设置好了 ,但是这种方法不够好,因为这样会对原来的代码和业务逻辑做了修改

因此我们可以定义一个新的对象Redisdata,里面定义一个字段 Local DataTime expireTime 这个就是我们设置的逻辑过期时间
现在想要我们的实体类具备这个逻辑过期时间的属性
1.第一种可以让我们的实体类继承redis Data,那这个实体类自然就具备了这个属性了,但是这个方法还是会去修改我们的实体类,因此还是有一定的侵入性。

2.另一种就是在redisData里面去添加一个Object属性 data,也就是说这个RedisData里面有过期时间 也有自带的一个数据,这个数据就是我们想要存进redis(即缓存)里面的数据,他是一个万能的存储数据的对象,这种方案就完全不需要对原来的实体类做任何的修改

前面也说了像这种热点数据的缓存我们是会做提前导入的,在实际的开发中,会用到后台管理系统可以把某一些热点数据提前存储到缓存中
现在我们没有一个后台管理系统,所以我们会给予一个单元测试的方法来把热点数据加入到缓存当中,等于提前做一个缓存预热
但是我们需要先在service里面定义一个saveDataToRedis

猜你喜欢

转载自blog.csdn.net/weixin_52055811/article/details/132061508
今日推荐