库存模块缓存架构
我们先来分析一下库存模块的业务场景,分为入库和出库,入库的话,在库存模块中需要添加库存,由于库存也是 写多读多
的场景,那么也是以 Redis 作为主存储,MySQL 作为辅助存储
出库的话,是在用户下单时,需要去库存中进行减库存的操作,并且用户退款时,需要增加库存
那么库存模块是存在高并发写的情况的,通过对商品库存进行分片存储
,存储在多台 Redis 节点上,就可以将高并发的请求分散在各个 Redis 节点中,并且提供了单台 Redis 节点库存不足时的合并库存
的功能
先来说一下如何对商品库存进行缓存分片,比如说有 100 个商品,Redis 集群有 5 个节点,先将 100 个商品拆分为 5个分片,再将 5 个分片分散到 Redis 集群的各个节点中,每个节点 1 个分片,那么也就是每个 Redis 节点存储 20 个商品库存
那么对于该商品的瞬间高并发的操作,会分散的打到多个 Redis 节点中,库存分片的数量一般和 Redis 的节点数差不多
这里分片库存的话,我们是在对商品进行入库的时候实现的,商品在入库的时候,先去 DB 中异步落库,然后再将库存分片写入各个 Redis 节点中,这里写入的时候采用渐进性写入
,比如说新入库一个商品有 300 个,有 3 个 Redis 节点,那么我们分成 3 个分片的话,1 个 Redis 节点放 1 个分片,1 个分片存储 100 个商品,那么如果我们直接写入缓存,先写入第一个 Redis 节点 100 个库存,再写入第二个 Redis 节点 100 个库存,如果这时写入第三个 Redis 节点 100 个库存的时候失败了,那么就导致操作库存的请求压力全部在前两个 Redis 节点中,采用 渐进性写入
的话,流程为:我们已经直到每个 Redis 节点放 100 个库存了,那么我们定义一个轮次的变量,为 10,表示我们去将库存写入缓存中需要写入 10 轮,那么每轮就写入 10 个库存即可,这样写入 10 轮之后,每个 Redis 节点中也就有 100 个库存了,这样的好处在于,即使有部分库存写入失败的话,对于请求的压力也不会全部在其他节点上,因为写入失败的库存很小,很快时间就可以消耗完毕
基于缓存分片的入库添加库存方案
在商品入库时,主要就是在 DB 中记录入库的日志并且保存库存信息,在 Redis 中主要将库存进行分片存储,用于分担压力,流程图如下:
在商品入库时,对库存进行分片,流程为:
- 计算当前 Redis 节点需要分配的库存数量
- 执行 lua 脚本进行库存分配
下边贴出库存分片的代码,主要是 executeStockLua
方法:
java
/**
* 执行库存分配,使用lua脚本执行库存的变更
* @param request 变更库存对象
*/
public void executeStockLua(InventoryRequest request) {
// Redis 存储商品库存的 key
String productStockKey = RedisKeyConstants.PRODUCT_STOCK_PREFIX + request.getSkuId();
// 当前已经分配的库存数量
Integer sumNum = 0;
Long startTime = System.currentTimeMillis();
try {
// 获取默认设定分桶
int redisCount = cacheSupport.getRedisCount();
// 商品入库数量
Integer inventoryNum = request.getInventoryNum();
// 单个机器预计分配的库存
Integer countNum = inventoryNum / redisCount;
// countNum 指的是每个机器每轮分配的库存数量,要么是单台机器预计分配的库存的 1/10,要么是 3 个
// 也就是如果单个机器预计分配的库存比较小的话,没必要每次分配的 1 个或者 2 个,因此设置每轮分配的库存数量最小值是 3
countNum = getAverageStockNum(countNum,redisCount);
int i = 0;
while (true){
// 对每台机器进行库存分配
for (long count = 0;count < redisCount; count++ ){
// 最后剩余的库存小于每轮分配库存数量的时候,则以最后剩余的库存为准
if (inventoryNum - sumNum < countNum){
countNum = inventoryNum - sumNum;
}
// 这里 cacheSupport 是提供的一个工具类,用于让 Redis 去执行 lua 脚本进行库存的分配
Object eval = cacheSupport.eval(count, RedisLua.ADD_INVENTORY, CollUtil.toList(productStockKey), CollUtil.toList(String.valueOf(countNum)));
if (!Objects.isNull(eval) && Long.valueOf(eval+"") > 0){
// 分配成功的才累计(可能出现不均匀的情况)
sumNum = sumNum + countNum;
i++;
}
if (sumNum.equals(inventoryNum)){
break;
}
}
//分配完成跳出循环
if (sumNum.equals(inventoryNum)){
break;
}
}
log.info("商品编号:"+request.getSkuId()+",同步分配库存共分配"+ (i)+"次"+",分配库存:"+sumNum+",总计耗时"+(System.currentTimeMillis() - startTime)+"毫秒");
}catch (Exception e){
e.printStackTrace();
// 同步过程中发生异常,去掉已被同步的缓存库存,发送消息再行补偿,这里出现异常不抛出,避免异常
request.setInventoryNum(request.getInventoryNum() - sumNum);
// 这个 MQ 的异步操作中,就去去对库存进行添加,之前已经成功添加 sumNum 个库存了,还需要再补偿添加 request.getInventoryNum() - sumNum 个库存
sendAsyncStockCompensationMessage(request);
log.error("分配库存到缓存过程中失败", e.getMessage(), e);
}
}
/**
* 获取每个机器预估的分配库存数量
* @param countNum
* @return
*/
private Integer getAverageStockNum(Integer countNum,Integer redisCount){
Integer num = 0;
/**
* countNum 为单个节点需要分配的库存总数
* StockBucket.STOCK_COUNT_NUM 代表每个节点最多分配的轮次,这里的默认值是 10,也就是单个节点最多分配 10 次库存
* redisCount 是 Redis 的节点数
* 如果 countNum > (redisCount * StockBucket.STOCK_COUNT_NUM)
* 那么每次分配的库存数我们以 redisCount 作为一个标准,假如 redisCount = 3
* redisCount * StockBucket.STOCK_COUNT_NUM 的含义就是分配 10 轮,每轮分配 3 个库存,如果当前节点需要分配的库存数是比(每次分配3个,共分配10轮)还要多的话
* 那么就每轮分配 countNum / 10
* 如果单个节点的库存总数小于(分配 10 轮,每轮分配 redisCount 个库存)的话,再判断 countNum 是否大于 3,如果大于 3,就每轮分配 3 个
* 如果小于 3,就分配 countNum 个
*/
if (countNum > (redisCount * StockBucket.STOCK_COUNT_NUM)){
num = countNum / StockBucket.STOCK_COUNT_NUM;
} else if (countNum > 3){
num = 3;
} else {
num = countNum;
}
return num;
}
下边来讲一下库存分配中,如何选择 Redis 节点并执行 lua 脚本向 Redis 中写入库存的:
java
Object eval = cacheSupport.eval(count, RedisLua.ADD_INVENTORY, CollUtil.toList(productStockKey), CollUtil.toList(String.valueOf(countNum)));
也就是上边这一行代码,先说一下参数,count 表示循环到哪一个 Redis 节点了,通过 count % redisCount
,就可以拿到需要操作的 Redis 节点的下标,表示需要操作哪一个 Redis,就在该 Redis 中执行 lua 脚本
RedisLua.ADD_INVENTORY
表示需要执行的 lua 脚本,CollUtil.toList(productStockKey)
表示 keys 的 list 集合,CollUtil.toList(String.valueOf(countNum))
表示 args 的 list 集合,这两个参数用于在 lua 脚本中进行取参数使用
那么再说一下 eval
方法执行的流程,首先维护一个 List<JedisPool>
集合,那么在 eval 方法中根据 count
参数拿到需要操作的 Redis 节点的下标,取出该 Redis 节点所对应的 Jedis 客户端,再通过该客户端执行 lua 脚本,eval 方法如下:
java
@Override
public Object eval(Long hashKey, String script,List<String> keys, List<String> args) {
/**
* jedisManager.getJedisByHashKey(hashKey) 这个方法就是将传入的 count 也就是 hashKey 这个参数
* 对 Redis 的节点数量进行取模,拿到一个下标,去 List 集合中取出该下标对应的 Jedis 客户端
*/
try (Jedis jedis = jedisManager.getJedisByHashKey(hashKey)){
return jedis.eval(script,keys,args);
}
}
那么在这个 eval 方法中,拿到存储当前库存分片的 Redis 客户端,在该客户端中执行 lua 脚本,脚本内容如下:
java
/**
* 初始化新增库存
* 这里的 KEYS[1] 也就是传入的 productStockKey = product_stock:{skuId}
* ARGV[1] 也就是 countNum,即当前 Redis 节点需要分配的库存数量
*/
public static final String ADD_INVENTORY =
"if (redis.call('exists', KEYS[1]) == 1) then"
+ " local occStock = tonumber(redis.call('get', KEYS[1]));"
+ " if (occStock >= 0) then"
+ " return redis.call('incrBy', KEYS[1], ARGV[1]);"
+ " end;"
+ "end;"
+ " redis.call('SET', KEYS[1], ARGV[1]);"
+ " return tonumber(redis.call('get', KEYS[1]));";