业务场景
一般来说,电商平台涉及到减库存的场景为:提交订单--收银台支付,这里会有减库存时机问题,主流使用第三种方案。
- 下单减库存。即提交订单后就用商品总库存-订单库存数量。用事务控制订单生成和库存更新,不会存在超卖问题。但是这里有个问题,下单后并不一定付款,如果存在恶意刷单会影响正常交易,且事务内生成订单且更新库存,业务量大会有性能问题。
- 付款减库存。提交订单后,并不扣减库存,直到支付成功后真正扣减库存。但是这里也有个问题,成功下单的用户,到支付时没有库存可用,导致交易失败。
- 预扣库存。提交订单后,库存保留一定时间,比如10分钟,超过这个时间库存释放。付款时真正完成库存扣减。这种方案并未完全解决超卖和刷单问题
库存扣减
库存扣减准确,支持高并发,满足高可用性(这个可以从保证整条链路上不存在单点,做兜底方案考虑,这里不做重点讨论)
这里需要关注以下点
- 库存剩余数量需大于扣减数量,库存不能扣成负数
- 扣减多条sku,需要保证原子性,一条扣减失败,全部回滚
- 有库存扣减才能加库存
- 保证幂等性
实现方案
实现思路总体分为两大类,第一种是基于Mysql,另一种基于缓存实现比如Redis
1.基于缓存实现
适合于高流量,对库存准确性要求不是非常高的场景下使用。
利用redis的incrby特性扣减库存。但是需要考虑缓存丢失恢复场景。举例一个发奖场景,Redis初始库存 = 总库存数量 - 已发放奖励数量,如果使用异步发奖,需要等待MQ中发奖消息消费完毕才能重新初始化Redis库存。否则会有不一致问题。
- 使用lua实现扣减逻辑
- 分布式环境下需使用分布式锁控制只有一个服务初始化库存,且需要注意初始化时机。
- 不具备数据库事务特性(比如批量扣减,只能将扣减的sku打包在lua脚本内,lua脚本循环做扣减,虽然保证原子性但是中途扣减失败无法回滚。假如最后一条sku扣减失败,结果返回失败,但是之前sku扣减成功的无法回滚)所以需要有对账定时任务,定期保证最终一致性。
- 可以采用redis集群模式分摊数据
static { /** * * @desc 扣减库存Lua脚本 * 库存(stock)-1:表示不限库存 * 库存(stock)0:表示没有库存 * 库存(stock)大于0:表示剩余库存 * * @params 库存key * @return * -3:库存未初始化 * -2:库存不足 * -1:不限库存 * 大于等于0:剩余库存(扣减之后剩余的库存) * redis缓存的库存(value)是-1表示不限库存,直接返回1 */ StringBuilder sb = new StringBuilder(); sb.append("if (redis.call('exists', KEYS[1]) == 1) then"); sb.append(" local stock = tonumber(redis.call('get', KEYS[1]));"); sb.append(" local num = tonumber(ARGV[1]);"); sb.append(" if (stock == -1) then"); sb.append(" return -1;"); sb.append(" end;"); sb.append(" if (stock >= num) then"); sb.append(" return redis.call('incrby', KEYS[1], 0 - num);"); sb.append(" end;"); sb.append(" return -2;"); sb.append("end;"); sb.append("return -3;"); STOCK_LUA = sb.toString(); }
2.基于数据库实现
1)方案1
- 基于乐观锁保证并发扣减下数据正确性
- 基于事务特性,保证批量扣减下,部分扣减失败,全部回滚
- 库存扣减和库存流水在同一个事务内
- 库存流水需要记录业务流水号,当库存归还场景下需要携带扣减业务流水号。
- 需要幂等控制
//避免扣成负数
update inventory set leaved_amount = leaved_amount - #{count} where sku_id='123' and leaved_amount >= #{count}
2)方案2
使用这种方案可以很大程度缓解库存校验和查询库存时的性能问题,但会带来库存实时性的问题,即redis和mysql主节点一致性问题。
3)总结:
使用数据库方案简单高效,基于数据库ACID特性很容易保证不出现超卖和并发下库存准确性。
缺点:性能瓶颈在mysql主节点的写入,