黑马点评项目 P35-P47 商铺查询缓存练习

1.1 商铺查询

  1. 根据商铺ID查询

  1. 从 redis 中查询商铺,如果没有就从数据库查询然后存入 reids

/**
     * 查询单个店铺
     * @param id 店铺ID
     * @return
     */
    @Override
    public Result queryShopById(Long id) {

        // 1.从 redis 中查询
        String cacheShopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        if(StrUtil.isNotBlank(cacheShopJson)) {
            // 存在直接返回
            return Result.ok(JSONUtil.toBean(cacheShopJson,Shop.class));
        }

        // 2.缓存没有的话从数据库查询
        QueryWrapper<Shop> shopQueryWrapper = new QueryWrapper<>();
        shopQueryWrapper.eq("id",id);
        Shop shop = shopMapper.selectOne(shopQueryWrapper);

        // 3.设置到 redis 里给后面做缓存用
        stringRedisTemplate.opsForValue().
                set(RedisConstants.CACHE_SHOP_KEY + id,
                        JSONUtil.toJsonStr(shop),
                        RedisConstants.CACHE_SHOP_TTL,
                        TimeUnit.MINUTES);

        return Result.ok(shop);
    }

1.2 商铺缓存与数据库的双写一致

两种方法

  • 先更新数据库,删除缓存,写入缓存

  • 先删除缓存,更新数据库,写入缓存

我们这里选用第一种

  1. 缓存命中直接返回,如果没有命中缓存就从数据库查询然后写入缓存

  1. 都存在着线程安全的问题

  1. 更新完之后会删除缓存,后续查询的时候就会没有命中缓存,然后就去数据库查询写入缓存

/**
     * 先操作数据库在更新
     * @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 + id);
        return Result.ok();
    }
}

1.3 缓存穿透

客户端请求的数据,缓存和数据库中都不存在

方案

  • 缓存空对象

优点 : 实现简单,维护方便

缺点 : 额外的内存消耗

  • 布隆过滤器

优点 : 内存占用少,没有多余的key

缺点 : 实现复杂.可能存在误判

1.3.1 实现缓存空对象

  1. key - 前缀 + ID

  1. 第一次没有命中缓存,然后去查询数据库

  1. 数据库也没有,缓存空值到 redis

  1. 后续再次访问就会命中这个缓存空值,不会再去查询到数据库

/**
     * 查询单个店铺
     * @param id 店铺ID
     * @return
     */
    @Override
    public Result queryShopById(Long id) {

        String key = CACHE_SHOP_KEY + id;

        // 1.从 redis 中查询
        String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
        /**
         * <li>{@code StrUtil.isNotBlank(null)     // false}</li>
         * <li>{@code StrUtil.isNotBlank("")       // false}</li>
         * <li>{@code StrUtil.isNotBlank(" \t\n")  // false}</li>
         * <li>{@code StrUtil.isNotBlank("abc")    // true}</li>
         */
        if(StrUtil.isNotBlank(cacheShopJson))
        {
            // 存在直接返回
            return Result.ok(JSONUtil.toBean(cacheShopJson,Shop.class));
        }

        // 上面的 isNotBlank 判断 null 是 false ,所以这里还要在判断一次
        if (cacheShopJson != null)
        {
            return Result.fail("店铺信息不存在");
        }

        // 2.缓存没有的话从数据库查询
        Shop shop = super.getById(id);

        // 缓存穿透 : 如果从数据库查询也是没有,缓存 null 值
        if (shop == null)
        {
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail("店铺不存在");
        }

        // 3.设置到 redis 里给后面做缓存用
        stringRedisTemplate.opsForValue().
                set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

        return Result.ok(shop);
    }

1.4缓存雪崩

同一时间大量的key同时失效,或者redis服务器宕机,客户端大量请求到数据库

方案

  • 给不同的key添加随机时间

  • redis集群提高服务的可用性

  • 给缓存业务添加降级限流策略

1.5 缓存击穿

热点key失效了,大量的请求到达数据库

方法

  • 互斥锁

优点 : 保持一致性

缺点 : 线程等待,有死锁的风险

  • 设置逻辑过期时间

优点 : 线程无需等待

缺点 : 实现复杂,不保证一致性

1.5.1 实现互斥锁

  1. 使用SETNX命令创建锁key(lock:shop:id)作为锁的表示,这个命令标识该key不存在时才会创建

  1. 判断是否获取到锁,没有的话休眠0.05秒,然后递归(注意!return,是递归的出口)

  1. 如果获取到锁,从缓存中查询,从数据库查询

  1. 最后释放锁

/**
     * 判断是否获得锁
     * @param lockKey
     * @return
     */
    private boolean tryLock(String lockKey) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 解锁
     * @param lockKey
     */
    private void unLock(String lockKey) {
        stringRedisTemplate.delete(lockKey);
    }


    /**
     * 互斥锁
     * @param id 店铺ID
     * @return
     */
    public Shop queryWithMutex(Long id) {

        String key = CACHE_SHOP_KEY + id;

        // 1.从 redis 中查询
        String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
        /**
         * <li>{@code StrUtil.isNotBlank(null)     // false}</li>
         * <li>{@code StrUtil.isNotBlank("")       // false}</li>
         * <li>{@code StrUtil.isNotBlank(" \t\n")  // false}</li>
         * <li>{@code StrUtil.isNotBlank("abc")    // true}</li>
         */
        if(StrUtil.isNotBlank(cacheShopJson))
        {
            // 存在直接返回
            return JSONUtil.toBean(cacheShopJson,Shop.class);
        }

        // 上面的 isNotBlank 判断 null 是 false ,所以这里还要在判断一次
        if (cacheShopJson != null)
        {
            return null;
        }

        // 互斥锁
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try {
            // 获取互斥锁
            boolean flag = tryLock(lockKey);
            // 判断是否获取成功
            if (!flag) {
                // 失败则休眠 0.05秒
                Thread.sleep(50);
                // 递归 记得 return !
                // return 是递归出口
                return queryWithMutex(id);
            }

            // 2.缓存没有的话从数据库查询
            shop = super.getById(id);
            // 模拟重建的延时
            Thread.sleep(200);

            // 缓存穿透 : 如果从数据库查询也是没有,缓存 null 值
            if (shop == null)
            {
                stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
                return null;
            }

            // 3.设置到 redis 里给后面做缓存用
            stringRedisTemplate.opsForValue().
                    set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 释放锁
            unLock(lockKey);
        }

        return shop;
    }

1.5.2 测试

使用jmeter压力测试高并发的情况下只查询了一次数据库

1.5.3 实现逻辑过期时间

逻辑

  1. 去redis查询,如果没有就返回null

  1. 命中就判断逻辑时间是否已经过期

  1. 已过期尝试获取互斥锁

  1. 判断是否成功获取到互斥锁

  1. 是,开启独立线程查询数据库,缓存到redis,释放互斥锁

  1. 否,返回旧数据

/**
     * 缓存逻辑过期时间
     * @param id 商铺ID
     * @param expireSeconds 过期时间
     */
    @Override
    public void saveShop2Redis(Long id, Long expireSeconds) {
        // 查询数据库
        Shop shop = super.getById(id);

        // 存入缓存
        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        redisData.setData(shop);

        // 并没有添加TTL时间,所以过期时间是我们自己定义
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSON.toJSONString(redisData));
    }
@Data
public class RedisData {
    // 逻辑时间
    private LocalDateTime expireTime;
    // 缓存数据
    private Object data;
}
/**
     * 逻辑过期时间
     * @param id 店铺ID
     * @return
     */
    public Shop queryWithLogicalExpire(Long id) {

        String key = CACHE_SHOP_KEY + id;

        // 从 redis 中查询
        String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
        // 未命中
        if(StrUtil.isBlank(cacheShopJson)) return null;

        // 命中
        RedisData data = JSONUtil.toBean(cacheShopJson, RedisData.class);
        // 将 Object转换为 Shop
        Shop shop = JSONUtil.toBean((JSONObject) data.getData(),Shop.class);
        LocalDateTime expireTime = data.getExpireTime();
        // 判断逻辑时间是否已经过期
        if (expireTime.isAfter(LocalDateTime.now())) return shop;// 未过期直接返回

        // 已过期
        String lockKey = LOCK_SHOP_KEY + id;
        // 获取互斥锁
        boolean isLock = tryLock(lockKey);

        // 判断是否获取锁
        if (isLock) {
            // 成功,创建独立线程
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 重建缓存
                    this.saveShop2Redis(id,20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 失败,返回过期的商铺信息
        return shop;
    }

1.5.4 测试

  1. 如果缓存里已经是过期,还没有删除

  1. 测试高并发场景在缓存重建完成之前是否得到的是旧数据

1.6 缓存工具类封装

互斥锁,设置逻辑过期时间,这种我们可以封装成一个方法,后续调用即可

  1. 缓存到 redis

  1. 设置逻辑过期时间

  1. 缓存穿透 (返回 null 对象)

  1. 缓存击穿 (设置逻辑过期时间,缓存重建,互斥锁)

1.6.1 缓存到 reids

/**
     * 存入 redis
     * @param key
     * @param value
     * @param time
     * @param unit
     */
    public void set(String key, Object value, Long time, TimeUnit unit){
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
    }

1.6.2 缓存逻辑过期时间

/**
     * 设置逻辑过期
     * @param key
     * @param value
     * @param time
     * @param unit
     */
    public void setWithLogicExpire(String key, Object value, Long time, TimeUnit unit){
        RedisData data = new RedisData();
        // 设置逻辑过期时间
        data.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        data.setData(value);

        // 存入 redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data));
    }

1.6.3 缓存穿透 (缓存空对象)

/**
     * 缓存穿透
     * @param keyPrefix key
     * @param id 实体类ID
     * @param type 返回的实体类类型
     * @param dbFallback 具体的调用哪个类的凡是规范
     * @param <R> 实体类
     * @param <ID> 什么类型的ID,可能是Long,可能是Integer
     * @return
     */
    public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time, TimeUnit unit)
    {
        String key = keyPrefix + id;

        // 从 redis 中查询
        String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(cacheShopJson)) return JSONUtil.toBean(cacheShopJson, type);// 存在直接返回// 存在直接返回
        if (cacheShopJson != null) return null; // 上面的 isNotBlank 判断 null 是 false ,所以这里还要在判断一次

        // 缓存没有的话从数据库查询,根据传递进来的类型来查询,因为你不知道调用什么接口的getByID()
        R r = dbFallback.apply(id);

        // 缓存穿透 : 如果从数据库查询也是没有,缓存 null 值
        if (r == null)
        {
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }

        // 设置到 redis 里给后面做缓存用
        this.set(key,r,time,unit);
        return r;
    }

1.6.4 缓存击穿 (互斥锁,缓存重建,设置逻辑过期时间)

// 创建线程
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 逻辑过期时间
     * @param id 店铺ID
     * @return
     */
    public <R,ID> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit)
    {
        String key = keyPrefix + id;

        // 从 redis 中查询
        String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
        // 未命中
        if(StrUtil.isBlank(cacheShopJson)) return null;

        // 命中
        RedisData data = JSONUtil.toBean(cacheShopJson, RedisData.class);
        // 将 Object转换为 Shop
        R r = JSONUtil.toBean((JSONObject) data.getData(),type);
        LocalDateTime expireTime = data.getExpireTime();
        // 判断逻辑时间是否已经过期
        if (expireTime.isAfter(LocalDateTime.now())) return r;// 未过期直接返回

        // 已过期
        String lockKey = LOCK_SHOP_KEY + id;
        // 获取互斥锁
        boolean isLock = tryLock(lockKey);

        // 判断是否获取锁
        if (isLock) {
            // 成功,创建独立线程
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 重建缓存
                    // 先查询数据库
                    R r1 = dbFallback.apply(id);
                    // 写入缓存
                    this.setWithLogicExpire(key,r1,time,unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 失败,返回过期的商铺信息
        return r;
    }

    /**
     * 判断是否获得锁
     * @param lockKey
     * @return
     */
    private boolean tryLock(String lockKey) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 解锁
     * @param lockKey
     */
    private void unLock(String lockKey) {
        stringRedisTemplate.delete(lockKey);
    }
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值