Redis实战篇2:商户缓存

时间:2024-05-31 16:56:45

说明

        该实战篇基于某马的Redis课程中的《某马点评项目》。非常适合有相关经验、缺少企业级解决方案,或者想要复习的人观看,全篇都会一步一步的推导其为什么要这么做,分析其优缺点,达到能够应用的地步。

        本实战篇中心思想就是把项目中的实战抽象成一个个的知识点进行讲解,让初学者达到举一反三的地步而不是只会照着视频敲代码而不去独立思考为什么要这么做。

        关于项目代码请移步到 某马程序员公众号,回复Redis获取。

一、如何添加缓存

        为什么要添加缓存?
        想必大部分的人都用过美团饿了么等APP,要知道的是,在这些APP中,小手一扒拉动辄就是十几二多条的商品信息需要刷新,而且根据付费推荐算法,很多人在同一个地区都会刷新出来同样的店铺,像这种高频热点数据,我们就可以使用Redis,以提高反应速度。 

        那么接下来分析一下,如何添加商户的缓存
        首先,根据我们学习篇3中的缓存更新策略,商户信息属于无须高一致性的信息,所以我们用超时剔除的方案即可,既能保证一定的准确性,同时又不浪费太多的性能。

二、添加缓存 

2.1 简单思路,查询、删除、更新的方法

关于商户信息操作,首先查询

  1. 根据ID查询店铺
  2. 先去Redis中查询,如果没有则去数据库中查询
  3. 将数据库中的结果写入Redis中,并且设置过期时间

删除更新操作:这个就要考虑到一致性,我们使用先操作数据库后删除缓存的办法进行(如果删除缓存操作出现了异常则数据库更新成功缓存更新失败,这样无法保证双写一致性,所以记得在删除更新操作的时候加上事务注解)

作为中间过渡思想不列举代码了

三、 缓存穿透与解决方案

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

我们要知道,为什么要用Redis,不仅仅是因为Redis的响应速度快,同时也是因为MySQL能承载的并发不高,为了避免很多用户同时访问MySQL数据从而使用的Redis,如果有大量缓存穿透的事件发生,那么就会有大量请求打到MySQL上,造成MySQL服务器宕机

关于什么是布隆过滤器,可以看看这篇大佬的文章,讲的很全面 

布隆过滤器https://blog.****.net/qq_41125219/article/details/119982158

        理解了前置内容,我们知道刚刚的2.1的简单方案并不可行,他会造成缓存穿透的问题,如果查询一直打向数据库,那么会造成数据库宕机的可能性。

3.1 缓存空值的解决方案

整体流程图如下 ,我们需要在2.1的基础上完成两件事:

  1. 在查询MySQL时判断有无商铺信息,如果没有,则缓存一个空值到Redis中
  2. 在查询Redis时,如果命中了商铺信息缓存,需要判断其是否为空值,如果为空则返回错误信息

public Result queryByIdOne(Long id){
    // 1. 从redis查缓存
    //RedisConstants.CACHE_SHOP_KEY:一个常量键名
    //id:商铺ID
    String key = RedisConstants.CACHE_SHOP_KEY+id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    // 2.1 从redis中获取的值存在且不为空值 则正常返回数据
    if(shopJson != null && !"".equals(shopJson)){
        // 2.1.1 把刚刚查询到的json转化为对象
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 2.2 如果不属于正常值 则判断其是否为空字符串
    if(shopJson != null){
        // 2.2.1 如果为空字符串,则返回信息
        return Result.fail("店铺信息不存在");
    }
    // 4. 不存在,根据ID查询数据库
    Shop shop = getById(id);
    // 5. 不存在,返回错误
    if(shop == null){
        // 5.1 如果数据库中都不存在该店铺的信息 则向redis中写入一个空值 防止缓存穿透
        stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
        return Result.fail("店铺信息不存在");
    }
    // 6. 存在,写入redis 设置缓存世界
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7. 返回数据
    return Result.ok(shop);
}

四、缓存雪崩与解决方案

缓存雪崩:是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
同样的道理,大量的key失效,则有大量的请求到MySQL服务器,这也会导致SQL服务器瞬间宕机。

为什么同一时段大量的缓存key同时失效?

在开发中,为了某一项事件或者活动的正常开始,我们通常会预先的把一些热点数据直接缓存到Redis中,这个叫预热,这种情况下就有同一时间段大量缓存失效的可能性。

五、缓存击穿

 缓存击穿:也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

雪崩与击穿的区别:

雪崩是一大推key失效
击穿是一个被高并发访问并且缓存重建业务较复杂的key失效

 
看上图,线程1查询未命中,去数据库中重建缓存,如果这个查询的内容并发高,并且重建缓存非常复杂,那么可能在重建的过程中有非常多的并发查询重建缓存的步骤在进行,那么在这段时间内所有的请求都会打到SQL服务器上面造成宕机。

我们常见的解决方案有两种:互斥锁  | 逻辑过期

5.1 互斥锁

互斥锁很好理解,在缓存重建之前获取一把锁,这样只有获取锁的线程可以执行缓存重建,其他线程需要等待,这样就避免了服务器宕机,但是同时也带来了性能下降的问题。

 

5.2 逻辑过期

当线程查询缓存并且发现其逻辑时间过期后,会尝试获取互斥锁,此时如果获取成功,则会开启一个新的线程来帮助其完成重建缓存的任务,自己则返回旧的已过期的数据,其他线程查询时如果过期也会尝试获取锁,如果失败了也会返回旧的已过期的数据,而不是选择等待,这样就避免了性能浪费的问题,但是也带来了数据不一致的问题,不适用于强一致性的场景下。



5.3 对比互斥锁与逻辑过期 

5.4 代码实现:基于互斥锁方案

需要注意的是,我们使用的锁并不是传统上的lock锁,而是setnx。
我们先来回顾一下setnx,他也是一个存方法,且紧当这个key的value为空的时候才可以存,如果不为空则会失败,我们可以用这个原理来实现互斥锁,同时在setnx的时候也要存储时间,否则可能会因为某些问题导致这个锁一直没有被释放从而造成的死锁问题。

 

  1. 获取锁与释放锁
    1. /**
       * 获取互斥锁
       * 锁的原理是通过redis的setnx只能添加一次的原理实现
       *  1. 如果没有值 则获取锁成功 返回true
       *  2. 如果有值,则会导致写入失败 从而返回false
       * @param key
       * @return
       */
      private boolean tryLock(String key){
          Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
          return BooleanUtil.isTrue(aBoolean);
      }
      /**
       * 释放互斥锁
       * 就是删除redis当中锁的键
       * @param key
       */
      private void unLock(String key){
          stringRedisTemplate.delete(key);
      }
  2. 主要业务
    1.     /**
           * 解决缓存击穿
           * @param id
           * @return
           */
      public Shop queryWithMutex(Long id){
          // 1. 从redis查缓存
          String key = RedisConstants.CACHE_SHOP_KEY+id;
          String shopJson = stringRedisTemplate.opsForValue().get(key);
          // 2. 判断是否存在
          // 2.1 从redis中获取的值存在且不为空值 则正常返回数据
          if(shopJson != null && !"".equals(shopJson)){
              // 2.1.1 把刚刚查询到的json转化为对象
              Shop shop = JSONUtil.toBean(shopJson, Shop.class);
              return shop;
          }
          // 2.2 如果不属于正常值 则判断其是否为空字符串
          if(shopJson != null){
              // 2.2.1 如果为空字符串,则返回信息
              return null;
              // 2.2.2 如果为null 证明没有去数据库查过 则去查询数据库信息
          }
          // 4 实现缓存重建
          // 4.1 获取互斥锁
          String lockKey = RedisConstants.LOCK_SHOP_KEY+id; //设置锁名 每个店铺有自己的锁
          Shop shop = null;
          try {
              boolean isLock = tryLock(lockKey);
              // 4.2 判断是否成功
              if(!isLock){
                  // 4.3 如果失败 则休眠,并且重新获取互斥锁
                  Thread.sleep(50);
                  queryWithMutex(id); // 递归 重新获取锁
              }
              // 4.4 如果成功得到了互斥锁则去根据ID查询数据库
              shop = getById(id);
              // 5. 不存在,返回错误
              if(shop == null){
                  // 5.1 如果数据库中都不存在该店铺的信息 则向redis中写入一个空值 防止缓存穿刺
                  stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                  return null;
              }
              // 6. 存在,写入redis 设置缓存世界
              stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
          } catch (InterruptedException e) {
              throw new RuntimeException(e);
          }finally {
              // 7. 释放锁
              unLock(lockKey);
          }
          // 8. 返回数据
          return shop;
      }

5.5 代码实现:基于逻辑过期方案

逻辑过期,顾名思义,就是不设置TTL过期时间,在传递数据的时候手动添加一个过期时间字段,也就是说,什么时候过期由程序员说的算而不是redis。

从Redis中查询缓存理论上来讲是不会出现查询不到的情况的,因为一般这种需要逻辑过期时间的的事件都是活动事件,需要在活动开始之前就人工的把热点信息全部都缓存到redis当中,之后在人工的删除。但是为了健壮性,还是考虑一下未命中的情况。
我们来看看如何传递,首先要知道的是,我们在同步数据到Redis当中还需要新增一个字段,也就是过期时间字段。方法为将需要同步到Redis中的数据存放到下类中并且可以通过setexpireTime来设置时间即可。

@Data
public class RedisData {
    private LocalDateTime expireTime; //过期时间
    private Object data; //同步的数据
}

然后写一个写入这个类的函数,即需传入店铺ID以及过期时间即可,时间单位为秒

public void savaShop2Redis(Long id,Long expireSeconds){
    // 1.查询店铺信息
    Shop shop = getById(id);
    // 2.封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    //plusSeconds(expireSeconds) :当前时间+多少秒
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // 3.写入redis缓存
  stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}

再来创建一个线程池

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

来看总代码:

/**
 * 解决缓存击穿(设置逻辑过期时间)
 * @param id
 * @return
 */
public Shop queryWithLogicalExpire(Long id){
    // 1. 从redis查缓存
    String key = RedisConstants.CACHE_SHOP_KEY+id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    // 2.1 从redis中获取的值如果为空
    if(shopJson == null || "".equals(shopJson)){
        // 3. 如果不存在 直接返回
        return null;
    }
    // 4. 如果命中,则将json反序列化为对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    // TODO 到时候在研究一下 这个其实返回的是JSONObject对象
    Shop shopRedis = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5. 判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())){
        //如果逻辑过期时间在当前时间之后,则未过期 例如
        // 逻辑时间为20:00 当前时间为19:30 逻辑时间在当前时间之后
        // 5.1 直接返回当前结果
        return shopRedis;
    }
    // 6. (如果到了这一步,证明已经过期了) 需要重建缓存
    // 6.1 获取互斥锁
    String lockKey = RedisConstants.LOCK_SHOP_KEY+id;
    boolean isLock = tryLock(lockKey);
    // 6.2 判断互斥锁是否成功
    if(isLock){
        // 获取成功 开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() ->{
            // 缓存重建
            try {
                this.savaShop2Redis(id,20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally {
                //释放锁
                unLock(lockKey);
            }
        });
    }
    // 7. 返回过期的数据
    return shopRedis;
}

六、封装函数

为什么要封装函数?我们可以看到上面,如果说有几十个功能都需要解决击穿/穿透问题,哪每一个都写一遍吗?肯定是不能的,这其中有很多可以抽象为共用方法来实现,来看规则

 

6.1 方法1 

/**
 * 将任意java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
 */
public void set(String key, Object value, Long time, TimeUnit unit) {
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}

6.2 方法2

/**
 * 将任意java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,
 * 用于处理缓存击穿问题
 */
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
    //封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
    redisData.setData(value);
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}

6.3 方法3

如果不懂泛型的话,需要泛型的知识作为补充

如何调用:
 

 Shop shop = cacheClient.queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY,id,
                Shop.class,this::getById,RedisConstants.CACHE_SHOP_TTL,TimeUnit.MINUTES);

主程序: 

/**
 * 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方法解决缓存穿透问题
 */

public <R,ID> R queryWithPassThrough(
        String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,
        Long time, TimeUnit unit){
    // 1. 从redis查缓存
    String key = keyPrefix+id;
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    // 2.1 从redis中获取的值存在且不为空值 则正常返回数据
    if(json != null && !"".equals(json)){
        // 2.1.1 把刚刚查询到的json转化为对象
        return JSONUtil.toBean(json, type);
    }
    // 2.2 如果不属于正常值 则判断其是否为空字符串
    if(json != null){
        // 2.2.1 如果为空字符串,则返回信息
        //return Result.fail("店铺信息不存在");
        return null;
        // 2.2.2 如果为null 证明没有去数据库查过 则去查询数据库信息
    }
    // 4. 不存在,根据ID查询数据库
    R r = dbFallback.apply(id);
    // 5. 不存在,返回错误
    if(r == null){
        // 5.1 如果数据库中都不存在该店铺的信息 则向redis中写入一个空值 防止缓存穿刺
        stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    // 6. 如果存在,写入redis 设置缓存世界
    this.set(key,r,time,unit);
    // 7. 返回数据
    return r;
}

 6.4 方法4

Shop shop1 = cacheClient.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id,
            Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
/**
 * 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
 */
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,
                 Function<ID,R> dbFallback,Long time, TimeUnit unit){
    // 1. 从redis查缓存
    String key = keyPrefix+id;
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    // 2.1 从redis中获取的值如果为空
    if(json == null || "".equals(json)){
        // 3. 如果不存在 直接返回
        return null;
    }
    // 4. 如果命中,则将json反序列化为对象
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    // TODO 到时候在研究一下 这个其实返回的是JSONObject对象
    R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5. 判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())){
        //如果逻辑过期时间在当前时间之后,则未过期 例如
        // 逻辑时间为20:00 当前时间为19:30 逻辑时间在当前时间之后
        // 5.1 直接返回当前结果
        return r;
    }
    // 6. (如果到了这一步,证明已经过期了) 需要重建缓存
    // 6.1 获取互斥锁
    String lockKey = RedisConstants.LOCK_SHOP_KEY+id;
    boolean isLock = tryLock(lockKey);
    // 6.2 判断互斥锁是否成功
    if(isLock){
        // 获取成功 开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() ->{
            // 缓存重建
            try {
                // 查询数据库
                R r1 = dbFallback.apply(id);
                // 重建缓存
                this.setWithLogicalExpire(key,r1,time,unit);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally {
                //释放锁
                unLock(lockKey);
            }
        });
    }
    // 7. 返回过期的数据
    return r;
}

 七、总结

 

三种缓存更新策略

主动更新方案:

 缓存穿透

缓存雪崩

缓存击穿