Redis-缓存穿透、缓存雪崩和缓存击穿

1.缓存穿透

        缓存穿透,是指查询一个数据库一定不存在的数据。正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存。

代码流程

  1. 参数传入对象主键ID
  2. 根据key从缓存中获取对象
  3. 如果对象不为空,直接返回
  4. 如果对象为空,进行数据库查询
  5. 如果从数据库查询出的对象不为空,则放入缓存(设定过期时间)

1.1 问题描述

       想象一下这个情况,如果传入的参数为-1,会是怎么样?这个-1,就是一定不存在的对象。就会每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库。即便是采用UUID,也是很容易找到一个不存在的KEY,进行攻击。

1.2 解决方案

       有很多种方法可以有效地解决缓存穿透问题

1.2.1 采用布隆过滤器

        采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

1.2.2 缓存空对象

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

缓存空对象会有两个问题:

  • 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
  • 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

 

2.缓存雪崩

2.1 问题描述

        缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

2.2 解决方案

2.2.1 加锁排队

        public object GetProductListNew()
        {
            const int cacheTime = 30;
            const string cacheKey = "product_list";
            const string lockKey = cacheKey;
            
            var cacheValue = CacheHelper.Get(cacheKey);
            if (cacheValue != null)
            {
                return cacheValue;
            }
            else
            {
                lock (lockKey)
                {
                    cacheValue = CacheHelper.Get(cacheKey);
                    if (cacheValue != null)
                    {
                        return cacheValue;
                    }
                    else
                    {
                        cacheValue = GetProductListFromDB(); //这里一般是 sql查询数据。              
                        CacheHelper.Add(cacheKey, cacheValue, cacheTime);
                    }                    
                }
                return cacheValue;
            }
        }   

        加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法。

2.2.2 缓存标记

        给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。

 public object GetProductListNew()
        {
            const int cacheTime = 30;
            const string cacheKey = "product_list";
            //缓存标记。
            const string cacheSign = cacheKey + "_sign";
            
            var sign = CacheHelper.Get(cacheSign);
            //获取缓存值
            var cacheValue = CacheHelper.Get(cacheKey);
            if (sign != null)
            {
                return cacheValue; //未过期,直接返回。
            }
            else
            {
                CacheHelper.Add(cacheSign, "1", cacheTime);
                ThreadPool.QueueUserWorkItem((arg) =>
                {
                    cacheValue = GetProductListFromDB(); //这里一般是 sql查询数据。
                    CacheHelper.Add(cacheKey, cacheValue, cacheTime*2); //日期设缓存时间的2倍,用于脏读。                
                });
                
                return cacheValue;
            }
        } 

  缓存标记:记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际key的缓存。

  缓存数据:它的过期时间比缓存标记的时间延长1倍,例:标记缓存时间30分钟,数据缓存设置为60分钟。 这样,当缓存标记key过期后,实际缓存还能把旧数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存。

  这样做后,就可以一定程度上提高系统吞吐量。

 

3.缓存击穿

3.1 问题描述

        对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。

        缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

3.2 解决方案
3.2.1 使用互斥锁(mutex key)

       业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。

        SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。在redis2.6.1之前版本未实现setnx的过期时间,所以这里给出两种版本代码参考:

//2.6.1前单机版本锁  
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);    
    }    
  }    
}  

最新版本代码: 

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

3.2.2  "提前"使用互斥锁(mutex key):

        加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法。
        还有一个解决办法解决方案是在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。伪代码如下:

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();    
        }    
    }    
}

3.2.3 "永远不过期"

       这里的“永远不过期”包含两层意思:

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

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

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

String get(final String key) {    
        V v = redis.get(key);    
        String value = v.getValue();    
        long timeout = v.getTimeout();    
        if (v.timeout <= System.currentTimeMillis()) {    
            // 异步更新后台异常执行    
            threadPool.execute(new Runnable() {    
                public void run() {    
                    String keyMutex = "mutex:" + key;    
                    if (redis.setnx(keyMutex, "1")) {    
                        // 3 min timeout to avoid mutex holder crash    
                        redis.expire(keyMutex, 3 * 60);    
                        String dbValue = db.get(key);    
                        redis.set(key, dbValue);    
                        redis.delete(keyMutex);    
                    }    
                }    
            });    
        }    
        return value;    
}

3.2.4 资源保护

采用netflix的hystrix,可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。

3.2.5  四种方案比较

解决方案优点缺点
简单分布式互斥锁(mutex key)

1. 思路简单

2. 保证一致性

1. 代码复杂度增大

2. 存在死锁的风险

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

“提前”使用互斥锁1. 保证一致性同上 
不过期(本文)

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

1. 不保证一致性。

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

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

资源隔离组件hystrix(本文)

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

2. hystrix监控强大。

 

 

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

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值