秒懂,Redis缓存穿透、缓存击穿、缓存雪崩概念以及应对策

如上图所示,我们在应用程序和Mysql数据库中建立一个中间层,即Redis缓存。通过Redis缓存可以有效减少查询数据库的时间消耗,这极大的提升了应用程序的性能和效率,特别是数据查询方面。但同时,它也带来了一些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。如果对数据的一致性要求极高,那么就不能使用缓存。另外的一些典型问题就是,缓存穿透、缓存击穿和缓存雪崩。本文将简单介绍缓存穿透、缓存雪崩和缓存击穿这三者之间的区别以及这三类问题的解决方法。

一、缓存穿透

缓存穿透是指用户访问了不存在的数据,导致缓存无法命中,大量的请求都要穿透到数据库进行查询,从而使得数据库压力过大,甚至挂掉。比如:数据库使用了id为正整数作为键,但是黑客使用负整数向服务器发起请求,这时所有的请求都没有在缓存中命中,从而导致大量请求数据库,如果超过了数据库的承载能力,会导致数据库服务器宏机。

一般解决缓存穿透的方法有:

(1)缓存空对象

这是一个简单粗暴的方法,方法是如果一个查询返回的结果是空的,仍然把这个空结果进行缓存,这样的话缓存层就存了更多的键,需要更多的内存空间,比较有效的方法是针对这类数据设置一个比较短的过期时间,让其自动过期。

(2)使用布隆过滤器拦截

这是一种常见而且有效的策略,它将所有可能存在的数据哈希到一个足够大的bitmap中,当查询一个不存在的key时会被这个bitmap拦截掉,从而避免了对底层数据库的查询压力。Redis实现了布隆过滤器,我们可以直接使用来达到过滤的目的。具体的原理和使用方法可以参考我的另一篇博文布隆过滤器(Bloom Filter)的原理和实现

二、缓存击穿(缓存并发)

缓存击穿,是指当某个key在过期的瞬间,有大量的请求并发访问过期的键,这类数据一般是热点数据,由于缓存过期了,会同时访问数据库来查询数据,并写回缓存,从而导致数据库瞬间压力过大。

缓存击穿的解决方法也有两个:

(1)设置热点数据永不过期

当遇到这种情况的时候,数据库很难扛下来这么大的并发。最简单的方法就是将热点数据缓存永不过期就好了。

(2)使用互斥锁

业界比较常用的方法,使用锁控制访问后盾服务的并发量。

  • 分布式锁:使用分布式锁,保证对于每个key同时只有一个线程去查询后端服务,其线程没有获取到分布式锁等待就好了。这种方式将高并发的压力转移到了分布式锁,因此对系统的分布式锁是否合格考验很大。关于分布式锁内容可以参考我的另一篇博文分布式锁的三种实现方案
  • 本地锁:与分布式锁的作用类似,我们通过本地锁的方式来限制只有一个线程去数据库中查询数据,而其他线程只需等待,等前面的线程查询到数据后再访问缓存。但是,这种方法只能限制一个服务节点只有一个线程去数据库中查询,如果一个服务有多个节点,则还会有多个数据库查询操作,也就是说在节点数量较多的情况下并没有完全解决缓存并发的问题。

基于Redis实现的分布式锁伪代码:

public String get(int key){
    String value=redis.get(key);
    if(vlaue==null){
        //缓存过期了
        //设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
        if(redis.setnx(key_mutex,1,3*60)){
            value=db.get(key);
            redis.set(key,value,expire_secs);
            redis.del(key_mutex);
        }else{
            //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
            sleep(50);
            get(key); 
        }
    }
    return value;
}

三、缓存雪崩

缓存雪崩是指设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,导致所有的查询同一时刻都落到了数据库上,造成了数据库压力过大。缓存雪崩与缓存击穿的区别在于这里针对很多key同时失效,前者则是针对某一个热点key失效

解决方案:

(1)不同的key,设置不同的过期时间,让缓存失效的时间尽量均匀

(2)在缓失效后,通过分布式锁或者分布式队列的方式控制数据库写缓存的线程数。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。(和缓存击穿中使用互斥锁类似)

(3)如果是因为某台缓存服务器宕机,可以考虑做主备,比如:redis主备,但是双缓存涉及到更新事务的问题,update可能读到脏数据,需要好好解决。

随机过期时间伪代码:

//伪代码
public String get(int key){                                     
    String value=redis.get(key);                                
    if(vlaue==null){                                            
        //缓存过期了                                                 
        value=db.get(key);                                      
        if(value!=null){   
            //正对不同热度的商品设置不同的缓存过期时间。
            if(value.equals("热门商品")){                           
                Random r=new Random();                          
                int cacheTime=3600+r.nextInt(3600);  //随机值      
                redis.set(key,value,cacheTime,TimeUnit.SECONDS);
            }else{                                              
                Random r=new Random();                          
                int cacheTime=600+r.nextInt(600);  //随机值        
                redis.set(key,value,cacheTime,TimeUnit.SECONDS);
            }                                                   
        }else{
            //null值也缓存,并设置短的过期时间,防止缓存穿透
            redis.set(key,null,60,TimeUnit.SECONDS);
        }                                                       
    }                                                           
    return value;                                               
}                                                               

解释:

在同一分类中的key,加上一个随机因子。这样能尽可能分散缓存过期时间,而且,热门类目的商品缓存时间长一些,冷门类目的商品缓存时间短一些,也能节省缓存服务的资源。

其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因为自然形成的缓存雪崩,一定是在某个时间段集中创建缓存,那么那个时候数据库能顶住压力,这个时候,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。这种情况就应该考虑使用主备缓存策略了。

最后再介绍几个有关缓存的概念

缓存预热

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

解决思路:

1、直接写个缓存刷新页面,上线时手工操作下;

2、数据量不大,可以在项目启动的时候自动进行加载;

3、定时刷新缓存;

缓存更新

除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),我们还可以根据具体的业务需求进行自定义的缓存淘汰,常见的策略有两种:

(1)定时去清理过期的缓存;

(2)当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。

两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂!具体用哪种方案,大家可以根据自己的应用场景来权衡。

缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。

降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。

在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:

(1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;

(2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;

(3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;

(4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

总结

本文介绍了缓存应用中比较典型的是三个问题:缓存穿透,缓存击穿和缓存雪崩的概念和基本的解决方法。最后还介绍了缓存预热、缓存更新以及缓存降级等概念。在缓存的应用中还有一个比较典型的问题就是如何保证数据的一致性问题,也欢迎大家参考我的另一篇博文Redis缓存和MySQL数据一致性方案详解

参考
  • [1] https://www.pieruo.com/13549.html
  • [2] https://www.cnblogs.com/midoujava/p/11277096.html

留言区

还能输入500个字符