(黑马点评)三、Redis最精华的缓存知识讲解,结合相关业务、压力测试,带你认识最全面的缓存知识,掌握各样的解决措施,提高系统业务响应速度

目录

 三、商户查询缓存系列功能实现

3.1 缓存的理解

3.2 查询商户店铺---添加Redis缓存

3.2.1 添加缓存逻辑理解

3.2.2 添加缓存逻辑实现

3.2.3 缓存功能效果展示

3.3(课后作业)查询商户分类列表---添加Redis缓存

3.3.1 使用String存储类型实现

3.3.2 使用List存储类型实现

3.3.3 缓存功能效果展示

3.4 (知识点)缓存更新策略最佳实践

3.4.1 数据一致性问题

3.4.2 缓存更新策略的最佳实践

总结:最佳的缓存更新策略:

3.4.3 实现查询商户店铺详细的缓存与数据库双写一致

3.5 (知识点)缓存穿透与解决策略

3.5.1 什么是缓存穿透?

3.5.2 缓存穿透的危害?

3.5.3 缓存穿透的解决措施

3.5.3.1 缓存空对象

3.5.3.2 布隆过滤器

3.5.3.3 解决缓存穿透——以请求店铺信息为例

3.6 (知识点)缓存雪崩与解决策略 

3.7 (知识点)缓存击穿与解决策略

3.7.1 缓存击穿的解决方案

3.7.1.1 基于互斥锁的解决方案

3.7.1.2 基于逻辑过期的解决方案

3.7.1.3 总结比较

3.7.2 基于互斥锁解决缓存击穿案例——以请求店铺信息为例

3.7.2.1 互斥锁解决缓存击穿思路流程

3.7.2.2 代码实现——抽取方法

3.7.2.3 功能测试——基于JMeter的压力测试

3.7.3 基于逻辑过期解决缓存击穿案例——以请求店铺信息为例

3.7.3.1 逻辑过期解决缓存击穿思路流程

3.7.3.2 构建RedisData对象——组合优于继承

3.7.3.3 代码实现

3.7.3.4 功能测试——基于JMeter的压力测试

3.7.4【故障排查】关于逻辑过期时间策略无法查询店铺信息的现象

3.8 封装Redis缓存工具

3.8.1 封装代码

3.8.2 使用方法


 三、商户查询缓存系列功能实现

3.1 缓存的理解

        我们的程序如果想要用户有一个比较良好的使用体验,在请求数据速度上必然要有所突出。因此我们一般会在项目中运用缓存策略,从而提高我们程序的响应速度。与此同时,在添加缓存策略之后,数据一致性、缓存穿透、雪崩、热Key、维护成本提高等相继出现。如何平衡好这种关系,成为了我们学习Redis的重要所在。

3.2 查询商户店铺---添加Redis缓存

3.2.1 添加缓存逻辑理解

        由于Redis访问快的优点,我们在客户端与数据库之间添加一层 Redis数据层。用户发送的请求首先打到Redis进行查询,

- 如果在Redis上查询命中,则直接返回给用户,从而减轻了底层数据库服务器数据压力

- 如果没能命中,则请求打到数据库进行查询,查询未命中则说明此次请求为 错误请求

- 数据库命中的话,就将数据写入Redis中 ,接着返回给用户。

缓存作用模型

3.2.2 添加缓存逻辑实现

    /**
     * 根据id查询商铺信息
     * @param id 商铺id
     * @return 商铺详情数据
     */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        return shopService.queryById(id);
    }


 /**
     * 根据id查询店铺(添加Redis缓存版)
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        //1. 根据id到Redis中查询用户信息
        //2. Redis命中 ----------------> 返回商户信息 -------> 结束
        //3. Redis未命中 查询数据库
        //4. 查询数据库未命中 ----------------> 返回错误信息 ------> 结束
        //5. 数据库命中 ---------------> 将数据写入Redis ------> 返回商户信息 -------> 结束
        String stopJson =  stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        if(StrUtil.isNotBlank(stopJson)){
            // 存在直接返回
            Shop shop = JSONUtil.toBean(stopJson, Shop.class); //将JSON字符串转换为对象
            return Result.ok(shop);
        }
//        query().eq("shop_id",id);
        // 查询数据库
        Shop shop = getById(id);
        if(shop == null){
            Result.fail("店铺不存在");
        }
        stringRedisTemplate.opsForValue().set(
                RedisConstants.CACHE_SHOP_KEY + id,
                  JSONUtil.toJsonStr(shop),
                  RedisConstants.CACHE_SHOP_TTL,
                  TimeUnit.MINUTES);
        return Result.ok(shop);
    }

3.2.3 缓存功能效果展示

第一次查询商户店铺,未缓存Redis,后台查询数据库

第二次再次查询商户店铺,已缓存Redis,后台没有查询店铺的数据库语句

3.3(课后作业)查询商户分类列表---添加Redis缓存

3.3.1 使用String存储类型实现

    /**
     * 查询店铺类型 (添加Redis版)
     * @return
     */
    @GetMapping("list")
    public Result queryTypeList() {
//        List<ShopType> typeList = typeService
//                .query().orderByAsc("sort").list();
        return typeService.queryTypeList();
    }


/**
     * 查询店铺类型列表(添加Redis版)
     * String 实现版
     * @return
     */
    @Override
    public Result queryTypeList() {
        // 1. 在Redis中查询店铺类型列表
        // 2. Redis命中 ------> 直接返回店铺类型数据 -------> 结束
        // 3. Redis未命中, 查询数据库
        // 4. 数据库未命中 -------> 返回报错信息 --------> 结束
        // 5. 数据库命中,-------> 将数据存入Redis --------> 返回店铺类型数据 --------> 结束
        String Key = RedisConstants.CACHE_SHOP_TYPE_KEY;
        String shopTypeJSON = stringRedisTemplate.opsForValue().get(Key);
        // 将字符串转换为对象
        List<ShopType> shopTypeList = null;
        if(StrUtil.isNotBlank(shopTypeJSON)){
            shopTypeList = JSONUtil.toList(shopTypeJSON, ShopType.class);
            return Result.ok(shopTypeList); // 返回店铺类型数据
        }
        // 查询数据库
        shopTypeList = query().orderByAsc("sort").list();
        // 将对象转换为字符串
        shopTypeJSON = JSONUtil.toJsonStr(shopTypeList);
        // 将数据存入Redis
        stringRedisTemplate.opsForValue().set(Key, shopTypeJSON);
        return Result.ok(shopTypeList);
    }

3.3.2 使用List存储类型实现

/**
     * 查询店铺类型列表(添加Redis版)
     * List 实现版
     * @return
     */
    @Override
    public Result queryTypeList() {
        // 1. 在Redis中查询店铺类型列表
        // 2. Redis命中 ------> 直接返回店铺类型数据 -------> 结束
        // 3. Redis未命中, 查询数据库
        // 4. 数据库未命中 -------> 返回报错信息 --------> 结束
        // 5. 数据库命中,-------> 将数据存入Redis --------> 返回店铺类型数据 --------> 结束
        String Key = RedisConstants.CACHE_SHOP_TYPE_KEY;
        // 获取列表中所有元素(字符串格式)
        List<String> shopTypeJSON = stringRedisTemplate.opsForList().range(Key, 0, -1);   // 获取列表中所有元素

        if(shopTypeJSON != null && !shopTypeJSON.isEmpty()){
            // Redis中存在数据,需要将所有的Value转换成 ShopType对象
            // 将字符串转换为对象
            List<ShopType> shopTypeList = new ArrayList<>();
            for(String str : shopTypeJSON){
                shopTypeList.add(JSONUtil.toBean(str, ShopType.class));
            }
            return Result.ok(shopTypeList); // 返回店铺类型数据
        }
        // 查询数据库
        List<ShopType> shopTypeList = query().orderByAsc("sort").list();
        if(shopTypeList == null || shopTypeList.isEmpty()){
            return Result.fail("店铺类型不存在");
        }
        // 将对象转换为字符串(每一项都是)
        for (ShopType shopType : shopTypeList) {
            stringRedisTemplate.opsForList().rightPushAll(Key, JSONUtil.toJsonStr(shopType));
        }
        // 设置过期时间
        stringRedisTemplate.expire(Key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shopTypeList);
    }

3.3.3 缓存功能效果展示

分类列表数据成功存储到Redis中
查询效率从1秒提升到45毫秒

3.4 (知识点)缓存更新策略最佳实践

3.4.1 数据一致性问题

        前面引入Redis时已经讲过了,缓存的使用可以降低后端负载、降低响应时间、提高读写效率。但也会带来系列问题,数据一致性便是其中最为常见的问题之一。

        数据一致性问题的根本原因就是缓存和数据库中的数据不同步,因此,我们需要提出一套良好的缓存更新方案,尽可能的使得缓存和数据库中的数据进行及时同步。

3.4.2 缓存更新策略的最佳实践

  • 内存淘汰机制【全自动触发】

        所谓内存淘汰机制,就是当Redis出现内存不足的情况下,会选择性的剔除一部分数据。这种全自动触发的淘汰机制不受控制,且触发概率不高,因此只适用于一些低一致性需求的业务

  • 超时剔除机制【半自动触发】

        超时剔除机制通常被我们当作一个保底策略,就是习惯性的给缓存数据设置一定的过期时间TTL。到期后,由Redis自动进行剔除,从而更好的利用缓存空间

  • 主动更新机制【手动触发】

    手动编码实现缓存更新,在修改数据库的同时更新缓存,实现双写。

    特别灵活,可控性好

    • 读写穿透(Read / Write Through Pattern)

              缓存与数据库整合成为一个服务,由服务来维护统一性。调用者调用该服务,无需关心缓存的一致性问题

      问题:想实现困难,市面上也很难找到这种服务

    • 写回方案  (Write Behind Caching Pattern)

             调用者只操作缓存,由其他一个独立线程异步的将缓存数据持久化到数据库,从而实现最终保持一致

      问题:异步任务复杂,一致性难以保证(异步非同步),宕机及丢失

    • 双写方案  (Cache Aside Pattern)

      由缓存的调用者,在更新数据库的同时去更新缓存

      • 更新缓存模式

        无效写操作较多,不推荐使用:

                例如对于一个数据,我们要进行100次的修改更新。如果每次都去更新,实际上只有最后一次更新是有效操作。所有对于查询少的情况下,更新缓存模式性能反而不太好。

      • 删除缓存模式

        无效写操作相对较少,推荐使用

        • 先操作缓存

          先操作缓存再操作数据库在多线程环境下出错情况说明:

          【线程1 做更新缓存操作    线程2 做查询操作】

                  由于先操作缓存,线程1执行删除缓存操作

                  如果 从删除缓存 到 更新完成这一个过程复杂 耗时特别长

                  同一时间,线程2执行查询操作,由于缓存未命中,查询数据库

                  此时还未更新完成,查询到旧的值,并把旧的值写回了缓存

                  这样一来,缓存数据和数据库数据产生了不一致

          这个概率比较大:

                   线程1先删掉缓存,然后执行一个耗时长的更新动作

                   而线程2 则是进行查询缓存 和 写入缓存的动作,耗时短

        • 先操作数据库

          先操作数据库再删除缓存出错情况说明:

          【线程1 做查询操作    线程2 做更新操作】

           假设恰好缓存失效了 

           线程1来查,刚好处于未命中状态,于是查询数据库【旧值】

           并准备把数据库数据写入缓存

           恰好线程2 执行更新数据库操作,将数据库的值更新成【新值】

           最后线程1终于开始执行写入缓存操作了,但是写的是【旧值】

          这个概率微乎其微:

          两个恰好,其次在线程1查询后 写缓存的这个微妙级别的操作之内,

          必须恰好有另外的线程完成了 更新数据库这一耗时长的操作。

总结:最佳的缓存更新策略:

        使用超时剔除机制作为保底策略,采用主动更新机制中的双写模式,选择删除缓存模式,先操作数据库后操作缓存。

3.4.3 实现查询商户店铺详细的缓存与数据库双写一致

超时剔除机制保底:

// 在原先的 queryById 方法中 添加过期时间
// 将数据写入Redis + 设置过期时间
        stringRedisTemplate.opsForValue().set(
                RedisConstants.CACHE_SHOP_KEY + id,
                  JSONUtil.toJsonStr(shop),
                  RedisConstants.CACHE_SHOP_TTL,
                  TimeUnit.MINUTES);

更新数据时,主动删除缓存:

注意添加事务,确保更新数据库和删除缓存的原子性

/**
     * 更新店铺信息
     * @param shop
     * @return
     */
    @Override
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if(id == null){
            return Result.fail("店铺id不能为空");
        }
        // 1. 更新数据库
        updateById(shop);
        // 2. 删除缓存
        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + shop.getId());
        // 3. 返回结果
        return Result.ok();
    }

3.5 (知识点)缓存穿透与解决策略

3.5.1 什么是缓存穿透?

        缓存穿透是指客户端请求的数据在缓存中和在数据库中都不存在,这样缓存永远不会生效,这些请求都会直接被达到数据库上。

3.5.2 缓存穿透的危害?

        若是有大量这种无效、恶意请求直接打到数据库。会极大地增加数据库本身的压力,很可能造成数据库宕机。就像DDOS攻击,导致正常的客户端请求无法得到及时的响应。

3.5.3 缓存穿透的解决措施

        以下介绍的两种方式都是被动的解决缓存穿透方案。除此之外,我们还可以采用主动的方案预防缓存穿透,比如:增强id的复杂度避免被猜测id规律做好数据的基础格式校验加强用户权限校验

3.5.3.1 缓存空对象
优点:缺点:
实现简单,维护方便

1. 额外的内存消耗

2. 可能造成短期的不一致问题

缓存空对象图解
3.5.3.2 布隆过滤器

当一个元素加入布隆过滤器中的时候,会进行如下操作:

  1. 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
  2. 根据得到的哈希值,在位数组中把对应下标的值置为 1。

当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:

  1. 对给定元素再次进行相同的哈希计算;
  2. 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
优点:缺点:
内存占用少,没有多余的Key

1. 实现复杂

2. 存在误判可能

布隆过滤器应用图解
3.5.3.3 解决缓存穿透——以请求店铺信息为例
解决缓存穿透的流程图

 缓存空对象实现代码

/**
     * 根据id查询店铺(添加Redis缓存版 + 解决缓存穿透[缓存空对象])
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        //1. 根据id到Redis中查询用户信息
        //2. Redis命中 ----------------> 返回商户信息 -------> 结束
        //2.改:Redis命中 ----------------> 判断是否为空对象-------> 结束
        //                                        | 非空
        //                                        ------> 返回商户信息 -------> 结束
        //3. Redis未命中 查询数据库
        //4. 查询数据库未命中 ----------------> 返回错误信息 ------> 结束
        //4.改: 查询数据库未命中----------> 将空对象写入Redis  ------> 结束
        //5. 数据库命中 ---------------> 将数据写入Redis ------> 返回商户信息 -------> 结束


        String stopJson =  stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        // 判断Redis中是否存在数据
        if(StrUtil.isNotBlank(stopJson)){
            // 存在直接返回
            Shop shop = JSONUtil.toBean(stopJson, Shop.class); //将JSON字符串转换为对象
            return Result.ok(shop);
        }
        // 判断Redis中是否存在空对象
        if  (RedisConstants.CACHE_PENETRATION_NULL_VALUE.equals(stopJson)){
            return Result.fail("店铺信息不存在");
        }

//        query().eq("shop_id",id);
        // 查询数据库
        Shop shop = getById(id);
        // 查询数据库不存在
        if(shop == null){
            // 将空对象写入Redis
            stringRedisTemplate.opsForValue().set(
                    RedisConstants.CACHE_SHOP_KEY + id,
                    RedisConstants.CACHE_PENETRATION_NULL_VALUE,
                    RedisConstants.CACHE_NULL_TTL,
                    TimeUnit.MINUTES
            );
            return Result.fail("店铺不存在");
        }

        // 将数据写入Redis + 设置过期时间
        stringRedisTemplate.opsForValue().set(
                RedisConstants.CACHE_SHOP_KEY + id,
                JSONUtil.toJsonStr(shop),
                RedisConstants.CACHE_SHOP_TTL,
                TimeUnit.MINUTES
        );
        return Result.ok(shop);
    }

 测试结果

第一次查询不存在的店铺,Redis缓存空对象,报错信息为:店铺不存在
第二次查询,Redis已缓存空对象,报错信息为:店铺信息不存在

3.6 (知识点)缓存雪崩与解决策略 

        缓存雪崩是指在同一时间段大量缓存key同时失效或者Redis服务宕机,导致大量请求打到数据库,带来巨大压力

缓存雪崩示意图

解决策略:

  1. 事前:Redis集群部署,主从 + 哨兵机制, 避免全盘崩溃宕机
  2. 事中:本地缓存 + 限流和降级,避免数据库压力过大造成宕机
  3. 事后:Redis持久化,机器重启后可以自动从磁盘加载数据,恢复缓存数据

3.7 (知识点)缓存击穿与解决策略

        缓存击穿问题又叫热点Key问题,是指一个高访问量并且缓存重建业务较为复杂的key突然失效了,导致无数请求直接打到数据库,造成巨大压力的情况

        如图,在线程1进行缓存重建的过程,由于重建业务耗时较长,在重建业务期间,有其他大量线程执行查询操作,由于缓存未命中,均尝试执行查询数据库重建缓存的重复操作。

3.7.1 缓存击穿的解决方案

3.7.1.1 基于互斥锁的解决方案

        为了防止在缓存重建的过程中,其余线程也去进行查询数据库重建缓存。互斥锁策略则是采用给第一个尝试重建缓存的线程添加互斥锁,其余的线程则在不断地进行 “尝试获取锁 --- 休眠等待”。从而减少了查询数据库造成的压力问题。

互斥锁解决缓存击穿的原理图

3.7.1.2 基于逻辑过期的解决方案

        首先,我们会给这种高访问量业务的Key设置一个逻辑过期时间(到期不会被Redis自动删除,以确保缓存必定命中)。

        然后,线程每次访问时,会先对当前时间与逻辑过期时间进行判断,过期则获取一个互斥锁,来表明自己是第一个发现需要缓存重建的线程。

        接着,该线程就会开启一个独立线程,专门用于执行缓存重建的任务。自己则是先返回旧的数据使用

        在缓存重建线程执行完成之前,互斥锁不会释放,此时其他线程在访问的过程中获取锁失败,则直接返回旧数据

        最后,当缓存重建线程执行完毕后,释放互斥锁。

3.7.1.3 总结比较

互斥锁追求的是对一致性要求较高的业务,但是代价是多线程等待,性能受到了一定的影响

逻辑过期追求的是高性能的服务,但是却牺牲了一致性。

3.7.2 基于互斥锁解决缓存击穿案例——以请求店铺信息为例

3.7.2.1 互斥锁解决缓存击穿思路流程
基于互斥锁解决缓存击穿的原理图
3.7.2.2 代码实现——抽取方法

        在实现功能的过程中,我们人为的在进行缓存重建的过程中添加了Thread休眠,这样使得整个缓存重建的时长变长,模拟复杂的重建业务,从而更容易能展示出互斥锁在高并发情况下减缓数据库压力的作用

        模拟重建的延迟情况 : Thread.sleep(100);

public Result queryById(Long id) {
        //1. 根据id到Redis中查询用户信息
        //2. Redis命中 ----------------> 返回商户信息 -------> 结束
        //2.改:Redis命中 ----------------> 判断是否为空对象-------> 结束
        //                                        | 非空
        //                                        ------> 返回商户信息 -------> 结束
        //3. Redis未命中  -----> 查询数据库
        //3.改:Redis未命中 -------> 尝试获取互斥锁 ------> 获取成功 -----> 查询数据库 -------> 将数据库结果写入Redis -------> 释放锁 ------> 返回商户信息 -------> 结束
        //                                     | 获取失败
        //                                     ------> 休眠一段时间后重试
        //4. 查询数据库未命中 ----------------> 返回错误信息 ------> 结束
        //4.改: 查询数据库未命中----------> 将空对象写入Redis  ------> 结束
        //5. 数据库命中 ---------------> 将数据写入Redis ------> 返回商户信息 -------> 结束
        // -------------------------------------------上述思路将被分装成两个方法分别解决 缓存穿透 和 缓存击穿 问题------------------------------------------------------------------------------------------------ //

        // 基于互斥锁解决缓存击穿问题
        Shop shop = queryWithMutex(id);
        if(shop == null){
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }



   /**
     * 获取互斥锁  setNx  ----- setIfAbsent
     * @param key
     * @return
     */
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放互斥锁
     * @param key
     */
    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }


    /**
     * 基于互斥锁解决缓存击穿问题保存代码
     * @param id
     * @return
     */
    private Shop queryWithMutex(Long id){
        String stopJson =  stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        // 判断Redis中是否存在数据
        if(StrUtil.isNotBlank(stopJson)){
            // 存在直接返回
            Shop shop = JSONUtil.toBean(stopJson, Shop.class); //将JSON字符串转换为对象
//            return Result.ok(shop);
            return shop;
        }
        // 判断Redis中是否存在空对象
        if  (RedisConstants.CACHE_PENETRATION_NULL_VALUE.equals(stopJson)){
//            return Result.fail("店铺信息不存在");
            return null;
        }

        // 4. 实现缓存重建
        //4.1 尝试获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            //4.2 获取互斥锁失败 休眠一段时间后 重新查询Redis(自旋)
            if(!isLock){
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //4.3 获取互斥锁成功 查询数据库 将商铺信息写入Redis
//        query().eq("shop_id",id);
            // 查询数据库
            shop = getById(id);

            // 模拟重建的延迟情况
            Thread.sleep(100);

            // 查询数据库不存在
            if(shop == null){
                // 将空对象写入Redis
                stringRedisTemplate.opsForValue().set(
                        RedisConstants.CACHE_SHOP_KEY + id,
                        RedisConstants.CACHE_PENETRATION_NULL_VALUE,
                        RedisConstants.CACHE_NULL_TTL,
                        TimeUnit.MINUTES
                );
    //            return Result.fail("店铺不存在");
                return null;
            }

            // 将数据写入Redis + 设置过期时间
            stringRedisTemplate.opsForValue().set(
                    RedisConstants.CACHE_SHOP_KEY + id,
                    JSONUtil.toJsonStr(shop),
                    RedisConstants.CACHE_SHOP_TTL,
                    TimeUnit.MINUTES
            );
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //4.4 释放互斥锁
            unLock(lockKey);
        }
        //4.5 返回商铺信息
//        return Result.ok(shop);
        return shop;
    }
3.7.2.3 功能测试——基于JMeter的压力测试

配置JMeter测试任务

 配置访问后端数据地址

访问后端数据接口地址:8081 

 清空缓存店铺信息

清空缓存数据

执行测试任务

全部访问通过,QPS200

后台查询数据库只执行了一次

3.7.3 基于逻辑过期解决缓存击穿案例——以请求店铺信息为例

3.7.3.1 逻辑过期解决缓存击穿思路流程
基于逻辑过期时间解决缓存击穿的原理图
3.7.3.2 构建RedisData对象——组合优于继承

        我们先前定义的Shop对象实际上是没有逻辑过期时间字段的。如何解决这个问题呢?明显有两种方法:

基于继承的方法:【对Shop有侵入性】

        创建一个父类,内涵 expireTime字段,让原先的Shop对象继承父类,从而获得逻辑过期时间字段。

基于组合的方法:

        定义一个组合对象RedisData,包含一个逻辑过期时间字段和一个Object对象

3.7.3.3 代码实现

代码包括:

1. 开启一个大小为10的线程池

2. 查询店铺方法

3. 封装RedisData对象的公用方法【需要用于单元测试获取数据,所以要定义成公用方法】

4. 封装利用逻辑过期时间解决缓存击穿的私有方法

单元测试代码

@SpringBootTest
class HmDianPingApplicationTests {

    @Resource
    private ShopServiceImpl shopService;

    @Test
    void testSaveShop() throws InterruptedException {
        shopService.saveShop2Redis(1L,10L);
    }
}

 /**
     * 开启线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);



/**
     * 根据id查询店铺(添加Redis缓存版 + 解决缓存穿透【缓存空对象】+ 互斥锁实现方案)
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        
        // 基于逻辑过期解决缓存击穿问题
        Shop shop = queryWithLogicalExpire(id);
        if(shop == null){
            return Result.fail("热点店铺不存在");
        }
        return Result.ok(shop);
    }



/**
     * 创建RedisData对象 【原Shop + 逻辑过期时间字段】
     * @param id
     * @param expireTime
     */
    public void saveShop2Redis(Long id, Long expireTime) throws InterruptedException {
        // 1.查询店铺数据
        Shop shop = getById(id);
        // 模拟休息情况
        Thread.sleep(100);
        // 2.封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
        // 3.写入Redis
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }



    /**
     * 基于逻辑过期解决缓存击穿问题 保存代码、
     * 为什么不用判断缓存穿透问题,因为一定有Key,如果没有Key必然不对,不需要让它继续访问数据库了,直接打回就可
     * @param id
     * @return
     */
    private Shop queryWithLogicalExpire(Long id){

        String stopJson =  stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        //1. 判断Redis中是否存在数据
        if(StrUtil.isBlank(stopJson)){
            //1.1  未命中直接结束
            return null;
        }
        //2. 命中了,判断缓存是否过期
        //2.1 将JSON字符串反序列为对象
        RedisData redisData = JSONUtil.toBean(stopJson,RedisData.class);
        JSONObject data =(JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data,Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();

        if(expireTime.isAfter(LocalDateTime.now())){ // 未过期
            // 直接返回
            return shop;

        }
        // 3. 已过期,需要缓存重建
        // 4. 尝试获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        if(isLock){
            //5. 获取互斥锁成功,开启独立线程,查询数据库,重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() ->{
                try {
                    //  重建缓存
                    this.saveShop2Redis(id, 20L);

                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 6. 释放互斥锁
                    unLock(lockKey);
                }
            });
        }
        return shop;
    }


3.7.3.4 功能测试——基于JMeter的压力测试

        想要测试【逻辑过期时间】策略,必须先在缓存中准备好数据,否则测试不成功,具体原因请看3.7.4故障排除

1. 执行测试方法获取Redis缓存店铺数据

等到该缓存数据过期后再执行方法

2. 修改后台数据库中店铺名,用于后续更好的观察该策略的效果

3. JMemet测试

全部请求都通过了,但是前面的请求查询到的数据还是 “102茶餐厅”【旧数据】

从这个请求开始,重建线程已经完成,后续请求都和后台数据库同步了,都是“103茶餐厅”

3.7.4【故障排查】关于逻辑过期时间策略无法查询店铺信息的现象

        当我们使用逻辑过期时间策略时,在直接访问店铺数据时会发现无论怎么样,店铺数据都不会被存储到缓存中,也不会去查询数据库,导致了店铺数据一直无法访问。

        这是出于逻辑过期时间策略 默认要求 热点Key数据是由管理员事先存放到Redis中设置好,等到活动结束后再人为的删除掉的。

        所以在第一次访问时,如果没有提前将店铺数据存放在Redis,该策略直接返回空对象后结束,因而造成了这种现象的发生。

无法正常获取信息
缓存不会存储信息

3.8 封装Redis缓存工具

3.8.1 封装代码

@Component
@Slf4j
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 开启线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 获取互斥锁  setNx  ----- setIfAbsent
     * @param key
     * @return
     */
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",RedisConstants.LOCK_SHOP_TTL,TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放互斥锁
     * @param key
     */
    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }

    /**
     * 构造方法
     * @param stringRedisTemplate
     */
    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将任意Java对象序列化为json并存储在String类型的key中,并且可以设置过期时间
     * @param key
     * @param value 任意java对象
     * @param time 过期时间
     * @param unit 过期时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit){
        // 序列化
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 将任意java对象序列化成json并存储在String类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
     * @param key
     * @param value 任意java对象
     * @param time  逻辑过期时间
     * @param unit  逻辑过期时间单位
     */
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
        // 封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(value);
        // 利用uint的 toSeconds 转换成Second
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入Redis【逻辑过期】
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
    }


    /**
     * 根据指定的key查询缓存,并反序列为指定的类型,利用缓存空值来解决缓存穿透问题
     * @param keyPrefix  key前缀
     * @param id         查询数据库id
     * @param type       返回值类型
     * @param dbFallback 数据库查询方法
     * @param time       过期时间
     * @param unit       过期时间单位
     * @return
     * @param <R>   返回值类型
     * @param <ID>   查询数据库id类型
     */
    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. 存在,直接返回
            // 反序列化回 type 类型
            return JSONUtil.toBean(json,type);
        }
        //判断是否为空值
        if(json != null){
            // 返回
            return null;
        }
        //4. 不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        //5. 不存在,返回错误
        if(r == null){
            // 写入空值
            stringRedisTemplate.opsForValue().set(key,CACHE_PENETRATION_NULL_VALUE,CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }
        //6. 存在,写入Redis
        this.set(key,r,time,unit);
        //7. 返回
        return r;
    }


    /**
     * 根据指定的key查询缓存,并反序列成指定类型,利用逻辑过期解决缓存击穿问题
     * @param KeyPrefix   key前缀
     * @param id          查询数据库id
     * @param type        返回值类型
     * @param dbFallback  数据库查询方法
     * @param time        逻辑过期时间
     * @param unit        逻辑过期时间单位
     * @return
     * @param <R>         返回值类型
     * @param <ID>        查询数据库id类型
     */
    public <R,ID> R queryWithLogicalExpire(
            String KeyPrefix,
            ID id,
            Class<R> type,
            Function<ID,R> dbFallback,
            Long time,
            TimeUnit unit
    ) {
        // 查询Redis
        String json =  stringRedisTemplate.opsForValue().get(KeyPrefix + id);
        //1. 判断Redis中是否存在数据
        if(StrUtil.isBlank(json)){
            //1.1  不存在直接结束
            return null;
        }
        //2. 命中了,判断缓存是否过期
        //2.1 将JSON字符串反序列为对象
        RedisData redisData = JSONUtil.toBean(json,RedisData.class);
        JSONObject data =(JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data,type);
        LocalDateTime expireTime = redisData.getExpireTime();

        if(expireTime.isAfter(LocalDateTime.now())){ // 未过期
            // 直接返回
            return r;

        }
        // 3. 已过期,需要缓存重建
        // 4. 尝试获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        if(isLock){
            //5. 获取互斥锁成功,开启独立线程,查询数据库,重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() ->{
                try {
                    //  重建缓存
                    //1. 查数据库
                    R r1 = dbFallback.apply(id);
                    //2. 写redis
                    this.setWithLogicalExpire(KeyPrefix,r1,time,unit);

                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 6. 释放互斥锁
                    unLock(lockKey);
                }
            });
        }
        return r;
    }
}

3.8.2 使用方法

/**
     * 根据id查询店铺(添加Redis缓存版 + 解决缓存穿透【缓存空对象】+ 互斥锁实现方案)
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        // 解决缓存穿透问题
//        Shop shop = queryWithPassThrough(id);

        // 基于互斥锁解决缓存击穿问题
//        Shop shop = queryWithMutex(id);

        // 基于逻辑过期解决缓存击穿问题 【需要提前准备好Redis缓存数据】
//        Shop shop = queryWithLogicalExpire(id);


        // 基于自定义封装的Redis缓存工具解决缓存穿透问题
//        Shop shop = cacheClient.queryWithPassThrough(
//                CACHE_SHOP_KEY,
//                id,
//                Shop.class,
//                this::getById,
//                CACHE_SHOP_TTL,
//                TimeUnit.SECONDS);

        // 基于自定义封装的Redis缓存工具解决缓存击穿问题【逻辑过期时间】
        Shop shop = cacheClient.queryWithLogicalExpire(
                CACHE_SHOP_KEY,
                id,
                Shop.class,
                this::getById,
                CACHE_SHOP_TTL,
                TimeUnit.MINUTES);

        if (shop == null){
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值