商品库存超售、少售问题

43 阅读9分钟

1. 背景介绍

如图1-1所示,当多个用户同时购买同一商品时,极易引发超售、少售问题。 举例分析  ”北冰洋“汽水库存仅剩2罐,此时用户A、B、C同时购买该款”北冰洋“汽水,其中A用户买2罐,B、C用户各买1罐。图1-1中①下单前查询库存校验都通过,但C用户优先减库存成功,库存剩1罐;然后A用户减库存成功,库存为-1罐;最后用户B用户下单减库存成功,库存为-2罐,出现超售;但由于商家货品不足,取消A、B用户订单后,库存恢复为1罐,出现少售1罐(本应B下单成功,A下单失败)。

编辑

2. 问题分析

结合上图1-1对下单减库存现状分析,发现存在以下三个问题:

  • 链路层问题— 如图1-1中②所示,下单成功后异步减库存,且订单未感知库存是否扣减成功。
  • 服务层问题— 如图1-1中③所示,在出现网络超时或出现重复调用等异常情况时,库存服务自身不能做到对库存精准增/减控制。
  • 存储层问题— 缓存允许库存减为负数,更加剧了超售现象发生。

其中,存储层问题可以通过更换存储 + 乐观锁来解决;

链路层问题可以改下单后异步减库存为下单前同步减库存来解决;

那么,服务层问题—库存服务出现网络超时或出现重复调用等异常情况时,尚不能做到对商品库存精准增/减控制,需要怎么解决呢? 结合以上的问题和用户下单减库存流程进行分析,当用户点击提交订单按钮后,订单服务同步调商品库存服务进行减库存,可能会出现以下的情况:

① 明确下单成功场景: 订单服务→库存服务(成功) & 库存服务→数据库(扣减库存成功)

② 明确下单失败—库存不足场景: 订单服务→库存服务(成功) & 库存服务→数据库(库存不足)

③ 超时异常未知场景:

a. 订单服务→库存服务(超时异常) & 库存服务→数据库(成功,订单服务感知不到库存扣减是否成功) b. 订单服务→库存服务(超时异常) & 库存服务→数据库(失败,订单服务感知不到库存扣减是否成功) c. 订单服务→库存服务(超时异常) & 库存服务→数据库(超时,订单服务&库存服务均感知不到库存扣减是否成功)

④ 超时异常时,如订单盲目重试—重复调用(减/恢复)库存接口场景: 订单服务→库存服务(成功) & 库存服务→数据库(库存不足)

其中,对于第①、②属于正常情况,订单服务能明确感知减库存成功或失败,进而决定是否应该下单成功。对于第③种情况,从订单服务到商品库存服务、商品库存服务到数据库,任一环节发生超时异常,很难判断商品库存是否扣减成功。此时,如果上游盲目重试(如第④种情况),则极易导致同一订单重复扣减商品库存。

为应对第③种异常未知情况,需保证订单与库存的一致性;为应对第④种情况需保证同一订单增/减库存接口的幂等性。

3. 方案调研

通过调研电商行业如:淘宝、京东、以及其他电商平台,发现在解决订单与库存一致性问题增/减库存接口幂等性问题的做法各不相同。也有不少电商平台并未做到商品防超售,根据业务自身对商品超售的可接受程度,在技术实现上做了或多或少的妥协。具体实现方案有以下几种:

序号方案优点缺点
基于MySQL存储利用MySQL行级锁防止减为负数如:update tableName set stock = stock -decrStockwhereskui​d={skuId} and stock >= ${decrStock}实现简单无法解决同一订单重复扣减/恢复库存问题,存在超售可能。
基于Redis存储利用Setnx保证增减库存幂等性(如:Key为订单ID+商品ID),防止同一订单多次增减库存利用Redis事务机制实现setnx与增减库存原子操作设计简单性能较高不易解决同一订单&同一SKU部分退货问题
基于AliSQL存储AliSQL内部采用请求排队、事务批量提交与MySQL相比性能较高AliSQL的使用和运维门槛较高无法解决同一订单重复扣减库存问题
基于MySQL存储利用MySQL行级锁防止减为负数(如:update tableName set stock = stock -decrStockwhereskui​d={skuId} and stock >= ${decrStock})同步记录库存变更流水,利用流水表唯一键(订单ID+商品ID)保证增减库存幂等性利用多表事务保证库存变更和记流水是原子操作如遇订单与库存服务超时异常,则重试可保证增/减库存接口幂等性可保证订单与库存数据一致性需同步记录库存变更流水,影响减库存性能需要多表事务或分布式事务,性能不佳

4. 方案选型和概述

对比以上四种方案,为做到精准&高效操作库存,结合自身业务需具备同一订单的同一商品部分退货的能力,最终选择在方案4基础上做以下优化:

  • 先同步减库存,后异步记录库存变更流水,保证减库存仅一次同步的MySQL操作。
  • 利用流水表唯一键(订单ID + 商品ID + 退货序号),保证同一订单减库存、同一订单同一商品部分退货的幂等性。
  • 通过对库存变更流水校验/回滚机制保证库存表与流水表的最终一致性,避免使用数据库事务。
  • 与订单系统约定异常处理规则,如:订单遇减库存超时,则下单失败,且需异步调恢复库存。

经以上几点优化,可解决订单与库存一致性问题和增/减库存接口幂等性问题,即上述第2段“问题分析”中的”服务层问题—库存服务出现网络超时或重复调用等异常情况时,尚不能做到对商品库存精准增/减控制“得以解决。

5. 解决之道

第一步:存储层—更换库存存储

利用MySQL行级锁防止库存减为负数。

update tableName set stock = stock -${decrStock} where sku_id = ${skuId} and stock >= ${decrStock}

第二步:服务层—库存服务具备对商品库存精准增/减控制能力(防超售能力)

用户提交订单作为电商平台最重要的环节,保证高成功率且高效下单,成为下单流程的终极目标。其中,下单减库存环节势必要做到尽可能高效的同时,还需要解决减库存网络超时或重复调用等异常问题,保证下单减库存的数据一致性和接口幂等性,具体方案如下图5-1左图所示;当用户取消订单时,因业务场景对耗时要求相对较低,采用异步恢复库存,且在开始和结束位置,引入order_id + sku_id + seq维度分布式锁,降低恢复库存时的并发,减轻校验流水环节对系统造成的压力。其中,未获取到锁的请求被放入延迟队列,后续重试,如下图5-1右图所示。

编辑

 下单减库存—正常处理流程,如图5-1左图:

① 减库存: 利用MySQL行级锁,防止并发情况下库存被减为负数(如:update tableName set stock = stock -decrStockwherestock>={decrStock})。

② 记流水: MySQL执行减库存成功后,异步通过DataBus记录库存变更流水至流水表。

下单减库存—异常流程,如图5-1左图:

① 同一订单重复扣减: 记流水时,势必在唯一键(order_id + sku_id + seq)上冲突,需根据回滚策略回滚重复扣减的库存。

② 库存服务→DB超时: 订单下单失败;因不确定DB是否执行成功(即是否有Binlog产生),另需库存服务发MQ记录减库存流水;如流水唯一键冲突,则根据回滚策略回滚库存。

③ 订单→库存超时 : 订单下单失败,且需异步调用恢复库存(详见恢复库存方案)。

④ DataBus不可用: 以MQ作为Databus灾备,在Databus大面积不可用时,可切换至MQ记录流水(公司级Databus集群自身保障其高可用,通常不会大面积挂掉)。

⑤ MQ不可用: 采用Mafka和RabbitMQ双通道,如与Mafka大面积挂掉,可切换至RabbitMQ,保证可用性(公司级分布式消息系统已具备多地域&多机房容灾能力,自身保障其高可用,通常不会大面积挂掉)。

消单恢复库存—异常处理流程, 如图5-1右图:

① 校验流水: 校验与恢复库存请求相同的order_id + sku_id + seq 的库存变更流水,校验详细逻辑如下:

  • 存在流水,且[ sum(流水库存数) + 恢复库存数 ] <= 0,则恢复库存;如[ sum(流水库存数) + 恢复库存数 ] > 0,则不恢复。
  • 不存在流水,因存在Databus延迟的可能,所以先将该恢复库存请求放入延迟队列,等待后续继续校验。后续校验时通过获取当前商品所在Databus分片消费的最新Binlog时间戳来判断是否处于Databus延迟状态,如未延迟则视为不存在对应的减库存流水,为非法恢复库存请求;如是Databus延迟,则再次放入延时队列,后续继续校验,直到Databus恢复正常。

② 恢复库存: 更新库存DB,进行恢复库存。

③ 记流水: MySQL执行恢复库存成功后,异步通过Databus记录库存变更流水至流水表。

消单恢复库存—异常处理流程, 如图5-1右图:

① 同一订单重复恢复库存: 流水校验时,可识别出非法恢复库存请求。

② 库存服务→DB超时: 将恢复库存请求放入延迟度队列,等待后续重试。

综上,该方案通过流水表唯一键、严格的回滚策略、与订单系统约定异常处理规则的方式保证了库存增/减库存的幂等性和数据一致性,可解决了开篇提到的第二个问题 ”服务层问题—库存服务出现网络超时或重复调用等异常情况时,尚不能做到对商品库存精准增/减控制“ 。且减库存全流程无锁、无事务,可保证下单高效减库存。