引言:在库存的变动中,最关键的节点是库存的扣减,在什么时候扣减库存非常重要。目前通用的库存扣减方案有以下几种
支付后扣减库存,缺点:成功下单的用户,到支付时没有库存可用,导致交易失败。
下单时就减库存,订单取消再把库存加回来,缺点:恶意刷单不支付导致大量库存被占用,影响商品售卖。
下单时先预减库存(对应数据库 占用库存加库存操作),支付完成时 释放占用库存(减操作),扣减可用库存。 同时商品在下单时判断商品的实际可售库存 = 可用库存 - 占用库存,如果 > 0,表示可以下订单,这样就不会导致 下单成功,但是支付时没有库存导致失败的场景。
具体方案
- 订单创建时,通过调用商品接口预占库存,如果预占库存成功,则创建订单,如果预占库存失败,则提示商品库存不足。这个接口必须是同步实时的,因为订单要根据预占库存的结果来判断订单能否创建。
- 订单支付、取消时,发送mq消息到商品,商品异步消费消息,根据消息的类别去操作库存。如果是未支付取消订单,则释放预占用库存。如果是支付后退款,则需要将可用库存加回来。如果是成功支付,则释放预占库存,并同时扣减可用库存。
需要注意的问题
-
商品超卖问题
:
正常情况下,一个订单过来,其中A商品买了n个,那么我们操作数据库的时候,直接 set stock = stock -n 。这种当然是有问题的,有可能会超卖导致商品库存为负数。当然我们可以在更新db之前,判断库存数是否 > n,如果大于n,再去扣减库存。这总当然可以,不过高并发下,可能依然会导致超卖。当然你可以加锁去保证单线程,不过这样就导致了接口的性能下降。其实sql 可以换个写法 set stock = stock -n where stock >= n。 这样利用了数据库的天然写法保证了商品不超卖。 -
高并发下的接口性能问题
步骤一订单创建实时调用商品占用库存接口,因为是实时调用,如果是高并发情况下,对于db占用库存的更新操作可能就会成为性能瓶颈(订单支付或者取消时,因为走了消息队列异步更新数据库,就不存在性能问题)。
如果解决这个问题呢?业界常用的做法是,将商品的可用库存放到redis中,当订单创建调用占用库存接口时,我们可以利用redis去抗并发,并且redis的命令支持原子性。
1.创建订单扣减缓存中的可用库存
缓存中更新库存和我们去更新数据库时遇到的场景一样,因为要判断库存是否大于下单购买数的逻辑要保持原子性,同时一个订单中需要判断多个商品的库存也是需要原子性,可以结合lua脚本来实现。
首先根据订单明细id查询扣减流水,是否已经操作过,做幂等性校验
然后查询sku的剩余库存,并根据下单购买数做校验,只要有一个sku 数量不足,则返回失败
修改缓存中的剩余库存数
缓存中插入扣减流水记录
2.支付成功后消息队列异步更新数据库中的可用库存
3.订单未支付取消时则需要将缓存中的库存加回来。根据订单明细id查询扣减流水,有扣减流水,则继续查询出订单中所有sku商品的库存,将扣减的库存再加回来。如果没有扣减流水,则跳过不处理。
4、订单已支付退款时,需要同时更新缓存中的库存和db中的库存。
缺点:上面我们说的用lua脚本执行命令,如果一个订单中的多个商品,,一部分成功,一部分扣减库存失败,那么是无法进行回滚操作的,虽然这种可能性很小,所以这种方案我们只能尽量保证redis集群的高可用。以上方案解决了高并发下的接口性能瓶颈,但是因为其复杂性有可能会导致redis中可用库存和数据库中的可用库存不一致(这里说的不一致是指没有未支付订单占用库存的情况),我们可能还需要定时任务去定时维护redis中可用库存和数据库中可用库存的一致性,用数据库中的库存 - 未支付订单的占用库存,然后更新到redis中
总结:以上基于缓存扣减库存,大部分情况是对一些活动的秒杀商品可能才会有如此高的并发,正常情况下也不可能将所用商户的所有商品库存都缓存到redis中,这样也不现实。所以绝大部分流量不高的情况下,我们可以采用数据库占用库存的方式,这种方式简单高效,不易出错。对于一些活动商品,我们则可以单独走缓存扣减可用库存的方式。