Redis中缓存穿透和缓存击穿的理解(结合具体案例)

今天学习到了Redis的缓存穿透、缓存雪崩、缓存击穿,其中缓存雪崩还没有做具体的案例,所以就不做介绍了。具体案例代码为黑马点评项目。

 

基本概念:

  • 缓存穿透指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会请求到数据库中。
  • 缓存击穿也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,大量的请求会在瞬间给数据库带来巨大的压力。

解决方案:

  • 缓存穿透
  1. 缓存null值。
  2. 布隆过滤。
  3. 增强id的复杂度,避免被猜测id规律。
  4. 做好数据的基础数据校验。
  5. 加强用户权限校验。
  6. 做好热点参数的限流。

其中前两点是比较被动的方案,后面的几点是比较主动的方案,这里主要来实现缓存null值来解决缓存穿透。

  • 缓存击穿
  1. 互斥锁。
  2. 逻辑过期。

缓存穿透的解决方案:

先来看使用缓存null值来解决缓存穿透的代码:

   private Shop queryWithPassThrough(Long id){
        String key= RedisConstants.CACHE_SHOP_KEY+id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(shopJson)){
            //不为空则说明有redis中缓存命中,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        //如果命中的是空值
        //这儿是在解决缓存穿透
        if (shopJson!=null){
            //这里需要理解一下,如果shopJson不是null,说明其只能是空字符串,说明其就是“”,已经
            //是解决过的缓存穿透的了
            return null;
        }
        Shop shop = getById(id);//mybatisplus 根据id去查询商铺信息
        if(shop==null){
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
        return shop;
    }//这是抽取出来解决缓存穿透的方法,在调用时,可对返回值做出具体的判断,如果为null,则可以给出对应的提示

对StrUtil包中的isNotBlank()的介绍:

StringUtils.isNotBlank(str) 等价于:
str != null && str.length > 0 && str.trim().length > 0

关于缓存穿透的解决方案:

缓存穿透利用缓存null值解决还是很简单的,基本的逻辑就是,当查询的信息在缓存和数据库中都没有时,为了避免缓存穿透,就把该信息在第一次查询数据库确定没有时,返回给redis空值,以便在下次查询时可以在redis中命中。

缓存击穿的解决方案:

运用互斥锁来实现具体代码:

    private Shop queryWithMutex(Long id){
        String key= RedisConstants.CACHE_SHOP_KEY+id;
        //从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //判断是否存在
        if(StrUtil.isNotBlank(shopJson)){
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //如果命中的是空值
        //这儿是在解决缓存穿透
        if (shopJson!=null){
            return null;
        }
        //1实现缓存重建,解决缓存击穿
        //1.1获取互斥锁
        String lockKey=RedisConstants.LOCK_SHOP_KEY+id;
        Shop shop=null;
        try {
            boolean isLock = tryLock(lockKey);
            //1.2判断是否获取锁成功
            if (!isLock){
                //1.3失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //获取锁 成功 也应该再次检测redis缓存是否存在,做doubleCheck的判断,这里我觉得主要是为了,有可能一个线程刚释放锁,另一个线程正好去获取锁,
            //此时获取到了锁,但此时redis的缓存已经更新,就没必要了
            //这里可考虑把这一段代码抽取为一个方法
            shopJson = stringRedisTemplate.opsForValue().get(key);
            //判断是否存在
            if(StrUtil.isNotBlank(shopJson)){
                return JSONUtil.toBean(shopJson, Shop.class);
            }
            //1.3成功,根据id查询数据库
            shop = getById(id);
            if(shop==null){
                //这里在解决缓存穿透
                stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL,TimeUnit.MINUTES);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //释放互斥锁
            unlock(lockKey);
        }
        return shop;
    }
    //解决缓存击穿,获取锁
    private boolean tryLock(String key){
        Boolean flag=stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.MINUTES);
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }

利用互斥锁来解决缓存击穿也是很好理解,当热点key失效时,大量的请求涌入,此时利用了互斥锁,只有一个线程会得到锁,得到锁以后该线程就会去查询数据库,然后将数据缓存到redis中,在这个过程中,其他的线程只能一直尝试从redis中获取对应的缓存数据再获取锁然后休眠,再递归的调用这个方法(这里用递归来解决循环是否不太合理?),直到从redis中获取到缓存数据。同时当一个线程获取锁成功时,应该进行再次的检测redis中有没有对应的缓存数据,这里考虑主要因为是,当得到锁的线程释放锁后,有其他线程在获取锁,那该线程就会获取到锁,又去mysql查询数据,此时已经没有意义了。这里也就不难看出利用互斥锁的缺点:线程需要等待,性能受影响,可能有死锁风险。优点:没有额外的内存消耗,保证一致性,实现简单。

这里主要理解来理解关于锁的实现:

SETNX:向Redis中添加一个key,只用当key不存在的时候才添加并返回1,存在则不添加返回0。并且这个命令是原子性的。添加成功表示获取到锁,添加失败表示未获取到锁。至于添加的value值无所谓可以是任意值(根据业务需求),只要保证多个线程使用的是同一个key,所以多个线程添加时只会有一个线程添加成功,就只会有一个线程能够获取到锁。而释放锁锁只需要将锁删除即可。对应到这里的java代码就是stringRedisTemplate.opsForValue().setIfAbsent()

利用逻辑过期解决缓存击穿的代码:

缓存击穿是发生在热点key上的问题,会提前将这些热点key缓存到redis中,并且设置的是逻辑过期,所以就不存在查询不到的情况,如果真的没从redis命中,那就直接返回null就可以了。以下为提前给redis加载热点key的代码:

    //提前将热点数据缓存到redis中
    public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
        //查询店铺数据
        Shop shop = getById(id);
        Thread.sleep(50000);
        //封装逻辑过期时间
        RedisData redisData=new RedisData();
        redisData.setData(shop);
        //设置逻辑过期时间
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //写入redis中
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
    }

以下代码为具体实现用逻辑过期解决缓存击穿:

    private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
    //逻辑过期 解决缓存击穿
    private Shop queryWithLogicalExpire(Long id){
        String key= RedisConstants.CACHE_SHOP_KEY+id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isBlank(shopJson)){
            //如果为空,则说明没有这个key,因为这里已经预热处理了,所以直接返回null
           return null;
        }
        //命中,需要把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        //Shop data = (Shop) redisData.getData();
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
            //没有过期 直接返回店铺信息
            return shop;
        }
        //已过期 需要重建缓存
        //缓存重建
        //获取互斥锁
        String lockKey=RedisConstants.LOCK_SHOP_KEY+id;
        boolean isLock = tryLock(lockKey);
        //判断是否获取锁成功
        if (isLock){
        //进行双重检测,防止获取锁成功已经有其他线程实现了缓存重建
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
            //没有过期 直接返回店铺信息
            return shop;
        }
            //成功开启独立线程 实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    this.saveShop2Redis(id,20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unlock(lockKey);
                }
            });
        }
        //返回过期的商铺信息
        return shop;
    }

逻辑过期解决缓存击穿也很好理解,就是给对应的缓存增加逻辑过期时间,当在查询时,与当前的时间进行对比,如果发现没有过期,则直接返回数据就可以了,如果发现过期,则另开一个线程去获取锁然后去实现缓存重建,而它会直接返回旧的数据,此时即使有其他线程来,发现缓存过期,但是也已经获取不到互斥锁,也会直接返回旧的数据。这就不难看出逻辑过期的的优点:线程无需等待,性能较好。缺点:不保证一致性,有额外的内存消耗,实现相对复杂。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值