架构与思维:一次缓存雪崩的灾难复盘
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 漏桶算法
imageRateLimiter 令牌桶算法
image另外可以考虑使用用本地缓存来进行缓冲,在 Redis Cluster 不可用的时候,不至于全线崩溃。
2.2.3 随机过期时间
可以给缓存设置过期时间时加上一个随机值时间,使得每个key的过期时间分布开来,不会集中在同一时刻失效。
随机值我们团队的做法是:n * 3/4 + n * random() 。所以,比如你原本计划对一个缓存建立的过期时间为8小时,那就是6小时 + 0~2小时的随机值。
这样保证了均匀分布在 6~8小时之间。如图:
image2.2.4 缓存预热
类似上面的那个案例,并不是还没过期,而是新功能发布,压根还没建设过缓存,所以可以在峰值期之前先做好部分缓存,避免瞬时压力太大。
所以如果10点是峰值期,那么可以预先在8~10点期间,可以逐渐的把大部分缓存建立起来。如图:
image3 缓存穿透
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 是否存在,如果不存在就直接返回,存在再走查缓存 ,投入数据库去查询,这样减轻了数据库的压力。
流程图如下:
image3.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以上,那对数据库是一种非常大的伤害。
业内有一种做法叫做空初始值,短暂的局部降级来保证整个数据库系统不被击穿。大概流程如下:
image可以看出,整个过程中我们牺牲了A、B、C、D的请求,他们拿回了一个空值或者默认值,但是这局部的降级却保证整个数据库系统不被拥堵的请求击穿。
这也是我面试中最喜欢问候选人的缓存类问题。