缓存的击穿、穿透和雪崩应该是再熟悉不过的词了,也是面试常问的高频试题。
    不过,对于这三大缓存的问题,有很多人背过了解决方案,却少有人能把思路给理清的。
    而且,网络上仍然充斥着,大量不太靠谱的解决方案,难免误人子弟。
    我的这篇文章,则会对这三大缓存问题,做一个深入的探讨和分析。
    最有价值的,不是答案本身,而是诞生答案的过程。
    缓存击穿
    缓存击穿是什么,大家应该心里都清楚,我只做一个简单通俗的解释:
    就是某一个热点数据,缓存中某一时刻失效了,因而大量并发请求打到数据库上,就像被击穿了一样。
    说白了,就是某个数据,数据库有,但是缓存中没有。
    那么,缓存击穿,就会使得因为这一个热点数据,将大量并发请求打击到数据库上,从而导致数据库被打垮。
    要解决这个问题之前,我们就先得对整个系统的架构有一定的了解:
    首先,在客户端很多的情况下,他们必然会去访问我们的 redis;
    这时候 redis 是做缓存用的,所以在后面,必然有一个 DB,比如我们的 MySQL;
    如果站在外围的层面来说,这个客户端其实就是一个 service;
    再往前延伸的话还有很多其它的服务,这个服务只是微服务群体中的一个;
    微服务如果再往前,就是网关,可以是业务网关,比如 Springcloud 的 Zuul;
    再往前,必定要有流量分发层,负载均衡器等等,比如 Nginx、LVS;
    如果项目够大,也可能会有 CDN 这样的把各地的流量分离;
    真正在最前面的,才是我们的用户,去访问我们的服务。
    真正的流量,也就来自与用户,他们才是行为的主体。
    用户的量足够大,才会有所谓的高并发,
    我们的系统,也就是从前往后,一层一层的过滤掉各种各样的请求,
    真正能抵达我们的数据库的,只有很少的一部分请求,这才是一个架构师在横看一个项目时,应该做的事情。

    Redis缓存击穿、雪崩、穿透详解 - 图1

    而我们的 redis,在整个系统体系中,作为缓存,就是将很多的请求抗住,过滤掉,
    所以最后的数据库的压力就会很小。
    那么既然 redis 是作为缓存,
    那要么就会给 key 设置过期时间,在一段实际后清除;
    或者,就是 LRU、LFU,清除冷数据。
    所以说,只要是作为缓存,那么就一定存在这种情况:
    一个 key,某一时间,要么过期了,要么 LRU 清除,然后,就突然有人来访问它。
    就好像是在 redis 上打了一个窟窿,击穿了,穿过去了。
    本来一个系统一个架构,请那么多人,花那么多力气,就是为了能让系统抗住更高的并发,让更少的请求打进我们的数据库里,
    结果,就因为一个 key 过期了,这么一个小小的事情,导致所有的请求疯狂打进我们的数据库。
    那么这件事情该怎么规避,首先不要去看网上什么到处都是的博客里边的描述,你需要先承认一点,就是肯定发生了高并发。
    如果一个系统本来就没啥并发量,那就压根没什么事,请求打到数据库上来呗,完全不是什么问题。

    Redis缓存击穿、雪崩、穿透详解 - 图2

    那么在高并发的情况下,一个 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缓存击穿、雪崩、穿透详解 - 图3

    看起来似乎可行,但是,还有问题吗?
    我要这么说肯定是有问题,但是,你可以想一想,存在什么问题?
    如果你不知道,说明对分布式锁还不够了解,那么,就继续跟着我分析。
    现在,我开始假设:
    首先,一堆请求访问 redis,发现为空;
    然后,这一堆并发开始尝试加锁,最终只有一个人,获取到了锁,其它人都失败;
    然后,持有锁的机器,断电了;
    其他人,一直等着,但是始终没有人等到锁被释放,或者 redis 被重新存入该数据。
    这是一个分布式锁最常见的问题,就是加锁进程死亡,导致锁无法被释放。
    于是就产生了死锁问题。

    Redis缓存击穿、雪崩、穿透详解 - 图4
    现在,既然出现了问题,那么,我们一定得想办法去解决。
    首先,对于我们的分布式集群系统,任意一台机器都有挂掉的可能。
    所以,我们首先要明确的思路就是,如何在加锁进程死亡的情况下,去释放这个锁。
    可以想到两种方案:
    第一:
    就是另起一个集群,负责专门监管锁的获取和释放;
    一但持有锁的进程宕机,监管集群就负责将死锁给释放。
    明显,这么做成本比较高昂,还不如用完善的 zookeeper 去实现分布式锁。
    第二种:
    就是平时比较常见的,用 redis 的设置过期时间,来保证,即使宕机,锁也能在超时过后自动释放。
    于是,之前的方案,就可以稍作修改:
    首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,其它人都失败;
    然后,锁被设置上过期时间,保证无论如何一定会被释放;
    然后,持有锁的机器,断电挂了。。。;
    其他人,等了一会,发现锁又没了,于是重新开始之前的操作。

    Redis缓存击穿、雪崩、穿透详解 - 图5

    看起来很完美。
    但是,还有问题吗?
    我要这么问了,那么一定说明有。
    那么,现在请你先不要拖到后文,先自己思考,会存在什么问题,然后再来看我的分析。
    现在我继续列出场景,
    假设:
    首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,其它人都失败;
    然后,持有锁的人还没来得及设置锁的过期时间,就挂了。。。
    其他人,一直等着,但是始终没有人等到锁被释放,或者 redis 被重新存入该数据,又是死锁。
    既然问题来了,我们就需要想办法,去加以解决。
    首先,可以确定的是,可能锁没有来得及增加过期时间,从而导致,可能出现死锁的情况。
    因为,之前的设置锁、和设置过期时间,是两步操作,不是原子的。
    有些人可能就会说,那就放一个原子操作啊!
    但是,redis 并没有一个 API,既可以 setnx,又同时给予它一个过期时间。
    那该怎么办?
    所以,这就需要考验,我们对 redis 的各种机制的掌握程度了。
    首先,redis 有事务这么一个概念,
    不过,redis 的事务不像 mysql 那样,可以支持回滚。
    那么不能回滚的事务也可以用来完成锁操作吗?
    虽然不支持回滚,但是主要是因为 redis 的事务是保证原子性的:
    事务中的命令要么全部被执行,要么全部都不执行。
    如果客户端在使用 MULTI 开启了一个事务之后,却因为断线而没有成功执行 EXEC ,那么事务中的所有命令都不会被执行。
    另一方面,如果客户端成功在开启事务之后执行 EXEC ,那么事务中的所有命令都会被执行。
    在一个事务中,只有全部的命令发送结束了,并且提交事务,那么整个事务中的所有指令,才会被 redis 执行;
    也就是说,在尝试去给 redis 的一个 key,加锁,只要不最终 EXEC 触发事务,那么这些方法就永远不会被执行;
    那也就是,要是 EXEC 触发事务执行,就一定会执行加锁和设置过期时间的命令。
    否则,没有 EXEC,就两条指令都不会执行。
    这样,就可以保证,redis 不会出现死锁的问题。
    这样,解决了死锁问题,就看起来很完美了。
    但是,
    这样就可以了吗?
    确实,如果只是解决了死锁的问题的情况下,是没有什么问题的。
    但是,因为我们在解决死锁问题的时候,引入了超时时间,所以,就会导致新的问题的产生。
    我们在解决一个问题的时候,往往会引入新的问题。
    现在,假设:
    首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,且设置了超时时间;
    然后,持有锁的线程,开始进行读数据库的操作;
    但是,由于各种不确定因素,它这次读数据库读得很慢,所以还没结束,锁就超时释放了;
    然后,第二个线程也拿了一个锁,开始它的操作;
    然后,第一个线程结束了,这时,本该所有其他线程可以访问数据库了,但是,由于第二个线程去加了锁,导致现在它们得额外继续等到第二个线程去释放;
    这样,就会增加了等待时间,响应延迟就会增大,如果,再多几次加锁过程,响应延迟就会越发严重,或者直接超时断开。
    Redis缓存击穿、雪崩、穿透详解 - 图6
    所以,在设置锁的超时时间的时候,该怎么设置?
    设置短一点?
    那就会很容易发生锁被别人又抢过去的情况;
    那设置长一点?
    那么就又可能使得阻塞时间变长。
    所以,锁的超时时间又成了问题。
    既然新问题出现了,我们就得想办法去解决它。
    而现在普遍的解决方案,就是多线程:
    在加锁了之后,由于锁会有过期时间,然而又不能保证,锁一定不会在执行结束过后过期,
    那么,我们就可以采用多线程的方案,让锁每隔一定时间,就重新设置它的超时时间。
    于是就出现下面这样的场景:
    首先,一个手速快的家伙抢到了锁,并且也设置了超时时间,比如 30 秒。
    然后,一个线程执行业务操作;又另起一个线程,去监管锁的时间;
    假设,这个业务做起来比较漫长,过了 10 秒还没结束;
    于是,监控线程感觉不妙,于是将过期时间又重新设置成了 30 秒;
    业务继续执行着,然后又过了 10 秒,锁的过期时间还剩 20 秒;
    于是,监控线程又感觉不妙,于是将过期时间再一次重新设置成了 30 秒;
    周而复始,只要业务没做完,锁就不会过期;
    假设 1,进程挂了,然后,30 秒一到,锁被释放;
    假设 2,业务执行完了,于是线程主动释放锁。
    于是,多线程的技术,就把这个缓存穿透的方案给解决了。

    Redis缓存击穿、雪崩、穿透详解 - 图7

    是不是觉得巨麻烦,竟然要从头到尾经历这么多的过程,才能最终,实现一个不起眼的缓存穿透!
    不过,实际上你再细想,其实,我上面提及的各种问题和解决方案,都刻意回避了一个问题:
    就是,redis 是单节点单实例的。
    也就是说,我们对这一个 key 的操作,都是在一个 redis 上,而没有同时牵扯到其他 redis;
    所以,只要这个 redis 不挂,那么就不存在问题。
    不过要是 redis 挂了,那么面临的问题,也就不是 redis 的缓存击穿问题了。
    而是系统的高可用的解决方案,比如我上一篇文章提到的:
    redis 的主从、主备,哨兵监控,来保证 redis 挂了之后,能立刻有 redis 前来替补。
    文章指引>>
    不为技术而技术:Redis 从单点到集群
    因为后面的这些个知识点,对集群有相关的知识,所以,我也很建议,你可以看一下我的这一篇文章。
    你也可以先看后面的,然后看完后,去看我之前的这一篇文章,做一次知识的整合和理解,然后再回过头来看到这里,也许你又会有不一样的感觉。
    不过,即使是主从模型,允许 redis 的从节点也提供读服务,
    这样就会存在数据在一定时间内不一致的情况,那么其实也没有太大的问题。
    假设:
    第一个线程,抢到了锁,然后访问完数据库,将数据写回主结点;
    然后,其它线程去从结点读取,由于可能数据同步的不及时,导致一部分结点读出的数据还是空;
    于是,那些读同步不及时的那部分从节点的线程,再重复一遍之前的操作;
    这样,就可能出现部分线程的往复多次操作,一直读,一直是空。
    如果从节点多的话,那么所有的从节点之中,至少大部分从结点,都是通信正常的,
    一般不会出现大面积坏死的情况;
    所以,如果有少量从节点没有数据,那么会导致的二次重新操作,也只是少量的一部分线程,这样,也只是再次加锁一次,多读取了一次数据库。
    然而实际上,也根本并不用那么麻烦,假设从节点没有读取到,可以直接去主节点读取,那么就不会出现数据迟迟读取不到的情况了。
    也就是,对于这样的加锁操作,没有必要要去涉及到从节点,所有的锁操作,直接对主节点即可。
    所以说,通过双线程的加锁操作,是可以解决缓存击穿的问题的。
    不过,由于我在上文,提到了这是一个分布式锁的概念,
    要是,我在这里,仅仅就这么结束的话,难免会有同志误以为这就已经是一个完美的分布式锁,
    所以,我再稍微提一下,redis 集群的分布式锁的知识点。
    对于单个 redis 来说,上面的知识点已经可以实现分布式锁了。

    但是,既然要讨论高并发高可用的系统,就会涉及到集群
    对于单个redis来说,假设,加锁的redis挂了,那该怎么办

    redis的主从模型,默认使用异步同步数据的方式,所以,存在数据不一致的情况。
    主节点挂了,从节点顶替的时候,是可能丢失数据的
    所以,这把锁很可能就丢了
    为了能够解决这样的问题,redis的作者给出了一个更好的实现,称为Redlocck,算是redis官方对于实现分布式锁的指导规范
    —————————————————————————————————

    在算法的分布式版本中,我们假设有N个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 连接有故障,导致无法加锁成功;
    然后,这时第一个加锁的哥们,由于网络故障,也没有加锁成功;
    从而,俩人都没加锁成功。
    其实,如果没有第一个家伙,第二个哥们是能加到锁的,
    但是,由于第一个加锁者,占据了锁的位置,占用了大量的时间,导致之后加锁的线程,就会因为被占用,很容易加不到锁,就会使得加锁资源被白白 浪费,系统的加锁过程就会变长,效率变低。
    所以,为了解决这个问题,就可以设置一个超时时间的概念,让加锁的每一步,都快速,轻盈,
    加的到就加的到,加不到就加不到,过程迅速,不拖泥带水,
    这样,就能使得加锁的过程更迅速,加锁冲撞而导致加不到锁的概率也会变低,从而使得加锁更加高效。
    所以,加锁的时候,设置超时时间,但是,如果加锁最终没有成功,就不给单独结点上的锁续命,就让它快速过期,这样,就能够使得集群之间的加锁更加高效迅速,而不容易出现争抢激烈的情况。
    所以,在这里,就不应该像之前那样,给锁延长超时时间。
    所以,在整个加锁过程中,整个加锁的过程,不能超过锁的有效时间,否则,就应算作加锁失败,要立刻清除所有单独结点上的锁。
    Redis缓存击穿、雪崩、穿透详解 - 图8