基于缓存分片的下单库存扣减方案
将商品进行数据分片,并将分片分散存储在各个 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 记录出库记录