文章目录
- [Saga 补偿型分布式事务设计与实现](#Saga 补偿型分布式事务设计与实现)
-
- 一、引言:从一个真实场景说起
- [二、灵魂拷问:TCC 的 Try 真的必要吗?](#二、灵魂拷问:TCC 的 Try 真的必要吗?)
-
- [2.1 TCC 的工作流程](#2.1 TCC 的工作流程)
- [2.2 库存场景下,Try 带来了什么?](#2.2 库存场景下,Try 带来了什么?)
- [2.3 TCC 的另一个问题:多一步就多一次开销](#2.3 TCC 的另一个问题:多一步就多一次开销)
- [2.4 我们的结论](#2.4 我们的结论)
- 三、方案选型决策链
-
- [3.1 四层决策框架](#3.1 四层决策框架)
- [四、Saga 补偿逻辑设计](#四、Saga 补偿逻辑设计)
-
- [4.1 核心问题:什么情况下需要补偿?](#4.1 核心问题:什么情况下需要补偿?)
- [4.2 关键设计:库存失败时订单也要补偿](#4.2 关键设计:库存失败时订单也要补偿)
- [4.3 设计原则](#4.3 设计原则)
- 五、技术实现
-
- [5.1 整体架构](#5.1 整体架构)
- [5.2 全局事务表设计](#5.2 全局事务表设计)
- [5.3 调用方实现(订单服务)](#5.3 调用方实现(订单服务))
- [5.4 超时兜底 Job](#5.4 超时兜底 Job)
- [5.5 被调用方实现(库存服务)](#5.5 被调用方实现(库存服务))
- [5.6 撤销占用接口实现](#5.6 撤销占用接口实现)
- [六、 Saga vs TCC vs 事务性发件箱对比](#六、 Saga vs TCC vs 事务性发件箱对比)
- 七、设计原则总结
- 八、结语
Saga 补偿型分布式事务设计与实现
一、引言:从一个真实场景说起
先说场景:我们有订单服务和库存服务,订单创建时需要调用库存服务扣减库存。
这个场景有一个关键约束:库存够不够需要同步返回结果。用户下单后需要立即知道库存是否充足,不可能让用户等几分钟后再告诉他"抱歉库存没了"。
面对这个约束,可选的分布式事务方案有哪些?
- 事务性发件箱:需要异步,无法满足实时性要求 ❌
- TCC:需要 Try → Confirm → Cancel 三阶段 ⚠️
- Saga:直接执行 → 失败补偿 ✓
我们最终选择了 Saga。但在做这个选择之前,我仔细思考了一个问题:TCC 的 Try 是否有必要?
二、灵魂拷问:TCC 的 Try 真的必要吗?
2.1 TCC 的工作流程
TCC 需要三个阶段:
Try(预留): 冻结库存 → 锁定这部分库存,别人不能用
Confirm(确认): 确认扣减,冻结的库存变为已扣减
Cancel(取消): 回滚释放,冻结的库存释放回去
2.2 库存场景下,Try 带来了什么?
TCC Try 的本质是"预留并锁定"。具体来说:
- Try 阶段:库存冻结,其他请求看不到 → 只有当前调用方可见
- Confirm 阶段:冻结的库存变为已扣减 → 对所有人可见
- Cancel 阶段:冻结的库存释放回去
我们选择的设计是另一种策略:
- 直接执行扣减 → 成功后立即对所有人可见
- 失败则回滚补偿 → 加回库存
两种策略的区别:
- TCC 模式:先锁住,成功后再放开 → 适合需要"隐藏"的场景
- Saga 模式:直接用,失败就加回来 → 适合"失败可回滚"的场景
2.3 TCC 的另一个问题:多一步就多一次开销
TCC: Try(调用) → Confirm(调用) → Cancel(调用)
Saga: 执行(调用) → 补偿(调用)
TCC 需要多一次网络调用。在高并发场景下,这会影响系统吞吐量。
2.4 我们的结论
TCC 的 Try 不是"不必要",而是"不需要"。 两种模式解决不同的问题:
- 需要资源"隐藏"到"可见"的过程 → 用 TCC
- 需要"直接用,失败回滚" → 用 Saga
这就是我们选择 Saga 的根本原因:不做 Try,直接执行,失败就补偿。
三、方案选型决策链
3.1 四层决策框架
在实际工程中,我们遵循以下决策优先级:
| 优先级 | 场景 | 方案 | 说明 |
|---|---|---|---|
| 1 | 可以异步处理 且 不需要回滚 | 事务性发件箱 | 事件驱动,可靠投递 |
| 2 | 需要同步反馈 + 需要回滚 | Saga | 失败可补偿 |
| 3 | 需要同步反馈 + 需要隐藏 | TCC | 预留锁定 |
| 4 | 2+ 服务参与 + 复杂协调 | 引入成熟框架 | 如 Seata |
四、Saga 补偿逻辑设计
4.1 核心问题:什么情况下需要补偿?
Saga 模式下,调用方(订单服务)需要在以下情况发起补偿:
| 场景 | 触发条件 | 补偿动作 |
|---|---|---|
| 订单创建失败 | 订单服务事务回滚 | 通知库存服务回滚(加库存) |
| 调用超时 | 网络超时,未明确返回 | 订单服务发起补偿(加库存) |
| 响应丢失 | 库存服务成功了但响应没回到订单 | 订单服务发起补偿(加库存) |
4.2 关键设计:库存失败时订单也要补偿
这里有一个关键点:如果库存服务没有明确返回"扣减成功",订单服务就需要发起补偿。
为什么?
因为网络可能出现问题:
- 订单服务发起调用 → 网络超时 → 库存服务实际已扣减成功
- 订单服务发起调用 → 库存服务扣减成功 → 响应丢失
这两种情况,库存服务都认为"扣减成功",但订单服务不知道。如果订单服务不发起补偿,库存就白扣了。
4.3 设计原则
Saga 设计原则:宁可重试补偿,不可遗漏补偿
只要没有明确成功,就认为可能失败,发起补偿。这是 Saga 实现的核心。
五、技术实现
5.1 整体架构
库存服务
订单服务
调用接口+globalTxId
记录事务状态
幂等检查
执行业务
更新状态
失败/超时
补偿
订单服务
全局事务表
库存服务
全局事务表
库存扣减
5.2 全局事务表设计
调用方(订单服务)和被调用方(库存服务)各自维护全局事务表:
sql
-- 调用方全局事务表(订单服务)
CREATE TABLE `order_global_tx` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`global_tx_id` VARCHAR(64) NOT NULL COMMENT '全局事务ID',
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-初始化 2-已提交 3-待回滚 4-已回滚完成',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '撤销重试次数',
`compensate_data` TEXT COMMENT '补偿数据JSON',
`time_out` DATETIME NOT NULL COMMENT '超时时间(建议值:业务正常执行时间 × 3,如正常 3 秒则设为 10 秒)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_global_tx_id` (`global_tx_id`),
KEY `idx_time_out` (`time_out`)
);
sql
-- 被调用方全局事务表(库存服务)
CREATE TABLE `inventory_global_tx` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`global_tx_id` VARCHAR(64) NOT NULL COMMENT '全局事务ID',
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-初始化 2-已提交 3-待回滚 4-已回滚完成',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '撤销重试次数',
`compensate_data` TEXT COMMENT '补偿数据JSON',
`time_out` DATETIME NOT NULL COMMENT '超时时间(建议值:业务正常执行时间 × 3,如正常 3 秒则设为 10 秒)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_global_tx_id` (`global_tx_id`),
KEY `idx_time_out` (`time_out`)
);
关键点:两套表独立存在,互不干扰。调用方记录整个 Saga 状态,被调用方记录子事务执行状态。
状态说明:
- 1-初始化:事务刚创建,还未明确结果
- 2-已提交:子事务已执行成功
- 3-待回滚:需要发起回滚(回滚动作尚未执行)
- 4-已补偿完成:补偿已执行完成
5.3 调用方实现(订单服务)
- 全局事务记录使用编程式事务插入,确保独立于业务事务
- 撤销占用使用「Revert」命名,核心原则:先执行业务(撤销),再更新状态
- 使用事务监听器监听 Spring 事务回滚,立即尝试撤销占用,配合 Job 兜底
java
@Service
public class OrderCommandService {
@Resource
private GlobalTransactionService globalTransactionService;
@Resource
private InventoryCommandCaller inventoryCommandCaller;
@Resource
private PlatformTransactionManager transactionManager;
/**
* 创建订单 - 调用库存扣减
*/
public void createOrder(OrderRequest request) {
// 1. 生成全局事务ID
String globalTxId = UUID.randomUUID().toString();
// 2. 写入订单服务全局事务表(初始化状态)
// 关键点:全局事务记录的插入必须独立于业务事务
TransactionStatus txStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
GlobalTransactionDTO dto = new GlobalTransactionDTO();
dto.setGlobalTxId(globalTxId);
dto.setBizType("ORDER_INVENTORY_OUTBOUND");
dto.setStatus(TransactionStatusEnum.INITIALIZED);
// 只存储必要的补偿字段
CompensateData data = new CompensateData();
data.setOrderId(request.getOrderId());
data.setSkuId(request.getSkuId());
data.setQuantity(request.getQuantity());
dto.setCompensateData(JSON.toJSONString(data));
// 设置超时时间:业务最大执行时间的 2-3 倍
// 如:业务正常 3 秒,则超时时间设为 10 秒
dto.setTimeOut(DateUtil.addSeconds(new Date(), 10));
globalTransactionService.addTransaction(dto);
transactionManager.commit(txStatus); // 独立事务提交
} catch (Exception e) {
transactionManager.rollback(txStatus);
throw e;
}
// 3. 执行业务逻辑(创建订单)在另一个独立事务中
try {
// 3.1 调用库存服务扣减库存
Request<XXX> requestVO = Request.<XXX>builder()
.globalTxId(globalTxId)
.data(request)
.build();
Result<XXX> result = inventoryCommandCaller.reserve(requestVO);
// 3.2 创建订单(业务逻辑)
// orderService.createOrder(request);
// 4. 根据结果处理
if (result.isSuccess()) {
// 库存扣减成功,更新状态为已提交
globalTransactionService.updateTransactionSuccess(globalTxId);
} else {
// 库存扣减失败,尝试撤销
compensate(globalTxId, request);
}
} catch (Exception e) {
// 调用异常,尝试撤销
compensate(globalTxId, request);
throw e;
}
}
/**
* 撤销占用逻辑
* 核心原则:先执行业务(撤销),再更新状态
*/
private void compensate(String globalTxId, OrderRequest request) {
try {
// 1. 先尝试撤销占用(业务动作)
Request<XXX> requestVO = Request.<XXX>builder()
.globalTxId(globalTxId)
.data(request)
.build();
inventoryCommandCaller.revert(requestVO);
// 2. 撤销成功,更新状态为「已回滚完成」
globalTransactionService.updateTransactionRolledBack(globalTxId);
} catch (Exception e) {
// 3. 撤销失败,更新状态为「待回滚」
// Job 扫描「待回滚」+超时的记录来兜底
globalTransactionService.updateTransactionRollbackPending(globalTxId);
log.error("撤销占用失败,等待 Job 兜底: {}", globalTxId, e);
}
}
}
/**
* 事务监听器:监听 Spring 事务回滚,立即尝试撤销占用
* 说明:尽可能快速地回滚库存,作为「立即」执行
* 配合定时 Job 兜底,实现「立即 + 兜底」双重保障
*
* 触发场景:
* 1. Spring 事务回滚 → 监听器触发 → 立即尝试撤销占用
* 2. 撤销成功 → 状态更新为「已回滚完成」
* 3. 撤销失败 → 状态更新为「待回滚」→ Job 兜底
*/
@Component
public class OrderTransactionRollbackListener {
@Resource
private GlobalTransactionService globalTransactionService;
@Resource
private InventoryCommandCaller inventoryCommandCaller;
/**
* 监听 Spring 事务回滚,立即尝试撤销占用
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onTransactionRollback(GlobalTransactionRollbackEvent event) {
String globalTxId = event.getGlobalTxId();
OrderRequest request = event.getCompensateData();
try {
// 立即尝试撤销占用
Request<XXX> requestVO = Request.<XXX>builder()
.globalTxId(globalTxId)
.data(request)
.build();
inventoryCommandCaller.revert(requestVO);
// 撤销成功,更新状态为「已回滚完成」
globalTransactionService.updateTransactionRolledBack(globalTxId);
} catch (Exception e) {
// 撤销失败,更新状态为「待回滚」
globalTransactionService.updateTransactionRollbackPending(globalTxId);
log.error("撤销占用失败,等待 Job 兜底: {}", globalTxId, e);
}
}
}
5.4 超时兜底 Job
- 区分"需要回滚"和"还在执行中",只有状态为「待回滚」且超时才发起回滚
- 依靠超时时间发现需要撤销的记录,核心:Job 通过「超时」+「待回滚状态」来发现需要撤销的记录
- 状态更新失败的异常处理:依靠超时时间兜底
Job 部署在调用方(订单服务),扫描超时且状态为「待回滚」的记录。
兜底范围:
- 状态为「待回滚」且超时:调用方已标记需要回滚,但撤销操作未成功,Job 兜底处理
| 扫描条件 | 含义 | Job 处理方式 |
|---|---|---|
| 待回滚 + 超时 | 调用方已标记需要回滚但未成功 | 直接尝试撤销 |
java
@Component
public class OrderGlobalTransactionJob {
@Resource
private GlobalTransactionService globalTransactionService;
@Scheduled(cron = "0 */5 * * * ?")
public void scanTimeoutAndRollbackPending() {
// 扫描超时且状态为「待回滚」的记录
List<GlobalTransactionDTO> timeoutList = globalTransactionService.selectTimeoutAndRollbackPending();
for (GlobalTransactionDTO dto : timeoutList) {
try {
// 直接尝试撤销占用
compensate(dto);
// 撤销成功,更新状态为「已回滚完成」
globalTransactionService.updateTransactionRolledBack(dto.getGlobalTxId());
} catch (Exception e) {
// 撤销失败,保持「待回滚」状态,Job 下次还会继续扫描
log.error("撤销占用失败,等待下次重试: {}", dto.getGlobalTxId(), e);
globalTransactionService.incrementRetryCount(dto.getGlobalTxId());
}
}
}
private void compensate(GlobalTransactionDTO dto) {
// 根据补偿数据发起撤销占用
OrderRequest request = JSON.parseObject(dto.getCompensateData(), OrderRequest.class);
// ... 调用撤销占用接口
}
}
/**
* 查询远程服务状态的响应对象
*/
public enum RemoteTransactionStatus {
COMMITTED, // 已提交
ROLLED_BACK, // 已回滚
UNKNOWN // 未知,需要补偿
}
5.5 被调用方实现(库存服务)
- 全局事务记录独立于业务事务,使用编程式事务确保插入在独立事务中
- 撤销占用接口使用「撤销占用」(Revert)命名,推荐使用方案二(依赖库存变更流水表)
- Job 兜底核心:依靠超时时间发现需要撤销的记录
java
@RestController
public class InventoryCommandController {
@Resource
private GlobalTransactionService globalTransactionService;
@PostMapping("/v1/reserve/availableStock")
public Result reserveAvailableStock(
@RequestHeader("X-Global-Tx-Id") String globalTxId,
@RequestBody ReserveAvailableStockDTO dto
) {
// 1. 幂等检查
GlobalTransactionDTO exist = globalTransactionService.selectByGlobalTxId(globalTxId);
if (exist != null && exist.getStatus() == TransactionStatusEnum.COMMITTED) {
return Result.success(); // 已执行,直接返回成功
}
// 2. 悬挂检查 - 检查是否已被回滚
if (exist != null && exist.getStatus() == TransactionStatusEnum.ROLLED_BACK) {
return Result.fail("事务已回滚");
}
// 3. 执行业务(库存扣减)
// ... 库存扣减逻辑
// 同时记录库存变更流水表(方案二需要)
inventoryChangeService.record(globalTxId, dto, ChangeTypeEnum.OCCUPATION);
// 4. 更新库存服务全局事务表
globalTransactionService.updateTransactionSuccess(globalTxId);
return Result.success();
}
}
5.6 撤销占用接口实现
- 撤销占用接口命名推荐使用「撤销占用」(Revert)
- 方案二(依赖库存变更流水表)是强烈推荐的方案,不依赖调用方传入参数
方案一:依赖调用方传入的占用信息
java
/**
* 撤销占用接口 - 方案一:依赖调用方传入的占用信息
* 说明:调用方在调用撤销接口时,需要传入当初占用的参数
*/
@PostMapping("/v1/revert/availableStock")
public Result revertAvailableStock(
@RequestHeader("X-Global-Tx-Id") String globalTxId,
@RequestBody ReserveAvailableStockDTO dto
) {
// 1. 检查是否已撤销
GlobalTransactionDTO exist = globalTransactionService.selectByGlobalTxId(globalTxId);
if (exist != null && exist.getStatus() == TransactionStatusEnum.ROLLED_BACK) {
return Result.success(); // 已撤销,直接返回
}
// 2. 依赖调用方传入的参数来回滚(加回库存)
inventoryService.addStock(dto.getSkuId(), dto.getQuantity());
// 3. 更新状态为已回滚
globalTransactionService.updateTransactionRollback(globalTxId);
return Result.success();
}
方案一说明:
- 调用方在调用撤销接口时,需要传入当初占用的参数(skuId、quantity 等)
- 被调用方根据这些参数执行反向操作(加回库存)
- 优点:实现简单,不需要额外存储
- 缺点:依赖调用方正确传入参数,如果参数丢失或不一致会导致回滚失败
方案二:依赖库存变更流水表回滚(强烈推荐)
java
/**
* 撤销占用接口 - 方案二:依赖库存变更流水表回滚(强烈推荐)
* 说明:不需要调用方传入任何业务参数,只需传入 globalTxId
* 被调用方根据自己维护的库存变更流水表来执行回滚
*/
@PostMapping("/v1/revert/availableStock")
public Result revertAvailableStock(
@RequestHeader("X-Global-Tx-Id") String globalTxId
) {
// 1. 幂等检查 - 检查是否已撤销
GlobalTransactionDTO exist = globalTransactionService.selectByGlobalTxId(globalTxId);
if (exist != null && exist.getStatus() == TransactionStatusEnum.ROLLED_BACK) {
return Result.success(); // 已撤销,直接返回
}
// 2. 查询库存变更流水表,获取之前库存变更的记录
InventoryChangeRecord record = inventoryChangeService.selectByGlobalTxId(globalTxId);
if (record == null) {
return Result.fail("未找到库存变更记录,无法撤销");
}
// 3. 根据流水表记录执行回滚(加回库存)
inventoryService.addStock(record.getSkuId(), record.getChangeQuantity());
// 4. 更新流水表状态为已撤销
record.setStatus(ChangeStatusEnum.REVERTED);
inventoryChangeService.update(record);
// 5. 更新全局事务表状态
globalTransactionService.updateTransactionRollback(globalTxId);
return Result.success();
}
方案二说明:
- 被调用方维护通用的「库存变更流水表」,记录所有库存变更操作
- 撤销接口不需要调用方传入任何业务参数,只需传入 globalTxId
- 被调用方根据流水表记录自己执行回滚
- 优点:不依赖调用方传入的参数,通用性强,即使调用方参数丢失也能正确回滚
两种方案对比:
| 维度 | 方案一(依赖传入参数) | 方案二(依赖流水表) |
|---|---|---|
| 实现复杂度 | 低 | 中 |
| 调用方传入参数 | 需要 | 不需要 |
| 参数丢失风险 | 有 | 无 |
| 适用场景 | 参数简单、调用方可信 | 通用场景,强烈推荐 |
| 推荐程度 | 可用 | 强烈推荐 |
附:库存变更流水表(方案二需要)
sql
CREATE TABLE `inventory_change_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`global_tx_id` VARCHAR(64) NOT NULL COMMENT '全局事务ID',
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型:OCCUPATION-占用 RELEASE-释放 ADJUST-调整',
`sku_id` VARCHAR(64) NOT NULL COMMENT '商品ID',
`change_quantity` INT NOT NULL COMMENT '变更数量(正数为增加,负数为扣减)',
`before_quantity` INT NOT NULL COMMENT '变更前库存',
`after_quantity` INT NOT NULL COMMENT '变更后库存',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-变更中 2-已确认 3-已撤销',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_global_tx_id` (`global_tx_id`)
);
六、 Saga vs TCC vs 事务性发件箱对比
| 维度 | 事务性发件箱 | Saga | TCC |
|---|---|---|---|
| 模式类型 | 消息可靠投递 | 分布式事务 | 分布式事务 |
| 实时性 | 异步 | 同步 | 同步 |
| 资源状态 | 不锁定 | 不锁定 | 锁定 |
| 业务侵入 | 无 | 补偿接口 | Try+Confirm+Cancel 三接口 |
| 一致性 | 最终一致 | 最终一致 | 强一致 |
| 适用场景 | 可异步 + 不需回滚 | 同步 + 需回滚 | 同步 + 需隐藏 + 需要回滚 |
七、设计原则总结
| 原则 | 说明 |
|---|---|
| 发件箱优先 | 尽可能用异步消息解决最终一致 |
| 按需选择模式 | Saga 和 TCC 解决不同问题:根据是否需要"隐藏"来选择 |
| 简化协调 | 1+1 场景不引入复杂框架 |
| 宁可重试补偿 | 没有明确成功就认为失败,发起补偿 |
| 边界清晰 | 复杂协调交给成熟框架 |
八、结语
Saga 补偿型事务不是一个"更优"的方案,它只是一个"更简单"的方案。
在工程实践中,复杂度是一种成本。我们的选择逻辑是:
- 优先用发件箱:能异步就异步,复杂度最低
- 其次用 Saga:需要同步但不需要预留,简单高效
- 再用 TCC:需要预留锁定,用户无感知
- 最后用框架:2+ 服务参与,复杂度高,交给专业框架