最近项目再次涉及到库存系统,其实以前已经写过几个库存类型的子系统了,今天简单说说库存系统的幂等性如何保障。
幂等依据
库存记录的是某个商品的剩余数量,而使用它的一般就是订单系统了。
一个订单会对某几个商品进行库存的锁定,付款后进行扣减,退款后会进行归还,这就是库存系统做的事情。
因为分布式系统中跨系统调用失败是常态,所以通常需要重试/补偿,而重试就会造成同一个订单的多次重复调用。如果重复调用导致了库存多次的扣减,那么接口就不是幂等性的。
所以,对于库存系统来说,订单ID就是一个幂等性的依据,一个订单关联的某种商品的多次库存扣减应该只生效一次。
幂等实现
库存扣减如果仅仅是让商品的数量减去N,那么肯定无法幂等,因为没有使用订单ID作为依据。
所以一般会有另外一张流水表,它的唯一键是:订单ID+商品ID,这样就无法重复插入相同的记录了。
另外一个问题是,商品数量减N和插入流水是2个表的操作,如果第一步失败第二步成功那么也是无法追溯的,所以2个表的操作必须作为原子操作,放在一个事务里。
现在如果同1个订单的2次重复扣减请求同时到达,那么肯定有1个请求的事务操作会失败,因为插入流水表因为唯一键的原因导致事务提交失败。
这样就OK了嘛?
No!并发问题没有解决。
假设2个不同订单的扣减请求同时到达,那么2个事务流程其实都是先读出现在的库存数量M,然后减去要扣减的数量N,再update回去,这就导致了”超卖”问题。
解决并发问题,需要在数据库层上悲观锁互斥,也就是锁住库存记录,所有库存请求串行执行,避免并发,整个流程大概是这样的:
- select stock from stock_table where product_id=xxx for update;行锁对应商品的库存,只有第一个开启事务的请求会继续向下执行,其他请求事务会挂起在该SQL等待。
- 接下来update stock_table set stock=stock-N where product_id=xxx更新库存。
- 然后插入流水insert into stock_log_table(order_id, product_id, count) values…,插入一条(订单ID,商品ID,数量)流水。
- 提交整个事务,如果成功则完成扣减,相关流水日志生成。
误区
接下来要说的是另外一个同事犯的错误,具体是什么样的错误呢?
在实现的库存扣减接口中,他首先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的先手操作更加简单一致。
如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~

在前公司做库存时,负责开发的同事在事务里采用了update stock=stock-N where stock>=N的乐观锁去做扣减
可以根据返回影响行数来判断是否减成功的
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
仔细看描述,假设你要在事务里这样:
begin;
update stock set count=count-1 where count>=1;
insert log …
commit;
你会发现无论count是几,事务都是可以commit的。
begin;
update stock set count=count-1 where count>=1;
if(affected_row>0) insert log …
commit;
这样就没问题了
我想了一下,update本身会行锁记录直到事务提交,在事务里update就是一个悲观锁,不能称为乐观锁,的确可以得到affected rows。
我还是认为用select锁库存记录作为互斥锁是有效简化预扣,实扣,回滚库存的方法,否则很容易因为库存锁和流水锁两个锁的顺序造成死锁,写代码时候需要注意。另外,在流程入口select行锁可以拿到库存数字,这比update后再select在逻辑上还是直白的多,性能其实没差。
我稍后纠正一下本文中的错误说法,欢迎继续讨论。
您好,有个问题咨询下。
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,如果你不在乎当前剩余多少库存,只想扣减库存,那么update库存,插入流水记录,这样就OK了。主要原因是,update是会锁行的,其他update就会阻塞,你拿到的更新结果是准确的。
2,如果流水记录需要修改(比如库存回滚,或者从预扣改为实扣),那么同时会涉及到对库存的修改以及对流水状态的修改。假设你不select库存行锁,那么就必须特别注意不要死锁:
假设是库存回滚操作,你先更新流水,再更新库存。而另一方希望从预扣改为实扣,那么它也要遵循先更新流水,再更新库存,而不是先库存后流水,否则两方会死锁。
3,通常应该select for update,因为可以准确拿到当前库存状态,将一些快照状态记录到流水表中,另外也是统一所有库存操作的逻辑流程,均在库存锁的保护下进行,也就没有什么死锁的顾虑了。
谢谢您的回复