在SafeInventory-分布式事务下,如何安全操作库存 ,一文中已经详细说明在本地事务下如何安全操作库存。
本文将继续讨论在分布式事务下, 如何安全操作库存。
在购物场景下,库存扣减是一个典型的分布式事务 ,通常涉及三个系统:订单系统 、库存系统 和支付系统 。其交互流程如下:
订单系统请求扣减库存 :在用户下单时,订单系统首先向库存系统请求扣减库存,只有在确认有足够库存的情况下,订单才会创建成功。
等待支付结果 :订单创建成功后,用户进入支付流程。此时,库存系统暂时将库存“冻结”,等待支付系统的支付结果。
支付成功或失败的处理 :
如果支付成功,库存系统确认并真正扣减库存并 生成对应用户券。
如果支付失败或超时,库存系统需要将预扣的库存恢复,确保库存不被不必要地扣减。
在分布式事务下,保证库存不超发 的方式与本地事务相同,仍然可以使用库存条件控制、乐观锁、悲观锁、分布式锁机制。
然而,在分布式事务中,库存扣减与业务操作的原子性不再能通过数据库事务天然保证。
库存的实际扣减与用户的支付结果密切相关,需要根据支付成功或失败来决定是否真正扣减库存。
在用户下单时,暂时冻结库存。
等到支付成功后,再真正扣减库存;如果支付失败,则释放预扣库存。
这种方案叫做预扣库存, 确保了库存扣减与支付流程的原子性,这是一个典型的两阶段提交(2PC)
项目完整代码,点击 github-SafeInventory查看
1 预扣库存 预扣库存方案, 需要增加一个流水表记录每个不重复的请求 需要扣减的数量。
同时极其重要的一点就是 库存表和流水表 需要在一个数据中, 通过数据库事务保证库存扣减和流水数据插入的原子性
1.1 表设计 1.1.1 库存表:inventory
1 2 3 4 5 6 7 8 9 10 11 12 CREATE TABLE inventory ( id bigint unsigned NOT NULL AUTO_INCREMENT, product_id INT NOT NULL , total_stock INT NOT NULL DEFAULT 0 COMMENT '总库存,固定不变' , available_stock INT NOT NULL DEFAULT 0 COMMENT '当前可用库存,总库存减去了实际已经使用的库存' , reserved_stock INT NOT NULL DEFAULT 0 COMMENT '预扣库存' , create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP , update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP , version INT NOT NULL DEFAULT 0 COMMENT '数据版本号,用作乐观锁控制' , UNIQUE KEY `idx_product_id` (`product_id`), PRIMARY KEY (id) );
total_stock
:表示券模板配置的总库存,固定不变。
available_stock
:当前实际可用的库存数量,减去了实际已经使用和已预扣的库存。
reserved_stock
:当前已预扣但尚未确认的库存数量。
在这种设计下,当前实际已使用的库存 可以表示为:
used_stock=total_stock − available_stock − reserved_stock
1.1.2 明细表/流水表:inventory_reservation_log
1 2 3 4 5 6 7 8 9 10 11 12 13 CREATE TABLE inventory_reservation_log ( id bigint unsigned NOT NULL AUTO_INCREMENT, request_id VARCHAR (64 ) NOT NULL COMMENT '请求唯一标识' , product_id INT NOT NULL , reservation_quantity INT NOT NULL COMMENT '本次预扣库存数量' , status INT DEFAULT 1 COMMENT '消息状态 1-pending 2-confirmed 3-rollback 4-unknown' , verify_try_count INT DEFAULT 0 COMMENT '状态回查 当前重试次数' , create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP , update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP , version INT NOT NULL DEFAULT 0 COMMENT '数据版本号,用作乐观锁控制' , UNIQUE INDEX idx_request_id (request_id), PRIMARY KEY (id) );
request_id
是每次预扣操作的唯一标识符, 在商品购买场景下就是订单id,可以用来追踪每次库存预扣的详细信息。
status
字段用于标记库存预扣的状态,分别为 1-PENDING(预扣中)
、2-CONFIRMED(已确认)
和 3-CANCELLED(已取消)
。
verify_try_count
通过异步定时任务保证数据的一致性,记录被定时任务扫描确定 status 的次数。
1.2 方案流程 库存表和流水表 需要在一个数据中, 通过数据库事务保证库存扣减和流水数据插入的原子性。
1.2.1 本地事务 预扣库存 以下sql 先只表示流程,不代表具体实际方案, 因为没有把控制并发不超卖的4种方案一一具体写下来。
预扣操作在 inventory
表中减少 available_stock
,并增加 reserved_stock
,同时在 inventory_reservation_log
表中插入一条状态为 PENDING
的预扣记录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 SELECT id, product_id AS productId, total_stock, available_stock, reserved_stock, version, create_time, update_time FROM inventory WHERE product_id = #{productId} UPDATE inventory SET available_stock = available_stock - #{quantity}, reserved_stock = reserved_stock + #{quantity}, version = version + 1 WHERE product_id = #{productId} AND version = #{version} INSERT INTO inventory_reservation_log (request_id, product_id, reservation_quantity, status) VALUES (#{requestId}, #{productId}, #{reservationQuantity}, #{status})
1.2.2 本地事务 确认扣减 在确认阶段,将预扣的库存从 reserved_stock
扣减,并标记 inventory_reservation_log
表中的记录为 CONFIRMED
。此时,available_stock
不发生变化,因为实际可用库存已经在预扣阶段调整。
1 2 3 4 5 6 7 8 9 10 11 12 13 UPDATE inventory_reservation_log SET status = #{status}, version = version + 1 WHERE request_id = #{requestId} AND version = #{version} UPDATE inventory SET reserved_stock = reserved_stock - #{reservedStock}, version = version + 1 WHERE product_id = #{productId}
1.2.3 本地事务 回滚预扣 如果预扣的库存在后续操作中失败或超时,需要将 reserved_stock
返还到 available_stock
中,并将 inventory_reservation——log
表中的状态更新为 CANCELLED
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 UPDATE inventory_reservation_log SET status = #{status}, version = version + 1 WHERE request_id = #{requestId} AND version = #{version} UPDATE inventory SET reserved_stock = reserved_stock - #{reservedStock}, available_stock = available_stock + #{reservedStock}, version = version + 1 WHERE product_id = #{productId}
由于分布式事务分为单服务多数据库 和 多服务多数据库 2种,接下来将分开讨论
2 多服务多数据库分布式事务-TCC 商品购买场景中的分布式事务 可以视为典型的多数据库、多数据源的分布式事务 。
在处理这种分布式事务时,常用的方案是TCC 模式,即Try、Confirm、Cancel 。
TCC 本质上就是两阶段提交(2PC)类似,但它更具业务属性,更加灵活。与商品购买库存扣减业务逻辑结合的 TCC 模式步骤如下:
Try :执行资源预留操作,在这里对应的是预扣库存 ,即暂时冻结库存以等待支付结果。
Confirm :在支付成功后,执行确认扣减库存 的操作。
Cancel :如果支付失败或超时,执行回滚预扣库存 ,将冻结的库存释放。
2.1 Try - 预扣库存 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 public class InventoryService { public boolean reserveInventory(Integer productId, Integer quantity, String requestId) { String lockKey = LOCK_KEY_PREFIX + productId; / / 模拟获取redis 分布式锁逻辑 boolean lockAcquired = redisDistributedLock.acquireLock(lockKey, requestId, EXPIRE_TIME); if (! lockAcquired) { logger.info("未获取到锁 productId: {}, quantity: {}, requestId:{}", productId, quantity, requestId); / / 获取锁失败,返回或重试 return false ; } try { return inventoryInternalService.reserveInventory(productId, quantity, requestId); } finally { / / 如果释放失败则重试或者等待过期 if (lockAcquired) { redisDistributedLock.releaseLock(lockKey, requestId); } } } } public class InventoryInternalService { / / TCC- try @Transactional public boolean reserveInventory(Integer productId, Integer quantity, String requestId) { InventoryModel inventory = inventoryMapper.selectByProductId(productId); if (inventory.getAvailableStock() < quantity) { logger.warn("库存不足: productId={}, requestedQuantity={}, availableStock={}", productId, quantity, inventory.getAvailableStock()); return false ; } / / 流水表插入 InventoryReservationLogModel model = new InventoryReservationLogModel(); model.setProductId(productId); model.setReservationQuantity(quantity); model.setRequestId(requestId); model.setStatus(ReservationStatus.PENDING.getValue()); inventoryReservationLogMapper.insertInventoryReservationLog(model); / / 库存扣减 int updatedRows = inventoryMapper.reserveStockWithVersion(productId, quantity, inventory.getVersion()); if (updatedRows = = 0 ) { logger.warn("库存扣减失败 productId: {}, quantity: {}, requestId:{}", productId, quantity, requestId); throw new RuntimeException("库存扣减失败"); } return true ; } }
2.1.1 先插入流水记录-幂等性保证 流水表中唯一索引索引字段可以用作幂等性的保证, 具体方案可见 如何处理重复请求保证幂等 。
2.1.2 扣减库存 使用SafeInventory-分布式事务下,如何安全操作库存 中提到的4种方案,保证库存扣减逻辑在并发请求下的安全操作。
示例代码使用Redis 分布式锁保证库存不超卖。
2.1.3 本地事务保证原子操作 本地事务保证库存表变更和流水表插入的原子操作,如果库存表变更失败,则事务回滚。
2.2 Confirm- 确认扣减库存 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public boolean confirmReservedInventory(Integer productId, String requestId) { InventoryReservationLogModel model = inventoryReservationLogMapper.selectByRequestId(requestId); if (model.getStatus() != ReservationStatus.PENDING.getValue()) { logger.warn("status is not pending ,can not confirm the reserved stock, requestId:{}", requestId); return false ; } return inventoryInternalService.confirmReservedInventory(productId, requestId, model.getVersion(), model.getReservationQuantity()); } @Transactional public boolean confirmReservedInventory(Integer productId, String requestId, Integer version ,Integer reservationQuantity) { / / 指定原始status 做乐观锁 int updateResult = inventoryReservationLogMapper.updateStatus( requestId, ReservationStatus.CONFIRMED.getValue(), version); if (updateResult != 1 ) { throw new RuntimeException("流水状态更新失败"); } int rollbackResult = inventoryMapper.confirmStock(productId, reservationQuantity); if (rollbackResult != 1 ) { throw new RuntimeException("库存回滚失败"); } return true ; }
2.2.1 本地事务保证原子操作 本地事务保证库存表变更和流水表变更的原子操作,如果部分失败,则事务回滚。
2.2.2 变更的并发操作 使用版本号字段,保证状态流转的正确以及防止数据覆盖
2.3 Cancel - 回滚预扣库存 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Transactional public boolean rollbackReservedInventory(Integer productId, String requestId) { InventoryReservationLogModel model = inventoryReservationLogMapper.selectByRequestId(requestId); if (model.getStatus() != ReservationStatus.PENDING.getValue()) { logger.warn("rollbackReservedInventory is not pending ,can not rollback the reserved stock"); return false ; } / / 指定原始status 做乐观锁 int updateResult = inventoryReservationLogMapper.updateStatus( requestId, ReservationStatus.ROLLBACK.getValue(), model.getVersion()); if (updateResult != 1 ) { throw new RuntimeException("流水状态更新失败"); } int rollbackResult = inventoryMapper.rollbackStock(productId, model.getReservationQuantity()); if (rollbackResult != 1 ) { throw new RuntimeException("库存回滚失败"); } return true ; }
2.3.1 库存回滚的2种场景 订单超时取消
监听订单的消息队列,针对状态是cancle 的订单, 找到流水表中预扣记录,执行库存回滚逻辑
定时任务每隔固定时间扫描一次流水表, 找出pendding 状态的流水记录,执行库存回滚逻辑
支付失败调用cancel 接口 2.3.2 本地事务保证原子操作 本地事务保证库存表变更和流水表变更的原子操作,如果部分失败,则事务回滚
2.3.3 变更的并发操作 使用版本号字段,保证状态流转的正确以及防止数据覆盖
3 单服务多数据库分布式事务-Confirm 阶段加入业务操作 以上介绍的库存预扣流程,各阶段只涉及库存表和和流水表的操作,可以使用本地事务保证操作的原子性。
但是如果各阶段加入业务操作,就要考虑库存操作(包括库存表和流水表 2张表的操作)和业务操作之间的原子性。
以 购买券包场景为例,它需要在在confirm 阶段 ,生成对应的用户券。
如果用户券表和库存表、流水表在同一个数据库, 那么用户券的生成 和 库存表、流水表 的变更 可以继续通过数据库事务保持原子性, 从而保证库存数据与用户券的一致性。
如果用户券分库分表, 和库存表、流水表不在同一个数据库,那么用户券的生成 和 库存操作就变成了一个 单服务多数据库的分布式事务 ,无法通过数据库事务保持原子性。
需要根据用户券插入结果决定库存是否真正扣减,这也是一个典型的两阶段提交(2PC) 。
此时如何保证内部数据一致性 和 整体数据一致性需要重点考虑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public boolean confirmReservedInventoryWithBusinessLogic (Integer productId, String requestId) { InventoryReservationLogModel model = inventoryReservationLogMapper.selectByRequestId(requestId); if (model.getStatus() != ReservationStatus.PENDING.getValue()) { logger.warn("status is not pending ,can not confirm the reserved stock, requestId:{}" , requestId); return false ; } boolean businessResult = businessService.createBusinessDate(); if (!businessResult) { logger.warn("业务逻辑执行失败,requestId:{},productId:{} " , requestId, productId); return false ; } boolean confirmResult = false ; try { confirmResult = inventoryInternalService.confirmReservedInventory(productId, requestId, model.getVersion(), model.getReservationQuantity()); } catch (Exception e) { logger.warn("业务逻辑执行失败,requestId:{},productId:{}, error:{}" , requestId, productId, e); } return confirmResult; }
3.1 先生成用户券,再确认扣减库存 如果库存服务在confirm 接口中,除了扣减库存,还要生成对应用户数据,比如购买券包,用户支付成功后,就应该先生成用户券,再执行库存数据库变更。
为什么要先生成用户券数据在扣减库存,我们希望 在流水表中的状态一旦是confirm 或者 cancle 就完全是确定不再需要考虑的状态, 如果先扣库存,就会导致cancel 、confirm 也不是确定的状态。
3.2 如果库存变更失败,如何处理-状态回查 为了保持数据的最终一致性,有3种方案考虑
如果库存扣减失败, 此时有2个角度的数据一致性需要考虑
内部数据一致性 :即库存reserved_stock 与 流水表保持一致
整体数据一致性:即整体可用库存数量、订单能拿到的状态、用户能看到的券列表保持一致。
如果此时用户券生成成功、但是库存变更失败,从整体流程上看,数据是一致性。只有内部数据不一致。可以采用状态回查方案保证reserved_stock 与 流水表 数据的最终一致性。
状态回查就是定期扫描流水表,找出X 分钟前创建但是状态 依然pending 的记录,查询对应的用户券数据,根据业务处理结果流转明细表状态。
3.3 流水表不分库分表吗 流水表记录用户的每个领券请求,其数据量理论上会与分库分表的用户券表的数据量接近,但在业务流程中,明细表一旦完成状态确认,其业务用途几乎不再重要 。因此,可以通过定期归档这些历史数据来控制明细表的体量,确保流水表和库存表能够位于同一个数据库中,从而利用本地事务机制保证一致性。