你的库存接口真的幂等吗?

最近项目再次涉及到库存系统,其实以前已经写过几个库存类型的子系统了,今天简单说说库存系统的幂等性如何保障。

幂等依据

库存记录的是某个商品的剩余数量,而使用它的一般就是订单系统了。

一个订单会对某几个商品进行库存的锁定,付款后进行扣减,退款后会进行归还,这就是库存系统做的事情。

因为分布式系统中跨系统调用失败是常态,所以通常需要重试/补偿,而重试就会造成同一个订单的多次重复调用。如果重复调用导致了库存多次的扣减,那么接口就不是幂等性的。

所以,对于库存系统来说,订单ID就是一个幂等性的依据,一个订单关联的某种商品的多次库存扣减应该只生效一次。

幂等实现

库存扣减如果仅仅是让商品的数量减去N,那么肯定无法幂等,因为没有使用订单ID作为依据。

所以一般会有另外一张流水表,它的唯一键是:订单ID+商品ID,这样就无法重复插入相同的记录了。

另外一个问题是,商品数量减N和插入流水是2个表的操作,如果第一步失败第二步成功那么也是无法追溯的,所以2个表的操作必须作为原子操作,放在一个事务里。

现在如果同1个订单的2次重复扣减请求同时到达,那么肯定有1个请求的事务操作会失败,因为插入流水表因为唯一键的原因导致事务提交失败。

这样就OK了嘛?

No!并发问题没有解决。

假设2个不同订单的扣减请求同时到达,那么2个事务流程其实都是先读出现在的库存数量M,然后减去要扣减的数量N,再update回去,这就导致了”超卖”问题。

解决并发问题,需要在数据库层上悲观锁互斥,也就是锁住库存记录,所有库存请求串行执行,避免并发,整个流程大概是这样的:

  1. select stock from stock_table where product_id=xxx for update;行锁对应商品的库存,只有第一个开启事务的请求会继续向下执行,其他请求事务会挂起在该SQL等待。
  2. 接下来update stock_table set stock=stock-N where product_id=xxx更新库存。
  3. 然后插入流水insert into stock_log_table(order_id, product_id, count) values…,插入一条(订单ID,商品ID,数量)流水。
  4. 提交整个事务,如果成功则完成扣减,相关流水日志生成。

误区

接下来要说的是另外一个同事犯的错误,具体是什么样的错误呢?

在实现的库存扣减接口中,他首先select查询了stock剩余量,发现不足直接返回调用者扣减失败的应答。

OK,这个看似没有什么问题,但是假设在应答后有人退款归还了一些库存,那么相同订单如果再次来调用扣减库存,又可以扣减成功,这就出问题了。

这直接违反了幂等性规则,第一次扣减失败,第二次扣减成功,同一个订单不同的结果,这不是很严重的BUG吗?

对,关键在于第一次扣减失败的结论没有落地为一条流水日志,直接把一个查询判断结论返回给调用者,导致重试调用时出现了不同的结果。

修复这个BUG很简单,在stock_log_table流水表中增加一个status状态字段,即便是库存不足也生成一条status=”扣减失败”的流水进去,这样再次请求可以幂等返回扣减失败。

这样的问题在于,即便库存已经售罄,只要交易中心生成了订单,其交易流程状态机一定会驱动调用一次库存扣减,相应的就会多一条扣减失败的流水日志,造成了不必要的存储(如前所述,不存储无法提供幂等性保障)。

这一点其实可以在交易中心避免,交易中心在生成订单前先查询剩余库存,如果库存不足则不生成订单,从而避免了订单生成一定要扣减库存的资源浪费。

后续:在留言区讨论中,网友提出可以在事务中采用update stock=stock-N where stock>=N的乐观锁方式取代select for update的行锁模式。

经过讨论和反思,在事务中update库存记录本身会产生行锁直到事务提交,本身等价于select for update悲观锁。

但鉴于库存有预扣,实扣,归还几种流程,部分同时涉及到对库存表,流水表的update操作,这其实涉及到2个表的行锁,若在开发逻辑时不注重update顺序,就会出现死锁,避免死锁的方法仍旧是保持一贯的先update库存表,因此从编码复杂度来说还是select for update的先手操作更加简单一致。

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~