Redis缓存穿透、击穿、雪崩问题

我们在使用Redis做数据缓存的时候,总会遇到一些缓存失效的问题,这些失效问题归纳起来可以分为缓存穿透、击穿、雪崩三类问题。这里我们分别看一下这三个问题:

缓存穿透

缓存穿透是由于key所对应的数据是不存在的,就是说既不在缓存中也不再关系数据库找那个,所以请求每次都到了关系型数据库(比如MySql),关系数据库中也是找不到的,使得数据库压力过大无法工作。

应对缓存穿透的方法

1)为请求设置一个缓存空值或缺省值,这样后续查询请求就可以直接从 Redis 中读取空值或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。
2)使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。这是一个比较推荐的方法。
3)最后,我们可以预防性的再前端针对请求做一些合法性的过滤,不让其访问数据,也就没有后面的问题了。

布隆过滤器如何工作

上面的方法二提到了布隆过滤器,这里要看一下他具体怎么工作。
布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,布隆过滤器会通过三个操作去标记某个数据是否存在:
1)首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
2)然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
3)最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。
如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit 数组对应 bit 位的值仍然为 0。示例如下图所示:


image.png

缓存击穿

缓存击穿是指key所对应的数据存在,只是不在缓存中(没有缓存起来或者过期失效了等等原因)。
这样每次都会击穿缓存而直接到关系数据库中去拿数据,然后再将这个数据重新写入到缓存中,这样关系数据库的压力过大也可能导致数据库无法工作。

应对缓存击穿的方法

1)可以设置热点数据的缓存不过期。
2)采用分布式锁去更新或者写入缓存数据库,让缓存重新生效。

具体案例

先看一下可能造成击穿的代码:

@Service
public class UserInfoServiceImpl implements UserInfoService {

    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate 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);
    }
}

流程如下所示:


image.png

如果,在//1到//2之间耗时1.5秒,那就代表着在这1.5秒时间内所有的查询都会走查询数据库。这也就是我们所说的缓存中的“缓存击穿”。
其实,你们项目如果并发量不是很高,也不用怕,并且我见过很多项目也就差不多是这么写的,也没那么多事,毕竟只是第一次的时候可能会发生缓存击穿。但,我们也不要抱着一个侥幸的心态去写代码,既然是多线程导致的,估计很多人会想到锁,下面我们使用锁来解决。
既然使用到锁,那么我们第一时间应该关心的是锁的粒度。如果我们放在方法findById上,那就是所有查询都会有锁的竞争,这里我相信大家都知道我们为什么不放在方法上。加锁后的修改版如下:

@Service
public class UserInfoServiceImpl implements UserInfoService {

    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate;

    @Override
    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);
    }

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

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

@Service
public class UserInfoServiceImpl implements UserInfoService {

    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate;

    @Override
    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);
    }

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

这样解决问题的话在正常的情况下是没有问题的,不过有人攻击的话就另当别论。
这里我们考虑返回空对象,就是当缓存中和数据库中都不存在的情况下,以id为key,空对象为value。
实现如下:

@Service
public class UserInfoServiceImpl implements UserInfoService {

    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate; 

    @Override
    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);
    }

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

布隆过滤器
布隆过滤器(Bloom Filter):是一种空间效率极高的概率型算法和数据结构,用于判断一个元素是否在集合中(类似Hashset)。它的核心一个很长的二进制向量和一系列hash函数,数组长度以及hash函数的个数都是动态确定的。
Hash函数:SHA1,SHA256,MD5..
布隆过滤器的用处就是,能够迅速判断一个元素是否在一个集合中。因此他有如下三个使用场景:
1)网页爬虫对URL的去重,避免爬取相同的URL地址
2)反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(垃圾短信)
3)缓存击穿,将已存在的缓存放到布隆过滤器中,当黑客访问不存在的缓存时迅速返回避免缓存及DB挂掉。
其内部维护一个全为0的bit数组,需要说明的是,布隆过滤器有一个误判率的概念,误判率越低,则数组越长,所占空间越大。误判率越高则数组越小,所占的空间越小。布隆过滤器的相关理论和算法这里就不聊了,感兴趣的可以自行研究。
优势和劣势
优势

全量存储但是不存储元素本身,在某些对保密要求非常严格的场合有优势;
空间高效率
插入/查询时间都是常数O(k),远远超过一般的算法
劣势

存在误算率(False Positive),默认0.03,随着存入的元素数量增加,误算率随之增加;
一般情况下不能从布隆过滤器中删除元素;
数组长度以及hash函数个数确定过程复杂;

@Service
public class UserInfoServiceImpl implements UserInfoService {

    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate;
    private static Long size = 1000000000L;

    private static BloomFilter 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);
    } 
    private boolean isEmpty(String string) {
        return !StringUtils.hasText(string);
    }
}

互斥锁
使用Redis实现分布式的时候,有用到setnx,这里大家可以想象,我们是否可以使用这个分布式锁来解决缓存击穿的问题。
使用锁的时候注意锁的粒度,这里建议换成分布式锁(Redis或者Zookeeper实现),因为我们既然引入缓存,大部分情况下都会是部署多个节点的,同时,引入分布式锁了,我们就可以使用方法入参id用起来,这样是不是更爽!

缓存雪崩

缓存雪崩是指的缓存服务器重启或者大量缓存集中在某个时间段失效,其实就是重启导致的缓存不可用或者是大面积的缓存击穿,这样同样会给关系数据库很大的压力。

为什么会产生缓存雪崩

这里可能有两种原因
1)缓存中有大量数据同时过期,导致大量请求无法得到处理。
2)缓存服务器重启或宕机。

缓存雪崩的解决方法是要看其产生的原因。
1)对应上面的原因1,一定要错开缓存数据过期的时间点,防止在同一时间大面积的缓存失效问题产生。常见的做法是针对缓存过期的时间增加一个较小的随机数(例如,随机增加 1~3 分钟)。
除了错开过期时间,另外还可以做服务降级来应对。具体做法是当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
2)对应上面的原因2,可以使用Redis哨兵或者集群用架构提升Redis服务本身的可用性。另外同样也可以使用策略1中的熔断限流的处理思路,也就是我们暂停业务应用对缓存系统的接口访问,就是说业务应用调用缓存接口时,缓存客户端并不把请求发给 Redis 缓存实例,而是直接返回,等到 Redis 缓存实例重新恢复服务后,再允许应用请求发送到缓存系统。

你可能感兴趣的:(Redis缓存穿透、击穿、雪崩问题)