基于社区电商的Redis缓存架构-库存模块缓存架构(下)

基于缓存分片的下单库存扣减方案

将商品进行数据分片,并将分片分散存储在各个 Redis 节点中,那么如何计算每次操作商品的库存是去操作哪一个 Redis 节点呢?

我们对商品库存进行了分片存储,那么当扣减库存的时候,操作哪一个 Redis 节点呢?

通过轮询的方式选择 Redis 节点,在 Redis 中通过记录商品的购买次数(每次扣减该商品库存时,都对该商品的购买次数加 1),key 为 product_stock_count:{skuId},通过该商品的购买次数对 Redis 的节点数取模,拿到需要操作的 Redis 节点,再进行扣减

如果只对这一个 Redis 进行操作,可能该 Redis 节点的库存数量不够,那么就去下一个 Redis 节点中判断库存是否足够扣减,如果遍历完所有的 Redis 节点,库存都不够的话,那么就需要将所有 Redis 节点的库存数量进行合并扣减了,合并扣减库存的流程为:

  • 先累加所有 Redis 节点上的库存数量
  • 判断所有的库存数量是否足够扣减,如果够的话,就去遍历所有的 Redis 节点进行库存的扣减;如果不够,返回库存不足即可

库存在高并发场景下,写操作还是比较多的,因此还是以 Redis 作为主存储,DB 作为辅助存储

用户下单之后,Redis 中进行库存扣减流程如下:

基于社区电商的Redis缓存架构-库存模块缓存架构(下)_第1张图片

出库主要有 2 个步骤:

  • Redis 中进行库存扣除
  • 将库存扣除信息进行异步落库

那么异步落库是通过 MQ 实现的,主要记录商品出库的一些日志信息,这里讲一下 Redis 中进行库存扣除的代码是如何实现的,在缓存中扣除库存主要分为 3 个步骤:

  • 拿到需要操作的 Redis 节点,进行库存扣除
  • 如果该 Redis 节点库存不足,则去下一个节点进行库存扣除
  • 如果所有 Redis 节点库存都不足,就合并库存进行扣除

先来说一下第一步,如何拿到需要操作的 Redis 节点,我们上边已经说了,通过轮询的方式,在 Redis 中通过 key:product_stock_count:{skuId} 记录对应商品的购买次数,用购买次数对 Redis 节点数取模,拿到需要操作的 Redis 节点的下标

这里该 Redis 节点库存可能不够,我们从当前选择的 Redis 节点开始循环,如果碰到库存足够的节点,就进行库存扣除,并退出不再继续循环,循环 Redis 节点进行库存扣除代码如下:

// incrementCount:商品的购买次数
Object result;
// 轮询 Redis 节点进行库存扣除
for (long i = incrementCount; i < incrementCount + redisCount - 1; i ++) {
  /**
   * jedisManager.getJedisByHashKey(hashKey) 这个方法就是将传入的 count 也就是 hashKey 这个参数
   * 对 Redis 的节点数量进行取模,拿到一个下标,去 List 集合中取出该下标对应的 Jedis 客户端
   */
  try (Jedis jedis = jedisManager.getJedisByHashKey(i)){
      // RedisLua.SCRIPT:lua 脚本
      // productStockKey:存储商品库存的 key:"product_stock:{skuId}"
      // stockNum 需要扣除的库存数量
      result = jedis.eval(RedisLua.SCRIPT, CollUtil.toList(productStockKey), CollUtil.toList(String.valueOf(stockNum));
  }
  if (Objects.isNull(result)) {
	continue;
  }
  if (Integer.valueOf(result+"") > 0){
	deduct = true;
	break;
  }
}
// 如果单个 Redis 节点库存不足的话,需要合并库存扣除
if (!deduct){
	// 获取一下当前的商品总库存,如果总库存也已不足以扣减则直接失败
	BigDecimal sumNum = queryProductStock(skuId);
	if (sumNum.compareTo(new BigDecimal(stockNum)) >=0 ){
        // 合并扣除库存的核心代码
		mergeDeductStock(productStockKey,stockNum);
	}
	throw new InventoryBizException("库存不足");
}

下边看一下库存扣除的 lua 脚本:

/**
 * 扣减库存
 * 先拿到商品库存的值:stock
 * 再拿到商品需要扣除或返还的库存数量:num
 * 如果 stock - num <= 0,说明库存不足,返回 -1
 * 扣除成功,返回 -2
 * 如果该商品库存不存在,返回 -3
 */
public static final String SCRIPT  =
        "if (redis.call('exists', KEYS[1]) == 1) then"
        + "    local stock = tonumber(redis.call('get', KEYS[1]));"
        + "    local num = tonumber(ARGV[1]);"
        + "    local results_num = stock - num"
        + "    if (results_num <= 0) then"
        + "        return -1;"
        + "    end;"
        + "    if (stock >= num) then"
        + "            return redis.call('incrBy', KEYS[1], 0 - num);"
        + "        end;"
        + "    return -2;"
        + "end;"
        + "return -3;";

对于单个 Redis 节点的库存扣除操作已经说完了,就是先选择 Redis 节点,再执行 lua 脚本扣除即可,如果发现所有 Redis 节点库存足够扣除,就需要合并库存,再进行扣除,合并库存扣除的代码如下:

private void mergeDeductStock(String productStockKey, Integer stockNum){
    // 执行多个分片的扣除扣减,对该商品的库存操作上锁,保证原子性
    Map<Long,Integer> fallbackMap = new HashMap<>();
    // 拿到 Redis 总节点数
    int redisCount = cacheSupport.getRedisCount();
    try {
        // 开始循环扣减库存
        for (long i = 0;i < redisCount; i++){
            if (stockNum > 0){
                // 对当前 Redis 节点进行库存扣除,这里返回的结果 diffNum 表示当前节点扣除库存后,还有多少库存未被扣除
                Object diffNum = cacheSupport.eval(i, RedisLua.MERGE_SCRIPT, CollUtil.toList(productStockKey), CollUtil.toList(stockNum + ""));
                if (Objects.isNull(diffNum)){
                    continue;
                }
                // 当扣减后返回得值大于0的时候,说明还有库存未能被扣减,对下一个分片进行扣减
                if (Integer.valueOf(diffNum+"") >= 0){
                    // 存储每一次扣减的记录,防止最终扣减还是失败进行回滚
                    fallbackMap.put(i, (stockNum - Integer.valueOf(diffNum+"")));
                    // 重置抵扣后的库存
                    stockNum = Integer.valueOf(diffNum+"");

                }
            }
        }
        // 完全扣除所有的分片库存后,还是未清零,则回退库存返回各自分区
        if (stockNum > 0){
            fallbackMap.forEach((k, v) -> {
                Object result = cacheSupport.eval(k, RedisLua.SCRIPT, CollUtil.toList(productStockKey), CollUtil.toList((0 - v) + ""));
                log.info("redis实例[{}] 商品[{}] 本次库存不足,扣减失败,返还缓存库存:[{}], 剩余缓存库存:[{}]", k,productStockKey, v, result);
            });
            throw new InventoryBizException("库存不足");
        }

    } catch (Exception e){
        e.printStackTrace();
        // 开始循环返还库存
        fallbackMap.forEach((k, v) -> {
            cacheSupport.eval(k, RedisLua.SCRIPT,CollUtil.toList(productStockKey),CollUtil.toList((0-v)+""));
        });
        throw new InventoryBizException("合并扣除库存过程中发送异常");
    }
}

在合并扣除库存中,主要有两个 lua 脚本:RedisLua.MERGE_SCRIPTRedisLua.SCRIPT,第一个用于扣除库存,第二个用于返还库存

第二个 lua 脚本上边在库存扣减的时候,已经说过了,我们只需要将参数加个负号即可,原来是扣除库存,这里添加库存就可以返还了

来看一下第一个 lua 脚本:

/**
 * 合并库存扣减
 * stock:该节点拥有库存
 * num:需要扣除库存
 * diff_num:扣除后剩余库存(如果该节点库存不足,则是负数)
 * 如果节点没有库存,返回 -1
 * 如果节点库存不足,令 num = stock,表示将该节点库存全部扣除完毕
 * 最后如果 diff_num 是负数,表示还有还有库存未扣减完毕,返回进行扣减
 */
public static final String MERGE_SCRIPT  =
        "if (redis.call('exists', KEYS[1]) == 1) then\n" +
        "    local stock = tonumber(redis.call('get', KEYS[1]));\n" +
        "    local num = tonumber(ARGV[1]);\n" +
        "    local diff_num = stock - num;\n" +
        "    if (stock <= 0) then\n" +
        "        return -1;\n" +
        "    end;\n" +
        "    if (num > stock) then\n" +
        "        num = stock;\n" +
        "    end;\n" +
        "    redis.call('incrBy', KEYS[1], 0 - num);\n" +
        "    if (diff_num < 0) then\n" +
        "        return 0-diff_num;\n" +
        "    end;\n" +
        "    return 0;\n" +
        "end;\n" +
        "return -3;";

总结

那么库存扣减的整个流程也就说完了,接下来总结一下,库存入库流程为:

  • DB 记录入库记录
  • Redis 对库存进行分片,采用渐进性写入缓存

库存出库流程为:

  • 轮询 Redis 节点进行扣除,如果所有节点库存不足,则合并库存进行扣除
  • 如果库存扣除成功,则 DB 记录出库记录

你可能感兴趣的:(Redis,缓存,redis,架构)