缓存穿透、缓存击穿、缓存雪崩概念及解决方案


一、缓存击穿

概念

一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。

案例

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

流程如下:
20210714223816.png

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

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

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

既然使用到锁,那么我们第一时间应该关心的是锁的粒度。
如果我们放在方法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);
}

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

双重检查锁

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

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

互斥锁

代码实现和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);
}

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

小结

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

二、缓存穿透

概念

访问一个不存在的key,缓存不起作用,请求会穿透到DB,流量大时DB会挂掉。

案例

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

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

方案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:布隆过滤器

具体请参考

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

小结

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

三、缓存雪崩

概念

大量的key设置了相同的过期时间,导致在缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。

一般多发生在项目初期缓存未加载或缓存过期时间相同

解决方案

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

总结

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