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

缓存击穿

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

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

描述:某一个热点 key,在缓存过期的一瞬间,同时有大量的请求打进来,由于此时缓存过期了,所以请求最终都会走到数据库,造成瞬时数据库请求量大、压力骤增,甚至可能打垮数据库。

缓存击穿如何解决

加锁更新

查询缓存,发现缓存中不存在,加锁,让其它线程等待,只让一个线程去更新缓存。
该方法是比较普遍的做法,即,在根据key获得的value值为空时,先锁上,再从数据库加载,加载完毕,释放锁。若其他线程发现获取锁失败,则睡眠50ms后重试。
在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。

关于互斥锁的选择,看到的大部分文章都是选择 Redis 分布式锁,因为这个可以保证只有一个请求会走到数据库,这是一种思路。

但是其实仔细想想的话,这边其实没有必要保证只有一个请求走到数据库,只要保证走到数据库的请求能大大降低即可,所以还有另一个思路是 JVM 锁。

JVM 锁保证了在单台服务器上只有一个请求走到数据库,通常来说已经足够保证数据库的压力大大降低,同时在性能上比分布式锁更好。

需要注意的是,无论是使用“分布式锁”,还是“JVM 锁”,加锁时要按 key 维度去加锁。

我看网上很多文章都是使用一个“固定的 key”加锁,这样会导致不同的 key 之间也会互相阻塞,造成性能严重损耗。

单机伪代码

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的代码如下所示:

public Object getData(String key) throws InterruptedException {
    Object value = redis.get(key);
    // 缓存值过期
    if (value == null) {
        // lockRedis:专门用于加锁的redis;
        // "empty":加锁的值随便设置都可以
        if (lockRedis.set(key, "empty", "PX", lockExpire, "NX")) {
            try {
                // 查询数据库,并写到缓存,让其他线程可以直接走缓存
                value = getDataFromDb(key);
                redis.set(key, value, "PX", expire);
            } catch (Exception e) {
                // 异常处理
            } finally {
                // 释放锁
                lockRedis.delete(key);
            }
        } else {
            // sleep50ms后,进行重试
            Thread.sleep(50);
            return getData(key);
        }
    }
    return value;
}

优点

思路简单

保证一致性

缺点

代码复杂度增大

存在死锁的风险

异步构建缓存

在这种方案下,构建缓存采取异步策略,会从线程池中取线程来异步构建缓存,从而不会让所有的请求直接怼到数据库上。该方案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;  

    }

优点

性价最佳,用户无需等待

缺点

无法保证缓存一致性

热点数据不过期

直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。

这种方式适用于比较极端的场景,例如流量特别特别大的场景,使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,不要到时候缓存刷新不上,一直是脏数据,那就凉了。

缓存穿透

什么是缓存穿透

描述:访问一个缓存和数据库都不存在的 key,此时会直接打到数据库上,并且查不到数据,没法写缓存,所以下一次同样会打到数据库上。

此时,缓存起不到作用,请求每次都会走到数据库,流量大时数据库可能会被打挂。此时缓存就好像被“穿透”了一样,起不到任何作用。

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

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

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

解决方案
缓存空值/默认值

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

缓存空值有两大问题:

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

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

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

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

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

布隆过滤器

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

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

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

网页爬虫对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;

}

接口校验

在正常业务流程中可能会存在少量访问不存在 key 的情况,但是一般不会出现大量的情况,所以这种场景最大的可能性是遭受了非法攻击。可以在最外层先做一层校验:用户鉴权、数据合法性校验等,例如商品查询中,商品的ID是正整数,则可以直接对非正整数直接过滤等等。

缓存雪崩

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

分布式系统都存在这样一个问题,由于网络的不稳定性,决定了任何一个服务的可用性都不是 100% 的。当网络不稳定的时候,作为服务的提供者,自身可能会被拖死,导致服务调用者阻塞,最终可能引发雪崩连锁效应。

当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力,造成数据库后端故障,从而引起应用服务器雪崩。

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

redis 集群彻底崩溃:不可用

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

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

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

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

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

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

雪崩效应产生的几种场景

1、缓存服务器挂了

2、高峰期缓存大面积失效

3、热点缓存失效,对热点数据持续高并发

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

事前解决方案
单缓存

1.避免缓存设置相近的有效期;为有效期增加随机值;统一规划有效期,失效时间均匀分布。
2.使用互斥锁:jvm锁机制;分布式锁机制,防止热点数据持续高并发访问

限流模式

限流模式则可以称为预防模式。限流模式主要是提前对各个类型的请求设置最高的QPS阈值,若高于设置的阈值则对该请求直接返回,不再调用后续资源。这种模式不能解决服务依赖的问题,只能解决系统整体资源分配问题,因为没有被限流的请求依然有可能造成雪崩效应。

超时机制设计
(1)超时分两种,一种是请求的等待超时,一种是请求运行超时。

(2)等待超时:在任务入队列时设置任务入队列时间,并判断队头的任务入队列时间是否大于超时时间,超过则丢弃任务。

(3)运行超时:直接可使用线程池提供的get方法。

双缓存方案
    主缓存:有效期按照经验值设置,主要读取的缓存,主缓存失效后从数据库加载最新值。

    备份缓存:有效期长,获取锁失败时读取的缓存,主缓存更新时需要同步更新备份缓存。

    其实就是缓存降级策略。

发生缓存雪崩之前,事情之前,怎么去避免 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(退路)错误处理信息。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值