黑马redis实战-商户查询缓存

商户查询缓存

本文的主题是商铺缓存。主要包括:添加商铺缓存到 redis,实现缓存和数据库的一致,
redis 缓存面临的三个问题的解决:缓存穿透,缓存雪崩,缓存击穿

实现效果:
在这里插入图片描述

1. 添加商户缓存

需求分析:根据 id 查询商铺,若 redis 中有商铺缓存,直接返回。否则查询数据库并将商铺信息缓存到 redis 中

代码实现:
① Controller 层,获取url中的请求参数 “id”

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

② redis 查询缓存,若缓存未命中,查询数据库,并保存到 redis 中

\\ ShopServiceImpl.java
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1.从 redis 查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 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(key, JSONUtil.toJsonStr(shop));
        // 7.返回
        return Result.ok(shop);
    }
}

问题记录:前端商铺显示 NaN
① return Result.ok() ,里面没有返回 shop

② redis 中有缓存记录,但是给店铺添加了过期时间,然后缓存没有清理掉,所以过期不显示。删除缓存刷新即可

2. 缓存与数据库双写一致

缓存更新策略:
① 内存淘汰:一致性差,利用 redis 的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存

② 超时剔除:给缓存数据添加 TTL 时间,到期后自动删除缓存,下次查询时更新缓存

③ 主动更新:编写业务逻辑,在修改数据库的同时,更新缓存

采用主动更新方式:修改数据库并更新缓存

问题:先修改数据库再更新缓存,还是先更新缓存再修改数据库?
在这里插入图片描述
根据上面添加商铺缓存的逻辑,若线程查询redis 缓存未命中,即执行 查询数据库并写入 redis 缓存的逻辑

方案一:线程1删除缓存之后,导致缓存失效. 由于操作数据库是涉及IO,不如redis直接操作内存,因此大概率情况下线程1在更新数据库期间会有线程查询缓存. 可以看到,线程2在线程1更新数据库的时候,查询缓存并拿到了数据库中的快照版本,并将过期的值写入缓存。最后的结果是 redis 中 a = 10,而 mysql 中 a = 20,缓存和数据库不一致

方案二:线程1 先更新数据库再删除缓存,之后线程2由于缓存未命中,查询到数据库中更新的值并写入缓存. 结果上,数据库和缓存数据库一致

问题:更新数据库和删除缓存之间有线程请求业务呢?
在这里插入图片描述
线程取到过期缓存:在线程 1 更新数据库还未来得及删除缓存前,线程2查询缓存,由于缓存还未删除,线程2拿到旧数据。但之后的线程由于缓存未命中,会查询数据库并更新缓存

多线程操作数据库:这种情况也会发生缓存和数据库不一致。接着左侧线程3缓存未命中去查询数据库的情况,这时有线程更新数据库,则会出现对缓存更新丢失的情况.即线程1 写入缓存的操作覆盖了线程2 写入缓存的操作。这种情况发生的前提是:更新数据库和删除缓存之间有线程查询缓存导致缓存被删除【即左图】,其次是 查询数据库和写入缓存之前有线程查询数据库并写入了缓存。这种情况会发生但是因为有条件限制,所以概率较低

综合来看,方案二虽然也会发生缓存过期以及缓存与数据库不一致的情况,但是出错概率要小一点

因此选择:先更新数据库,再删除缓存

更新方式:利用 postman 修改店铺信息并发送给服务器
在这里插入图片描述
代码实现:

// ShopController.java
/**
 * 更新商铺信息
 * @param shop 商铺数据
 * @return 无
 */
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
    // 写入数据库
    return shopService.update(shop);
}
// ShopServiceImpl.java
@Override
@Transactional
public Result update(Shop shop) {
    Long id = shop.getId();
    if(id == null){
        return Result.fail("店铺id不能为空");
    }
    // 1. 更新数据库
    updateById(shop);
    // 2. 删除缓存
    stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
    return Result.ok();
}

3. 缓存穿透

缓存穿透:客户端请求的数据在缓存中和数据库中都不存在,每次查询数据库都不会写入缓存,缓存无法生效

解决方案:
① 缓存空对象:查询数据库不存在,写入缓存时写入null

② redis + 布隆过滤器:不存在则直接拒绝,不再查询缓存及数据库

缓存空对象解决缓存穿透:查询数据库不存在,在redis 中写入 null;查询 redis 为 null,返回错误信息

@Override
public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1.从 redis 查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if(StrUtil.isNotBlank(shopJson)){
        // 3.存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // ② 判断命中的是否是空值
    if(shopJson != null){
        // 返回一个错误信息
        return Result.fail("店铺信息不存在!");
    }
    // 4.不存在,根据id查询数据库
    Shop shop = getById(id);
    // 5.不存在,返回错误
    if(shop == null){
        //① 将空值写入 redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        //返回错误信息
        return Result.fail("店铺不存在!");
    }
    // 6.存在,写入 redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 7.返回
    return Result.ok(shop);
}

4. 缓存雪崩

缓存雪崩:同一时段大量缓存 key 同时失效或 Redis 服务宕机,导致大量请求到达数据库

解决方案:

① 给不同的 key 的TTL 添加随机值

② 利用 Redis 集群提高服务可用性

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

④ 给业务添加多级缓存

5. 缓存击穿

缓存击穿:热点 key 被高并发访问且缓存重建业务较复杂的 key 失效

解决方案:

① 互斥锁:加锁保证只有一个线程执行 缓存重建,未获取互斥锁的线程休眠重试直到缓存命中

② 逻辑过期:不设置TTL而是设置逻辑过期时间,保证国歌线程可读取旧值,也会因逻辑过期执行缓存重建。只有获取互斥锁的线程执行缓存重建,未获取互斥锁的线程返回旧数据

5.1 互斥锁解决缓存击穿

① 缓存未命中的情况下,才执行缓存重建

② 互斥锁实现缓存重建的逻辑

  1. 获取锁

  2. 缓存重建:查询数据库,若存在则写到缓存

  3. 释放锁

@Override
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.从 redis 查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if(StrUtil.isNotBlank(shopJson)){
        // 3.存在,直接返回
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    // 判断命中的是否是空值
    if(shopJson != null){
        // 返回一个错误信息
        return null;
    }
    // 4.未命中,实现缓存重建
    // 4.1. 获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shop = null;// 这里是代码被放到 try-catch后自动添加的
    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(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            //返回错误信息
            return null;
        }
        // 6.存在,写入 redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        // 7.释放互斥锁
        unlock(lockKey);
    }
    // 8. 返回
    return shop;
}
private boolean tryLock(String key){
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
    return BooleanUtil.isTrue(flag);
}

private void unlock(String key){
    stringRedisTemplate.delete(key);
}
5.2 逻辑过期时间解决缓存击穿

问题:如何设置逻辑过期时间?

① 定义 RedisData 类使用聚合的方式封装原有信息为 data 属性,并添加 expireTime 作为逻辑过期时间

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

② 缓存重建时获取当前时间设置 expireTime ,并将 RedisData 保存到 redis 中

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(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

逻辑过期解决缓存击穿的思路:

  1. 从redis中获取 redisData 对象,从而获取逻辑过期时间

  2. 将获取的逻辑过期时间与当前时间比较,未过期直接返回

  3. 已过期则执行缓存重建

  4. 获取互斥锁进行缓存重建 tryLock()

  5. 获取到互斥锁的线程开启独立线程,实现缓存重建

利用上面创建的线程池进行缓存重建

  1. 重建缓存 saveShop2Redis()

  2. 释放锁

问题:获取到互斥锁的线程如何实现实现缓存重建?

① 创建线程池用于开启独立线程重建缓存

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

② 使用 executor.submit() 异步执行缓存重建任务

if (isLock) {
    // 6.3 获取锁成功,开启独立线程,实现缓存重建
    CACHE_REBUILD_EXECUTOR.submit(()->{
        try {
            // 重建缓存
            this.saveShop2Redis(id, 20L);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // 释放锁
        unlock(lockKey);
    });
}

完整代码

@Override
public Result queryById(Long id) {
    // 逻辑过期解决缓存击穿
    Shop shop = queryWithLogicalExpire(id);
    if(shop == null){
        return Result.fail("店铺不存在!");
    }
    return Result.ok(shop);
}
// 缓存击穿
// ② 使用逻辑过期时间的方式解决缓存击穿

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id){
    String key = CACHE_SHOP_KEY + id;
    // 1.从 redis 查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if(StrUtil.isBlank(shopJson)){
        // 3.不存在,返回空
        return null;
    }
    // 4.命中,先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();

    // 5.判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())){
        // 5.1 未过期,直接返回店铺信息
        return shop;
    }
    // 5.2 已过期,需要缓存重建
    // 6.缓存重建
    // 6.1 获取互斥锁
    String lockKey = 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) {
                throw new RuntimeException(e);
            }
            // 释放锁
            unlock(lockKey);
        });
    }
    return shop;
}
private boolean tryLock(String key){
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
    return BooleanUtil.isTrue(flag);
}

private void unlock(String key){
    stringRedisTemplate.delete(key);
}
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值