扣减库存——分布式一致性和分布式事务 - 图1

库存扣减场景

商品下单之后,需要将数据库中的商品库存量减少。这是一个典型的并发条件下的一致性问题。追求的目标是不能超卖,即库存量不能小于0。
扣减库存一般涉及至少3步:

  • 读取现有库存
  • 计算出新库存
  • 回写新库存

首先我们要明确该场景下是否要求强一致性,或者说下单成功和扣减库存之间是否要求具有原子性。如果不要求,则可以通过异步的方式来扣减库存。但一般情况下是要求的。

方案1:应用层面加线程锁(不实用)

各个语言都提供了线程层面的锁机制,可以保证多个线程访问间的一致性。但是,这种方案只适用于单机部署的场景。而现在几乎没有应用只部署一个节点,因此这个方案几乎没有实用价值。

方案2:数据库事务,手动加排他锁

如果我们的数据库是单机部署的,或者说只要数据库支持事务,那我们当然就首选事务来保证一致性。由于先读取库存,再计算出新库存,最后回写,为了避免“更新丢失”,我们这里的事务隔离级别不能选择传统的RR。但是直接使用Serializable又会锁表,代价太大,因此应该在RR级别下,手动给要更新的行加排他锁。即:

  1. SELECT storage FROM itmes WHERE itemNumber='111' FOR UPDATE

在事务结束时,排他锁自动释放。
该方案最为通用、可靠。由于只加行级锁,所以性能也还行。

方案3:乐观并发策略

如果对于每一个商品来说,更新库存的并发量并不高,那么就可以采用乐观并发的策略,减少锁带来的损耗。
具体实施就是依赖如下的SQL:

  1. UPDATE storage=storage-1purCount
  2. WHERE itemNumber='111'
  3. AND storage>=purCount

通过返回的更新数目,来 判断此次更新是否成功。如果失败,则让应用层决定如何处理。

方案4:分布式锁

如果我们要多个分布式的数据库,无法提供事务机制,那么就只能依赖于外部的分布式锁或分布式事务机制。

方案5:基于预占的库存扣减

基于预占的库存扣减.drawio.png
本质上这是一个两阶段提交的分布式事务。预占库存操作有两方面功能:1.加锁(锁定一部分库存,不让别人操作);2.使库存服务处于prepare状态。
上图中“预占库存”和“确认扣减库存”都需要通过流水号来确保幂等。
如果在“预占库存”阶段出错,则业务方重试即可。
如果在“本地事务”阶段或“确认扣减库存”阶段或出错,则业务方可以手动重试,也可以依靠后台任务自动重试。自动重试记录一些状态以便识别需重试的任务及其处于的阶段。
被预占的库存,在一段时间后若没有确认扣减,则可自动释放。

分布式事务的思考

  1. 一个事务可类比为一个函数,该函数具有

lock,prepare,commit,unlock
谁最后commit,取决于谁有失败补偿能力。如果有地方方事务协调者,则事务参与方谁先commit都行,失败后由事务协调者来发起补偿。