在我们日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题,可是一旦涉及大数据量的需求,比如一些商品抢购的情景,或者是主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘,磁盘读/写速度比较慢的问题而存在严重的性能弊端,一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。 为了克服上述的问题,项目通常会引入NoSQL技术,这是一种基于内存的数据库,并且提供一定的持久化功能。 redis技术就是NoSQL技术中的一种,但是引入redis又有可能出现缓存穿透,缓存击穿,缓存雪崩等问题。本文就对这三种问题进行较深入剖析。
|
|
key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题。 使用互斥锁(mutex key) 业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。 SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。
那么在高并发的情况下,一个 key 过期了,然后,就是几千几万的并发蜂拥而至,这该怎么解决? 先,大部分人会想到这么一个答案:热点数据永不过期。 在网络上盛行的解决方案有很多: 设置 key 永不过期,在修改数据库时,同时更新缓存; 设置 key 永远不会过期; 所以,大部分人,都只是停留于纸上谈兵的阶段,正确与否,没有实际场景的验证,只能靠直觉去判断; 下面我来详细分析,为什么,这些解决方案,在实际的生产环境中,是无法胜任的。 首先,我来分析,key 永不失效的解决方案,为什么不可行。 因为,对于一个需要解决缓存击穿问题的企业,他们的业务量一定是普通人无法想象和企及的; 为什么呢? 但是,你可以说,只存热点数据啊! 但是,什么叫热点数据?你觉得是就是吗? 真正的环境中,热点数据是在时时变化着的,我们可以对一些热点做一些预估,但是,我们永远无法保证我们能预估到多少。 第二个,网络上很流行的答案就是:加锁 synchronized 加锁,而且还衍生出双重检查锁; 首先,对于缓存穿透的情况,肯定是高并发场景,所以数据库才可能扛不住。 所以,在如此多的 Tomcat 集群的情况下,一把 Java 锁,是不可能锁住一个集群的。 虽然说,用 ReentrantLock,tryLock 加锁,成功的去数据库读取数据; 但是,一个 Java 锁最多只能够锁一个 JVM 进程,对于一个集群来说,这绝对是远远不够的。 所以实际上,缓存穿透,加锁解决,必须还要涉及到分布式锁的概念。 这里不谈 zookeeper 之类的东西,既然谈 redis,那么就用 redis 来解决这个问题。 首先,当一个 key 失效,不管是时间过期,还是被 LRU、LFU 剔除,
看起来似乎可行,但是,还有问题吗? 我要这么说肯定是有问题,但是,你可以想一想,存在什么问题? 如果你不知道,说明对分布式锁还不够了解,那么,就继续跟着我分析。 现在,我开始假设: 首先,一堆请求访问 redis,发现为空;
首先,对于我们的分布式集群系统,任意一台机器都有挂掉的可能。 可以想到两种方案: 明显,这么做成本比较高昂,还不如用完善的 zookeeper 去实现分布式锁。 第二种: 于是,之前的方案,就可以稍作修改: 首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,其它人都失败;
但是,还有问题吗? 我要这么问了,那么一定说明有。 那么,现在请你先不要拖到后文,先自己思考,会存在什么问题,然后再来看我的分析。 现在我继续列出场景, 首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,其它人都失败; 首先,可以确定的是,可能锁没有来得及增加过期时间,从而导致,可能出现死锁的情况。 有些人可能就会说,那就放一个原子操作啊! 但是,redis 并没有一个 API,既可以 setnx,又同时给予它一个过期时间。 那该怎么办? 所以,这就需要考验,我们对 redis 的各种机制的掌握程度了。 首先,redis 有事务这么一个概念, 那么不能回滚的事务也可以用来完成锁操作吗? 虽然不支持回滚,但是主要是因为 redis 的事务是保证原子性的: 在一个事务中,只有全部的命令发送结束了,并且提交事务,那么整个事务中的所有指令,才会被 redis 执行; 这样,就可以保证,redis 不会出现死锁的问题。 这样,解决了死锁问题,就看起来很完美了。 但是, 确实,如果只是解决了死锁的问题的情况下,是没有什么问题的。 但是,因为我们在解决死锁问题的时候,引入了超时时间,所以,就会导致新的问题的产生。 现在,假设: 首先,一堆并发开始尝试加锁,最终只有一个人,获取到了锁,且设置了超时时间;
所以,锁的超时时间又成了问题。 既然新问题出现了,我们就得想办法去解决它。 而现在普遍的解决方案,就是多线程: 在加锁了之后,由于锁会有过期时间,然而又不能保证,锁一定不会在执行结束过后过期, 于是就出现下面这样的场景: 首先,一个手速快的家伙抢到了锁,并且也设置了超时时间,比如 30 秒。
不过,实际上你再细想,其实,我上面提及的各种问题和解决方案,都刻意回避了一个问题: 也就是说,我们对这一个 key 的操作,都是在一个 redis 上,而没有同时牵扯到其他 redis; 不过要是 redis 挂了,那么面临的问题,也就不是 redis 的缓存击穿问题了。 文章指引>> 因为后面的这些个知识点,对集群有相关的知识,所以,我也很建议,你可以看一下我的这一篇文章。 不过,即使是主从模型,允许 redis 的从节点也提供读服务, 假设: 第一个线程,抢到了锁,然后访问完数据库,将数据写回主结点; 然而实际上,也根本并不用那么麻烦,假设从节点没有读取到,可以直接去主节点读取,那么就不会出现数据迟迟读取不到的情况了。 所以说,通过双线程的加锁操作,是可以解决缓存击穿的问题的。 不过,由于我在上文,提到了这是一个分布式锁的概念, 对于单个 redis 来说,上面的知识点已经可以实现分布式锁了。 但是,既然要讨论高并发高可用的系统,就会涉及到集群。 对于单个 redis 来说,假设,加锁的 redis 挂了,那该怎么办? redis 的主从模型,默认使用异步同步数据的方式,所以,存在数据不一致的情况, 所以,这把锁很可能就丢了。 为了能够解决这样的问题,Redis 的作者 antirez 给出了一个更好的实现,称为 Redlock,算是 Redis 官方对于实现分布式锁的指导规范。 如果你们不善于阅读英文,那么就直接看我中文的描述: 在算法的分布式版本中,我们假设有 N 个 redis,且这些节点是完全独立的,也就是不存在任何主从关系,一个 redis 的死活和其他 redis 没有任何关系。 那么,接下来,就请思考一下,加锁的操作: 那么,应该给几台结点加锁呢? 如果采用全部结点加锁成功,才表示加锁成功,那么就成了强一致性, 那么应该给几个结点加锁成功,才表示加锁成功呢? 那么既然最基础的问题解决了,下面,假设出现这么一个场景: 一个线程,向 redis 集群发起加锁操作,然后第一个结点加锁成功了; 一个线程,向 redis 集群发起加锁操作,向 1、2 redis 加锁,都加锁成功了; 所以,为了解决这个问题,就可以设置一个超时时间的概念,让加锁的每一步,都快速,轻盈, 所以,加锁的时候,设置超时时间,但是,如果加锁最终没有成功,就不给单独结点上的锁续命,就让它快速过期,这样,就能够使得集群之间的加锁更加高效迅速,而不容易出现争抢激烈的情况。 所以,在整个加锁过程中,整个加锁的过程,不能超过锁的有效时间,否则,就应算作加锁失败,要立刻清除所有单独结点上的锁。
首先获取当前时间(毫秒数); 看起来似乎很完美了,但是,我继续抛出一个问题。 假设一共有 5 个 redis ,分别是 ABCDE: 客户端1成功锁住了A, B, C, 加锁成功(但 D 和 E 没有锁住)。
在默认情况下,redis 的 AOF 持久化方式是每秒写一次磁盘(即执行 fsync),因此最坏情况下可能丢失 1 秒的数据。 那么,既然锁可能因宕机而丢失,已经无法再恢复。于是,antirez 又提出了延迟重启 (delayed restarts)的概念。 不过关于 Redlock 还有一点细节值得拿出来分析一下: 这是为什么呢? 设想这样一种情况,客户端发给某个 redis 节点的获取锁的请求成功到达了该 redis 节点,这个节点也成功执行了 SET操作,但是它返回给客户端的响应包却丢失了。 因为这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。 谈到这里,对于缓存的击穿,以及涉及到的一点 redis 分布式锁的知识,你应该已经了解得差不多了。 |
一个一定不存在缓存及查询不到的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。 有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法(我们采用的就是这种),如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
缓存穿透 缓存穿透,与击穿的区别就是, 所以,缓存击穿可以规避,因为只是 redis 缓存数据失效了,而数据库里有数据,只要把数据库里的数据更新到 redis 上,那么就可以解决掉缓存击穿的问题。 但是,缓存穿透,意味着,这个数据,数据库里也没有。 那么,想要解决缓存穿透,就必须想办法,能够识别出,哪些请求的数据,是数据库没有的,然后,对这些请求的查询,进行过滤。 如果你以前没有了解过这些知识,那你可以先想一想,可以用什么办法? 比较简单的,就是选择,当用户查询不存在的数据时,将这个 key,存入 redis,然后用一个特殊的 value 来表示,这是一个不存在的数据。 而且,很明确的一点,就是数据是无限的,我们不可能找出所有的数据库中不存在的数据; 但是,由于数据量的巨大,我们必须得想一个方法,怎样用尽可能少的空间和时间,去对数据是已有在做一个判断。 那么,既然数据无法完整存储,那么是否可以,只保留 key,省略 value,从而使得单位范围内内存能存储的信息量大了很多。 所以,是不是就可以额外开辟一片 Set,用来专门存储 key,这样,每次要访问数据库前,先去 key set 中查询时候存在,如果存在,那么再去访问数据库。 这样,确实可以使得缓存不会穿透了。而且相比缓存全量 key、value,只存储 key 会使得内存的占用变小了很多。 但是,理论上听起来似乎不错,假设,我们一台 redis,用来缓存后端 4T 的热点数据; 所以,到了数据面前,就能发现仍然是一个超高成本的方法。 那么,既然如果存储 key,空间仍然很大,那么我们能否想出一个更节省空间的存储方式? 一般有点经验的都会想到用 bit,也就是用一个位来存储,这已经是计算机中的一个最小的存储单位了。 现在,先不管如何实现,我们先来看一下,假设用 bitmap,那么空间的花费代价有多少。 由此可见,用 bitmap,确确实实可以达到对空间利用的极致。 那么,既然空间的问题解决了,下面就要解决如何使用这些空间: 有点经验的,立刻就能够想到,用哈希映射。 所以,采用哈希映射,就可以将那些所有存在的 key,全部对应到这个 bitmap 的每一个槽位上, 看起来似乎很完美,既解决了空间的问题,又可以保证每一个 key,能够映射到一个槽位上。 但是,仔细一思考,就会发现,其实还有问题。 首先,我们先谈我们熟悉的:HashMap。 但是,由于此时的槽位已经缩减为 bit,已经不能够再往上去追加其它的数据结构了,所以,就无法用链表解决冲突碰撞的产生。 所以,bitmap 由于它的精简,因此,不能够将碰撞给消除解决。
那么,该怎么办? 实际上,这个问题是无法被完全解决的,由于节省空间,每一个槽位被精简到了一个比特,所以,能表示的信息已经只有 0、1 两种,从而无法表示出其他信息。 那么,假设增加空间,加一个 bit,体积就会增大一倍;再加两个,体积又会翻一倍; 那么,既然作为软件工程的学习者,我们必须有这么一个思维,就是不较真,不去追求极限。 这里也是如此,既然不能解决冲突问题,那么,可以想办法,让冲突发生的概率更小,而不是去完全地让冲突消失。 所以,就可以延伸到布隆算法。 首先,因为在单次哈希的情况下,会产生一定的碰撞; 这样,只要对应出我们的需求,去调整 bitmap 的大小,以及哈希函数的个数,就可以得到不同的过滤的百分比,虽然可能出现漏网之鱼,不过那也已经是少之又少了。
首先,对于布隆过滤器,我们可以把它放在客户端。 不过,由于是基于JVM内存的一种布隆过滤器, 第二,我们也可以选择,客户端包含算法,然后,把 bitmap,存到 redis 上去, 第三,或者,还可以,直接把算法,和 bitmap,一并放到 redis 上去,也就是在 redis 当中集成这么一个模块。 不管怎样,至少,穿透的问题,似乎已经迎来了大结局。 不过,你有没有想到布隆过滤器有一个缺点, 你现在想一想,是不是这么一个情况! 也就是说,如果数据频繁增删改,是不太适合用布隆过滤器的。 那么,解决的话,可以用布谷鸟这样的,带删除功能的,来满足动态变化的需求。
|
有一个简单方案就时将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。或者Redis查DB数据的时候将请求加一个随机延迟时间,让请求不要集中。
缓存雪崩 缓存雪崩,指的是大面积的 key 同时过期,导致大量并发打到我们的数据库。 所以,对于雪崩来说,一般,少量的 key 失效,所带来的数据库的并发压力是不会太大的。 那就算一个 key 失效,也会对数据库造成很大的影响,那么你把雪崩的所有 key 拆成一个一个 key 来看,也就是雪崩可以拆分成一个一个缓存击穿的集合。 其实在真实场景中,雪崩才是一个更容易发生的一个问题,它不像击穿那么极端,一个 key 就成千上万的并发,直接把数据库打垮了; 那么既然缓存击穿已经给过解决方案了,那么我们现在要关注的,则是如何缓解雪崩所带来的压力。 因为,key 是同时失效,所以导致很多 key 的并发,一起压上来,才会使得数据库的并发压力过大, 首先一个很常见的做法就是,分散 key 的过期时间。 确实,这么做是可行的,因为这个问题的本质,就是要让瞬间到来的并发,把它分散开。 看起来就很像一个削峰的操作。 这个方法,是最简单有效的。所以一般情况,我们都采用这种方式。
就是,在某一个固定的时间,由于业务要求,必须使得数据刷新,并且不允许出现旧数据。 像这样的业务应该怎么办? 因为这个场景,非常类似削峰操作,所以有人会觉得,可以用 MQ,先把读请求打入 MQ,再一个一个依次消费。
这样可行吗? 首先,从系统实现来说,是可以保证,数据库的请求压力先被扛下来,然后异步消费。 所以,对于读请求,不适合用队列的方式,因为这已经把请求串行化了,不再是并发执行。 于是,还有人提出观点,让缓存提前开始更新。 但是,提前更新了之后,比如 58 分开始更新,59 分的时候,有大量数据又被修改了呢? 所以,缓存是必须要在 12 点准时失效,准时更新的。你无法让更新时间进行变化。 那么,你还能想到什么办法? 因为此刻,redis 中的数据,是必须立即失效的,你不能够改变。 既然 redis 不可以,那么其它地方可以吗? 所以,这就是考验你思维和功力的时候。 既然 redis 无法分散过期时间,那么,我们去查数据的时候,是不是可以把时间稍微地分散一下? 所以到了下面这种情景: 时间一到,redis 数据全部失效;
带来的影响,也就是客户等待时,会多那么几十毫秒的延迟,不过对于人来说,是微乎其微,可以接受的。 所以,对于时点性要求高的业务要求,雪崩的问题,想要解决,还必须稍微多思考,变通一下。
|
来源:Redis缓存击穿、雪崩、穿透!(超详细)_lin777lin的博客-CSDN博客_redis缓存击穿
来源:REDIS缓存穿透,缓存击穿,缓存雪崩原因+解决方案 - 大码哥 - 博客园