Redis缓存异常(击穿/雪崩)及解决方案

Redis是一个完全开源的、遵守BSD协议的、高性能的key-value数据结构存储系统,它支持数据的持久化,可以将内存中的数据保存在磁盘中,而且不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储,功能十分强大,Redis还支持数据的备份,即master-slave模式的数据备份,从而提高可用性。当然最重要的还是读写速度快,作为我们平常开发中最常用的缓存方案被广泛应用。但在实际应用过程中,它会存在缓存雪崩、缓存击穿和缓存穿透等异常情况,如果忽视这些情况可能会带来灾难性的后果,下面主要对这些缓存异常和常见处理方案进行相应分析与总结。

缓存异常有四种类型

  • 缓存和数据库的数据不一致
  • 缓存雪崩
  • 缓存击穿
  • 缓存穿透

一、缓存击穿

1.1 概念

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

1.2 解决方案

@Service
public class UserInfoServiceImpl implements UserInfoService {

    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate<Long, String> redisTemplate;

    @Override
    public UserInfo findById(Long id) {
        //查询缓存
        String userInfoStr = redisTemplate.opsForValue().get(id);
        //如果缓存中不存在,查询数据库
        //1
        if (isEmpty(userInfoStr)) {
            UserInfo userInfo = userMapper.findById(id);
            //数据库中不存在
            if (userInfo == null) {
                return null;
            }
            userInfoStr = JSON.toJSONString(userInfo);
            //2
            //放入缓存
            redisTemplate.opsForValue().set(id, userInfoStr);
        }
        return JSON.parseObject(userInfoStr, UserInfo.class);
    }

    private boolean isEmpty(String string) {
        return !StringUtils.hasText(string);
    }
}

流程如下:

1093034214.png

//1//2之间耗时1.5秒,那就代表着在这1.5秒时间内所有的查询都会走查询数据库。这也就是我们所说的缓存中的缓存击穿

其实,你们项目如果并发量不是很高,也不用怕,并且我见过很多项目也就差不多是这么写的,也没那么多事,毕竟只是第一次的时候可能会发生缓存击穿。

但,我们也不要抱着一个侥幸的心态去写代码,既然是多线程导致的,估计很多人会想到锁,下面我们使用锁来解决。

1.2.1 方例1:锁

既然使用到锁,那么我们第一时间应该关心的是锁的粒度。
如果我们放在方法findById上,那就是所有查询都会有锁的竞争,这里我相信大家都知道我们为什么不放在方法上。

public UserInfo findById(Long id) {
    //查询缓存
    String userInfoStr = redisTemplate.opsForValue().get(id);
    if (isEmpty(userInfoStr)) {
        //只有不存的情况存在锁
        synchronized (UserInfoServiceImpl.class) {
            UserInfo userInfo = userMapper.findById(id);
            //数据库中不存在
            if (userInfo == null) {
                return null;
            }
            userInfoStr = JSON.toJSONString(userInfo);
            //放入缓存
            redisTemplate.opsForValue().set(id, userInfoStr);
        }
    }
    return JSON.parseObject(userInfoStr, UserInfo.class);
}

看似解决问题了,其实问题还是没得到解决,还是会缓存击穿,因为排队获取到锁后,还是会执行同步块代码,也就是还会查询数据库,完全没有解决缓存击穿。

1.2.2 方案2:双重检查锁

由此,我们引入双重检查锁,我们在上的版本中进行稍微改变,在同步模块中再次校验缓存中是否存在。

public UserInfo findById(Long id) {
    //查询缓存
    String userInfoStr = redisTemplate.opsForValue().get(id);
    //第一次校验缓存是否存在
    if (isEmpty(userInfoStr)) {
        //上锁
        synchronized (UserInfoServiceImpl.class) {
            //再次查询缓存,目的是判断是否前面的线程已经set过了
            userInfoStr = redisTemplate.opsForValue().get(id);
            //第二次校验缓存是否存在
            if (isEmpty(userInfoStr)) {
                UserInfo userInfo = userMapper.findById(id);
                //数据库中不存在
                if (userInfo == null){
                    return null;
                }
                userInfoStr = JSON.toJSONString(userInfo);
                //放入缓存
                redisTemplate.opsForValue().set(id, userInfoStr);
            }
        }
    }
    return JSON.parseObject(userInfoStr, UserInfo.class);
}

1.2.3 方案3:互斥锁

代码实现和synchronized锁实现大致相同

//获取Redisson对象实例 省略... 
public UserInfo findById(Long id) {
    String userInfoStr = redisTemplate.opsForValue().get(id);
    //判断缓存是否存在,是否为空对象
    if (isEmpty(userInfoStr)) {
        RLock lock = Redisson.getLock(id);
        try {
	        //尝试获取锁
	        //waitTimeout尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
            //leaseTime锁的持有时间,超过这个时间锁会自动失效
            //leaseTime值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完
            if (lock.tryLock((long) waitTimeout, (long) leaseTime, TimeUnit.SECONDS)) {
                userInfoStr = redisTemplate.opsForValue().get(id);
                if (isEmpty(userInfoStr)) {
                    UserInfo userInfo = userMapper.findById(id);
                    if (userInfo == null) {
                        //构建一个空对象
                        userInfo = new UserInfo();
                    }
                    userInfoStr = JSON.toJSONString(userInfo);
                    redisTemplate.opsForValue().set(id, userInfoStr);
                }
            }
        } finally {
	        lock.unlock();
        }	
    }
    UserInfo userInfo = JSON.parseObject(userInfoStr, UserInfo.class);
    //空对象处理
    if (userInfo.getId() == null) {
        return null;
    }
    return JSON.parseObject(userInfoStr, UserInfo.class);
}

这样,看起来我们就解决了缓存击穿问题,大家觉得解决了吗?

1.3 小结

在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key

二、缓存穿透

2.1 概念

缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

2.2 解决方案

回顾上面的案例,在正常的情况下是没问题,但是一旦有人恶意攻击呢?比如说:入参id=-1,在数据库里并没有这个id,怎么办呢?

第一步、缓存中不存在
第二步、查询数据库
第三步、由于数据库中不存在,直接返回了,并没有操作缓存
第四步、再次执行第一步.....死循环了吧

2.2.1 方案1:设置空对象

就是当缓存中和数据库中都不存在的情况下,以idkey,空对象为value

set(id, 空对象);

回到上面的四步,就变成了。

比如说:入参id=-1,在数据库里并没有这个id,怎么办呢?

第一步、缓存中不存在
第二步、查询数据库
第三步、由于数据库中不存在,以idkey,空对象为value放入缓存中
第四步、执行第一步,此时,缓存就存在了,只是这时候只是一个空对象。

代码实现部分:

public UserInfo findById(Long id) {
    String userInfoStr = redisTemplate.opsForValue().get(id);
    //判断缓存是否存在,是否为空对象
    if (isEmpty(userInfoStr)) {
        synchronized (UserInfoServiceImpl.class) {
            userInfoStr = redisTemplate.opsForValue().get(id);
            if (isEmpty(userInfoStr)) {
                UserInfo userInfo = userMapper.findById(id);
                if (userInfo == null) {
                    //构建一个空对象
                    userInfo = new UserInfo();
                }
                userInfoStr = JSON.toJSONString(userInfo);
                redisTemplate.opsForValue().set(id, userInfoStr);
            }
        }
    }
    UserInfo userInfo = JSON.parseObject(userInfoStr, UserInfo.class);
    //空对象处理
    if (userInfo.getId() == null) {
        return null;
    }
    return JSON.parseObject(userInfoStr, UserInfo.class);
}

2.2.2 方案2:布隆过滤器

具体请参考布隆过滤器

private static Long size = 1000000000L;
private static BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), size);

@Override
public UserInfo findById(Long id) {
    String userInfoStr = redisTemplate.opsForValue().get(id);
    if (isEmpty(userInfoStr)) {
        //校验是否在布隆过滤器中
        if (bloomFilter.mightContain(id)) {
            return null;
        }
        synchronized (UserInfoServiceImpl.class) {
            userInfoStr = redisTemplate.opsForValue().get(id);
            if (isEmpty(userInfoStr) ) {
                if (bloomFilter.mightContain(id)) {
                    return null;
                }
                UserInfo userInfo = userMapper.findById(id);
                if (userInfo == null) {
                    //放入布隆过滤器中
                    bloomFilter.put(id);
                    return null;
                }
                userInfoStr = JSON.toJSONString(userInfo);
                redisTemplate.opsForValue().set(id, userInfoStr);
            }
        }
    }
    return JSON.parseObject(userInfoStr, UserInfo.class);
}

2.3 小结

  1. 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截
  2. 采用布隆过滤器,使用一个足够大的bitmap,用于存储可能访问的key,不存在的key直接被过滤;
  3. 访问key未在DB查询到值,也将空值写进缓存,但可以设置较短过期时间。

三、缓存雪崩

3.1 概念

缓存雪崩是指缓存同一时间大面积的失效,所有的请求都会落到数据库上,造成数据库瞬时承受大量请求而崩掉。一般多发生在项目初期缓存未加载或缓存过期时间相同

3.2 解决方案

  1. 缓存数据设置过期时间时加一个随机值时间,防止同一时间大量数据过期现象发生。
  2. 一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。
  3. 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
  4. 设置热点数据永远不过期。
  5. 给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。

四、其他

除了上述三种常见的Redis缓存异常问题之外,还经常听到的有缓存预热和缓存降级两个名词,与其说是异常问题,不如说是两种的优化处理方法。

4.1 缓存预热

缓存预热就是系统上线前后,将相关的缓存数据直接加载到缓存系统中去,而不依赖用户。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户直接查询事先被预热的缓存数据,这样可以避免那么系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。

根据数据不同量级,可以有以下几种做法:

  1. 数据量不大:项目启动的时候自动进行加载。
  2. 数据量较大:后台定时刷新缓存。
  3. 数据量极大:只针对热点数据进行预加载缓存操作。

4.2 缓存降级

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

缓存降级是指当缓存失效或缓存服务出现问题时,为了防止缓存服务故障,导致数据库跟着一起发生雪崩问题,所以也不去访问数据库,但因为一些原因,仍然想要保证服务还是基本可用的,虽然肯定会是有损服务。因此,对于不重要的缓存数据,我们可以采取服务降级策略。

一般做法有以下两种:

  1. 直接访问内存部分的数据缓存。
  2. 直接返回系统设置的默认值。

缓存降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。

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

  1. 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
  2. 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
  3. 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
  4. 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

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

4.3 热点数据和冷数据

热点数据,缓存才有价值

对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。频繁修改的数据,看情况考虑使用缓存

对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。

数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。

那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力,比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。

4.4 缓存热点key

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

解决方案

对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询

五、总结

缓存击穿:使用双重检查锁的方式来解决,看到双重检查锁,大家肯定第一印象就会想到单例模式,这里也算是给大家复习一把双重检查锁的使用。
缓存穿透:将空对象放入缓存中、布隆过滤器、使用锁的时候注意锁的力度,建议换成分布式锁(Redis或者Zookeeper实现),多个节点时可用Redisson提供的分布式锁!
缓存雪崩:数据预热、分散缓存失效时间、数据永不过期

posted @ 2022-02-09 15:40  夏尔_717  阅读(454)  评论(0编辑  收藏  举报