黑马点评2——商户查询缓存(P37店铺类型查询业务添加缓存练习题答案)redis缓存、更新、穿透、雪崩、击穿、工具封装

文章目录

什么是缓存?

缓存也要考虑成本的问题,不是随便用的

添加Redis缓存


java 复制代码
    @Override
    public Result queryById(Long id) {
        String redisKey = RedisConstants.CACHE_SHOP_KEY + id;
        // 1. 从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(redisKey);
        // 2. 判读是否存在
        if(StrUtil.isNotBlank(shopJson)){
            // 3. 存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 4. 不存在,根据id查询数据库
        Shop shop = getById(id);
        // 5. 不存在,写入redis
        if(shop == null){
            return Result.fail("店铺不存在!");
        }
        // 6. 存在,写入redis
        stringRedisTemplate.opsForValue().set(redisKey,JSONUtil.toJsonStr(shop));
        // 7. 返回
        return Result.ok(shop);
    }

店铺类型查询业务添加缓存练习题

java 复制代码
@Override
    public Result queryTypeList() {
        // 1. 从redis查询店铺类别缓存
        List<String> shopTypeRedisKey = stringRedisTemplate.opsForList().range(RedisConstants.CACHE_SHOP_TYPE_KEY,0,-1);
        // 2. 判断是否命中缓存
        if(!CollectionUtils.isEmpty(shopTypeRedisKey)){
            // 3. 存在,直接返回,即是命中缓存
            // 使用stream流将json集合转为
            List<ShopType> shopTypeList = shopTypeRedisKey.stream()
                    .map(item -> JSONUtil.toBean(item, ShopType.class))
                    .sorted(Comparator.comparingInt(ShopType::getSort))
                    .collect(Collectors.toList());
            // 返回缓存数据
            return Result.ok(shopTypeList);
        }
        // 4. 不存在,查询数据库
        List<ShopType> shopTypes = query().orderByAsc("sort").list();
        // 判断数据库中是否有数据
        if(CollectionUtils.isEmpty(shopTypes)){
            // 不存在则缓存一个空集合,解决缓存穿透
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_TYPE_KEY, Collections.emptyList().toString(),RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("商品分类信息为空");
        }
        // 5. 数据存在,先写入redis,再返回
        // 使用stream流将bean集合转为json集合
        List<String> shopTypeCache = shopTypes.stream()
                .sorted(Comparator.comparingInt(ShopType::getSort))
                .map(item -> JSONUtil.toJsonStr(item))
                .collect(Collectors.toList());

        stringRedisTemplate.opsForList().rightPushAll(RedisConstants.CACHE_SHOP_TYPE_KEY,shopTypeCache);
        stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_TYPE_KEY,RedisConstants.CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
        // 6. 返回(按类别升序排序)
        return Result.ok(shopTypes);
    }

缓存更新策略



操作缓存和数据库的顺序,不论谁先进行都可能会有线程安全的问题

但方案二的发生可能性更小,所以更优

总结:

给查询商铺的缓存添加超时剔除和主动更新的策略

查询店铺:

java 复制代码
  @Override
    public Result queryById(Long id) {
        String redisKey = RedisConstants.CACHE_SHOP_KEY + id;
        // 1. 从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(redisKey);
        // 2. 判读是否存在
        if(StrUtil.isNotBlank(shopJson)){
            // 3. 存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }

        // 4. 不存在,根据id查询数据库
        Shop shop = getById(id);
        // 5. 不存在,返回错误
        if(shop == null){
            return Result.fail("店铺不存在!");
        }
        // 6. 存在,写入redis
        stringRedisTemplate.opsForValue().set(redisKey,JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 7. 返回
        return Result.ok(shop);
    }

修改店铺:

java 复制代码
@Override
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if(id == null){
            return Result.fail("店铺id不能为空");
        }
        // 更新数据库,在删除缓存
        updateById(shop);
        // 删除缓存
        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
        return Result.ok();
    }

缓存穿透

客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库

缓存空对象

可以设置一个TTL,解决内存消耗问题

可能存在短期不一致的问题,控制TTL的时间,可以一定程度的缓解这个问题。

布隆过滤

客户端个redis之间,在加一层过滤------布隆过滤器------哈希算法二进制位保存数据

布隆过滤器说如果不存在一定是不存在,但存在不一定是100% 的

先看一下之前查询商铺信息的业务流程

物品们采用方案一应该把空数据写入redis


缓存雪崩

解决方案

  • 给不同的key的TTL添加随机值------针对问题一
  • 利用redis集群提高服务的可用性------针对问题二
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

缓存击穿

解决方案

互斥锁和逻辑过期

基于互斥锁方式解决缓存击穿问题

获取锁:

  • redis的setnx指令可以在key不存在的时候写,存在的时候不能写,就类似于互斥

释放锁:

  • 删掉就行了

设置锁的时候要设置有效期,避免因为某种原因锁得不到释放

java 复制代码
 @Override
    public Result queryById(Long id) {
        // 缓存穿透
//        Shop shop = queryWithPassThrough(id);

        // 互斥锁解决缓存击穿
        Shop shop = queryWithMutex(id);
        if(shop == null){
            return Result.fail("店铺不存在!");
        }
        // 7. 返回
        return Result.ok(shop);
    }
 /**
     * 解决缓存击穿(互斥锁)的写法
     * @param id
     * @return
     */
    public Shop queryWithMutex(Long id){
        String redisKey = RedisConstants.CACHE_SHOP_KEY + id;
        // 1. 从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(redisKey);
        // 2. 判读是否存在
        if(StrUtil.isNotBlank(shopJson)){
            // 3. 存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }

        // 命中的是否是空值
        if(shopJson != null){
            // 返回一个错误信息
            return null;
        }
        //4. 开始实现缓存重建
        // 4.1 获取互斥锁
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try{
            boolean isLock = tryLock(lockKey);
            // 4.2 判断是否获取成功
            if(!isLock){
                // 4.3 如果失败,则休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }

            // 4.4 如果成功,根据id查询数据库
            shop = getById(id);
            // 模拟重建的延时------测试的时候打开
//            Thread.sleep(200);
            // 5. 不存在,返回错误
            if(shop == null){
                // 将空值写入redis------解决缓存穿透
                stringRedisTemplate.opsForValue().set(redisKey,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6. 存在,写入redis
            stringRedisTemplate.opsForValue().set(redisKey,JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e){
            throw new RuntimeException(e);
        }finally {
            // 释放互斥锁
            unLock(lockKey);
        }
        // 7. 返回
        return shop;
    }
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);  // 因为flag是封装类,而要求的返回值是基本数据类型,在返回的时候就会进行自动的拆箱,拆箱的时候会出现空指针
    }

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

基于逻辑过期的方式解决缓存击穿问题

有个小问题,我们想要给存入redis的数据添加过期时间,但是我们的Shop实体类中又没有过期时间这个字段怎么办呢?

我们去给这个Shop实体添加过期时间字段可行吗?可行,但是对代码有侵入性,而且这个字段除了这里其他地方都用不到。

那怎么办?

我们可以声明一个RedisData的实体类,里面有一个过期时间的属性,让Shop继承这个实体类,Shop也就有了过期时间的属性了,但还是有一点点不好,还是需要修改源代码,需要修改Shop,有一定的侵入性,虽然也蛮好的。

还有一种方案:在RedisData中在声明一个Object的字段,把想要存储的数据放到Object中。

java 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

实际的项目肯定会有管理系统在后台点击,把热点数据提前缓存进redis,我们这里用一个单元测试完成这个功能。

先写一个缓存进redis的方法

java 复制代码
    public void saveShop2Redis(Long id, Long expireSeconds){
        // 1. 查询店铺数据
        Shop shop = getById(id);
        // 2. 封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        ///3.写入redis
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

在编写一个单元测试

java 复制代码
@SpringBootTest
class HmDianPingApplicationTests {

    @Autowired
    private ShopServiceImpl shopService;

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

}

下面我们完成基于逻辑过期的方式解决缓存击穿的商铺查询的代码

java 复制代码
// 使用线程池来开辟新线程
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 解决缓存击穿(逻辑过期)的写法
     * @param id
     * @return
     */
    public Shop queryWithLogicalExpire(Long id){
        String redisKey = RedisConstants.CACHE_SHOP_KEY + id;
        // 1. 从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(redisKey);
        // 2. 判读是否存在
        if(StrUtil.isBlank(shopJson)){
            // 3. 不存在,直接返回
            return null;
        }
        // 4. 命中需要判断过期时间,需要先把json反序列化位对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject jsonData = (JSONObject) redisData.getData(); // 如果不强转就是一个Object,但本质上是JSONObject,所以先转成JSONObject
        Shop shop = JSONUtil.toBean(jsonData, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5. 判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
            // 5.1 未过期,直接返回店铺信息
            return shop;
        }
        // 5.2 已过期,需要缓存重建
        // 6. 缓存重建
        // 6.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2 判断是否获取锁成功
        if(isLock){
            //6.3成功 开启独立线程实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
               try {
                   // 重建缓存
                   this.saveShop2Redis(id,20L);
               }catch (Exception e){

               } finally {
                   // 释放锁
                   unLock(lockKey);
               }
            });
        }
        // 6.4 返回过期的商铺信息
        return shop;
    }

缓存工具封装

把封装的代码放到CacheClient这个类中,并添加@Component注解,把这个bean交给Spring管理,封装的工具类如下:

java 复制代码
@Slf4j
@Component
public class CacheClient {


    private final StringRedisTemplate stringRedisTemplate;

    // 用构造器注入
    public CacheClient(StringRedisTemplate stringRedisTemplate){
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public void set(String key, Object value, Long time, TimeUnit unit){
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
    }

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


    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String redisKey = keyPrefix + id;
        // 1. 从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(redisKey);
        // 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(redisKey,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6. 存在,写入redis
        this.set(redisKey, r, time, unit);
        // 7. 返回
        return r;
    }

    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 redisKey = keyPrefix + id;
        // 1. 从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(redisKey);
        // 2. 判读是否存在
        if(StrUtil.isBlank(json)){
            // 3. 不存在,直接返回
            return null;
        }
        // 4. 命中需要判断过期时间,需要先把json反序列化位对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        JSONObject jsonData = (JSONObject) redisData.getData(); // 如果不强转就是一个Object,但本质上是JSONObject,所以先转成JSONObject
        R r = JSONUtil.toBean(jsonData, type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5. 判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
            // 5.1 未过期,直接返回店铺信息
            return r;
        }
        // 5.2 已过期,需要缓存重建
        // 6. 缓存重建
        // 6.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2 判断是否获取锁成功
        if(isLock){
            //6.3成功 开启独立线程实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    // 重建缓存
                    // 先查数据库
                    R r1 = dbFallback.apply(id);
                    // 写入redis
                    this.setWithLogicalExpire(redisKey, r1, time, unit);
                }catch (Exception e){

                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 6.4 返回过期的商铺信息
        return r;
    }


    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);  // 因为flag是封装类,而要求的返回值是基本数据类型,在返回的时候就会进行自动的拆箱,拆箱的时候会出现空指针
    }

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


}

封装这个工具类,有很多的技巧要总结:

  1. 传递的参数和返回的数据类型要泛型
  2. 函数式编程:在封装queryWithPassThrough的时候,里面在redis查询不存在的时候,我们要去查询数据库,那查询数据库的代码,我们泛型传递的参数,调用哪个查询数据库的函数去查询数据库呢?这时要用函数式编程,把要用到的函数通过参数传递过来,有参数有返回值就用Function<ID, R> dbFallback,使用的时候直接R r = dbFallback.apply(id);即可,调用这个工具方法的时候把具体的查询函数作为参数传进去。

那这些工具类在调用的时候又该怎么调用呢?

java 复制代码
  @Override
    public Result queryById(Long id) {
        // 缓存穿透
//        Shop shop = cacheClient.queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        // 逻辑过期解决缓存击穿问题
        Shop shop = cacheClient.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

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

那我们的缓存击穿想测试的话,还是得先用单元测试的方法,先往redis中写入点热点数据,现在就可以改进我们的单元测试代码

java 复制代码
@SpringBootTest
class HmDianPingApplicationTests {
    @Autowired
    private CacheClient cacheClient;

    @Test
    void testSaveShop() {
        Shop shop = shopService.getById(1L);
        cacheClient.setWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY + 1L,shop,10L, TimeUnit.SECONDS);
    }
}
相关推荐
奶糖趣多多1 分钟前
Redis知识点
数据库·redis·缓存
CoderIsArt1 小时前
Redis的三种模式:主从模式,哨兵与集群模式
数据库·redis·缓存
师太,答应老衲吧3 小时前
SQL实战训练之,力扣:2020. 无流量的帐户数(递归)
数据库·sql·leetcode
Channing Lewis4 小时前
salesforce case可以新建一个roll up 字段,统计出这个case下的email数量吗
数据库·salesforce
毕业设计制作和分享5 小时前
ssm《数据库系统原理》课程平台的设计与实现+vue
前端·数据库·vue.js·oracle·mybatis
ketil275 小时前
Redis - String 字符串
数据库·redis·缓存
Hsu_kk6 小时前
MySQL 批量删除海量数据的几种方法
数据库·mysql
编程学无止境6 小时前
第02章 MySQL环境搭建
数据库·mysql
knight-n7 小时前
MYSQL库的操作
数据库·mysql
包饭厅咸鱼7 小时前
QML----复制指定下标的ListModel数据
开发语言·数据库