【Redis】2.浅谈缓存穿透、缓存雪崩与缓存击穿

1. 缓存穿透

缓存穿透就是指用户不断发起请求查询不存在的数据(如id为-1或者id非常非常大),这样子缓存永远不要生效,这些请求都会被打到数据库中,导致数据库中有很大压力。

解决方案:

  1. 缓存空对象
  2. 布隆过滤

1.1 缓存空对象

缓存空对象的实现思路:

当请求想要获取不存在的数据的时候,首先会先请求redis,redis没有命中,此时会访问数据库,在数据库中也没有查询到数据,这时候这个请求就穿透了缓存,直击redis。当这种请求的数量极其庞大的时候,redis的承受能力比数据库强,那么数据库肯定会因为承受不住那么多请求而出现宕机。于是,我们可以在查询数据库的时候查不到数据,往redis中存入一个空值,那么当用户再次请求这个不存在的数据的时候,就能在redis中找到,找到之后只需要判断是否是空值,如果是空值而返回不存在

在这里插入图片描述

缓存空对象有以下优点:

  1. 实现简单
  2. 维护方便

同时也存在以下缺点:

  1. 需要额外的内存消耗
    1. 每一次请求不存在的数据都会存一个空对象到redis中造成了内存消耗,解决方法就是给这些空值设置一个过期时间(有效期不长)
  2. 可能造成短期的不一致
    1. 当请求这个数据不存在的时候,redis设置成空对象,当此时新增了这条数据,那么redis还是空的,也就是代表不存在。因此会出现短期的不一致性。

为什么缓存空值,而不是缓存null呢?

首先先说说缓存不存在的时候,这时候查询缓存得到的数据为null。

由此可见,如果缓存了null,那么缓存击穿问题还是无法得到解决。


1.2 布隆过滤

当不存在的数据请求越来越多的时候,内存损耗也就越来越大,这时候用缓存空对象的办法,显然会造成很多内存的浪费。这时候,就可以考虑使用布尔过滤了。

布尔过滤地城使用的是bit数组存储数据,该数组的默认值为0

布尔过滤器第一次初始化的时候,会将数据库中的已经存在的key通过某种hash算法计算,每个key都会计算出多个位置,然后将该位置的元素值设置为1

当有用户发送请求过来的时候,用同样的hash算法计算位置

  • 如果多个位置中的元素值都是1,则说明该key在数据库中已存在。这时允许继续往后面操作。
  • 如果有1个以上的位置上的元素值是0,则说明该key在数据库中不存在。这时可以拒绝该请求,而直接返回。

在这里插入图片描述

布尔过滤有以下优点:

  1. 内存占用较少,没有多余key

也有以下缺点

  1. 实现起来比较复杂
  2. 可能存在误判的情况
    1. 因为有可能出现hash冲突的,也就是说不同的key,可能会计算出相同的位置

1.3 使用缓存空对象解决缓存穿透

在这里插入图片描述

原本的业务是,当在redis中查不到该key的时候,进入数据库查,数据库查不到则返回错误提示

使用缓存空对象之后,业务逻辑编程在redis中查不到该key的时候,进入数据库查,数据库中无论查得到还是查不到,都往redis中存,当查不到的时候往redis中存空对象

public <R,ID> R queryWithPassThrough(
        String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
    String key = keyPrefix + id;
    // 1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(json)) {
        // 3.存在,直接返回
        return JSONUtil.toBean(json, type);
    }
    // 判断命中的是否是空值
    if (json != null) {
        // 返回一个错误信息
        return null;
    }

    // 4.不存在,根据id查询数据库
    //交给调用者
    R r = dbFallback.apply(id);
    // 5.不存在,返回错误
    if (r == null) {
        // 将空值写入redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 返回错误信息
        return null;
    }
    // 6.存在,写入redis
    this.set(key, r, time, unit);
    return r;
}

这是一个封装好的方法

  1. String keyPrefix:存redis的key的前缀
  2. ID id:要查的id,因为不确定类型,有可能是String,也有可能是Long,所以用泛型
  3. Class< R > type:缓存命中时,需要将json转化为对象,这时候需要用到这个对象的class
  4. Function<ID, R> dbFallback:由于查数据库的时候,每个对象的查询调用的方法都不一样,一样的是传入一个参数,返回一个对象,所以使用Function封装
  5. Long time:缓存过期时间
  6. TimeUnit unit:过期时间指定的类型,比如秒、分

调用该方法如下

Shop shop = cacheClient
    .queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

1.4 总结

除了上面的解决方案,还有以下的方法

  1. 增强id的复杂度,避免被猜测id规律
  2. 做好数据的基础格式校验
  3. 加强用户权限校验
  4. 做好热点参数的限流

2. 缓存雪崩问题

缓存雪崩是指在某一个时刻,大量key同时失效或者redis宕机,导致请求直接到达数据库,给数据库带来了巨大的压力。

既然是这样,解决起来也很简单

  1. 给不同的key的TTL值添加随机值
    1. 设置TTL的时候,在后面加一个随机值
  2. 利用redis集群提高服务可用性
    1. 构建Redis集群,当某个redis宕机的时候,从节点切换到主节点,继续提供服务。
  3. 给缓存业务添加降级限流策略
    1. 控制每秒进入应用程序的请求数,避免过多的请求被发到数据库
  4. 给业务添加多级缓存

在这里插入图片描述


3. 缓存击穿

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

如下图,缓存刚好过期,线程1查缓存发现缓存中无数据,于是查数据库重构缓存数据。但在线程1未重构缓存数据的时候,大量线程如线程2、线程3、线程4也未命中,同时查询数据库。这样会导致数据库压力大,若超出数据库的承受能力,会使得数据库宕机,从而程序挂掉。

在这里插入图片描述

解决缓存击穿,无非就两个解决方案

  1. 互斥锁
  2. 逻辑过期

3.1 互斥锁

利用锁能实现互斥性。当线程过来的时候,只能一个个地访问数据库,造成线程串行,从而影响查询效率。

当线程1过来发现缓存没有命中,那么它将会获取锁,线程1将会查询数据库并重构缓存数据。在这一过程中,线程2进入,同样缓存没有命中,想要获取互斥锁,但是互斥锁被线程1持有,因此线程2获取互斥锁失败。这时候线程2将会休眠一会,休眠结束后重试。如果某一时间,重试发现缓存命中,说明线程1重建缓存成功。

在这里插入图片描述

使用互斥锁有以下优点:

  1. 没有额外地内存消耗
  2. 保证一致性
  3. 实现简单

同时也存在以下缺点:

  1. 线程需要等待,性能收影响
  2. 存在死锁风险

代码实现如下

public <R, ID> R queryWithMutex(
    String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    String key = keyPrefix + id;
    // 1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // 3.存在,直接返回
        return JSONUtil.toBean(shopJson, type);
    }
    // 判断命中的是否是空值
    if (shopJson != null) {
        // 返回一个错误信息
        return null;
    }

    // 4.实现缓存重建
    // 4.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    R r = null;
    try {
        boolean isLock = tryLock(lockKey);
        // 4.2.判断是否获取成功
        if (!isLock) {
            // 4.3.获取锁失败,休眠并重试
            Thread.sleep(50);
            //重试
            return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
        }
        // 4.4.获取锁成功,根据id查询数据库
        r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }finally {
        // 7.释放锁
        unlock(lockKey);
    }
    // 8.返回
    return r;
}

private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    //可能会出现拆箱
    return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

注意:

setIfAbsent 是java中的方法
setnx 是 redis命令中的方法

两者是等同的

在 Redis 里,所谓 SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。

如果setnx 返回ok 说明拿到了锁;如果setnx 返回 nil,说明拿锁失败,被其他线程占用。

换成客户端服务器则是如下:
客户端执行以上的命令:

  • 如果服务器返回 OK ,那么这个客户端获得锁。
  • 如果服务器返回 NIL ,那么客户端获取锁失败,可以在尝试稍后再重试。

3.2 逻辑过期

对于逻辑过期这种方案,需要在存在redis的数据中放入一个过期时间的数据,用来判断缓存数据是否逻辑过期。

同时该key的缓存设置为永不过期

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

整体思路是这样的:在redis查询,缓存命中后,当前时间与redis中存的逻辑过期时间比较,如果当前时间已经超过redis中存的过期时间,那么开启一个独立线程去重构数据,重构完成之后释放互斥锁。否则,返回redis中的数据。

注意:假如另外一个线程尝试获取锁失败,那么不会像第一种互斥锁的解决方案一样,这里会返回redis中的旧数据,不会等待互斥锁的释放,这样做大大提高了性能,但是却牺牲了数据一致性。

在这里插入图片描述

编码实现


private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);


public <R, ID> R queryWithLogicalExpire(
    String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    String key = keyPrefix + id;
    // 1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isBlank(json)) {
        // 3.存在,直接返回
        return null;
    }
    // 4.命中,需要先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5.判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())) {
        // 5.1.未过期,直接返回店铺信息
        return r;
    }
    // 5.2.已过期,需要缓存重建
    // 6.缓存重建
    // 6.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // 6.2.判断是否获取锁成功
    if (isLock){
        //获取锁成功之后应该再次检测redis是否过去,做doublecheck,如果存在则无需重建缓存
        // 6.3.成功,开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                // 查询数据库
                R newR = dbFallback.apply(id);
                // 重建缓存
                this.setWithLogicalExpire(key, newR, time, unit);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally {
                // 释放锁
                unlock(lockKey);
            }
        });
    }
    // 6.4.返回过期的商铺信息
    return r;
}

// 重建缓存
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
    // 设置逻辑过期
    RedisData redisData = new RedisData();
    redisData.setData(value);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
    // 写入Redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}

参考:黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目


评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

起名方面没有灵感

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值