缓存的击穿、穿透和雪崩,对于这三大缓存的问题,有很多人背过了八股文式的解决方案,面试也能答上一二,却少有人能把思路给理清的。
这三个问题产生的前提是高并发,但是一旦发生,会导致大量的请求积压到数据库层,并发量很大,甚至会引起数据库宕机或是故障,造成严重的生产事故。下面我将一一分析,保证让你看得明白透彻!
系统架构
在解释缓存问题之前,我们先来复习一下系统架构,知道问题出在哪,才能找到解决之道。
一个大的spring cloud微服务集群,从客户端发送请求开始,会先经过F5、Nginx等流量分发层,然后到spring cloud gateway的服务网关,最后才到我们的后端服务service。而service会先从缓存查找数据,没有再去数据库找。
redis作为缓存,在整个系统体系中,就是将很多的请求扛住,过滤掉,从而使得最后的数据库的压力就会很小。很显然只有在大流量、高并发的时候,redis没能扛住访问请求,才会导致击穿、穿透、雪崩的出现。
缓存击穿
缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中获取,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,直接影响数据库无法处理其他请求。
说白了,就是某一个热点数据,缓存中某一时刻失效了,因而大量并发请求打到数据库上,就像redis被打了一个窟窿,被击穿了一样,此时,该数据redis中没有,数据库中才有。
解决方案
网上查到的方法都是说设置 key 永不过期,也就是不设置过期时间,但是数据总是会变的,那这时候该如何处理呢?思考以下几种方式,看是否有缺陷。
- 在更新数据库时,同时更新key
- 后台起一个定时任务,每隔一段时间,在 key 快要失效的时候,提前将 key 刷新为最新数据
- 每次获取 key 都检查,key 还有多久过期,如果快过期,则更新这个 key
这些方案其问题在于,更新操作不是原子性的,无法保证数据库和redis缓存数据的一致性,所以在实际的生产环境中,是无法胜任的。
这时候就可以考虑使用redis分布式锁,通过分布式锁来实现数据库和redis的数据同步。
具体操作如下:当service访问缓存key为空时,先加锁,加锁成功后往mysql写数据,接着更新缓存,然后释放锁;加锁失败,说明有其他service已经加上了锁,需等待或循环尝试查缓存和加锁。
但是加锁也有讲究,不是简单的setnx命令。如果加分布式锁的service在加锁成功后突然挂掉,那锁就无法释放,所以加分布式锁的同时,还要原子性的给锁设置超时时间。
其实redis的set命令就能够满足我们当前的需求:
SET key value [EX seconds] [PX milliseconds] [NX|XX]
其中,key是要存储的键名,value是要存储的值。EX和PX参数可选,用于设置键的过期时间,单位分别为秒和毫秒。NX和XX参数也可选,用于控制键的创建行为,NX表示只在键不存在时创建,XX表示只在键已存在时执行操作。
那这就万无一失了吗,答案肯定是否定的,分布式锁key的时候设置多长我们并不清楚,太短可能线程还没执行完业务逻辑,太长响应又过慢。为解决这一问题,需要引入redisson工具,利用redisson的看门狗机制来控制key的超时时间。
缓存穿透
缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。此时,应用也无法从数据库中读取数据再写入缓存,来服务后续请求,这样一来,缓存也就成了“摆设”。
解决方案
第一种方案是,缓存空值或缺省值。一旦发生缓存穿透,可以针对查询的数据,在 Redis 中缓存一个特殊的缺省值。应用发送的后续请求再进行查询时,就可以直接从 Redis 中读取该值,返回给业务使用,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。
不过,数据库中不存在的数据有很多,像依据前端入参作为key的缓存,如果每个都设置缺省值就会造成空间上的浪费,这时候就得考虑方案二。
第二种方案是,使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。布隆过滤器的底层数据结构是bitmap,即用一个比特位来表示一个key,花费 1 个字节的空间,就能存储 8 个 key。
一个比特位只能是0或1,无法表示更多信息,一个key经过一次哈希算法算的哈希值(即要落在第几个比特位)就有可能和其他key存在冲突。为解决冲突,就需要多次哈希,相应的一个key就需要多个比特位来存储。
这样,只要对应出我们的需求,去调整 bitmap 的大小,以及哈希函数的个数,就可以大大过滤出存在的key,降低缓存穿透的概率。
如果数据频繁增删改,是不太适合用布隆过滤器的。因为一个数据变更之后,布隆过滤器无法删除 key,因此,只能重新创建一个布隆过滤器,再加载一遍所有的数据,创建出新的bitmap。那么,解决的话,可以使用带删除功能的布谷鸟布隆过滤器,来满足动态变化的需求。
最后一种方案是,在请求入口的前端进行请求检测。缓存穿透的一个原因是有大量的恶意请求访问不存在的数据,所以,一个有效的应对方案是在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。这样一来,也就不会出现缓存穿透问题了。
缓存雪崩
缓存雪崩,指的是大面积的 key 同时过期,导致大量并发打到我们的数据库。不像击穿,只是因为 1 个 key 的过期。那就算一个 key 失效,也会对数据库造成很大的影响,那么可以把雪崩的所有 key 拆成一个一个 key 来看,也就是雪崩可以拆分成一个一个缓存击穿的集合。
其实在真实场景中,雪崩才是一个更容易发生的一个问题,它不像击穿那么极端,一个 key 就成千上万的并发,直接把数据库打垮了;而是,可能就一个 key 几十几百的并发,然后大量的 key 一过期,然后就使得好多并发同时叠加起来,累积到上千上万个,把数据库打崩了。
解决方案
首先,我们可以避免给大量的数据设置相同的过期时间。如果业务层的确要求有些数据同时失效,可以在用 EXPIRE 命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加 1~3 分钟),这样一来,不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,同时也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。
除了微调过期时间,我们还可以通过服务降级,来应对缓存雪崩。
一旦发生了缓存雪崩,数据库的每秒请求数突然增加到每秒 1 万个,此时,我们就可以启动请求限流机制,在请求入口前端只允许每秒进入系统的请求数为 1000 个,再多的请求就会在入口前端被直接拒绝服务。所以,使用了请求限流,就可以避免大量并发请求压力传递到数据库层。
不过,要考虑一种情况,就是,如果你的业务对时点性要求高,必须每天的指定时间,去更新我们的数据,比如游戏排行每日零点更新。在某一个固定的时间,由于业务要求,必须使得数据刷新,并且不允许出现旧数据,让缓存全部失效。像这样的业务应该怎么办?
既然 redis 无法分散过期时间,那就在业务层下功夫。时间一到,redis 数据全部失效,大量并发前来查询,在 service 服务层查询时,设置一个短暂的随机延迟时间,这样,少量的请求,先查询,就会读数据库,然后存入 redis;其他请求,由于随机时间稍稍慢了点,就可以去 redis 读出数据。
相当于是从业务层把时间分散。客户可能会多那么几十毫秒的延迟,不过影响微乎其微,是可以接受的。
总结
从问题成因来看,缓存雪崩和击穿主要是因为数据不在缓存中了,而缓存穿透则是因为数据既不在缓存中,也不在数据库中。所以,缓存雪崩或击穿时,一旦数据库中的数据被再次写入到缓存后,应用又可以在缓存中快速访问数据了,数据库的压力也会相应地降低下来,而缓存穿透发生时,Redis 缓存和数据库会同时持续承受请求压力。
对于缓存的击穿、雪崩、穿透,看似很平常简单的问题,重要的,不是死记硬背八股文式的答案,而是能够从系统和架构的角度,去理清设计原由和解决思路。
粉丝福利, 免费领取C/C++ 开发学习资料包、技术视频/项目代码,1000道大厂面试题,内容包括(C++基础,网络编程,数据库,中间件,后端开发/音视频开发/Qt开发/游戏开发/Linuxn内核等进阶学习资料和最佳学习路线)↓↓↓↓↓↓见下面↓↓文章底部点击免费领取↓↓