【Redis笔记】一起学习Redis | 如何应对缓存穿透,缓存雪崩?

Redis · 2019-07-31

一起学习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接口,上线时认为操作一下
  • 如果数据量不大,可以在项目启动的时候自动触发加载

(二) 缓存降级
当突发访问量激增,导致一些服务的性能骤降,严重时可能产生系统崩溃的连锁反应。此时为了保证系统的其他服务仍然能够提供一定的服务,所以就需要对受损服务进行降级,停止其可用性或部分可用性,避免让损害在系统中蔓延。

  • 缓存降级说白了就是服务降级的一种具体体现,服务降级的最终目的就是保证核心服务可用,即使只能提供部分功能。所以在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅,从而梳理出哪些必须誓死保护,哪些可降级

比如某些业务的缓存系统因为出现了缓存雪崩,导致所有请求都都落在数据库上,导致数据库读写过慢,接近奔溃。此时我们就需要考虑将这部分的业务,是否进行降级,暂时提供对外服务。比如

参考资料


文章推荐:

【Redis笔记】一起学习Redis | 聊聊Redis的持久化策略,AOF和RDB

【Redis笔记】一起学习Redis | 聊聊缓存,数据库的双写数据不一致问题

【Redis笔记】一起学习Redis | 从消息队列到PubSub模型

【Redis笔记】一起学习Redis | 如何应对缓存穿透,缓存雪崩?

【Redis笔记】一起学习Redis | 大海捞针,了解scan命令

发表评论

控制面板

您好,欢迎到访网站!

  查看权限