您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
LUA脚本实现控单规则
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
LUA脚本实现控单规则
自猿其说Tech
2022-09-30
IP归属:未知
21200浏览
### 1 需求背景 物流销售策略是淡季放低折扣吸引客户签约,在大促月份也就是旺季调整折扣政策,增加收入业绩,有一些客户本身签的就是淡旺季价格的合同,可以实现价格加收或放价,但很多客户签的合同就是固定折扣,且签约的时候是低折扣,所以基于希望旺季增加收入的需求,我们对低折扣的客户在旺季会有加收策略,但如果不触及客户利益,客户是不会同意无缘无故的加收,所以限量和加收是给到客户的选择题,不加收即限量。 此外,旺季期间,终端业务单元的货物吞吐量承压,时常出现爆仓情况,因此有限的资源需要更多的服务优质客户。 ### 2 实现方案 鉴于此场景与扣减库存场景类似,即用户每下一单,对于库存数量进行减一处理,超出限制后库存不足,则限制下单,所以此处采用类似的方案,用户下单时,对于下单额度进行减一,直到减到0不能下单为止,以下为控单流程图。 ![](//img1.jcloudcs.com/developer.jdcloud.com/479db03f-fb35-47b2-9f0d-c0c73ae88b5120220930094917.jpg) ### 3 业务流程 #### 3.1 控单逻辑 ##### 3.1.1 定义lua脚本 我们先定义一下扣减库存的lua脚本 ```java public static final String DEDUCT_STOCK_LUA = "if (redis.call('exists', KEYS[1]) == 1) then" + " local stock = tonumber(redis.call('get', KEYS[1]));" + " local num = tonumber(ARGV[1]);" + " if (stock == -1) then" + " return -1;" + " end;" + " if (stock >= num) then" + " return redis.call('incrby', KEYS[1], 0 - num);" + " end;" + " return -2;" + " end;" + " return -3;"; ``` 通过分析以上lua脚本,可以看出脚本的执行流程。 - 第一步:判断了一下KEYS[1]是否存在,不存在则返回-3,-3标识库存未初始化; - 第二步:KEYS[1]值存在,则获取到KEYS[1]的值stock,即库存数量,在获取到ARGV[1],将其转化为数字; - 第三步:判断一下stock库存数量是否是-1,-1表示不限制库存数量; - 第四步:如果库存数量不为-1,那么就判断下stock库存数量是否是大于等于要扣减的库存数,如果大于等于,表示库存是充足的可以进行扣减操作,然后返回扣减后当前库存数量; - 第五步:如果小于0,则库存不足,不能扣减,此时返回-2,表示库存不足。 在lua脚本中,包含了KEYS[1]与ARGV[1],分别代表两个入参,一个是操作的key,一个是操作的变量值(库存扣减数量)。 ##### 3.1.2 调用lua脚本 脚本定义好后,我们就可以定义扣减库存的方法,用来执行lua脚本,以下是调用lua脚本的方法。 ```java private Long deductStock(String key, int num) { // 脚本里的KEYS参数 List<String> keys = new ArrayList<String>(); keys.add(key); // 脚本里的ARGV参数 List<String> args = new ArrayList<String>(); args.add(Integer.toString(num)); // 将sha保存到Redis中缓存起来 String sha = jimClient.get(LuaScriptEnum.DEDUCT_STOCK_LUA.getKey()); if (StringUtils.isEmpty(sha)) { sha = jimClient.scriptLoad(LuaScriptEnum.DEDUCT_STOCK_LUA.getScript()); jimClient.set(LuaScriptEnum.DEDUCT_STOCK_LUA.getKey(), sha); jimClient.expire(LuaScriptEnum.DEDUCT_STOCK_LUA.getKey(), 7, TimeUnit.DAYS); } Object result = jimClient.evalsha(sha, keys, args, false); if (result != null) { return Long.valueOf(String.valueOf(result)); } log.error("扣减库存返回值为空"); return 0L; } ``` scriptLoad方法会将lua脚本上传到Redis集群所有实例中,并返回sha。 ```java String sha = jimClient.scriptLoad(LuaScriptEnum.DEDUCT_STOCK_LUA.getScript()); ``` evalsha方法是执行上传到Redis节点上的lua脚本。 第一个参数是lua脚本对应的sha值。第二个和第三个参数keys与args分别代表脚本中的参数对应的数据。最后一个参数是指是否只读,true表示只读,false表示有写操作。 ```java Object result = jimClient.evalsha(sha, keys, args, false); ``` 执行完lua脚本后会返回扣减库存后剩余库存的值,将此值作为结果返回。 此处将scriptLoad的返回值sha缓存到了Redis中,正常情况下scriptLoad一次就可以,此处将sha缓存到Redis中保留一周的时间,一周后重新scriptLoad一次并重新放到缓存中。 scriptLoad是一个耗时操作并且每次结果都一样,没有必要每次都进行scriptLoad,当然如果Redis进行扩容后,那么新的节点是不会有lua脚本的,此时调用evalsha方法有可能报错,所以最好是在报错后重新scriptLoad一下上传lua脚本,也可以每次执行脚本前都进行scriptLoad,只不过会稍微耗时一点。 ##### 3.1.3 扣减库存操作 ```java public long stockProcess(String key, int num, long initStockNum) { // 扣减库存 long stock = deductStock(key, num); // 库存未初始化的时候需要加锁初始化 if (stock == UNINITIALIZED_STOCK) { // 获取锁初始化库存 String lockKey = String.format("%s_%s", key, "lock"); String lockValue = String.valueOf(System.currentTimeMillis()); Boolean lock = jimClient.set(lockKey, lockValue, 2000L, TimeUnit.MILLISECONDS, false); try { if (lock) { // 双重检查,避免并发问题 stock = deductStock(key, num); if (stock == UNINITIALIZED_STOCK) { // 将库存设置到redis long expireTime = DateFormatUtil.getExpireTime(); jimClient.set(key, String.valueOf(initStockNum), expireTime, TimeUnit.MILLISECONDS, false); // 调一次扣库存的操作 stock = deductStock(key, num); } } else { //为获取到锁,则重新再获取一次库存 TimeUnit.MILLISECONDS.sleep(50); stock = deductStock(key, num); } } catch (Exception e) { log.error("库存操作失败:{}", e.getMessage(), e); } finally { // 删除自己的锁 String value = jimClient.get(lockKey); if (lockValue.equals(value)) { jimClient.del(lockKey); } } } return stock; } ``` 此代码是扣减库存的操作。 - 第一步,根据key值进行扣减库存操作,如果返回值为-3,表示库存还未初始化,需要进行初始化库存操作。 - 第二步,获取分布式锁,用来在并发场景下初始化库存,保证只有一个线程来初始化库存,防止重复创建。 - 第三步,如果获取到分布式锁,则在进行一次扣减操作,双重检查锁,防止获取到锁时,其他线程已经初始化完成了。 - 第四步,如果库存依然未初始化,则由本线程初始化一下库存数据,并设置超时时间,因为控单量的库存数量是当天有效,所以此处设置库存有效时间为当天的剩余时间。 - 第五步,在finally中获取分布式锁的value值,判断是否是自己的分布式锁,如果是,则删除自己的分布式锁,防止误删。 ##### 3.1.4 业务判断处理 业务代码中需要判断当前商家是否有生效中的控单规则,如果有控单规则,则需要调用扣减库存的操作,用来判断是否可以进行下单,如果扣减库存后返回值大于等于0,则表示库存充足可以下单,那么用户能够正常下单,否则禁止下单。 ```java // 进行扣减库存操作,每次减一,result为扣减库存后的剩余库存,未初始化库存默认允许下单 limitOrderRuleDto.setNeedRecord(Boolean.TRUE); long result = stockServiceHelperStatic.stockProcess(key, 1, orderQuantityLimit); if (result == StockServiceHelper.UNINITIALIZED_STOCK) { // 有控单规则,但未初始化缓存值,默认允许下单 log.warn("控单规则未初始化默认允许下单!"); limitOrderRuleDto.setLimit(Boolean.FALSE); limitOrderRuleDto.setMessage("控单规则未初始化默认允许下单!"); } else if (result == StockServiceHelper.UNLIMITED_STOCK) { // 不限库存 String message = String.format("不限库存:%s", result); log.info(message); limitOrderRuleDto.setLimit(Boolean.FALSE); limitOrderRuleDto.setMessage(message); } else if (result >= 0) { // 数据库下单量加一 LimitOrderDetail limitOrderDetail = list.get(0); limitOrderDetailManagerStatic.incrbyPrimaryKey(limitOrderDetail); // 剩余库存大于0,则可以下单 String message = String.format("可用单量剩余:%s", result); log.info(message); limitOrderRuleDto.setLimit(Boolean.FALSE); limitOrderRuleDto.setMessage(message); } else { // 库存小于0则禁止下单 String message = "可用单量不足禁止下单"; log.warn(message); limitOrderRuleDto.setLimit(Boolean.TRUE); limitOrderRuleDto.setMessage(message); } ``` #### 3.2 更新库存 更新库存目前存在三种场景。 - 第一种:用户库存充足情况下下单,在扣减库存后进入下单逻辑,但是下单失败,则需要回滚库存数量。 - 第二种:用户当天下完单后,又取消了该订单,则回滚库存数量。 - 第三种:销售修改了控单限制数量,有可能增加,也有可能减少。 ##### 3.2.1 定义LUA脚本 ```java public static final String UPDATE_STOCK_LUA = "if (redis.call('exists', KEYS[1]) == 1) then" + " local stock = tonumber(redis.call('get', KEYS[1]));" + " local num = tonumber(ARGV[1]);" + " if (num > 0) then" + " return redis.call('incrby', KEYS[1], num);" + " end;" + " if (num < 0 and stock >= -num) then" + " return redis.call('incrby', KEYS[1], num);" + " end;" + " if (num < 0 and stock < -num) then" + " return redis.call('incrby', KEYS[1], 0 - stock);" + " end;" + " return -2;" + " end;" + " return -3;"; ``` lua脚本中判断和的关系,是通过and关键字,而不是&&,所以此处需要注意。 lua脚本中ARGV[1]参数可以为正也可以为负,表示是增加库存还是减少库存,当是减少库存时,判断库存是否足够,如果库存足够,则直接减少即可;如果库存不足,则将现有库存直接置为0,即减去当前所有库存;这样库存数量就剩余0,禁止用户下单。 ##### 3.2.2 调用LUA脚本 ```java private Long updateStock(String key, int num) { // 脚本里的KEYS参数 List<String> keys = new ArrayList<String>(); keys.add(key); // 脚本里的ARGV参数 List<String> args = new ArrayList<String>(); args.add(Integer.toString(num)); // 将sha保存到Redis中缓存起来 String sha = jimClient.get(LuaScriptEnum.UPDATE_STOCK_LUA.getKey()); if (StringUtils.isEmpty(sha)) { sha = jimClient.scriptLoad(LuaScriptEnum.UPDATE_STOCK_LUA.getScript()); jimClient.set(LuaScriptEnum.UPDATE_STOCK_LUA.getKey(), sha); jimClient.expire(LuaScriptEnum.UPDATE_STOCK_LUA.getKey(), 7, TimeUnit.DAYS); } Object result = jimClient.evalsha(sha, keys, args, false); if (result != null) { return Long.valueOf(String.valueOf(result)); } log.error("修改库存返回值为空"); return 0L; } ``` 此处需要注意将sha缓存起来,避免每次都进行scriptLoad操作。 ##### 3.2.3 修改库存逻辑 ```java public long updateStock(String key, int num) { if (num == 0) { log.warn("num为0不做任何操作"); return 0L; } boolean hasKey = jimClient.exists(key); // 判断key是否存在,存在就直接更新 if (hasKey) { if (num > 0) { log.info("添加库存时库存缓存对象存在,直接添加"); return jimClient.incrBy(key, num); } else { log.info("扣减库存时库存缓存对象存在,LUA脚本扣减"); return this.updateStock(key, num); } } else { log.warn("更新库存时但是key值不存在,无需操作"); } return num; } ``` lua脚本中是可以增加库存也可以减少库存的,但是在更新库存的代码中,只减少库存时调用了lua脚本执行,将查询库存和扣减库存一起执行,保证原子性,避免查询库存时是足够扣减的,当真正扣减操作时将库存扣减成负数;添加库存时,直接执行incrBy方法添加即可。 #### 3.3 缓存sha值 虽然可以每次执行lua脚本文件前,都可以调用一下scriptLoad方法,但由于scriptLoad是一个耗时操作,所以并不建议这样操作,而是在执行一次后,将sha值进行保存,这样就每次直接使用sha值就可以。 ```java // 将sha保存到Redis中缓存起来 String sha = jimClient.get(LuaScriptEnum.UPDATE_STOCK_LUA.getKey()); if (StringUtils.isEmpty(sha)) { sha = jimClient.scriptLoad(LuaScriptEnum.UPDATE_STOCK_LUA.getScript()); jimClient.set(LuaScriptEnum.UPDATE_STOCK_LUA.getKey(), sha); jimClient.expire(LuaScriptEnum.UPDATE_STOCK_LUA.getKey(), 7, TimeUnit.DAYS); } ``` 这里是将sha保存到了Redis缓存中,并设置成7天缓存。7天后sha过期重新上传即可,此处过期时间可以自己根据业务进行设置,不建议设置永久缓存。 为了防止修改了lua脚本内容后,但是Redis中的sha值没有改变,所以这里做了一个配置,每次系统启动,都将最新的lua脚本scriptLoad到Redis上,并将sha值进行缓存。 ```java @Slf4j @Configuration @DependsOn(value = {"jimClient"}) public class LuaScriptConfig { @Resource private Cluster jimClient; @PostConstruct public void init() { LuaScriptEnum[] values = LuaScriptEnum.values(); for (LuaScriptEnum luaScriptEnum : values) { String key = luaScriptEnum.getKey(); String script = luaScriptEnum.getScript(); String desc = luaScriptEnum.getDesc(); String sha = jimClient.scriptLoad(script); jimClient.set(key, sha); jimClient.expire(key, 7, TimeUnit.DAYS); log.info("加载{}脚本保存到Redis缓存,key值为:{},sha值为:{}", desc, key, sha); } } } ``` ### 4 总结 #### 4.1 扣减库存与更新库存的区别 扣减库存与更新库存都有减少库存的操作,是否可以公用一个lua脚本呢,这里有两个区别: 扣减库存的时候如果库存数量不足,是要返回-2,表示库存不足禁止下单的语义,而更新库存减少数量时,如果库存数量不足,则需要直接将库存数量置为0的,后续禁止下单。 扣减库存只需要减少数量,更新库存还有新增数量的情况,所以两个lua脚本不一样。 尽量做到一个脚本执行有一个逻辑,避免交叉,否则容易在修改脚本时对其他逻辑产生影响。 #### 4.2 缓存sha值 lua脚本在执行时需要调用scriptLoad方法,将脚本上传到Redis节点上,没有必要每次都上传,但是Redis在扩容后,新扩容的节点不包含lua脚本,所以调用evalsha的时候有可能报错,此时最好在程序中进行异常处理,捕获异常后再重新scriptLoad一下脚本,将脚本重新上传一下即可。 每次执行lua脚本时都执行一次scriptLoad方法也是可以的,只不过会比较耗时,这样不会出现脚本不存在的问题,但是并不建议这样操作。 ------------ ###### 自猿其说Tech-JDL京东物流技术与数据智能部 ###### 作者:丁冬
原创文章,需联系作者,授权转载
上一篇:SQL抽象语法树及改写场景应用
下一篇:Pipeline流水线校验批量下单
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
本文将从Optional所解决的问题开始,逐层解剖,由浅入深,文中会出现Optioanl方法之间的对比,实践,误用情况分析,优缺点等。与大家一起,对这项Java8中的新特性,进行理解和深入。
01
Taro小程序跨端开发入门实战
为了让小程序开发更简单,更高效,我们采用 Taro 作为首选框架,我们将使用 Taro 的实践经验整理了出来,主要内容围绕着什么是 Taro,为什么用 Taro,以及 Taro 如何使用(正确使用的姿势),还有 Taro 背后的一些设计思想来进行展开,让大家能够对 Taro 有个完整的认识。
01
Flutter For Web实践
Flutter For Web 已经发布一年多时间,它的发布意味着我们可以真正地使用一套代码、一套资源部署整个大前端系统(包括:iOS、Android、Web)。渠道研发组经过一段时间的探索,使用Flutter For Web技术开发了移动端可视化编程平台—Flutter乐高,在这里希望和大家分享下使用Flutter For Web实践过程和踩坑实践
01
配运基础数据缓存瘦身实践
在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
01
Taro小程序跨端开发入门实战
01
Flutter For Web实践
01
配运基础数据缓存瘦身实践
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号