黑马点评(2)- 商户查询缓存

发布时间 2023-06-21 19:51:00作者: hbjiaxin

02 商户查询缓存

(0)前期准备

1、接口

  • 根据id查询商铺信息;
  • 更新商铺信息。
ShopController
@RestController
@RequestMapping("/shop")
public class ShopController {

    @Autowired
    private ShopService shopService;

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

    /**
     * 更新商铺信息
     * @param shop 商铺数据
     */
    @PutMapping()
    public Result updateShop(@RequestBody Shop shop) {
        // 更新数据库,删除redis中缓存
        return shopService.update(shop);
    }
}

2、数据库表

image

(1)缓存是什么

  • 缓存定义:数据交换的缓冲区(Cache),存贮数据的临时地方,一般读写性能高;
  • 缓存使用:如浏览器缓存、应用层缓存、数据库缓存、CPU缓存;
  • 缓存作用:降低后端负载,提高服务读写响应速度;
  • 缓存成本:开发成本、运维成本、一致性问题。

(2)查询商户信息添加Redis缓存

  • 查询商户信息时,先查询Redis缓存,若缓存没有则查询商户信息,若查到则将该数据添加到Redis。

image

1、key的结构

  • 商户信息:key-value:shop缓存标识 + 商户id --- 商户信息;

2、查询商户信息

ShopServiceImpl
    /**
     * 根据id查询店铺,第一版:先从redis查缓存,若无再查Mysql
     */
    public Result queryById(Long id) {
        String cacheShopKey = CACHE_SHOP_KEY + id;
        // 1. 从Redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(cacheShopKey);
        // 2. 判断缓存是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 缓存存在
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 3. 缓存未命中,根据id查询数据库
        Shop shop = this.getById(id);
        if (shop == null) {
            // 数据库中不存在数据,返回错误
            return Result.fail("商铺不存在");
        }
        // 4. 数据库中存在数据,将商铺信息写入redis,并设置超时时间,避免redis缓存过多数据
        stringRedisTemplate.opsForValue().set(cacheShopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 5. 返回商铺信息
        return Result.ok(shop);
    }

(3)缓存更新策略

(4)结合Redis更新商铺信息

(5)缓存三大问题

1、缓存穿透

  • 客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
  • 解决方案:
    • 缓存空对象:对于不存在的数据也在Redis中建立缓存,值为空,并设置一个较短的TTL时间;
      • 优点:实现简单,维护方便;
      • 缺点:额外的内存消耗,可能造成短期不一致。
    • 布隆过滤:利用布隆过滤算法,在请求进入Redis之前判断是否存在,若不存在则直接拒绝请求(类比hash思想,判断某个数据的多个hash是否匹配上,匹配上则放行,存在有hash冲突,即可能放行不存在的数据);
      • 优点:内存占用较少,没有多余的key;
      • 缺点:实现复杂,存在误判可能。(可以放行一些不存在的key)

image

【1】使用空值解决缓存穿透

  • 注意:value为空值的key的过期时间应该设置较短,避免长时间的数据不一致。

image

ShopServiceImpl
    /**
     * 解决缓存击穿问题
     * 根据id查询店铺,第二版:先从redis查缓存,若无再查Mysql,mysql也无则赋空值至redis
     */
    public Result queryById(Long id) {
        Shop shop = queryWithPassThrough(id);
        if (shop == null) {
            return Result.fail("商铺不存在");
        }
        return Result.ok(shop);
    }

    /**
     * 缓存穿透(使用空值)
     */
    private Shop queryWithPassThrough(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1. 从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2. 判断缓存中是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 存在,则返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        // 3. 判断缓存是否是空值
        if (shopJson != null) {
            return null;
        }
        // 4. 不存在,根据id查询数据库
        Shop shop = getById(id);
        if (shop == null) {
            // 不存在,则设置空值到redis中,过期时间比正常数据短
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        // 5. 数据库中存在数据,将商铺信息写入redis,并设置超时时间,避免redis缓存过多数据
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 6. 返回
        return shop;
    }

2、缓存击穿

  • 热点Key问题,即热点Key(高并发访问)突然失效,无数请求该key的请求直接打到数据库上。
  • 解决方案:
    • 互斥锁,进行数据库查询从而缓存重构的过程由一个请求进行;
    • 逻辑过期,不设置Redis key的过期时间,而是自己在value中添加一个过期字段,之后若key逻辑过期则让一个请求使用互斥锁去缓存重构,其他请求直接返回旧数据。

image

【1】使用互斥锁解决缓存击穿

image

ShopServiceImpl
    /**
     * 解决缓存击穿问题
     * 根据id查询店铺,第三版:使用互斥锁
     */
    public Result queryById(Long id) {
        Shop shop = queryWithMutex(id);
        if (shop == null) {
            return Result.fail("商铺不存在");
        }
        return Result.ok(shop);
    }

    /**
     * 缓存击穿(使用互斥锁)
     */
    public Shop queryWithMutex(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1. 查询缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson)) {
            // 2. 缓存命中返回数据
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        // 3. 缓存未命中
        // 3.1 缓存是否是否是空值
        if (shopJson != null) {
            return null;
        }
        // 4. 实现缓存重建
        // 4.1 尝试获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        Shop shop = null;
        // 4.2 判断是否获取成功
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            try {
                // 4.4 成功,根据id查询数据库
                shop = getById(id);
                // 模拟延迟时间
                Thread.sleep(200);
                if (shop == null) {
                    // 不存在,将空值写入redis
                    stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                    return null;
                }
                // 存在,写入redis
                stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                // 7. 释放互斥锁
                unlock(lockKey);
            }
        } else {
            // 4.3 失败,休眠并等待重试
            try {
                Thread.sleep(50);
                return queryWithMutex(id);
            } catch (InterruptedException ex) {
                ex.printStackTrace();
            }
        }
        //  返回
        return shop;
    }

    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

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

【2】使用逻辑过期解决缓存击穿

image

  • 前提:对数据进行预热处理,redis中先保存热点商户信息;
  • 封装一个redis value的实体类,其中封装data与过期时间;
  • 缓存重构的操作新开一个线程去异步操作,返回的是缓存的旧数据,再次请求时才获得新数据。
封装逻辑过期时间实体类
/**
 * 缓存数据:添加过期时间字段
 */
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
热点数据预热
    /**
     * 作用1:模拟商铺热点数据预热(给使用逻辑过期策略解决缓存击穿问题做前提准备,前提是执行单元测试中的预热方法【见DianPingApplicationTest】)
     * 作用2:缓存重建
     * 该方法在ShopServiceImpl中
     */
    public void saveShopRedis(Long id, Long expireSeconds) throws InterruptedException {
        // 1. 查询店铺数据
        Shop shop = getById(id);
        if (shop == null) {
            return;
        }
        // 模拟缓存重建的时间
        Thread.sleep(200);
        // 2. 封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        // 3. 写入redis,不需要设置过期时间(有逻辑过期时间)
        String key = CACHE_SHOP_KEY + id;
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 数据预热,测试使用逻辑过期解决缓存击穿前执行
     * 该测试方法在DianPingApplicationTest中
     */
    @Test
    public void test() throws InterruptedException {
        shopService.saveShopRedis(1L, 10L);
    }
ShopServiceImpl
    /**
     * 解决缓存击穿问题
     * 根据id查询店铺,第四版本:使用逻辑删除
     */
    @Override
    public Result queryById(Long id) {
        Shop shop = queryWithLogicalExpire(id);
        if (shop == null) {
            return Result.fail("商铺不存在");
        }
        return Result.ok(shop);
    }


    /**
     * 缓存击穿(使用逻辑过期):需提前导入热点数据
     * 思路:如某时间段的热点商品,提前加入热点数据,如数据不存在,直接返回,数据存在,判断是否过期
     * 想法:万一该预热商品更新数据时,得考虑是删除缓存还是对商品信息缓存重建。
     * 此处针对的是热点数据,不是所有数据
     */
    public Shop queryWithLogicalExpire(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1. 从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2. 判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            // redis中不存在,则返回
            return null;
        }
        // 3. 缓存命中,JSON反序列化成对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 4. 判断数据是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //  数据未过期,返回店铺信息
            return shop;
        }
        // 5. 数据过期,更新缓存数据
        // 6. 尝试获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.1 获取成功
        if (isLock) {
            // 注意:获取锁成功应该再次检测redis缓存是否过期,做DoubleCheck,如果存在则无需重建缓存。
            // DoubleCheck:请求1刚刚把Redis数据更新为新数据并释放了锁,请求2查询到旧数据后拿到锁,就得需要再次查询Redis看是否过期,减少与数据库交互的可能次数。
            String shopJson1 = stringRedisTemplate.opsForValue().get(key);
            if (StringUtil.isBlank(shopJson1)) {
                return null;
            }
            RedisData redisData1 = JSONUtil.toBean(shopJson1, RedisData.class);
            LocalDateTime expireTime1 = redisData.getExpireTime();
            if (expireTime1.isAfter(LocalDateTime.now())) {
                return JSONUtil.toBean((JSONObject) redisData1.getData(), Shop.class);
            }
            // 7. 开启新线程进行缓存重建(使用线程池)
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 8. 查询数据库数据
                    // 9. 更新缓存数据到redis中
                    saveShopRedis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 10. 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.2. 获取失败,返回旧数据
        // 返回旧数据
        return shop;
    }

【3】互斥锁和逻辑过期对比

解决方案 优点 缺点
互斥锁 无额外内存消耗;保存一致性;实现简单 线程可能需要等待,性能受影响;可能有死锁风险
逻辑删除 线程无需等待,性能较好(缓存重建可新开一个线程,返回旧数据) 不保证一致性,有额外内存消耗;实现复杂

3、缓存雪崩

  • 同一

(6)缓存工具封装

03 优惠券秒杀

04 好友关注

05 达人探店

06 附近的商户

07 用户签到

08 UV统计