黑马点评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);
    }
}
相关推荐
月光水岸New2 小时前
Ubuntu 中建的mysql数据库使用Navicat for MySQL连接不上
数据库·mysql·ubuntu
狄加山6752 小时前
数据库基础1
数据库
我爱松子鱼2 小时前
mysql之规则优化器RBO
数据库·mysql
chengooooooo2 小时前
苍穹外卖day8 地址上传 用户下单 订单支付
java·服务器·数据库
Rverdoser3 小时前
【SQL】多表查询案例
数据库·sql
Galeoto3 小时前
how to export a table in sqlite, and import into another
数据库·sqlite
希忘auto4 小时前
详解Redis在Centos上的安装
redis·centos
人间打气筒(Ada)4 小时前
MySQL主从架构
服务器·数据库·mysql
leegong231114 小时前
学习PostgreSQL专家认证
数据库·学习·postgresql
喝醉酒的小白4 小时前
PostgreSQL:更新字段慢
数据库·postgresql