4. 补偿设计原则:幂等性、空补偿、防悬挂、安全回滚——四大补偿设计铁律
在审批流与分布式事务补偿设计中,补偿操作并非简单的“反向操作”。若缺乏严谨的设计约束,补偿逻辑本身可能引发数据不一致、重复执行、死锁甚至系统雪崩。业界经过大量实践,总结出四大补偿设计铁律:幂等性、空补偿、防悬挂、安全回滚。这四条原则是构建可靠事务补偿骨架的基石。
4.1 四大铁律全景图
下图展示了四大补偿设计铁律在分布式事务补偿流程中的位置与相互关系。每个原则解决一个特定的补偿风险点。
4.2 铁律一:幂等性(Idempotency)
定义:同一个补偿操作无论执行多少次,其产生的业务效果与执行一次完全相同。这是补偿设计中最基本、最重要的原则。
为什么需要:在分布式系统中,网络超时、消息重试、节点故障恢复等场景会导致补偿请求被多次发送。若补偿操作不具备幂等性,将引发数据重复扣减、重复退款、重复释放资源等严重事故。
幂等性实现策略对比
| 策略 | 实现方式 | 适用场景 | 注意事项 |
|---|---|---|---|
| 数据库唯一约束 | 补偿记录表对(事务ID, 步骤ID)建立唯一索引 | 所有基于关系型数据库的补偿 | 需处理唯一冲突异常,返回已处理状态 |
| 状态机前置检查 | 补偿前检查业务记录状态,仅允许特定状态执行补偿 | 有明确状态流转的业务(如订单状态) | 状态定义需覆盖所有异常路径 |
| Redis 分布式锁 + 去重表 | 补偿请求先获取锁,再写入去重表,最后执行补偿 | 高并发、低延迟场景 | 锁超时时间需大于补偿执行时间 |
| Token 令牌机制 | 补偿发起时生成一次性Token,执行端校验并消耗Token | 跨系统、跨服务的补偿调用 | Token 生成与校验需原子性 |
// 幂等性补偿实现示例(伪代码)
public boolean compensate(String txId, String stepId) {
// 1. 检查去重表
if (compensateLogDao.exists(txId, stepId)) {
return true; // 已执行过,直接返回成功
}
// 2. 开启本地事务
Transaction tx = beginTransaction();
try {
// 3. 执行业务补偿(如释放库存)
inventoryService.release(txId, stepId);
// 4. 写入补偿日志(唯一约束保证幂等)
compensateLogDao.insert(txId, stepId, Status.SUCCESS);
tx.commit();
return true;
} catch (DuplicateKeyException e) {
// 并发重复插入,说明其他线程已执行
tx.rollback();
return true;
} catch (Exception e) {
tx.rollback();
throw new CompensateException("补偿执行失败", e);
}
}
4.3 铁律二:空补偿(Empty Compensation)
定义:当补偿操作执行时,对应的正向操作(Try/Confirm)尚未执行或尚未完成,补偿操作应直接返回成功,而不应报错或产生副作用。
为什么需要:在异步消息或网络延迟场景下,补偿请求可能先于正向操作到达。若补偿操作因找不到正向操作记录而抛出异常,将导致整个事务补偿链路中断,甚至引发死循环重试。
空补偿处理流程
- 接收补偿请求:携带事务ID、步骤ID、补偿参数。
- 查询正向操作状态:根据事务ID查询正向操作是否已执行。
- 分支判断:
- 若正向操作已执行:执行正常补偿逻辑(需幂等)。
- 若正向操作未执行:记录空补偿日志,直接返回成功。
- 若正向操作执行中:等待或异步重试,避免并发冲突。
- 返回结果:统一返回成功,避免上游重试风暴。
// 空补偿处理示例
public boolean compensateWithEmptyCheck(String txId, String stepId) {
// 1. 查询正向操作状态
TryRecord record = tryRecordDao.selectByTxId(txId, stepId);
if (record == null) {
// 2. 正向操作未执行 → 空补偿
emptyCompensateLogDao.insert(txId, stepId, "EMPTY");
return true; // 直接成功
}
if (record.getStatus() == TryStatus.PENDING) {
// 3. 正向操作执行中 → 等待或异步重试
throw new RetryableException("正向操作未完成,稍后重试");
}
// 4. 正向操作已执行 → 正常补偿(幂等)
return doCompensate(txId, stepId);
}
4.4 铁律三:防悬挂(Anti-Suspension)
定义:防止补偿操作“悬挂”在系统中,即补偿操作先于正向操作到达并执行后,正向操作才到达。此时正向操作应被拒绝执行,避免业务状态不一致。
为什么需要:空补偿解决了补偿先到时的“不报错”问题,但若正向操作后续到达并执行,将导致“补偿已执行,正向操作又执行”的矛盾状态。防悬挂机制确保正向操作能够感知到补偿已发生,从而拒绝执行。
防悬挂状态机
4.5 铁律四:安全回滚(Safe Rollback)
定义:补偿操作(回滚)必须能够独立、安全地执行,不依赖后续操作的状态或结果。回滚逻辑应基于正向操作执行前的快照或记录,而非当前系统状态。
为什么需要:在复杂的审批流或多步骤事务中,回滚操作可能发生在任意步骤之后。若回滚逻辑依赖后续步骤的状态(如“只有步骤3完成后才能回滚步骤2”),则当步骤3失败时,步骤2将无法回滚,形成死锁。
安全回滚 vs 不安全回滚
| 对比维度 | 安全回滚 | 不安全回滚 |
|---|---|---|
| 依赖范围 | 仅依赖本步骤正向操作的执行记录 | 依赖后续步骤或其他服务的状态 |
| 执行条件 | 任何时候均可执行,无需前置条件 | 需要特定状态或锁才能执行 |
| 失败影响 | 仅影响本步骤,可独立重试 | 可能导致整个事务无法回滚 |
| 典型实现 | 基于快照恢复、逆向操作、状态重置 | 基于当前余额、当前库存等可变数据 |
| 示例 | 扣减库存:回滚时根据记录恢复原始库存量 | 扣减库存:回滚时读取当前库存并加回(可能已被其他事务修改) |
// 安全回滚示例:基于快照恢复
public boolean safeRollback(String txId, String stepId) {
// 1. 获取正向操作执行前的快照
Snapshot snapshot = snapshotDao.selectByTxId(txId, stepId);
if (snapshot == null) {
throw new IllegalStateException("未找到正向操作快照,无法安全回滚");
}
// 2. 基于快照恢复原始状态
// 例如:恢复库存数量为快照中的值
inventoryDao.updateStock(snapshot.getSkuId(), snapshot.getOriginalStock());
// 3. 记录回滚完成
rollbackLogDao.insert(txId, stepId, snapshot.getOriginalStock());
return true;
}
// 不安全回滚示例(反模式)
public boolean unsafeRollback(String txId, String stepId) {
// 错误:基于当前库存回滚,可能已被其他事务修改
int currentStock = inventoryDao.getCurrentStock(skuId);
inventoryDao.updateStock(skuId, currentStock + 1); // 假设只扣了1个
// 若其他事务也扣减了库存,此处会错误地增加库存
}
4.6 四大铁律协同工作流
在实际的审批流补偿设计中,四大铁律并非孤立存在,而是协同工作。以下是一个完整的补偿执行流程,展示了四者的配合关系:
- 接收补偿请求(携带全局事务ID)
- 幂等性检查:查询补偿日志表,若已执行则直接返回成功。
- 空补偿检查:查询正向操作记录,若不存在则记录空补偿日志并返回成功。
- 防悬挂检查:若正向操作记录存在但状态为“已补偿”,则拒绝执行并返回“已补偿”状态。
- 安全回滚执行:基于正向操作快照,执行独立的逆操作。
- 记录补偿日志:写入补偿成功记录,确保后续幂等。