5

架构与思维:一次缓存雪崩的灾难复盘

 2 years ago
source link: https://www.cnblogs.com/wzh2010/p/13874211.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

1 真实案例

云办公系统用户实时信息查询功能优化发布之后,系统发生宕机事件(系统挂起,页面无法加载)。

1.1 背景

我们IM原有的一个功能,当鼠标移动到用户头像的时候,会显示出用户的基本信息。信息比较简单,只包含简单的用户名、昵称、性别、邮箱、电话等基本数据,
这是一个典型的数据查询,大概过程如下左侧,访问用户基本信息的时候会先去Redis中查一下,如果不存在,就把大约2W左右的用户数据一次性取出来,保存在Redis中,因为用户基本信息在同一张表上,用户信息表的数据量也很少,所以一直也没什么问题。
过程如下图左侧所示。
后续对功能做了优化,原有采集的信息除了用户的基本信息之外,还采集了教育经历、工作经历、所获勋章等。
这些信息存储在不同的表里面,所以采集过程是一个复杂的联表查询,特别是有些基础表数据量比较大,执行效率也是比较慢的。
如果把所有用户全部取出来并存储在一个Redis节点中,明显已经不适用,一个是批量查询导致数据库执行效率慢,一个是Redis单节点数据太大。
所以开发同学做了下优化,每次只取单个用户的综合信息存在Redis中,一个用户建一个缓存,如上图右侧所示。 

1.2 问题处理

这种做法看着没啥问题,当晚发布后,在第二天的上午10点~11点就发生了系统瓶颈卡顿,最后挂起的情况,数据库的内存、CPU全部飙上去了。
第一时间的处理方法是降级,程序回滚到之前只提供基本信息的阶段,其他的前端默认显示空信息。接着就是对问题进行分析了,后确认原因是产生了 缓存雪崩了。
新发布的系统,缓存池是空的,在早上10点高峰期的时候,大量的人员到IM上进行访问,系统开始初次建立每个人的缓存信息,大量的请求查询不到缓存,直接透过缓存池投向数据库,造成瞬时DB请求量井喷。这是典型的缓存雪崩了。 
同时因为,失效时间相近(8小时失效),所以也有潜在的缓存雪崩。
应急处理方案:适当处理缓存的机制,采用布隆过滤器、空初始值、随机缓存失效时间方式来预防缓存击穿和缓存雪崩的产生。
最终解决方案:改回原来缓存全公司员工信息的方式,根据执行计划和SlowLog,优化获取员工信息的SQL脚本,去掉不需要的字段和无意义的连接。   

2 缓存雪崩

2.1 概念

缓存雪崩是指大量的key设置了相同的过期时间,导致在缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。

上面的哪个问题,初次访问的数据都是未建立缓存的,跟同时失效的情况一样,当峰值期到来的时候,会大量的请求查询不到缓存,直接透过缓存池投向数据库,造成瞬时DB请求量井喷。

2.2 解决方案分析

2.2.1 缓存集群+数据库集群

在系统容量设计的时候,应该能够预见后期会有大量的请求,所以在发生雪崩前对缓存集群实现高可用,如果是使用 Redis,可以使用 主从+哨兵 ,Redis Cluster 来避免 Redis 全盘崩溃的情况。

同样的,也需要对数据库进行高可用保障,因为透过缓存之后,真正考验的是数据库的抗压能力。所以 1主N从 甚至 数据库集群 是我们需要重点去考虑的。

2.2.2 适当的限流、降级

可以使用 Hystrix进行限流 + 降级 ,比如像上面那种情况,一下子来了1W个请求,不是当前系统的吞吐能力能够承受的,假设单秒TPS的能力只能是 5000个,那么剩余的 5000 请求就可以走限流逻辑。

可以设置一些默认值,然后调用我们自己降级逻辑去FallBack,保护最后的 MySQL 不会被大量的请求挂起。 除了Hystrix之外,阿里的Sentinel 和 Google的RateLimiter 都是不错的选择。

Sentinel 漏桶算法

RateLimiter 令牌桶算法

另外可以考虑使用用本地缓存来进行缓冲,在 Redis Cluster 不可用的时候,不至于全线崩溃。

2.2.3 随机过期时间

可以给缓存设置过期时间时加上一个随机值时间,使得每个key的过期时间分布开来,不会集中在同一时刻失效。

随机值我们团队的做法是:n * 3/4 + n * random() 。所以,比如你原本计划对一个缓存建立的过期时间为8小时,那就是6小时 + 0~2小时的随机值。

这样保证了均匀分布在 6~8小时之间。如图: 
2.2.4 缓存预热
类似上面的那个案例,并不是还没过期,而是新功能发布,压根还没建设过缓存,所以可以在峰值期之前先做好部分缓存,避免瞬时压力太大。
所以如果10点是峰值期,那么可以预先在8~10点期间,可以逐渐的把大部分缓存建立起来。如图:

3 缓存穿透

3.1 概念

缓存穿透是指访问一个不存在的key,缓存不起作用,请求会穿透到DB,流量井喷时会导致DB挂掉。

比如 我们查询用户的信息,程序会根据用户的编号去缓存中检索,如果找不到,再到数据库中搜索。如果你给了一个不存在的编号:XXXXXXXX,那么每次都比对不到,就透过缓存进入数据库。

这样风险很大,如果因为某些原因导致大量不存在的编号被查询,甚至被恶意伪造编号进行攻击,那将是灾难。

3.2 解决方案分析

3.2.1 缓存空值

发生穿透的原因是缓存中没有存储这些空数据的key,或者压根这个数据的key是不会存在的,从而导致每次查询都进入数据库中。

我们就可以将这些key的值设置为null,并写到缓存池中。后面再出现查询这个key 的请求的时候,直接返回null,这样就在缓存池中就被判断返回了,压力在缓存层中,不会转移到数据库上。

3.2.2 BloomFilter

我们称作布隆过滤器,BloomFilter 类似于一个hbase set 用来判断某个元素(key)是否存在于某个集合中。

这种方式在大数据场景应用比较多,比如 Hbase 中使用它去判断数据是否在磁盘上。还有在爬虫场景判断url 是否已经被爬取过。

这种方案可以加在第一种方案中,在缓存之前在加一层 BloomFilter ,把存在的key记录在BloomFilter中,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 ,投入数据库去查询,这样减轻了数据库的压力。

流程图如下:

3.2.3 两种方案的选择判断

前面说过,可能会存在一些恶意攻击,伪造出大量不存在的key ,这种情况下如果我们如果采用缓存空值的办法,就会产生大量不存在key的null数据。显然是不合适的,这时我们完全可以使用第二种方案进行过滤掉这些key。

所以,判断的依据是:

针对key非常多、请求重复率比较低的数据,我们就没有必要进行缓存,使用 BloomFilter 直接过滤掉。

而对于空数据的key有限的,重复率比较高的,我们则可以采用 缓存空值的办法 进行处理。 

4 缓存击穿

4.1 概念

一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。(注意跟上面两种的区别)

4.2 解决方案

4.2.1 锁的方式
分布式锁场景,在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。

这种现象是多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。

其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

锁不好的地方就是在其他线程在拿不到锁的时候就等待,这个会造成系统整体吞吐量降低,用户体验度也不好。

4.2.2 空初始值

这是一种短暂降级的方式:

如果一个缓存失效的时候,有无数个请求狂奔而来,而第一个请求从进入缓存池,判空,再到数据库检索,再查询出结果并返回设置缓存的这个过程里,缓存是不存在的。

这个就很危险,超高并发下这个短暂的过程足已让千千万万请求投向数据库。更别提这可能是个慢查询,整个过程可能长达2s以上,那对数据库是一种非常大的伤害。

业内有一种做法叫做空初始值,短暂的局部降级来保证整个数据库系统不被击穿。大概流程如下:

可以看出,整个过程中我们牺牲了A、B、C、D的请求,他们拿回了一个空值或者默认值,但是这局部的降级却保证整个数据库系统不被拥堵的请求击穿。

这也是我面试中最喜欢问候选人的缓存类问题。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK