从零开始SpringCloud Alibaba实战(86)——再谈缓存击穿、缓存穿透、缓存雪崩

文章目录

缓存击穿

什么时候Redis中没有要查询的数据呢?答案是过期和新增:过期:在Redis会有一个key值,每个key值都有一个ttl,也就是生命周期,一旦过期了就只能存在数据库里了;新增:插入,更新的数据还未来得及同步到Redis中。
如果线程查询一个数据库中不存在的值,此时从数据库中就返回一个空值。当一个线程不断执行同一条查询语句查询这个Redis和数据库都不存在的数据时,比如说执行上万次同一条查询语句,那么每次都穿过Redis,这样Redis就没有意义了。那么为了解决这个问题,就可以在Redis中设置空值,同一条查询语句对应的key(数据库中没有的值)都对应着一个空值,当查询其他查询语句也查询数据库中不存在的值时,都要对每个key设置一个空值,随着越来越多这样的key,Redis服务器就会承受不了这么大的压力,例如下面的例子就引出了Redis缓存穿透:

缓存击穿: 一个并发访问量比较大的key在某个时间过期,导致所有的请求直接打在DB上。

从零开始SpringCloud Alibaba实战(86)——再谈缓存击穿、缓存穿透、缓存雪崩

缓存击穿如何解决

加锁更新

查询缓存,发现缓存中不存在,加锁,让其它线程等待,只让一个线程去更新缓存。
该方法是比较普遍的做法,即,在根据key获得的value值为空时,先锁上,再从数据库加载,加载完毕,释放锁。若其他线程发现获取锁失败,则睡眠50ms后重试。

至于锁的类型,单机环境用并发包的Lock类型就行,集群环境则使用分布式锁( redis的setnx)
单机伪代码

static Lock reenLock = new ReentrantLock();

//最后使用互斥锁的方式来实现.
  public List<String> getData() throws InterruptedException {
        List<String> result = new ArrayList<String>();
        // 从缓存读取数据
        result = getDataFromCache();
        if (result.isEmpty()) {
            if (reenLock.tryLock()) {
                try {
                    System.out.println("我拿到锁,从DB获取数据库后写入缓存");
                    // 从数据库查询数据
                    result = getDataFromDB();
                    // 将查询到的数据写入缓存
                    setDataToCache(result);
                } finally {
                    reenLock.unlock();// 释放锁
                }
            } else {//我没有拿到锁
                result = getDataFromCache();// 先查一下缓存
                if (result.isEmpty()) {
                    System.out.println("我没拿到锁,缓存也没数据,等一下");
                    Thread.sleep(100);// 
                    return getData();// 递归调用重试
                }
            }
        }
        return result;
        }

集群环境的redis的代码如下所示:

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

    }  

  }  

}  

从零开始SpringCloud Alibaba实战(86)——再谈缓存击穿、缓存穿透、缓存雪崩
优点

思路简单

保证一致性

缺点

代码复杂度增大

存在死锁的风险

异步构建缓存

在这种方案下,构建缓存采取异步策略,会从线程池中取线程来异步构建缓存,从而不会让所有的请求直接怼到数据库上。该方案redis自己维护一个timeout,当timeout小于System.currentTimeMillis()时,则进行缓存更新,否则直接返回value值。

集群环境的redis代码如下所示:

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;  

    }

优点

性价最佳,用户无需等待

缺点

无法保证缓存一致性

布隆过滤器

布隆过滤器的巨大用处就是,能够迅速判断一个元素是否在一个集合中。因此他有如下三个使用场景:

网页爬虫对URL的去重,避免爬取相同的URL地址

反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(同理,垃圾短信)

缓存击穿,将已存在的缓存放到布隆过滤器中,当黑客访问不存在的缓存时迅速返回避免缓存及DB挂掉。

OK,接下来我们来谈谈布隆过滤器的原理

其内部维护一个全为0的bit数组,需要说明的是,布隆过滤器有一个误判率的概念,误判率越低,则数组越长,所占空间越大。误判率越高则数组越小,所占的空间越小。

private static BloomFilter bloomFilter =BloomFilter.create(Funnels.integerFunnel(), size);
String get(String key) {

String value = redis.get(key);

if (value == null) {

    if(!bloomfilter.mightContain(key)){
        return null;

    }else{
       value = db.get(key);  

       redis.set(key, value);  

    }

}

return value;

}

缓存穿透

什么是缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
从零开始SpringCloud Alibaba实战(86)——再谈缓存击穿、缓存穿透、缓存雪崩

与上面讲到的缓存击穿的区别:
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力

缓存穿透可能有两种原因:

自身业务代码问题
恶意攻击,爬虫造成空命中

解决方案

缓存空值/默认值

一种方式是在数据库不命中之后,把一个空对象或者默认值保存到缓存,之后再访问这个数据,就会从缓存中获取,这样就保护了数据库。

缓存空值有两大问题:

空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的

方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。

例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致。

这时候可以利用消息队列或者其它异步方式清理缓存中的空对象。

布隆过滤器

除了缓存空对象,我们还可以在存储和缓存之前,加一个布隆过滤器,做一层过滤。

布隆过滤器里会保存数据是否存在,如果判断数据不不能再,就不会访问存储。

缓存雪崩

简单说:由于缓存不可用,导致大量请求访问后端服务,可能 mysql 扛不住高并发而打死, 像滚雪球一样,影响越来越大,最后导致整个网站崩溃不可用

至于为什么会像滚雪球一样?整个与整个系统的架构有关;

redis 集群彻底崩溃:不可用

缓存服务在请求 redis 时,会有大量的线程阻塞,占用资源

超时请求失败之后,会去 mysql 查询原始数据,mysql 抗不住,被打死

源头服务由于 mysql 被打死,对源服务的请求也被阻塞,占用资源

缓存服务大量的资源全部耗费在访问 redis 和 源服务上;最后自己被拖死,无法提供服务

nginx 无法访问缓存服务,只能基于本地缓存提供服务,当缓存过期后,就耗费在访问缓存服务上

最后整个网站崩溃,页面加载不出来任何数据

考虑的比较完善的一套方案,分为事前、事中、事后三个层次去思考再怎么来应对缓存雪崩的场景

事前解决方案

发生缓存雪崩之前,事情之前,怎么去避免 redis 彻底挂掉

redis本身的高可用性、复制、主从架构,操作主节点,读写,数据同步到从节点,一旦主节点挂掉,从节点跟上

双机房部署,一套 redis cluster,部分机器在一个机房,另一部分机器在另外一个机房

还有一种部署方式,两套 redis cluster,两套 redis cluster 之间做一个数据的同步,redis 集群是可以搭建成树状的结构的

一旦说单个机房出了故障,至少说另外一个机房还能有些 redis 实例提供服务

事中解决方案

redis cluster 已经彻底崩溃了,已经开始大量的访问无法访问到 redis 了

ehcache 本地缓存
所做的多级缓存架构的作用上了 ,ehcache 的缓存应对零散的 redis 中数据被清除掉的现象,另外一个主要是预防 redis 彻底崩溃

多台机器上部署的缓存服务实例的内存中,还有一套 ehcache 的缓存,还能支撑一阵

对 redis 访问的资源隔离
对 redis 访问使用 hystrix 进行隔离,防止自己资源大量阻塞在访问 redis 上

对源服务访问的限流以及资源隔离
同上,防止自己资源大量阻塞在访问源服务上,同时 hystrix 在资源隔离时也做到了限流

事后解决方案

redis 数据可以恢复,之前讲解过各种备份机制,redis 数据备份和恢复,redis 重新启动起来

redis 数据彻底丢失了或者数据过旧,快速缓存预热,redis 重新启动起来

由于事中做了限流与隔离,缓存服务不会被打死,通过熔断策略 和 half-open 策略, 可以自动可以恢复对 redis 的访问,发现 redis 可以访问了,就自动恢复了

熔断降级

服务熔断:当缓存服务器宕机或超时响应时,为了防止整个系统出现雪崩,暂时停止业务服务访问缓存系统。
服务降级:当出现大量缓存失效,而且处在高并发高负荷的情况下,在业务系统内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的 fallback(退路)错误处理信息。

上一篇:三面蚂蚁金服(交叉面)定级阿里P6,看完你还觉得算法不重要


下一篇:惊喜Alibaba架构师手写《Java一无所知到精通》文档