基于缓存分片的下单库存扣减方案
将商品进行数据分片,并将分片分散存储在各个 Redis 节点中,那么如何计算每次操作商品的库存是去操作哪一个 Redis 节点呢?
我们对商品库存进行了分片存储
,那么当扣减库存的时候,操作哪一个 Redis 节点呢?
通过轮询
的方式选择 Redis 节点,在 Redis 中通过记录商品的购买次数(每次扣减该商品库存时,都对该商品的购买次数加 1),key 为 product_stock_count:{skuId}
,通过该商品的购买次数对 Redis 的节点数取模,拿到需要操作的 Redis 节点,再进行扣减
如果只对这一个 Redis 进行操作,可能该 Redis 节点的库存数量不够,那么就去下一个 Redis 节点中判断库存是否足够扣减,如果遍历完所有的 Redis 节点,库存都不够的话,那么就需要将所有 Redis 节点的库存数量进行合并扣减了,合并扣减库存的流程为:
- 先累加所有 Redis 节点上的库存数量
- 判断所有的库存数量是否足够扣减,如果够的话,就去遍历所有的 Redis 节点进行库存的扣减;如果不够,返回库存不足即可
库存在高并发场景下,写操作还是比较多的,因此还是以 Redis 作为主存储,DB 作为辅助存储
用户下单之后,Redis 中进行库存扣减流程如下:
出库主要有 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_SCRIPT
和 RedisLua.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 记录出库记录