SafeInventory-分布式事务下,如何安全操作库存,一文中已经详细说明在本地事务下如何安全操作库存。

本文将继续讨论在分布式事务下, 如何安全操作库存。

在购物场景下,库存扣减是一个典型的分布式事务,通常涉及三个系统:订单系统库存系统支付系统。其交互流程如下:

  1. 订单系统请求扣减库存:在用户下单时,订单系统首先向库存系统请求扣减库存,只有在确认有足够库存的情况下,订单才会创建成功。

  2. 等待支付结果:订单创建成功后,用户进入支付流程。此时,库存系统暂时将库存“冻结”,等待支付系统的支付结果。

  3. 支付成功或失败的处理
    • 如果支付成功,库存系统确认并真正扣减库存并 生成对应用户券。
    • 如果支付失败或超时,库存系统需要将预扣的库存恢复,确保库存不被不必要地扣减。

在分布式事务下,保证库存不超发的方式与本地事务相同,仍然可以使用库存条件控制、乐观锁、悲观锁、分布式锁机制。

然而,在分布式事务中,库存扣减与业务操作的原子性不再能通过数据库事务天然保证。

库存的实际扣减与用户的支付结果密切相关,需要根据支付成功或失败来决定是否真正扣减库存。

  1. 在用户下单时,暂时冻结库存。

  2. 等到支付成功后,再真正扣减库存;如果支付失败,则释放预扣库存。

这种方案叫做预扣库存,确保了库存扣减与支付流程的原子性,这是一个典型的两阶段提交(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 模式步骤如下:

  1. Try:执行资源预留操作,在这里对应的是预扣库存,即暂时冻结库存以等待支付结果。
  2. Confirm:在支付成功后,执行确认扣减库存的操作。
  3. 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种场景

订单超时取消

  1. 监听订单的消息队列,针对状态是cancle 的订单, 找到流水表中预扣记录,执行库存回滚逻辑
  2. 定时任务每隔固定时间扫描一次流水表, 找出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个角度的数据一致性需要考虑

  1. 内部数据一致性 :即库存reserved_stock 与 流水表保持一致

  2. 整体数据一致性:即整体可用库存数量、订单能拿到的状态、用户能看到的券列表保持一致。

如果此时用户券生成成功、但是库存变更失败,从整体流程上看,数据是一致性。只有内部数据不一致。可以采用状态回查方案保证reserved_stock 与 流水表 数据的最终一致性。

状态回查就是定期扫描流水表,找出X 分钟前创建但是状态 依然pending 的记录,查询对应的用户券数据,根据业务处理结果流转明细表状态。

3.3 流水表不分库分表吗

流水表记录用户的每个领券请求,其数据量理论上会与分库分表的用户券表的数据量接近,但在业务流程中,明细表一旦完成状态确认,其业务用途几乎不再重要。因此,可以通过定期归档这些历史数据来控制明细表的体量,确保流水表和库存表能够位于同一个数据库中,从而利用本地事务机制保证一致性。