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

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

幂等依据

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

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

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

所以,对于库存系统来说,订单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的先手操作更加简单一致。

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

你的库存接口真的幂等吗?》有8个想法

  1. 匿名

    在前公司做库存时,负责开发的同事在事务里采用了update stock=stock-N where stock>=N的乐观锁去做扣减
    可以根据返回影响行数来判断是否减成功的

    回复
    1. 匿名

      mysql> select * from new_table;
      +—-+—-+—-+
      | id | c1 | c2 |
      +—-+—-+—-+
      | 2 | 4 | 9 |
      | 1 | 6 | 8 |
      +—-+—-+—-+
      2 rows in set (0.00 sec)

      mysql> begin;
      Query OK, 0 rows affected (0.00 sec)

      mysql> update new_table set c1=5 where id=1 and c1>1;
      Query OK, 1 row affected (0.01 sec)
      Rows matched: 1 Changed: 1 Warnings: 0

      mysql> commit;
      Query OK, 0 rows affected (0.01 sec)

      mysql> select * from new_table;
      +—-+—-+—-+
      | id | c1 | c2 |
      +—-+—-+—-+
      | 2 | 4 | 9 |
      | 1 | 5 | 8 |
      +—-+—-+—-+
      2 rows in set (0.00 sec)

      试了下,有返回affected row

      回复
      1. yuer 文章作者

        仔细看描述,假设你要在事务里这样:

        begin;

        update stock set count=count-1 where count>=1;

        insert log …

        commit;

        你会发现无论count是几,事务都是可以commit的。

        回复
  2. yuer 文章作者

    我想了一下,update本身会行锁记录直到事务提交,在事务里update就是一个悲观锁,不能称为乐观锁,的确可以得到affected rows。

    我还是认为用select锁库存记录作为互斥锁是有效简化预扣,实扣,回滚库存的方法,否则很容易因为库存锁和流水锁两个锁的顺序造成死锁,写代码时候需要注意。另外,在流程入口select行锁可以拿到库存数字,这比update后再select在逻辑上还是直白的多,性能其实没差。

    我稍后纠正一下本文中的错误说法,欢迎继续讨论。

    回复
  3. mlboy

    您好,有个问题咨询下。
    mysql update 本身不就是带有更新级别行锁的吗?为什么之前要先select一下,如果没有用到那是不是就不需要select for update 这个手动触发锁的操作了。
    也就是说直接多条
    update stock_table set stock=stock-N where product_id=xxx
    因为本身update就是行锁的

    除非需要 防止出现select 脏读 出现 ,比如说要更新一个stock_table_bill 的流水表中需要记录【当时的库存】否则,直接一条update stock_table set stock=stock-N where product_id=xxx 即可?

    这么理解对吗?

    回复
    1. yuer

      1,如果你不在乎当前剩余多少库存,只想扣减库存,那么update库存,插入流水记录,这样就OK了。主要原因是,update是会锁行的,其他update就会阻塞,你拿到的更新结果是准确的。

      2,如果流水记录需要修改(比如库存回滚,或者从预扣改为实扣),那么同时会涉及到对库存的修改以及对流水状态的修改。假设你不select库存行锁,那么就必须特别注意不要死锁:

      假设是库存回滚操作,你先更新流水,再更新库存。而另一方希望从预扣改为实扣,那么它也要遵循先更新流水,再更新库存,而不是先库存后流水,否则两方会死锁。

      3,通常应该select for update,因为可以准确拿到当前库存状态,将一些快照状态记录到流水表中,另外也是统一所有库存操作的逻辑流程,均在库存锁的保护下进行,也就没有什么死锁的顾虑了。

      回复

发表评论

您的电子邮箱地址不会被公开。