关于Redis雪崩、穿透及热点问题梳理及解决方案

缓存穿透

概念

缓存穿透是指查询一个一定不存在的数据,由于缓存不会命中,需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都去数据库查询,造成缓存穿透。

在流量小时没有问题,如果流量非常大或有恶意攻击(意用户模拟请求很多缓存中不存在的数据,由于缓存中都没有,导致这些请求短时间内直接落在了数据库上,导致数据库异常),就会利用这个漏洞,使服务端(尤其是数据库)的压力增大,严重会导致系统崩溃。
 

解决方案

方案一:前端过滤

从redis缓存查询数据以及查询数据库之前,对组成key的查询条件进行过滤,通常采取如下措施:

1. 非法请求过滤:首先根据业务,对请求的key为非法key(如key不满足约定的格式)进行拦截和过滤。

2. 对一定不存在查询结果的查询条件进行过滤:对所有可能查询的参数(即一定能查询到结果的查询条件参数,通过这些参数能构造出redis缓存key)以hash形式存储,在控制层进行校验,若不符合查询条件则丢弃。最常见的则是采用布隆过滤器,将所有可能存在的数据(一定能查到数据的查询条件)哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

   问题:如何构造hash值或布隆过滤器,当实际数据量很大时,所有数据都得通过布隆过滤器,代价是否较大,另外有数据更新(新增、修改、删除)时,如何同步更新hash或布隆过滤器以保持缓存和数据的同步?

另外布隆过滤器不准确(通过布隆过滤器推断出来不存在的数据一定是不存在的;但推断出存在的数据有可能不存在,另外布隆过滤器无法删除元素;此时有一定的误差,但由于已经过过滤掉了大部分不合法请求,因而可以在推断出存在时,再通过查询redis缓存和数据库来进行二次确认查询)。

   有数据更新(新增、修改、删除)时,修改布隆过滤器有问题吗?是否对不经常修改的数据才适合用布隆过滤器?

方案二:查询redis及数据库时进行过滤

也可以采用一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

存在的问题及解决办法:

 –缓存太多空值,占用更多空间。如果是攻击,问题更严重,优化办法:给个空值较短的过期时间,最多不超过5分钟,过期自动个剔除

–存储层更新代码了,缓存层还是空值。(优化:后台更新值,更新对应的key为最新内容,使缓存和数据库保持一致)   。

炸一看,该方案与布隆过滤器方案有冲突,因为通过布隆过滤,已经将不存在查询结果的请求直接返回了,此时没有必要再缓存空值。但由于布隆过滤器不准确特性,即通过布隆过滤器推断出存在的数据有可能在数据库中不存在,因此在这种情况下,有可能从数据库中查询不到数据,此时,也可以将null作为值,缓存到redis中,可以进一步减少对数据库的穿透。

方案三:接口限流与熔断、降级

除了前述方案外,作为最后的保险措施,重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。

缓存穿透处理流程:

缓存穿透的问题,肯定是再大并发情况下。依此为前提,我们分析缓存穿透的原因如下:

1、恶意攻击,猜测你的key命名方式,然后估计使用一个你缓存中不会有的key进行访问。

2、第一次数据访问,这时缓存中还没有数据,则并发场景下,所有的请求都会压到数据库。

3、数据库的数据也是空,这样即使访问了数据库,也是获取不到数据,那么缓存中肯定也没有对应的数据。这样也会导致穿透。

缓存穿透在于一步步规避穿透的原因,如图:

如上图所示,解决的步骤如下:

1、在web服务器启动时,提前将有可能被频繁并发访问的数据写入缓存。—这样就规避大量的请求在第3步出现排队阻塞,解决第一次数据访问缓存穿透问题。另外也可以在此步骤构建布隆过滤器,将所有存在查询结果的key存入布隆过滤器。

2、规范key的命名,并且统一缓存查询和写入的入口。这样,在入口处,对key的规范进行检测,这样保存恶意的key被拦截。也可以通过布隆过滤器,对不存在的查询结果的请求直接返回空。

3、Synchronized双重检测机制,这时我们就需要使用同步(Synchronized)机制(单机环境),在同步代码块前查询一下缓存是否存在对应的key,然后同步代码块里面再次查询缓存里是否有要查询的key。 这样“双重检测”的目的,还是避免并发场景下导致的没有意义的数据库的访问(也是一种严格避免穿透的方案)。集群环境下,需要使用分布式锁。

下例是分布式环境的代码逻辑:

/**
UserService
*/
public class UserServiceImpl{
   

/**
* 查询用户信息的service方法,先判断userId格式是否合法,不合法则直接返回null,之后通过布隆过滤器
判断用户信息是否存在,不存在也返回null,之后再调用getUserWithLock,从redis缓存或数据库中查询用
户信息。
*/
public User queryUser(String userId){
    if(isValid(userId){
        return null;
    }
    //若启用布隆过滤器对数据进行了缓存,则先从布隆过滤器查看数据是否存在,不存在直接返回'null'值。若这一步在该调用前已经判断过了,则无需再在此处判断
    if (!bloomFilter.mightContain(key)) {
        return null;
    }
    
    String userInfo = getUserWithLock3(userIdKey,jedis,lockKey, uniqueId,expireTime);

    return String2User(userInfo);
}

/**
业界比价普遍的一种做法,即根据key获取value值为空时,锁上,从数据库中load数据后再释放锁。
若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用分布式锁,
单机的话用普通的锁(synchronized、Lock)就够了。


这样做思路比较清晰,也从一定程度上减轻数据库压力,但是锁机制使得逻辑的复杂度增加,吞吐量也降低了,有点治标不治本。


*/
private String getUserWithLock1(String key, Jedis jedis, String lockKey, String uniqueId, long expireTime) {
    // 通过key获取value
    String value = redisService.get(key);
    if (StringUtil.isEmpty(value)) {
        // 分布式锁,详细可以参考https://blog.csdn.net/fanrenxiang/article/details/79803037
        //封装的tryDistributedLock包括setnx和expire两个功能,在低版本的redis中不支持
        try {
            boolean locked = redisService.tryDistributedLock(jedis, lockKey, uniqueId, expireTime);
            if (locked) {
                value = userService.getById(key);
                if(null ==value){
                  //查询结果为null,但仍然放到缓存中,超时时间要设置的短一些,不超过5分钟
                  redisService.set(key, 'null', 5min);
                }else{
                   //查询结果不为null,超时时间30分钟或者更长(根据业务情况而定)
                   redisService.set(key, value, 30min);
                }
                redisService.del(lockKey);
                return value;
            } else {
                // 其它线程进来了没获取到锁便等待50ms后重试
                Thread.sleep(50);
                getWithLock(key, jedis, lockKey, uniqueId, expireTime);
            }
        } catch (Exception e) {
            log.error("getWithLock exception=" + e);
            return value;
        } finally {
            redisService.releaseDistributedLock(jedis, lockKey, uniqueId);
        }
    }
    return value;
}

/**

优化:降低锁的粒度,只对要查询的key加锁。由于前面通过格式校验或布隆过滤器过滤了一部分请求,以及预
先加载机制已经加载了一部分缓存,因此能够进入数据库查询数据的请求已经被大大降低,因此只对查询的key
进行枷锁,不会导致过多的穿透。
但这样做还会出现缓存雪崩问题。
**/
private String getUserWithLock2(String key, Jedis jedis, String lockKey, String uniqueId, long expireTime) {
    // 通过key获取value
    String value = redisService.get(key);
    if (StringUtil.isEmpty(value)) {
        // 分布式锁,详细可以参考https://blog.csdn.net/fanrenxiang/article/details/79803037
        //封装的tryDistributedLock包括setnx和expire两个功能,在低版本的redis中不支持
        try {
            //优化,降低锁粒度
            lockKey="lock_"+key;
            boolean locked = redisService.tryDistributedLock(jedis, lockKey, uniqueId, expireTime);
            if (locked) {
                value = userService.getById(key);
                //有可能查询为null,但仍然放到缓存中
                redisService.set(key, value);
                redisService.del(lockKey);
                return value;
            } else {
                // 其它线程进来了没获取到锁便等待50ms后重试
                Thread.sleep(50);
                getWithLock(key, jedis, lockKey, uniqueId, expireTime);
            }
        } catch (Exception e) {
            log.error("getWithLock exception=" + e);
            return value;
        } finally {
            redisService.releaseDistributedLock(jedis, lockKey, uniqueId);
        }
    }
    return value;
}



}


4、不管数据库中是否有数据,都在缓存中保存对应的key,值为空就行。这样是为了避免数据库中没有这个数据,导致的平凡穿透缓存对数据库进行访问。这一步会导致排队,但是第一步中我们说过,为了避免大量的排队,可以提前将可以预知的大量请求提前写入缓存。

5、第4步中的空值如果太多,也会导致内存耗尽。导致不必要的内存消耗。这样就要定期的清理空值的key(或将key的过期时间短一些,最长5分钟)。避免内存被恶意占满,导致正常的功能不能缓存数据。

6、当对数据进行更新(插入、修改、删除)时,同步更新Redis缓存,使缓存数据与数据库保持一致。

 

缓存雪崩

概念

如果缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,则造成了缓存雪崩。

因为缓存层承载了大量的请求,有效的保护了存储 层,但是如果缓存由于某些原因,整体不能够提供服务,于是所有的请求,就会到达存储层,存储层的调用量就会暴增,造成存储层也会挂掉的情况。缓存雪崩的英文解释是奔逃的野牛,指的是缓存层当掉之后,并发流量会像奔腾的野牛一样,打向后端存储。

存在这种问题的一个场景是:当缓存服务器重启、崩溃或者大量缓存集中在某一个时间段失效,这样在失效的时候,大量数据会去直接访问DB,此时给DB很大的压力。

 

解决方案

方案一:设置redis集群和DB集群的高可用

如果redis出现宕机情况,可以立即由别的机器顶替上来。这样可以防止一部分的风险

该方案可以和其它方案同时采用。

方案二:数据预热

在系统上线前,或在即将发生大并发访问前,可以通过缓存reload机制,预先去更新缓存,手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

该方案可以和其它方案同时采用。

方案三:设置缓存永远不过期

两种方法:

方法一:缓存双备(不推荐)

建立备份缓存,缓存A和缓存B,A设置超时时间,B不设值超时时间,先从A读缓存,A没有读B,并且更新A缓存和B缓存。问题:成本高,不推荐。

方法二:物理不过期,逻辑过期(推荐)

    (1) 从缓存上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。

     (2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期.

 从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。

永远不过期代码如下:

String get(final String key) {  
        V v = redis.get(key);  
        String value = v.getValue();  
        long timeout = v.getTimeout();  
        //快过期了,设定的时间,这里设定的是60秒,可以根据业务进行调整
        long willExpireTime = 600000;
        //已经过期了,需要从数据库重新加载数据,并更新到Redis缓存中。
        //问题:在访问该key的时候,可能缓存已经过期很长时间了,返回的数据太旧。
        //优化:在系统启动时,启动一个后台异步线程,一直扫描缓存中所有即将过期的key,
         //并对快过期的可以,执行以下线程的相同逻辑。而在该方法中,直接返回缓存中的key即可
        if (v.timeout<=System.currentTimeMillis()) {  
            // 异步更新后台异常执行  
            threadPool.execute(new Runnable() {  
                public void run() {  
                    String keyMutex = "mutex:" + key;  
                    //加锁,防止其它线程操作当前key
                    if (redis.setnx(keyMutex, "1")) {  
                        // 3 min timeout to avoid mutex holder crash  
                        redis.expire(keyMutex, 3 * 60);  
                        String dbValue = db.get(key);  
                        
                        v.setValue(dbValue);
                        v.setTimeout(System.currentTimeMillis()+
                                     logicExpireTimeDuration);

                        redis.set(key, v);  
                        //解锁
                        redis.delete(keyMutex);  
                    }  
                }  
            });  
        }  
        return value;  
    }  

 

方案四:使失效时间尽量均匀,防止大量key同时失效

不同的key,设置不同的过期时间,具体值可以根据业务决定,让缓存失效的时间点尽量均匀

可以给缓存设置过期时间时加上一个随机值时间(如0~60秒),使得每个key的过期时间均匀分布开来,不会集中在同一时刻失效,这样不会出现同时穿透,也即雪崩的问题。

如果这个key的访问频率频繁(如何判断是否频繁?)的时候,我们可以让它每查一次就给它加点有效时间。这样就能解决雪崩问题了。

存在问题:redis崩溃时,还是会雪崩,此时只能靠最后的防御手段:降级、限流或熔断。

该方案与方案三冲突,二选一,推荐方案三。

方案五:使用互斥锁

在缓存失效后,通过加锁(代码参见方案三的方法二)或者消息队列来控制读数据库写缓存的线程数量,防止失效时大量线程请求数据库。

比如对某个key只允许一个线程查询数据和写缓存,其他线程等待(代码参见方案三的方法二)

存在问题:缓存崩溃时,所有key会同时失效。若对一个key只允许一个线程查询数据和写缓存,若大量请求同时访问不同的key,还是会雪崩。

方案六:资源保护

使用netflix的hystrix,可以做各种资源的线程池隔离,从而保护主线程池。

依赖隔离组件为后端限流并降级。如Hystrix。        

方案七:
 

   1. 加锁排队. 限流-- 限流算法. 1.计数 2.滑动窗口 3.  令牌桶Token Bucket 4.漏桶 leaky bucket [1]

 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。 

 

热点key问题

出现原因

我们通常使用 缓存 + 过期时间的策略来帮助我们加速接口的访问速度,减少了后端负载,同时保证功能的更新,一般情况下这种模式已经基本满足要求了。

       但是有两个问题如果同时出现,可能就会对系统造成致命的危害:

      (1) 这个key是一个热点key(例如一个重要的新闻,一个热门的八卦新闻等等),所以这种key访问量可能非常大。

      (2) 缓存的构建是需要一定时间的。(可能是一个复杂计算,例如复杂的sql、多次IO、多个依赖(各种接口)等等)

 

       于是就会出现一个致命问题:在缓存失效的瞬间,有大量线程来构建缓存(见下图),造成后端负载加大,甚至可能会让系统崩溃 。

 

解决方案

方案一:使用互斥锁(mutex key)

这种解决方案思路比较简单,就是只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以了(如下图)

如果是单机,可以用synchronized或者lock来处理,如果是分布式环境可以用分布式锁就可以了(分布式锁,可以用memcache的add, redis的setnx, zookeeper的添加节点操作)。

代码如下:

String get(String key) {  
   String value = redis.get(key);  
   if (value  == null) {  
    if (redis.setnx(key_mutex, "1")) {  
        // 3 min timeout to avoid mutex holder crash  
        redis.expire(key_mutex, 3 * 60)  
        value = db.get(key);  
        redis.set(key, value);  
        redis.delete(key_mutex);  
    } else {  
        //其他线程休息50毫秒后重试  
        Thread.sleep(50);  
        get(key);  
    }  
  }  
}  

方案二:"提前"使用互斥锁(mutex key)

  在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。

相对于方案一
优点:避免cache失效时刻大量请求获取不到mutex并进行sleep?--根据代码,只是提前获取最新数据而已,没有达到这种效果啊?
缺点:代码复杂性增大,因此一般场合用方案一也已经足够。

伪代码如下:

v = memcache.get(key);  
if (v == null) {  
    if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {  
        value = db.get(key);  
        memcache.set(key, value);  
        memcache.delete(key_mutex);  
    } else {  
        sleep(50);  
        retry();  
    }  
} else {  
    if (v.timeout <= now()) {  
        if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {  
            // extend the timeout for other threads  
            v.timeout += 3 * 60 * 1000;  
            memcache.set(key, v, KEY_TIMEOUT * 2);  
  
            // load the latest value from db  
            v = db.get(key);  
            v.timeout = KEY_TIMEOUT;  
            memcache.set(key, value, KEY_TIMEOUT * 2);  
            memcache.delete(key_mutex);  
        } else {  
            sleep(50);  
            retry();  
        }  
    }  
}  

方案三:设置缓存永远不过期

同缓存雪崩

方案四:降级熔断

比如用hystrix,可以做资源的隔离保护主线程池。

四种方案对比:

      作为一个并发量较大的互联网应用,我们的目标有3个:

      1. 加快用户访问速度,提高用户体验。

      2. 降低后端负载,保证系统平稳。

      3. 保证数据“尽可能”及时更新(要不要完全一致,取决于业务,而不是技术。)

      所以第二节中提到的四种方法,可以做如下比较,还是那就话:没有最好,只有最合适。

解决方案优点缺点
简单分布式锁(Tim yang)

 1. 思路简单

2. 保证一致性

1. 代码复杂度增大

2. 存在死锁的风险

3. 存在线程池阻塞的风险

加另外一个过期时间(Tim yang)

 1. 保证一致性

相对于方案一
优点:避免cache失效时刻大量请求获取不到mutex并进行sleep
缺点:代码复杂性增大,因此一般场合用方案一也已经足够。

同上 
不过期(本文)

1. 异步构建缓存,不会阻塞线程池

1. 不保证一致性。

2. 代码复杂度增大(每个value都要维护一个timekey)。

3. 占用一定的内存空间(每个value都要维护一个timekey)。

资源隔离组件hystrix(本文)

1. hystrix技术成熟,有效保证后端。

2. hystrix监控强大。

 

 

1. 部分访问存在降级策略。

总结

   1.  热点key + 过期时间 + 复杂的构建缓存过程 => mutex key问题

   2. 构建缓存一个线程做就可以了。

   3. 四种解决方案:没有最佳只有最合适。

 


缓存击穿

概念

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

解决方案

方案一

加互斥锁:
同本文缓存雪崩-方案一

方案二

缓存不过期:

  1. 从缓存上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。
  2. 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期。

 

 

(四)缓存并发问题

这里的并发指的是多个redis的client同时set key引起的并发问题。其实redis自身就是单线程操作,多个client并发操作,按照先到先执行的原则,先到的先执行,其余的阻塞。当然,另外的解决方案是把redis.set操作放在队列中使其串行化,必须的一个一个执行,具体的代码就不上了,当然加锁也是可以的,至于为什么不用redis中的事务,留给各位看官自己思考探究。

 

 

 

缓存数据的淘汰

缓存淘汰的策略有两种: (1) 定时去清理过期的缓存。 (2)当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。 

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

 

1. 预估失效时间 2. 版本号(必须单调递增,时间戳是最好的选择)3. 提供手动清理缓存的接口。

 

淘汰机制

保存在内存中的缓存数据如果过期或失效,为了更合理利用内存空间,提高内存使用效率。    

什么是淘汰机制

在内存中保存的Key被清除掉,

(1)定时去清理过期的缓存,使用Expire;

(2) LRU、LFU、FIFO算法剔除;

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

可以通过redis.conf设置# maxmemory <bytes>这个值来开启内存淘汰功能 

 

redis数据淘汰策略

volatile-lru 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰

volatile-ttl 从已设置过期时间的数据集中挑选将要过期的数据淘汰

volatile-random 从已设置过期时间的数据集中任意选择数据淘汰

allkeys-lru 从所有数据集中挑选最近最少使用的数据淘汰

allkeys-random 从所有数据集中任意选择数据进行淘汰

noeviction 禁止驱逐数据

 

缓存是为了有效加速应用的读写速度,同时为后端降低负载,对日常访问量大的系统至关重要。如果缓存出现问题,不只不能降低压力,还会给后端服务造成更大的问题。所以在开发过程中,要避免预防这些问题出现。

 

 

 

https://carlosfu.iteye.com/blog/2269687

https://carlosfu.iteye.com/blog/2249316

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值