缓存的击穿、穿透和雪崩应该是再熟悉不过的词了,也是面试常问的高频试题。
不过,对于这三大缓存的问题,有很多人背过了解决方案,却少有人能把思路给理清的。
而且,网络上仍然充斥着,大量不太靠谱的解决方案,难免误人子弟。
我的这篇文章,则会对这三大缓存问题,做一个深入的探讨和分析。
最有价值的,不是答案本身,而是诞生答案的过程。
缓存击穿
缓存击穿是什么,大家应该心里都清楚,我只做一个简单通俗的解释:
就是某一个热点数据,缓存中某一时刻失效了,因而大量并发请求打到数据库上,就像被击穿了一样。
说白了,就是某个数据,数据库有,但是缓存中没有。
那么,缓存击穿,就会使得因为这一个热点数据,将大量并发请求打击到数据库上,从而导致数据库被打垮。
要解决这个问题之前,我们就先得对整个系统的架构有一定的了解:
首先,在客户端很多的情况下,他们必然会去访问我们的 redis;
这时候 redis 是做缓存用的,所以在后面,必然有一个 DB,比如我们的 MySQL;
如果站在外围的层面来说,这个客户端其实就是一个 service;
再往前延伸的话还有很多其它的服务,这个服务只是微服务群体中的一个;
微服务如果再往前,就是网关,可以是业务网关,比如 Springcloud 的 Zuul;
再往前,必定要有流量分发层,负载均衡器等等,比如 Nginx、LVS;
如果项目够大,也可能会有 CDN 这样的把各地的流量分离;
真正在最前面的,才是我们的用户,去访问我们的服务。
真正的流量,也就来自与用户,他们才是行为的主体。
用户的量足够大,才会有所谓的高并发,
我们的系统,也就是从前往后,一层一层的过滤掉各种各样的请求,
真正能抵达我们的数据库的,只有很少的一部分请求,这才是一个架构师在横看一个项目时,应该做的事情。
而我们的 redis,在整个系统体系中,作为缓存,就是将很多的请求抗住,过滤掉,
所以最后的数据库的压力就会很小。
那么既然 redis 是作为缓存,
那要么就会给 key 设置过期时间,在一段实际后清除;
或者,就是 LRU、LFU,清除冷数据。
所以说,只要是作为缓存,那么就一定存在这种情况:
一个 key,某一时间,要么过期了,要么 LRU 清除,然后,就突然有人来访问它。
就好像是在 redis 上打了一个窟窿,击穿了,穿过去了。
本来一个系统一个架构,请那么多人,花那么多力气,就是为了能让系统抗住更高的并发,让更少的请求打进我们的数据库里,
结果,就因为一个 key 过期了,这么一个小小的事情,导致所有的请求疯狂打进我们的数据库。
那么这件事情该怎么规避,首先不要去看网上什么到处都是的博客里边的描述,你需要先承认一点,就是肯定发生了高并发。
如果一个系统本来就没啥并发量,那就压根没什么事,请求打到数据库上来呗,完全不是什么问题。
那么在高并发的情况下,一个 key 过期了,然后,就是几千几万的并发蜂拥而至,这该怎么解决?
有些学艺不精的会给出这些布隆啊、过期时间散开啊、改进缓存算法、延长过期时间这些个答案,
那说明对缓存这个概念还没有深刻的认识。
首先,大部分人会想到这么一个答案:热点数据永不过期。
在网络上盛行的解决方案有很多:
设置 key 永不过期,在修改数据库时,同时更新缓存;
后台起一个定时任务,每隔一段时间,再 key 快要失效的时候,提前将 key 刷新为最新数据;
每次获取 key 都检查,key 还有多久过期,如果快过期,则更新这个 key;
分级缓存,一级缓存失效,还有二级缓存垫背。
这一类的回答还有很多,统称归纳起来,就是让 redis 的缓存不过期,
普遍的做法就是:
设置 key 永远不会过期;
在缓存还没有失效前,就更新缓存。
如果你给出过这样的答案,那说明你应该没有在实际的生产环境中,没有真正碰到过,且需要处理这样的情况,
因为确实,存在缓存击穿问题的,光并发量的要求,就可以排除掉 99% 的企业,
所以,真正有实际场景的,去考虑解决缓存击穿问题的人,少之又少。
所以,大部分人,都只是停留于纸上谈兵的阶段,正确与否,没有实际场景的验证,只能靠直觉去判断;
或者,浏览了很多的文章博客,发现人人都这么说,便也自然而然认为,事实就是这么一回事。
下面我来详细分析,为什么,这些解决方案,在实际的生产环境中,是无法胜任的。
首先,我来分析,key 永不失效的解决方案,为什么不可行。
因为,对于一个需要解决缓存击穿问题的企业,他们的业务量一定是普通人无法想象和企及的;
所以,他们的数据量是巨大的,因而需要缓存,去保留热点数据,减轻数据库的压力;
所以根据这一点就可以明确,不存在缓存不会失效的情况。
为什么呢?
因为数据的量巨大,我们的 redis 缓存,是基于内存的,一个单点,一般也不会分配过大的内存,来保证它足够灵活。
但是,即便是集群,所能存储的数据量也是有限的。redis 不可能把所有的数据全部缓存入内存,没有什么企业可以说用内存就可以存储所有的数据。
但是,你可以说,只存热点数据啊!
但是,什么叫热点数据?你觉得是就是吗?
真正的环境中,热点数据是在时时变化着的,我们可以对一些热点做一些预估,但是,我们永远无法保证我们能预估到多少。
在这样千变万化的环境中,一个明星干了点什么事,就能掀起你无法想象的流量;
比如 xxx 怎么怎么的了,然后新浪就瘫痪了,也不是没有的事。
所以,数据是流动的。
所以在真实场景中的热点数据,是绝对不可能是由人去评估的,所有的热点数据,都是根据时时的流量,系统缓存自动过期掉那一部分已经冷门的数据,然后又缓存起新的热点数据。
所以说,热点数据,一定是时时出现,时时消失的,我们靠人的大脑,是无法直接判断出所有在某个时间点会出现的热点数据。
所以这必须由我们的系统,能够直接去应对数据的变化,在巨量数据的流动中找到平衡。
所以,我们的 redis 缓存,也不可能让 key 永远不会过期。
所以,redis 也不是你想让它存一些不过期的数据就行的,由于热点数据的不断变动,redis 必须在时刻淘汰旧的数据,缓存入新的数据。
第二个,网络上很流行的答案就是:加锁
synchronized 加锁,而且还衍生出双重检查锁;
ReentrantLock.tryLock(),缓存没有,尝试加锁,抢不到就睡一会,抢到的那一个查数据库;
redis 的命令 setnx(),只有一个线程能设置成功,也就是能加到锁,只有加到锁了,才读数据库,然后存会 redis 里,其它则等待一会,然后再去 redis 取。
其实加锁确实是可以解决的,
但是,如果你要是写了 synchronized,那你一定会被直接炒鱿鱼。
这种都是严重的问题,会使你的系统可能就瘫了。
首先,对于缓存穿透的情况,肯定是高并发场景,所以数据库才可能扛不住。
所以,查询 redis,或者 mysql 的,一定不可能是单台 tomcat 进程。
所以,在如此多的 Tomcat 集群的情况下,一把 Java 锁,是不可能锁住一个集群的。
而且,synchronized 一但加锁,是不可撤销的,它不像 ReentrantLock 那样,可以 tryLock,加不到锁也可以返回。
所以,一但使用了 synchronized 加锁,会使得所有的读 redis 缓存也加锁。
读请求加互斥锁绝对是致命的,这个系统绝对是一启动就被流量击垮。
虽然说,用 ReentrantLock,tryLock 加锁,成功的去数据库读取数据;
而那些失败的,则睡眠一段时间,再重新去缓存读取,
这个流程已经开始像实际的解决方案了。
但是,一个 Java 锁最多只能够锁一个 JVM 进程,对于一个集群来说,这绝对是远远不够的。
而且,去 redis 里读取数据的,可能不仅仅只是 Java 进程,像 Nginx 也能直接访问 redis、mysql,是不是有点超出你的认知?
如果你的解决方案仅仅是一把 Java 锁,那么绝对达不到生产环境的要求。
所以实际上,缓存穿透,加锁解决,必须还要涉及到分布式锁的概念。
这里不谈 zookeeper 之类的东西,既然谈 redis,那么就用 redis 来解决这个问题。
首先,当一个 key 失效,不管是时间过期,还是被 LRU、LFU 剔除,
假设会有 1w 个并发来访问这个 key,那么它们就会先查询 redis,然后都发现,这个 key 不存在;
然后,它们就会对应的,往 redis 用 setnx 设置一个 key,来表示这是一把锁;
然后,只有一个线程,会设置成功,然后去读取数据库,写回 redis;
其他的 9999 个线程,则 sleep 一小会,然后再去访问我们的 redis。
有人看到这,首先会问,这个 sleep 要多久?
这个是要根据压测,以及线上环境进行调整的,一般会给出一个合适的值,也就是大约从数据库取出数据的时间。
所以,正常情况是不会出现大面积长时间等待的情况的。
看起来似乎可行,但是,还有问题吗?
我要这么说肯定是有问题,但是,你可以想一想,存在什么问题?
如果你不知道,说明对分布式锁还不够了解,那么,就继续跟着我分析。
现在,我开始假设:
首先,一堆请求访问 redis,发现为空;
然后,这一堆并发开始尝试加锁,最终只有一个人,获取到了锁,其它人都失败;
然后,持有锁的机器,断电了;
其他人,一直等着,但是始终没有人等到锁被释放,或者 redis 被重新存入该数据。
这是一个分布式锁最常见的问题,就是加锁进程死亡,导致锁无法被释放。
于是就产生了死锁问题。
现在,既然出现了问题,那么,我们一定得想办法去解决。
首先,对于我们的分布式集群系统,任意一台机器都有挂掉的可能。
所以,我们首先要明确的思路就是,如何在加锁进程死亡的情况下,去释放这个锁。
可以想到两种方案:
第一:
就是另起一个集群,负责专门监管锁的获取和释放;
一但持有锁的进程宕机,监管集群就负责将死锁给释放。
明显,这么做成本比较高昂,还不如用完善的 zookeeper 去实现分布式锁。
第二种:
就是平时比较常见的,用 redis 的设置过期时间,来保证,即使宕机,锁也能在超时过后自动释放。
于是,之前的方案,就可以稍作修改:
首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,其它人都失败;
然后,锁被设置上过期时间,保证无论如何一定会被释放;
然后,持有锁的机器,断电挂了。。。;
其他人,等了一会,发现锁又没了,于是重新开始之前的操作。
看起来很完美。
但是,还有问题吗?
我要这么问了,那么一定说明有。
那么,现在请你先不要拖到后文,先自己思考,会存在什么问题,然后再来看我的分析。
现在我继续列出场景,
假设:
首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,其它人都失败;
然后,持有锁的人还没来得及设置锁的过期时间,就挂了。。。
其他人,一直等着,但是始终没有人等到锁被释放,或者 redis 被重新存入该数据,又是死锁。
既然问题来了,我们就需要想办法,去加以解决。
首先,可以确定的是,可能锁没有来得及增加过期时间,从而导致,可能出现死锁的情况。
因为,之前的设置锁、和设置过期时间,是两步操作,不是原子的。
有些人可能就会说,那就放一个原子操作啊!
但是,redis 并没有一个 API,既可以 setnx,又同时给予它一个过期时间。
那该怎么办?
所以,这就需要考验,我们对 redis 的各种机制的掌握程度了。
首先,redis 有事务这么一个概念,
不过,redis 的事务不像 mysql 那样,可以支持回滚。
那么不能回滚的事务也可以用来完成锁操作吗?
虽然不支持回滚,但是主要是因为 redis 的事务是保证原子性的:
事务中的命令要么全部被执行,要么全部都不执行。
如果客户端在使用 MULTI 开启了一个事务之后,却因为断线而没有成功执行 EXEC ,那么事务中的所有命令都不会被执行。
另一方面,如果客户端成功在开启事务之后执行 EXEC ,那么事务中的所有命令都会被执行。
在一个事务中,只有全部的命令发送结束了,并且提交事务,那么整个事务中的所有指令,才会被 redis 执行;
也就是说,在尝试去给 redis 的一个 key,加锁,只要不最终 EXEC 触发事务,那么这些方法就永远不会被执行;
那也就是,要是 EXEC 触发事务执行,就一定会执行加锁和设置过期时间的命令。
否则,没有 EXEC,就两条指令都不会执行。
这样,就可以保证,redis 不会出现死锁的问题。
这样,解决了死锁问题,就看起来很完美了。
但是,
这样就可以了吗?
确实,如果只是解决了死锁的问题的情况下,是没有什么问题的。
但是,因为我们在解决死锁问题的时候,引入了超时时间,所以,就会导致新的问题的产生。
我们在解决一个问题的时候,往往会引入新的问题。
现在,假设:
首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,且设置了超时时间;
然后,持有锁的线程,开始进行读数据库的操作;
但是,由于各种不确定因素,它这次读数据库读得很慢,所以还没结束,锁就超时释放了;
然后,第二个线程也拿了一个锁,开始它的操作;
然后,第一个线程结束了,这时,本该所有其他线程可以访问数据库了,但是,由于第二个线程去加了锁,导致现在它们得额外继续等到第二个线程去释放;
这样,就会增加了等待时间,响应延迟就会增大,如果,再多几次加锁过程,响应延迟就会越发严重,或者直接超时断开。
所以,在设置锁的超时时间的时候,该怎么设置?
设置短一点?
那就会很容易发生锁被别人又抢过去的情况;
那设置长一点?
那么就又可能使得阻塞时间变长。
所以,锁的超时时间又成了问题。
既然新问题出现了,我们就得想办法去解决它。
而现在普遍的解决方案,就是多线程:
在加锁了之后,由于锁会有过期时间,然而又不能保证,锁一定不会在执行结束过后过期,
那么,我们就可以采用多线程的方案,让锁每隔一定时间,就重新设置它的超时时间。
于是就出现下面这样的场景:
首先,一个手速快的家伙抢到了锁,并且也设置了超时时间,比如 30 秒。
然后,一个线程执行业务操作;又另起一个线程,去监管锁的时间;
假设,这个业务做起来比较漫长,过了 10 秒还没结束;
于是,监控线程感觉不妙,于是将过期时间又重新设置成了 30 秒;
业务继续执行着,然后又过了 10 秒,锁的过期时间还剩 20 秒;
于是,监控线程又感觉不妙,于是将过期时间再一次重新设置成了 30 秒;
周而复始,只要业务没做完,锁就不会过期;
假设 1,进程挂了,然后,30 秒一到,锁被释放;
假设 2,业务执行完了,于是线程主动释放锁。
于是,多线程的技术,就把这个缓存穿透的方案给解决了。
是不是觉得巨麻烦,竟然要从头到尾经历这么多的过程,才能最终,实现一个不起眼的缓存穿透!
不过,实际上你再细想,其实,我上面提及的各种问题和解决方案,都刻意回避了一个问题:
就是,redis 是单节点单实例的。
也就是说,我们对这一个 key 的操作,都是在一个 redis 上,而没有同时牵扯到其他 redis;
所以,只要这个 redis 不挂,那么就不存在问题。
不过要是 redis 挂了,那么面临的问题,也就不是 redis 的缓存击穿问题了。
而是系统的高可用的解决方案,比如我上一篇文章提到的:
redis 的主从、主备,哨兵监控,来保证 redis 挂了之后,能立刻有 redis 前来替补。
文章指引>>
不为技术而技术:Redis 从单点到集群
因为后面的这些个知识点,对集群有相关的知识,所以,我也很建议,你可以看一下我的这一篇文章。
你也可以先看后面的,然后看完后,去看我之前的这一篇文章,做一次知识的整合和理解,然后再回过头来看到这里,也许你又会有不一样的感觉。
不过,即使是主从模型,允许 redis 的从节点也提供读服务,
这样就会存在数据在一定时间内不一致的情况,那么其实也没有太大的问题。
假设:
第一个线程,抢到了锁,然后访问完数据库,将数据写回主结点;
然后,其它线程去从结点读取,由于可能数据同步的不及时,导致一部分结点读出的数据还是空;
于是,那些读同步不及时的那部分从节点的线程,再重复一遍之前的操作;
这样,就可能出现部分线程的往复多次操作,一直读,一直是空。
如果从节点多的话,那么所有的从节点之中,至少大部分从结点,都是通信正常的,
一般不会出现大面积坏死的情况;
所以,如果有少量从节点没有数据,那么会导致的二次重新操作,也只是少量的一部分线程,这样,也只是再次加锁一次,多读取了一次数据库。
然而实际上,也根本并不用那么麻烦,假设从节点没有读取到,可以直接去主节点读取,那么就不会出现数据迟迟读取不到的情况了。
也就是,对于这样的加锁操作,没有必要要去涉及到从节点,所有的锁操作,直接对主节点即可。
所以说,通过双线程的加锁操作,是可以解决缓存击穿的问题的。
不过,由于我在上文,提到了这是一个分布式锁的概念,
要是,我在这里,仅仅就这么结束的话,难免会有同志误以为这就已经是一个完美的分布式锁,
所以,我再稍微提一下,redis 集群的分布式锁的知识点。
对于单个 redis 来说,上面的知识点已经可以实现分布式锁了。
但是,既然要讨论高并发高可用的系统,就会涉及到集群。
对于单个 redis 来说,假设,加锁的 redis 挂了,那该怎么办?
redis 的主从模型,默认使用异步同步数据的方式,所以,存在数据不一致的情况,
主节点挂了,从节点顶替的时候,是可能丢失数据的,
所以,这把锁很可能就丢了。
为了能够解决这样的问题,Redis 的作者 antirez 给出了一个更好的实现,称为 Redlock,算是 Redis 官方对于实现分布式锁的指导规范。
如果你们不善于阅读英文,那么就直接看我中文的描述:
在算法的分布式版本中,我们假设有 N 个 redis,且这些节点是完全独立的,也就是不存在任何主从关系,一个 redis 的死活和其他 redis 没有任何关系。
那么,接下来,就请思考一下,加锁的操作:
首先,既然是分布式锁,那么就不能只对单台结点加锁,因为上面已经描述过了,一但该结点宕机,就可能会使得锁丢失,因此,存在单点故障的问题。
所以,就必须对集群中的多个结点加锁。
那么,应该给几台结点加锁呢?
如果采用全部结点加锁成功,才表示加锁成功,那么就成了强一致性,
如果你阅读过我的《Redis从单点到集群》这篇文章,应该能明白,强一致性,会对可用性产生冲击,因而不适合采取这样的方式。
那么应该给几个结点加锁成功,才表示加锁成功呢?
在 Redlock 的实现中,加锁的 redis 结点,只要满足,N/2+1,也就是过半,即代表加锁成功。
为什么要过半呢?
因为过半才能保证,真正加锁成功的,只有一个。
过半又不要求全部,这样,保证了持有锁的唯一性,并且也保证了集群的可用性足够好。
那么既然最基础的问题解决了,下面,假设出现这么一个场景:
假设,N=5,有 5 台 redis:
一个线程,向 redis 集群发起加锁操作,然后第一个结点加锁成功了;
然后,它又紧接着立刻向下一个结点发起加锁,也加锁成功了;
然后,它又向第三台 redis 加锁,也加锁成功了;
那么,这时候,已经代表,它获得了 redis 集群的分布式锁;
但是,它不知道,它前两个节点加的锁,已经过期了,这时候,它只加了一把锁。
然后,另一个人揭竿而起,也立刻加上了 3 个节点,也代表获取了锁。
这下,就出现了一个集群,有两人持锁,锁就不可靠了。
不过,在之前提到过,我们可以给 redis 的锁,延续超时时间。
于是,假设:
一个线程,向 redis 集群发起加锁操作,向 1、2 redis 加锁,都加锁成功了;
但是,加锁到 redis 3,由于网络通信延迟,一直卡在那加锁;
这时,另一个哥么看不下去了,于是他也发起加锁操作;
于是,它向 redis 1、2 开始加锁;
1、2 因为已经被加过锁了,所以加锁失败,然后加锁 3、4、5;
但是,由于它和 4、5 连接有故障,导致无法加锁成功;
然后,这时第一个加锁的哥们,由于网络故障,也没有加锁成功;
从而,俩人都没加锁成功。
其实,如果没有第一个家伙,第二个哥们是能加到锁的,
但是,由于第一个加锁者,占据了锁的位置,占用了大量的时间,导致之后加锁的线程,就会因为被占用,很容易加不到锁,就会使得加锁资源被白白浪费,系统的加锁过程就会变长,效率变低。
所以,为了解决这个问题,就可以设置一个超时时间的概念,让加锁的每一步,都快速,轻盈,
加的到就加的到,加不到就加不到,过程迅速,不拖泥带水,
这样,就能使得加锁的过程更迅速,加锁冲撞而导致加不到锁的概率也会变低,从而使得加锁更加高效。
所以,加锁的时候,设置超时时间,但是,如果加锁最终没有成功,就不给单独结点上的锁续命,就让它快速过期,这样,就能够使得集群之间的加锁更加高效迅速,而不容易出现争抢激烈的情况。
所以,在这里,就不应该像之前那样,给锁延长超时时间。
所以,在整个加锁过程中,整个加锁的过程,不能超过锁的有效时间,否则,就应算作加锁失败,要立刻清除所有单独结点上的锁。
现在,想来你应该能大致理解,Redlock 加锁的大致过程了,下面我就用简略的语言,翻译一下官方对于 Redlock 的加锁操作:
首先获取当前时间(毫秒数);
按顺序依次向 N 个 redis 节点获取锁,其中要保证 key 相同,且 value 随机;
为了保证在某个 redis 节点不可用的时候算法能够继续运行,获取锁的操作还有一个超时时间,它要远小于锁的有效时间(几十毫秒量级)。
客户端在向某个 redis 节点获取锁失败以后,应该立即尝试下一个 redis 节点。这里的失败,应该包含任何类型的失败,比如该 redis 节点不可用,或者该 redis 节点上的锁已经被其它客户端持有(注:Redlock 原文中这里只提到了 redis 节点不可用的情况,但也应该包含其它的失败情况)。
计算整个获取锁的过程总共消耗了多长时间,就是用当前时间减去第1步记录的时间。
如果客户端从大多数 redis 节点(>= N/2+1,也就是过半)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间,那么这时客户端才认为最终获取锁成功,否则,认为最终获取锁失败。
如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第 3 步计算出来的获取锁消耗的时间。
如果最终获取锁失败了(可能由于获取到锁的 redis 节点个数少于 N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有 redis 节点释放锁。
加锁的过程比较复杂,不过释放锁的过程就简单多了:
向所有 redis 节点发起释放锁即可,不管这些节点当时在加锁的时候成功与否。
看起来似乎很完美了,但是,我继续抛出一个问题。
假设一共有 5 个 redis ,分别是 ABCDE:
客户端1成功锁住了A, B, C, 加锁成功(但 D 和 E 没有锁住)。
节点 C 崩溃重启了,但客户端 1 在 C 上加的锁没有持久化下来,丢失了。
节点 C 重启后,客户端 2 锁住了 C,D,E,加锁成功。
这样,客户端 1 和客户端 2 就同时获得了锁。
这时候该怎么办?
我们是不能保证,在分布式及群中,没有结点会宕机的。
在默认情况下,redis 的 AOF 持久化方式是每秒写一次磁盘(即执行 fsync),因此最坏情况下可能丢失 1 秒的数据。
为了尽可能不丢数据,redis 也允许设置成每次修改数据都进行 fsync,但这会降低性能。
当然,即使执行了 fsync 也仍然有可能丢失数据(这取决于系统而不是 redis 的实现)。
所以,上面分析的由于节点重启引发的锁失效问题,总是有可能出现的。
那么,既然锁可能因宕机而丢失,已经无法再恢复。于是,antirez 又提出了延迟重启 (delayed restarts)的概念。
也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间。
这样的话,只要这个结点不重启,如果此时,持有锁的线程所占据的 redis 只剩下了 2 台,那么,这把锁就无法被继续维持,那么,只要失效时间一到,锁就会被保证被迫过期释放。
所以,延迟重启,就使得这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。
不过关于 Redlock 还有一点细节值得拿出来分析一下:
在最后释放锁 的时候,antirez 在算法描述中特别强调,客户端应该向所有 redis 节点释放锁。
也就是说,即使当时向某个节点获取锁没有成功,在释放锁的时候也不应该漏掉这个节点。
这是为什么呢?
设想这样一种情况,客户端发给某个 redis 节点的获取锁的请求成功到达了该 redis 节点,这个节点也成功执行了 SET操作,但是它返回给客户端的响应包却丢失了。
这在客户端看来,获取锁的请求由于超时而失败了,但在 redis 这边看来,加锁已经成功了。
因此,释放锁的时候,客户端也应该对当时获取锁失败的那些 redis 节点同样发起请求。
因为这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。
谈到这里,对于缓存的击穿,以及涉及到的一点 redis 分布式锁的知识,你应该已经了解得差不多了。
而且,其实实际上,你会发现,我谈论到的知识,绝对是不限于仅仅在这个 redis 身上的,而是这整个架构的设计和思维。
如果你仅仅把眼光放在这个小小的 redis 上,那你是永远不会达到架构师的水平的。
缓存雪崩
其实把缓存击穿搞清楚了,那么你去理解缓存雪崩也会容易许多。
缓存雪崩,指的是大面积的 key 同时过期,导致大量并发打到我们的数据库。
不像击穿,只是因为 1 个 key 的过期。
所以,对于雪崩来说,一般,少量的 key 失效,所带来的数据库的并发压力是不会太大的。
而是大量 key 的同时失效,导致所有 key 的并发加起来,会影响到我们的数据库。
那就算一个 key 失效,也会对数据库造成很大的影响,那么你把雪崩的所有 key 拆成一个一个 key 来看,也就是雪崩可以拆分成一个一个缓存击穿的集合。
其实在真实场景中,雪崩才是一个更容易发生的一个问题,它不像击穿那么极端,一个 key 就成千上万的并发,直接把数据库打垮了;
而是,可能就一个 key 几十几百的并发,然后大量的 key 一过期,然后就使得好多并发,同时叠加起来,累积到上千上万个,把数据库打崩了。
那么既然缓存击穿已经给过解决方案了,那么我们现在要关注的,则是如何缓解雪崩所带来的压力。
因为,key 是同时失效,所以导致很多 key 的并发,一起压上来,才会使得数据库的并发压力过大,
所以,我们很正常的思路就是,就是让并发分散开来。
首先一个很常见的做法就是,分散 key 的过期时间。
确实,这么做是可行的,因为这个问题的本质,就是要让瞬间到来的并发,把它分散开。
而给了一定的随机过期时间之后,就能够使得 key 会分散开,一个一个过期,
所以,并发量就会分成一部分,一部分,少量的打到数据库上。
看起来就很像一个削峰的操作。
这个方法,是最简单有效的。所以一般情况,我们都采用这种方式。
不过,要考虑一种情况,就是,如果你的业务对时点性要求高,必须每天的指定时间,去更新我们的数据。
就比如游戏每日零点更新,或者财报记录……等等等等。
就是,在某一个固定的时间,由于业务要求,必须使得数据刷新,并且不允许出现旧数据。
所以,必须让缓存全部失效。
像这样的业务应该怎么办?
因为这个场景,非常类似削峰操作,所以有人会觉得,可以用 MQ,先把读请求打入 MQ,再一个一个依次消费。
这样可行吗?
首先,从系统实现来说,是可以保证,数据库的请求压力先被扛下来,然后异步消费。
但是,对于读请求,是不应该用到消息队列的。
如果是异步写,没有什么问题,用户只要求能把数据存入即可。
但是对于读,如果用队列依次读取,那么,大量用户的响应延迟,就会变高,这对用户体验的影响是不容忽视的。
所以,对于读请求,不适合用队列的方式,因为这已经把请求串行化了,不再是并发执行。
于是,还有人提出观点,让缓存提前开始更新。
但是,提前更新了之后,比如 58 分开始更新,59 分的时候,有大量数据又被修改了呢?
所以,数据是不准确的,那些 59 分修改了之后的客户,在 12 点查看数据的时候,发现数据仍然没有变化,他们就会认为,系统的 12 点更新的说法不靠谱,公司不值得信任。
所以,缓存是必须要在 12 点准时失效,准时更新的。你无法让更新时间进行变化。
那么,你还能想到什么办法?
因为此刻,redis 中的数据,是必须立即失效的,你不能够改变。
那么,对于 redis,就不能够把时间分散开来。
既然 redis 不可以,那么其它地方可以吗?
所以,这就是考验你思维和功力的时候。
既然 redis 无法分散过期时间,那么,我们去查数据的时候,是不是可以把时间稍微地分散一下?
所以到了下面这种情景:
时间一到,redis 数据全部失效;
大量并发前来查询;
在 service 服务层,查询时,设置一个短暂的随机延迟时间;
这样,于是查询的操作,就被分散了开来;
少量的请求,先查询,就会读数据库,然后存入 redis;
其他请求,由于随机时间,稍稍慢了点,就可以去 redis 读出数据。
这就是,从业务层,再把时间分散。
带来的影响,也就是客户等待时,会多那么几十毫秒的延迟,不过对于人来说,是微乎其微,可以接受的。
所以,对于时点性要求高的业务要求,雪崩的问题,想要解决,还必须稍微多思考,变通一下。
缓存穿透
很多人会把缓存穿透和击穿搞混,主要是名词方面的混淆。
更多的,我觉得关注意思即可。
缓存穿透,与击穿的区别就是,
击穿:数据库里“有”数据;
穿透:数据库里“没”数据。
所以,缓存击穿可以规避,因为只是 redis 缓存数据失效了,而数据库里有数据,只要把数据库里的数据更新到 redis 上,那么就可以解决掉缓存击穿的问题。
但是,缓存穿透,意味着,这个数据,数据库里也没有。
所以,就不可能会把数据存到 redis 缓存里,因此只要有人来查询,就一定缓存中查不到,所以就一定要走数据库。
那么,假设很多人,故意去查那些数据库里也没有的记录,我们的 redis 就起不到屏障的作用,因为 redis 里不可能有数据,所以并发查询就一定会打到数据库的身上。
那么,想要解决缓存穿透,就必须想办法,能够识别出,哪些请求的数据,是数据库没有的,然后,对这些请求的查询,进行过滤。
如果你以前没有了解过这些知识,那你可以先想一想,可以用什么办法?
比较简单的,就是选择,当用户查询不存在的数据时,将这个 key,存入 redis,然后用一个特殊的 value 来表示,这是一个不存在的数据。
但是,如果有大量的请求,都请求各不相同的不存在的数据,那么,redis 的缓存,就会用来存储大量没用的数据,就会造成空间的浪费。
而且,一般这部分的请求,都是人刻意攻击服务器。
而且,很明确的一点,就是数据是无限的,我们不可能找出所有的数据库中不存在的数据;
但是,对于数据库里已有的数据,那就是有限的,所以我们可以找出所有已经存在的数据;
这样,当请求打过来的时候,我们就能以此来判断,这个数据是否存在。
但是,由于数据量的巨大,我们必须得想一个方法,怎样用尽可能少的空间和时间,去对数据是已有在做一个判断。
正是因为 redis 的小,内存空间的可贵,才使得,我们不能够去缓存所有的数据,因而才会有查询 DB 的操作。
所以,我们不可能直接将完整的数据信息全部搬入内存。
那么,既然数据无法完整存储,那么是否可以,只保留 key,省略 value,从而使得单位范围内内存能存储的信息量大了很多。
因为数据的大部分空间,都是 value 占用的,一般 key 和 value 相比,都是非常非常小的。
所以,是不是就可以额外开辟一片 Set,用来专门存储 key,这样,每次要访问数据库前,先去 key set 中查询时候存在,如果存在,那么再去访问数据库。
这样,确实可以使得缓存不会穿透了。而且相比缓存全量 key、value,只存储 key 会使得内存的占用变小了很多。
但是,理论上听起来似乎不错,假设,我们一台 redis,用来缓存后端 4T 的热点数据;
现在为了实现缓存穿透的解决,假设一个 key : value = 1 : 99,那么需要缓存的总量 key 大约要 40G。
所以,到了数据面前,就能发现仍然是一个超高成本的方法。
那么,既然如果存储 key,空间仍然很大,那么我们能否想出一个更节省空间的存储方式?
一般有点经验的都会想到用 bit,也就是用一个位来存储,这已经是计算机中的一个最小的存储单位了。
现在,先不管如何实现,我们先来看一下,假设用 bitmap,那么空间的花费代价有多少。
因为一个 key 只占用一个 bit,所以,假设我们花费 1 个字节的空间,就能存储 8 个 key;
假设,我们用了 1 M字节的空间,就可以存储 800w 个 key;
那么我们在增多一点,用 100MB,那么就能存储 8亿 个 key;
由此可见,用 bitmap,确确实实可以达到对空间利用的极致。
那么,既然空间的问题解决了,下面就要解决如何使用这些空间:
也就是如何把一个 key,和一个 bit 去对应起来。
有点经验的,立刻就能够想到,用哈希映射。
就比如我们的 HashMap,我们的每一个 key,都能通过哈希函数,转换成一个数组的下标,然后将键值对,存储在 HashMap 中。
HashSet,就是只存储 key,而不存储 value。
所以,采用哈希映射,就可以将那些所有存在的 key,全部对应到这个 bitmap 的每一个槽位上,
这样,我们可以将所有存在的 key,把它映射到一个 bit 槽位上,然后用 1 表示,其他剩余的部分,就用 0 表示,
这样,当一个查询的 key 被映射到 0 这个槽位,那么就代表这个数据不存在,所以就可以直接返回。
因此,就可以实现对请求数据的过滤。
看起来似乎很完美,既解决了空间的问题,又可以保证每一个 key,能够映射到一个槽位上。
但是,仔细一思考,就会发现,其实还有问题。
首先,我们先谈我们熟悉的:HashMap。
我们知道,如果 HashMap 出现冲突的话,会用一个链表去连接起那些冲突的结点,从而保证,所有的 key,都能在里面完好无缺的存着,不存在不同的 key 将其他 key 挤占的情况。
但是,由于此时的槽位已经缩减为 bit,已经不能够再往上去追加其它的数据结构了,所以,就无法用链表解决冲突碰撞的产生。
而且,不单单是不能用链表,还有一个原因,就是由于 bit 槽位只存 0、1,即使碰撞,也无法判断原有的信息是什么,到底是不是同一条信息。
所以,bitmap 由于它的精简,因此,不能够将碰撞给消除解决。
那么,该怎么办?
实际上,这个问题是无法被完全解决的,由于节省空间,每一个槽位被精简到了一个比特,所以,能表示的信息已经只有 0、1 两种,从而无法表示出其他信息。
除非增加空间来保存更多的信息,否则就无法被解决。
那么,假设增加空间,加一个 bit,体积就会增大一倍;再加两个,体积又会翻一倍;
看起来,这种方式,为了绝对的解决冲突,花费的代价,是有点高的。
我们费尽心思节下来的空间,又会被重新花费。
那么,既然作为软件工程的学习者,我们必须有这么一个思维,就是不较真,不去追求极限。
比如强一致性,因为会破坏可用性,所以一般都会采取弱一致性,或者最终一致性。
这里也是如此,既然不能解决冲突问题,那么,可以想办法,让冲突发生的概率更小,而不是去完全地让冲突消失。
所以,就可以延伸到布隆算法。
首先,因为在单次哈希的情况下,会产生一定的碰撞;
因此,为了降低碰撞而导致的概率,于是,采用多次哈希的方式,每一次哈希都往一个槽位写上 1;
当来查询一个不存在的 key 的时候,就可以进行同样的多次哈希,第一次可能碰巧撞对,得到 1,但是后面还有两次,这样,就不一定有那么好的运气,还能够撞对。
因此,这样,可以降低缓存穿透的概率。
这样,只要对应出我们的需求,去调整 bitmap 的大小,以及哈希函数的个数,就可以得到不同的过滤的百分比,虽然可能出现漏网之鱼,不过那也已经是少之又少了。
不过,对于布隆过滤器,我们的使用还是需要去思考一下的。
首先,对于布隆过滤器,我们可以把它放在客户端。
也就是每一个客户端都包含这么一个算法,以及存储了一个 bitmap,来过滤无效的查询请求。
这样,redis 的压力就比较轻。
不过,由于是基于JVM内存的一种布隆过滤器,
所以重启即失效;
而且由于存储在本地内存中,导致无法应用在分布式场景;
因此也不支持大数据量存储。
第二,我们也可以选择,客户端包含算法,然后,把 bitmap,存到 redis 上去,
这样,客户端就是无状态的,因而可以轻松的复制。
第三,或者,还可以,直接把算法,和 bitmap,一并放到 redis 上去,也就是在 redis 当中集成这么一个模块。
这样,客户端就又省去了代码,就能够更加灵活;
而且也省去了重启失效和定时任务维护的成本;
但是,由于布隆过滤器外迁到了 redis 上,从而会导致网络 I/O 的开销增大,并且性能会比在 JVM 上的 Google 布隆过滤器性能略低。
不管怎样,至少,穿透的问题,似乎已经迎来了大结局。
不过,你有没有想到布隆过滤器有一个缺点,
就是,我们谈到这里,好像只字未提,布隆过滤器的删除。
也就是,我们的布隆过滤器,只能往里边添加数据,而不能够删除数据。
你现在想一想,是不是这么一个情况!
也就是说,如果数据频繁增删改,是不太适合用布隆过滤器的。
因为,一个数据变更之后,布隆过滤器无法删除 key,因此,只能重新创建一个布隆过滤器,再加载一遍所有的数据,创建出 bitmap。
那么,解决的话,可以用布谷鸟这样的,带删除功能的,来满足动态变化的需求。
作者的话
其实,缓存的击穿、雪崩、穿透,看似很平常简单的问题,其实背后能够涉及到的知识点,可以有很多。
很多时候,对于一个问题,不是去拘泥于这个问题,而是你能够,联想到这个问题所置身的场景,能够理解清楚,整个系统的环境,能够从一个高的维度,去看这一系列的过程。
所以,对于我们来说,重要的,不是去背过这些答案,而是能够从一个系统、一个架构的角度,去理清设计的原由和思路。
这样,你才能够去面对,这个不断变化着的时代。
————————————————
版权声明:本文为CSDN博主「小龙JWY」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_44051223/article/details/105590857