首先我们有这么一个前提,击穿、穿透以及雪崩,这三个场景都是在Redis作为缓存的情况下所发生的。也就是说Redis后面还会有一个关系型物理数据库,Redis本身并不作为唯一的数据库来使用。
Redis作为缓存的话,必然会有两件事情:
① Key会设置过期时间;
② 通过LRU算法或者LFU算法删除不需要的数据。
那么就会存在这种可能性,当数据在Redis中存在的时候没有访问,但是刚刚被删除以后立马就被访问了。而此时在Redis中这个数据已经被删除了,那么请求过来的时候,Redis中查不到这条数据,就会穿过Redis去请求它后面的数据库,从数据库中去查询这条数据。这就相当于在Redis身上打了一个窟窿穿透过去了,这就是Redis的击穿,一种非常形象的说法。当然这个前置条件也是在高并发的情况下,如果不是大量并发同一时间来访问的话,就那么两三个访问,那查库和查Redis并没有什么本质上的区别。所以对于击穿来说无外乎就是三个点,在高并发的情况下,曾经有你不要,现在没有你想要。
那么这个解决方案是什么?首先我们要知道延长它的过期时间,并不是一个可行的解决方案:本身Redis是作为一个缓存就肯定会受到内存大小的限制,Redis能储存的数据是有限的,所以不可能在Redis中将数据储存过久的时间;而且即使Key的有效时间设置的再长,总有过期的时候。
其实解题思路并不难,我们现在的已知条件就是:并发有了,我们要阻止这个并发到达数据库,但是Redis里又没有数据,那要怎么阻止呢?我们可以给这些高并发的请求增加一个逻辑:
①当发现Redis中没有数据以后,执行setnx(),这就相当于给Redis中设置了一把锁;
② 由于Redis是单进程单实例,所有的并发在请求Redis的时候都是排着队一个一个来,那么肯定 就只有第1个请求能抢到这把锁;
③ 只有获得这一把锁的请求,才能去访问数据库;
⑤ 访问数据库的请求,将数据库中的查询结果重新设置到Redis中,释放锁;
⑥ 没有获得锁的请求,sleep一段时间,sleep结束之后,返回①继续执行。
但是这种解决方案存在的一个问题就是:如果第1个请求挂了,它获得的锁就永远不会释放,其余的请求就一直处于等待状态,造成死锁。
这个问题也不难解决,可以设置锁的过期时间。假设第1个请求在获得锁以后,设置这把锁的过期时间为1秒钟,那么即便这个请求挂掉以后,1秒钟之后,这把锁仍然会释放,不会影响其余的请求继续争抢锁。
那这就完美了吗,还有没有别的问题?首先这把锁的过期时间并不好确定,时间设置长了只会造成资源上的浪费。而且最重要的问题是,第1个请求并没有挂掉,在锁有效的时间内,它还没有处理结束导致锁超时。
一般解决这种问题的方案是使用多线程:在第1个请求获得锁以后,重新开一个线程去监视这个获得锁的请求,每隔一段时间去看看获得锁的请求任务有没有处理完,如果没有处理完,就继续将锁的时间重置,往后延伸一段时间。那么在处理任务的时间内,这把锁就永不过期。
只不过采用这种方案的话,会让客户端的代码逻辑稍显复杂。而且这种方案也并不能100%的保证问题完美解决,通常当你使用一个方案去解决一个问题,势必会引入另外新的问题。所以从技术的角度来说,方案讨论到这里已经足够了,企业级的项目一般代码写到这种水平也可以了。
这就是自己手动实现分布式协调,确实很麻烦,后面说到Zookeeper时候,会对分布式协调这一块进行更加详细的讲解。
穿透这个概念,其实之前在介绍Bloom过滤器的时候有说过:从业务接收查询的数据是数据库中根本就不存在的数据。
假设一个电商公司只卖电子产品,但是用户偏偏就在搜索框里搜索母婴产品。用户搜索的产品数据库中肯定不存在,那更不要说缓存了。这些请求就会全部穿过Redis直接到达数据库,并且这些查询都是毫无意义的查询结果,一定是没有的,只会白白的增加数据库的压力,浪费性能。
所以穿透和击穿根本的区别在于:击穿所查询的数据是数据库中有,只是Redis缓存中过期了;而穿透所查询的数据是数据库中根本就没有。
那么针对穿透这个问题的解决方案是什么?之前所说的Bloom过滤器,就是一个有效的解决方案。不过Bloom过滤器本身也存在一些问题:Bloom过滤器中的数据只能增加,不能删除。
① 可以使用布谷鸟过滤器替代;
② 如果继续使用Bloom过滤器的话,可以对数据库中增加的商品在Reids中设置一个Key,数据为null。这样下次请求来Redis中查询的话,查询到这个数据其实是空的,再去数据库中查,查询之后设置到Redis中缓存。
前面我们说了击穿是因为一个key失效,面对高并发的情况下导致请求全部压到数据库上,我们得规避这个问题。实际上在线上环境中这种情况并没有那么常见,反倒是雪崩,如果架构方面没有设计好的话,容易引发这个问题。
什么时候产生雪崩?Reids中大量的Key同时失效,在高并发的情况下,请求过来的时候,Redis中查不到这些数据,就会穿过Redis去请求它后面的数据库,从数据库中去查询这条数据。这种场景在企业级应用中非常常见:比如设计一批Key的有效期到晚上12点结束,第2天就会使用一批新的数据替代。
解决雪崩这个问题一般有两种方案:
① 随机(均匀)分布过期时间;
② 强依赖击穿解决方案。
从上面举的例子来看,其实第1种解决方案并不实际,因为最容易产生雪崩的场景往往是人为设置同一时间设置大批Key同时过期,从根源上直接掐死了随机分布过期时间的可能。第1种方案的使用场景不是没有,只是比较少,只是很多时候雪崩的造成是人为刻意的。所以更多时候企业中对于雪崩的解决方案,其实就是强依赖击穿的解决方案。
当然如果在流量过大的时候,可以在业务层加一段关于零点延时的逻辑:让所有的请求随机阻塞不同的时间再来访问,归根结底就是用尽一切方法在同一时间点规避超大流量同时访问。这种解决方案会更加轻盈一点。
如果用Redis作为分布式锁,要怎么做?光是一个setnx()就可以了吗?时间到了活还没干完怎么办?或者时间还没到但是自个儿挂了怎么办?
其实所有的解决方案在上面击穿中已经讲了,无非就是三点:
① setnx()
② 过期时间
③ 多线程监控(守护线程,或事务线程),延长过期时间
或者使用Redisson,但Redisson其实并不常见,否则也不会再诞生Zookeeper了,用Zookeeper做分布式事务锁是最好的。其实这里面涉及到一个常识就是:都已经是拿来做锁了,任何东西一旦触碰到锁以后,也就说明在效率方面已经不会有太高的要求,只会对准确性和一致性要求极高。而这恰恰与Redis的设计理念是背道而驰的,虽然Zookeeper肯定是没有Redis快(下一篇来讲讲这是为什么),但是在数据的可靠性、API开发的简易性这块Zookeeper比较占优势。