分布式事务设计:如何通过补充字段解决Try空回滚与Confirm悬挂问题
30
0
0
0
在设计分布式事务或涉及Try/Confirm/Cancel流程的资源表时,除了基础的 status(状态)和 version(乐观锁版本号)字段外,要处理你提到的空回滚(Try执行了但没记录)和悬挂(Confirm执行了但Cancel又进来)这两个棘手问题,通常需要引入一套更完整的防悬挂和空回滚补偿机制。
以下是建议补充的关键字段和设计机制:
1. 核心补充字段
为了唯一标识一笔业务,防止重复执行或遗漏执行,必须增加一个全局唯一的业务标识ID:
biz_id(业务流水号/全局事务ID):- 作用: 这是最关键的字段。Try、Confirm、Cancel三个阶段的操作必须关联同一个
biz_id。 - 解决场景:
- 空回滚: 当Cancel阶段来临时,如果根据
biz_id查询不到任何记录,说明Try阶段根本没有执行(或者数据被异常删除)。此时,Cancel操作不应该抛出异常,而应该直接返回成功,或者插入一条“虚拟”的空回滚记录以标记结束。 - 悬挂: 当Try阶段来临时,如果发现该
biz_id已经存在Confirm或Cancel的记录,说明这是一个过期的请求(网络延迟导致Try晚到了),此时Try操作必须拒绝执行。
- 空回滚: 当Cancel阶段来临时,如果根据
- 作用: 这是最关键的字段。Try、Confirm、Cancel三个阶段的操作必须关联同一个
2. 针对“悬挂场景”的额外机制
悬挂场景的核心是:Confirm/Cancel 先执行,Try 后执行。这会导致 Try 阶段的数据被错误创建,无法被后续的 Cancel 清理。
除了 biz_id 的校验,通常还需要以下两种策略之一:
策略 A:状态机前置检查 (利用 status 字段)
- 在 Try 阶段执行前,必须先查询
status。 - 如果
status已经是CONFIRMED或CANCELED,说明事务已经结束,Try 阶段必须直接返回成功(幂等),绝对不能插入新的资源记录。 - 这要求
status的流转是不可逆的,且 Try 阶段必须先查后写。
- 在 Try 阶段执行前,必须先查询
策略 B:插入“悬挂保护表”或利用“预留记录”
- 字段
gmt_modified(修改时间): 配合逻辑判断。如果 Try 阶段发现记录存在,但修改时间晚于当前时间戳(或者对比业务逻辑时间),说明可能是悬挂。 - 机制: 在 Try 阶段插入记录时,不仅仅插入资源表,还需要在一张专门的事务控制表中记录
biz_id和try_executed_at时间戳。 - Confirm/Cancel 执行时,检查该时间戳。如果 Try 的执行时间晚于 Confirm/Cancel 的执行时间,则判定为悬挂,Try 必须回滚自己插入的数据。
- 字段
3. 针对“空回滚场景”的机制
空回滚的核心是:Cancel 先执行,Try 后执行。此时资源表没有数据,Cancel 如果直接报错,会导致事务流程中断。
- 机制:
- Cancel 阶段根据
biz_id查询资源表。 - 如果查不到记录:
- 方案 1 (推荐): 直接返回成功,什么都不做。因为 Try 还没执行,后续 Try 执行时会看到 Cancel 已经结束(通过状态机),从而放弃执行。
- 方案 2: 插入一条特殊的“空回滚记录”,标记该事务已取消。Try 阶段执行时,如果发现这条记录,则执行回滚逻辑并删除它。
- Cancel 阶段根据
总结:推荐的表结构设计
为了保证事务最终一致性,资源表建议至少包含以下字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
BigInt | 主键 |
biz_id |
Varchar | 核心:全局唯一ID,用于关联所有阶段 |
status |
TinyInt | 状态:0-Trying, 1-Confirmed, 2-Cancelled, 3-EmptyRollback |
version |
Int | 乐观锁:防止并发修改导致的数据不一致 |
resource_data |
Text/Json | 业务资源数据 |
gmt_create |
Datetime | 创建时间(用于判断悬挂:Try不能晚于Confirm太久) |
gmt_modified |
Datetime | 修改时间 |
逻辑伪代码示例 (Try 阶段防悬挂):
// Try 阶段逻辑
public void tryTransaction(BizDTO dto) {
// 1. 检查是否已经存在该业务ID的记录
Resource record = resourceDao.selectByBizId(dto.getBizId());
if (record != null) {
// 2. 如果存在,检查状态是否为已结束 (Confirmed/Cancelled)
if (record.getStatus() == CONFIRMED || record.getStatus() == CANCELLED) {
// 说明发生了悬挂:Confirm/Cancel 先执行了
// 此时 Try 必须放弃,直接返回,保证幂等性
return;
}
// 如果状态是 Trying,说明是重试,继续执行即可
}
// 3. 执行 Try 业务逻辑,插入资源记录
// 注意:这里通常会插入一条 status=Trying 的记录
resourceDao.insert(new Resource(dto.getBizId(), TRYING, ...));
}
逻辑伪代码示例 (Cancel 阶段防空回滚):
// Cancel 阶段逻辑
public void cancelTransaction(BizDTO dto) {
// 1. 根据 BizId 查询
Resource record = resourceDao.selectByBizId(dto.getBizId());
// 2. 如果查不到记录(空回滚)
if (record == null) {
// 直接返回成功,或者插入一条空回滚标记
// 不抛异常,避免阻断事务框架的重试机制
return;
}
// 3. 如果记录存在,使用乐观锁更新
int rows = resourceDao.updateStatusByIdAndVersion(
dto.getBizId(), CANCELLED, record.getVersion()
);
if (rows == 0) {
throw new ConcurrencyException("并发修改,请重试");
}
// 4. 执行具体的资源释放逻辑
releaseResource(record);
}
通过 biz_id 结合 status 状态机和 version 乐观锁,可以完美覆盖空回滚和悬挂这两个极端场景,确保数据的最终一致性。