Redis缓存穿透、击穿、雪崩、预热、更新、降级

Redis是高性能的分布式内存数据库,对于内存数据库经常会出现下面几种情况,也经常会出现在Redis面试题中:缓存穿透、缓存击穿、缓存雪崩、缓存预热、缓存更新、缓存降级。本篇分别介绍这些概念以及对应的解决方案。

一、缓存穿透

缓存穿透:key对应的数据在数据源并不存在,每次针对此key的请求从缓存获取不到,请求都会到数据源,从而可能压垮数据源。

一言以蔽之:查询Key,缓存和数据源都没有,频繁查询数据源。

比如用一个不存在的用户id获取用户信息,无论论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。

解决缓存穿透的方案主要有两种:

  1.  方案一:当查询不存在时,也将结果保存在缓存中。但是这可能会存在一种问题:大量没有查询结果的请求保存在缓存中,这时我们就可以将这些请求的key设置得更短一些;
  2.  方案二:提前过滤掉不合法的请求,可以使用Redis中布隆过滤器

 布隆过滤器设计思想

布隆过滤器由一个长度为m比特的位数组(bit array)与k个哈希函数(hash function)组成的数据结构。位数组初始化均为0,所有的哈希函数都可以分别把输入数据尽量均匀地散列。

当要向布隆过滤器中插入一个元素时,该元素经过k个哈希函数计算产生k个哈希值,以哈希值作为位数组中的下标,将所有k个对应的比特值由0置为1。

当要查询一个元素时,同样将其经过哈希函数计算产生哈希值,然后检查对应的k个比特值:如果有任意一个比特为0,表明该元素一定不在集合中;如果所有比特均为1,表明该集合有可能性在集合中。为什么不是一定在集合中呢?因为不同的元素计算的哈希值有可能一样,会出现哈希碰撞,导致一个不存在的元素有可能对应的比特位为1,这就是所谓“假阳性”(false positive)。相对地,“假阴性”(false negative)在BF中是绝不会出现的。

总结一下:布隆过滤器认为不在的,一定不会在集合中;布隆过滤器认为在的,可能在也可能不在集合中。

举个例子:下图是一个布隆过滤器,共有18个比特位,3个哈希函数。集合中三个元素x,y,z通过三个哈希函数散列到不同的比特位,并将比特位置为1。当查询元素w时,通过三个哈希函数计算,发现有一个比特位的值为0,可以肯定认为该元素不在集合中。

布隆过滤器特性

第一个问题,为什么说全都是1的情况是很可能存在,而不是一定存在呢?

        还记得前面说的哈希函数的特点,根据同一个哈希函数得到相同的哈希值,输入值不一定相等。类似于Java中两个对象的hashcode相等,但是不一定相等的道理。说白了,映射函数得到位数组上映射点全都是1,不一定是要查询的这个变量之前存进来时设置的,也有可能是其他变量映射的点。

所以这里引出了布隆过滤器的其中一个特点,存在一定的误判

第二个问题,布隆过滤器能不能删除元素呢?

        答案是不能的。因为在位数组上的同一个点有可能有多个输入值映射,如果删除了会影响布隆过滤器里其他元素的判断结果。

如上图,如果删除obj1,把4,7,15置为0,那么判断obj2是否存在时就会导致因为映射点7是0,结果判断obj2是不存在的,结果出错。

这是第二个特点,不能删除布隆过滤器里的元素。​​​​​​​

布隆过滤器优缺点
优点:
1.节省空间:不需要存储数据本身,只需要存储数据对应hash比特位;
2.时间复杂度低:插入和查找的时间复杂度都为O(k),k为哈希函数的个数。
缺点:
1.存在假阳性:布隆过滤器判断存在,可能出现元素不在集合中;判断准确率取决于哈希函数的个数;
2.不能删除元素:如果一个元素被删除,但是却不能从布隆过滤器中删除,这也是造成假阳性的原因了。
布隆过滤器适用场景:
爬虫系统url去重
垃圾邮件过滤
黑名单

基于布隆过滤器,我们可以先将数据库中数据的key存储在布隆过滤器的位数组中,每次客户端查询数据时先访问Redis:
如果Redis内不存在该数据,则通过布隆过滤器判断数据是否在底层数据库内;
如果布隆过滤器告诉我们该key在底层库内不存在,则直接返回null给客户端即可,避免了查询底层数据库的动作;
如果布隆过滤器告诉我们该key极有可能在底层数据库内存在,那么将查询下推到底层数据库即可。

布隆过滤器有误判率,虽然不能完全避免数据穿透的现象,但已经可以将99.99%的穿透查询给屏蔽在Redis层了,极大的降低了底层数据库的压力,减少了资源浪费。

二、缓存击穿

缓存击穿:key对应的数据库存在,但在redis中过期,此时若有大量并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

一言以蔽之:查询Key,缓存过期,大量并发,频繁查询数据源。

业界比较常用的做法:使用互斥锁。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db(查询数据库),而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,这种思路比较简单,就是让一个线程回写缓存,其他线程等待回写缓存线程执行完,重新读缓存即可。

 利用互斥锁保证同一时刻只有一个客户端可以查询底层数据库的这个数据,一旦查到数据就缓存至Redis内,避免其他大量请求同时穿过Redis访问底层数据库

String get(String key) {  
   String value = redis.get(key);  
   if (value  == null) {  
// 如果key不存在,则设置为1
    if (redis.setnx(key_mutex, "1")) {  
        // 设置key的过期时间为3分钟  
        redis.expire(key_mutex, 3 * 60)  
// 从db中加载数据,但注意:只有一个线程能进入到这里,其他线程访问的时候已有课key_mutex
        value = db.get(key);  
// 从数据库中加载成功,则设置对应的数据
        redis.set(key, value);  
        redis.delete(key_mutex);  
    } else {  
        //其他线程休息50毫秒后重试  
        Thread.sleep(50);  
        get(key);  
    }  
  }  
}  

在使用互斥锁的时候需要避免出现死锁或者锁过期的情况:
        使用lua脚本或事务将获取锁和设置过期时间作为一个原子性操作(如:set kk vv nx px 30000),以避免出现某个客户端获取锁之后宕机导致的锁不被释放造成死锁现象;
        另起一个线程监控获取锁的线程的查询状态,快到锁过期时间时还没查询结束则延长锁的过期时间,避免多次查询多次锁过期造成计算资源的浪费。

三、缓存雪崩

缓存雪崩:当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力。

一言以蔽之:缓存不可用(服务器重启或缓存失效),频繁查询数据源。

        缓存雪崩是缓存击穿的"大面积"版,缓存击穿是数据库缓存到Redis内的热点数据失效导致大量并发查询穿过redis直接击打到底层数据库,而缓存雪崩是指Redis中大量的key几乎同时过期,然后大量并发查询穿过redis击打到底层数据库上,此时数据库层的负载压力会骤增,我们称这种现象为"缓存雪崩"。事实上缓存雪崩相比于缓存击穿更容易发生,对于大多数公司来讲,同时超大并发量访问同一个过时key的场景的确太少见了,而大量key同时过期,大量用户访问这些key的几率相比缓存击穿来说明显更大。 

解决方案:
        在可接受的时间范围内随机设置key的过期时间,分散key的过期时间,以防止大量的key在同一时刻过期;
        对于一定要在固定时间让key失效的场景(例如每日12点准时更新所有最新排名),可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。让一部分查询先将数据缓存起来;延长热点key的过期时间或者设置永不过期,这一点和缓存击穿中的方案一样。

四、缓存预热

        缓存预热如字面意思,当系统上线时,缓存内还没有数据,如果直接提供给用户使用,每个请求都会穿过缓存去访问底层数据库,如果并发大的话,很有可能在上线当天就会宕机,因此我们需要在上线前先将数据库内的热点数据缓存至Redis内再提供出去使用,这种操作就成为"缓存预热"。

        缓存预热的实现方式有很多,比较通用的方式是写个批任务,在启动项目时或定时去触发将底层数据库内的热点数据加载到缓存内。

五、缓存更新

        缓存服务(Redis)和数据服务(底层数据库)是相互独立且异构的系统,在更新缓存或更新数据的时候无法做到原子性的同时更新两边的数据,因此在并发读写或第二步操作异常时会遇到各种数据不一致的问题。如何解决并发场景下更新操作的双写一致是缓存系统的一个重要知识点。
        第二步操作异常:缓存和数据的操作顺序中,第二个动作报错。如数据库被更新, 此时失效缓存的时候出错,缓存内数据仍是旧版本;
缓存更新的设计模式有四种:
Cache aside:查询:先查缓存,缓存没有就查数据库,然后加载至缓存内;更新:先更新数据库,然后让缓存失效;或者先失效缓存然后更新数据库; 
Read through:在查询操作中更新缓存,即当缓存失效时,Cache Aside 模式是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载;
Write through:在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由缓存自己更新数据库; 
Write behind caching:俗称write back,在更新数据的时候,只更新缓存,不更新数据库,缓存会异步地定时批量更新数据库;
Cache aside:
        为了避免在并发场景下,多个请求同时更新同一个缓存导致脏数据,因此不能直接更新缓存而是另缓存失效。
        先更新数据库后失效缓存:并发场景下,推荐使用延迟失效(写请求完成后给缓存设置1s过期时间),在读请求缓存数据时若redis内已有该数据(其他写请求还未结束)则不更新。当redis内没有该数据的时候(其他写请求已令该缓存失效),读请求才会更新redis内的数据。这里的读请求缓存数据可以加上失效时间,以防第二步操作异常导致的不一致情况。
        先失效缓存后更新数据库:并发场景下,推荐使用延迟失效(写请求开始前给缓存设置1s过期时间),在写请求失效缓存时设置一个1s延迟时间,然后再去更新数据库的数据,此时其他读请求仍然可以读到缓存内的数据,当数据库端更新完成后,缓存内的数据已失效,之后的读请求会将数据库端最新的数据加载至缓存内保证缓存和数据库端数据一致性;在这种方案下,第二步操作异常不会引起数据不一致,例如设置了缓存1s后失效,然后在更新数据库时报错,即使缓存失效,之后的读请求仍然会把更新前的数据重新加载到缓存内。
推荐使用先失效缓存,后更新数据库,配合延迟失效来更新缓存的模式;

四种缓存更新模式的优缺点:
Cache Aside:实现起来较简单,但需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository);
Read/Write Through:只需要维护一个数据存储(缓存),但是实现起来要复杂一些;
Write Behind Caching:与Read/Write Through 类似,区别是Write Behind Caching的数据持久化操作是异步的,但是Read/Write Through 更新模式的数据持久化操作是同步的。优点是直接操作内存速度快,多次操作可以合并持久化到数据库。缺点是数据可能会丢失,例如系统断电等。
        缓存本身就是通过牺牲强一致性来提高性能,因此使用缓存提升性能,就会有数据更新的延迟性。这就需要我们在评估需求和设计阶段根据实际场景去做权衡了。

六、缓存降级

        缓存降级是指当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,即使是有损部分其他服务,仍然需要保证主服务可用。可以将其他次要服务的数据进行缓存降级,从而提升主服务的稳定性。
        降级的目的是保证核心服务可用,即使是有损的。如去年双十一的时候淘宝购物车无法修改地址只能使用默认地址,这个服务就是被降级了,这里阿里保证了订单可以正常提交和付款,但修改地址的服务可以在服务器压力降低,并发量相对减少的时候再恢复。
        降级可以根据实时的监控数据进行自动降级也可以配置开关人工降级。是否需要降级,哪些服务需要降级,在什么情况下再降级,取决于大家对于系统功能的取舍。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值