一起学习Redis | 如何应对缓存穿透,雪崩?
- 前提概要
- 获取缓存的流程
- 需知
- 缓存穿透
- 什么是缓存穿透?
- 缓存穿透场景
- 解决方案
- 缓存雪崩
- 什么是缓存雪崩?
- 缓存雪崩场景
- 解决方案
- 缓存其他知识点补充
- 理解热点数据"永不过期"
- 针对特殊热点数据,特殊对待
- 缓存预热,缓存降级
前提概要
获取缓存的流程
通常情况下的缓存,数据库搭配流程如下
需知
- 我知道有些地方会将部分场景专门叫做缓存击穿,以区别缓存穿透。
- 我也知道也有一些地方会将缓存穿透的部分场景称之为缓存雪崩,以区别缓存穿透
但是本文不想纠结这些没有太多意义的东西。资料都是人为整理的,我们最主要的是要弄清楚问题是什么,如何解决即可。有些概念性的名称本身就未必准确。所以我这里不做深究
缓存穿透
什么是缓存穿透?
什么是缓存穿透?
缓存穿透说白了,就是外部的请求,直接跳过了数据的(Redis)缓存层,直接将压力压在底层的关系型数据库身上,造成服务响应缓慢甚至崩溃
缓存穿透场景
(一) 用户访问了缓存,数据库都不存在的数据
- 第一种情况就是,用户访问了缓存和数据库都没有的数据。比如用户不断的发起访问请求,访问ID根本就不存在的数据, 导致缓存层一直都查询不到,不断的把请求压在数据库中。通常情况下,业务中读取不存在的数据的请求量并不会大,但是如果出现了一些异常情况,比如黑客攻击,故意大量访问某些不存在的数据,就很有可能会将服务拖垮,停止服务
- 这种情况,有些地方又会称之为缓存击穿,但是我这里不弄这么多概念出来混淆视听了。
(二) 用户访问了缓存没有,但数据库有的数据
这二种情况就用户访问了缓存没有,但数据库有的数据。为什么会造成这样的情况呢?我们分为两种情况分析
- ① 数据库存在数据,但是生成缓存需要耗费教长的时间,如果此时恰好又大量请求进入,因为缓存还未完全生效,会有部分数据的请求压倒数据库上。
情况①的典型的例子就是电商系统的分页,因为电商数据量巨大,不可能把所有的数据都缓存起来,所以有时候只能按照分页缓存。但是业务上难以预测用户到底会访问那些页,因此最简答的方法就是每次点击页数时,查库并生成缓存。但是生成缓存也是需要一定时间,如果此时有大量访问,很有可能就会在缓存空窗期,突破到数据库层。 - ② 数据库存在数据,但是部分缓存因时间过期而失效了,如果此时有大量并发请求恰好请求到缓存失效的数据上,就会突破缓存,直接访问数据库
情况②的例子就更多见了,因为我们为了内存的更高效率的使用,通常会按需对部分缓存设置过期时间。没用的时候就删除,用到的时候再生成,节省空间。另外也有部分数据会对业务代码控制流产生影响,为了避免程序崩溃后,不该存在的部分缓存数据依然存在,所以也需要设置过期时间。
解决方案
(一) 对于第一种情况,缓存,数据库都没有。
接口层增加校验
,如用户鉴权,非法数据校验,如ID要符合格式将结果为空的查询缓存起来
,如将从数据库没有取到,从缓存也没有取到的数据查询,可以考虑将key与空结果缓存到Redis中,并设置一个60s有效期。那么在这一分钟里,所有同样的访问,都将直接从缓存中获得NULL限流策略
,针对同一用户或同一IP的大量访问,限制每秒或每分钟的访问次数,从而降低服务压力采用布隆过滤器
,将数据库有的数据放一份副本到足够大的布隆过滤器中,只有用户访问,先把条件从布隆过滤器中判断一下,如果存储则放行,如果没有则直接拦截返回,从而避免了对底层数据库的访问
(二) 对于第二种情况,缓存没有,数据库有
加速缓存生成
,不要让生成缓存耗费太长时间,尽量缩小无缓存空窗期。或是必须生成缓存后,才能对外提供服务热点数据不过期
,将用户频繁访问的热点数据设置为永不过期,这就可以保证访问不会落到数据库采用互斥锁
对于缓存过期后,大量并发访问同一数据的情况,可以考虑对向数据库load数据的动作加以互斥锁。既不管你有多少的并发请求要获取该数据,只允许一个请求从数据库load数据,写入缓存。其他的请求会获取锁失败后,去缓存获取。这样就可以保证只有一个请求落地到数据库层,其他请求依然是访问缓存。
# python3 伪方法
def get_data(key):
# 从Redis获取数据
data = get_data_from_redis(key)
if not data: # 如果缓存没有数据,则去数据库load
if lock.acquire(): # 但之前要先获取互斥锁,如果获取成功
data = get_data_from_mysql()
if data: # 如果数据库中存在数据
set_data_to_redis() # 将数据写入缓存
lock.release()
else: # 如果没有抢到锁,则休眠1s,重新调用get_data方法
time.sleep(1)
data = get_data(key)
return data
缓存雪崩
什么是缓存雪崩?
什么是缓存雪崩?
- 缓存雪崩是指缓存中大批数据都到了过期时间,在同一时刻失效了。而此时的用户请求又很多,致使大量的请求因缓存没有数据而落到数据库上,导致数据库压力峰值过大,瞬间宕机。说白了,就是同一时刻,大批缓存失效,而新缓存又未跟上,外部访问量又很大。
- 缓存雪崩和缓存穿透不同的是,缓存穿透是大量请求访问同一过期数据,这可以加锁解决。但缓存雪崩是大量的缓存都过期失效了,但此时有大量平摊到这些过期数据的请求。例如有10w个缓存数据过期,而恰好又有10w分别访问这10w过期数据请求出现,这就会导致10w的线程短时间都请求数据库获取数据,从而导致数据库峰值突进,而影响服务。
说白了,缓存雪崩就是同一时间大量缓存失效,导致大量请求压倒数据库上,导致数据库读写峰值飙升,可能宕机甚至系统崩溃
雪崩场景
缓存雪崩没有这么多情况,很简单,就是大量缓存失效,同时又存在大量的访问。从而导致这些请求都跳过了缓存层,直接把压力落在数据库上,导致系统性能急剧下降。严重将导致数据库宕机,服务出现不可用。下面是两种方向的风险
(一) 压垮数据库,导致服务不可用
- 缓存失效,大量请求落到关系型数据库。因访问量过大,导致数据库读写性能骤降,严重将导致数据库宕机,从而引起连锁反应,最终造成系统崩溃
(二) 压垮Redis, Redis性能急剧下降
- 因为同时大量的Key失效,会造成Redis线程频繁执行定时删除任务,从而在一定程度上,阻塞了Redis其他的业务操作的。使得Redis读写QPS受到影响,性能降低
解决方案
(一) 我们可以由如下的一些解决方案
过期时间随机化
,对缓存数据的过期实际根据一定的合理范围,设置随机值,防止同一时间大量缓存数据过期的现象发生热点数据不过期
,对待频繁访问的热点数据,我们可以使设置其不会过期。缓存分布式集群化
,将数据分散存储到不同的集群节点,降低单节点的压力和风险值多级缓存策略
, 建立多级缓存,如本地缓存作为一级缓存,外部存储作为二级缓存。二级缓存挂了,可以先去一级缓存读取
(二) 多方案整合起来解决雪崩
事前,预防缓存雪崩:
- Redis集群,分布式化,保证高可用,避免全盘崩溃(clusted or Sentinel)
- Redis数据的过期时间在一个合理的范围内随机化,尽量避免同一时间大量数据过期
- 多级缓存策略,使用guava或encache作为本地一级缓存,外部存储Redis作为二级缓存
事中,减小雪崩造成的影响:
- 一旦发生雪崩,导致大量请求压向数据库,可以通过消息队列等手段对数据库的访问削峰限流,避免MySQL承担不合理的请求,保证一定的服务可用性
缓存其他知识点补充
理解热点数据"永不过期"
从面的缓存穿透和缓存雪崩的解决方案中,我们都看到了一种解决办法,就是让 "热点数据永不过期"
。但是你要怎么理解这个永不过期呢?
(一) 这里的“永远不过期”包含两层意思
-
从Redis视角去上看,不对热点数据的Key设置过期时间,也就是硬核角度的物理形式永不过期。
-
从业务功能视角上看,Redis依然对热点数据的Key设置
物理过期时间
,但这个过期并非业务过期时间
,业务过期时间实际要比Redis的物理过期实际要小。同时在代码上启动定时任务,通过后台线程每隔一段时间对Redis的热点数据进行全局扫描,判断热点数据的业务生命周期是否到期,如果过期,就由后台线程重新更新缓存,如果不过期则略过。
虽然物理意义的Redis过期时间,简单粗暴,但是不利于内存空间的高效利用,同时如果Redis结点的内存使用告急,触发内存淘汰算法,导致永不过期的热点数据被删除了,可能会需要一些麻烦的补救。所以业务上的逻辑永不过期也是很常见的良好实践
(二) 业务"永不过期"的实现
Redis物理上的Key不过期,很好失效,不设置过期时间即可,所以我们重点讲讲逻辑上的永不过期
# python3伪代码
def check_data_task(keys):
redis_ttl = 3600 # Redis物理过期时间为3600s
logic_ttl = 60 # 业务逻辑过期实际为60s
for key in keys: # 检查所有的key
key_sign = key + '_sign' # 业务逻辑过期标识的key
data_sign = get_data_from_redis(key_sign) # 获得key对应的业务过期标识
if not data_sign: # 如果key对应的标识为null,代表key的数据已经逻辑过期了
data = get_data_from_mysql(key)
if data: # 如果数据库有该数据,则重新更新业务过期标识和缓存数据
update_data_to_redis(key_sign, time.time(), logic_ttl)
update_data_to_redis(key, data, redis_ttl)
else: # 如果data_sign不是nil, 则代表没有业务过期,所以直接跳过
pass
以上是一段实现逻辑永远不过期的伪代码,既通过一个定时任务的后台线程,每隔一段时间,扫描所有有过期时间的key, 如果发现过期,则重新刷回缓存,如果没有过期则略过。它的原理就是对待同一个数据,存在两个key, 一个是数据本身,一个是其是否业务过期的标识。(其实数据本身可以是物理不过期,也可以是有有一定的过期时间,因为我们判断数据是否过期并不根据数据本身来判断,而是根据其是否业务过期的标识来判断)
所以如果要实现业务逻辑上的永不过期,就至少需要做两个步骤,一是维护一份数据list
,便于扫描;二是对数据维护一份业务过期的标识
,需要消耗一份空间资源。
针对特殊热点数据,特殊对待
虽然缓存系统本身的性能很高,但对于一些特别热点的热点数据,如果大部分甚至所有的业务请求都命中到同一份缓存中,会使得这份缓存所在的节点服务器压力变大,甚至导致同节点的其他缓存数据的请求被阻塞,影响可用性。比如日常崩溃的微博,只要某大V明星来一个爱情宣告或是爱情死亡宣告,基本微博都得死,导致运维和开发又得加班加点。
所以面对几千万粉丝围观的热点微博数据,我们的解决办法就是特殊数据特殊对待,通过 复制多份缓存
, 建立多分副本,分散到集群的各个结点中,减轻单台节点服务器的压力,说白了就是负载均衡。具体的手段就是针对流量明星的微博,生成100份的相同缓存,通过在key上区别编号,分散到各个集群节点中,每次读取缓存,都随机读取100份中的其中一份
缓存预热,缓存降级
(一) 缓存预热
缓存预热就是系统上线后,提前将相关的缓存数据直接加载到缓存系统中,避免在用户请求的时刻,才生产缓存。说白了就是提前生成缓存,将用户要查询的数据事先加载到缓存系统中了。如下是缓存预热具体实现方案
- 提供触发生成缓存的api接口,上线时认为操作一下
- 如果数据量不大,可以在项目启动的时候自动触发加载
(二) 缓存降级
当突发访问量激增,导致一些服务的性能骤降,严重时可能产生系统崩溃的连锁反应。此时为了保证系统的其他服务仍然能够提供一定的服务,所以就需要对受损服务进行降级,停止其可用性或部分可用性,避免让损害在系统中蔓延。
- 缓存降级说白了就是服务降级的一种具体体现,服务降级的最终目的就是保证核心服务可用,即使只能提供部分功能。所以在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅,从而梳理出哪些必须誓死保护,哪些可降级
比如某些业务的缓存系统因为出现了缓存雪崩,导致所有请求都都落在数据库上,导致数据库读写过慢,接近奔溃。此时我们就需要考虑将这部分的业务,是否进行降级,暂时提供对外服务。比如