3、补偿事务设计原则
Saga 事务的核心在于当正向流程中的某个本地事务失败时,能够通过执行一系列补偿事务来回滚已提交的操作。补偿事务的设计质量直接决定了 Saga 的可靠性与数据一致性。本章聚焦三大核心设计原则:补偿幂等性、补偿可逆性以及补偿空操作与悬挂处理。
3.1 补偿幂等性设计
在分布式系统中,网络超时、节点重启、消息重试是常态。补偿事务可能被多次触发执行,因此必须保证无论执行多少次,其最终结果与执行一次完全一致。
核心定义: 补偿幂等性是指对同一个补偿操作执行多次,系统状态不发生改变,且不会产生副作用。
3.1.1 幂等性实现策略
| 策略 | 实现方式 | 适用场景 |
|---|---|---|
| 唯一请求键 | 每次补偿请求携带全局唯一 ID(如 UUID),服务端记录已处理 ID,重复请求直接返回成功。 | 所有补偿操作的基础保障 |
| 状态机驱动 | 业务数据维护状态字段,补偿操作仅在当前状态为“待补偿”时执行,执行后更新为“已补偿”。 | 订单、账户等有明确状态流转的业务 |
| 版本号乐观锁 | 更新数据时校验版本号,版本号不匹配则放弃更新,返回成功。 | 高并发写入场景 |
| 数据库去重表 | 建立补偿去重表,以事务 ID 为唯一索引,插入成功才执行补偿逻辑。 | 对数据一致性要求极高的金融场景 |
3.1.2 代码示例:基于状态机的幂等补偿
// 补偿扣减库存操作
public boolean compensateDeductStock(String txId, String productId, int quantity) {
// 1. 检查补偿去重表
if (compensationLogDao.exists(txId)) {
return true; // 已补偿过,直接返回成功
}
// 2. 检查当前库存状态(状态机校验)
Inventory inventory = inventoryDao.selectForUpdate(productId);
if (inventory.getStatus() != InventoryStatus.DEDUCTED) {
// 状态不对,可能是空操作或已回滚,直接返回成功
return true;
}
// 3. 执行补偿:增加库存
int affectedRows = inventoryDao.increaseStock(productId, quantity,
inventory.getVersion());
if (affectedRows == 0) {
// 乐观锁冲突,说明其他节点已处理,重试即可
throw new RetryableException("乐观锁冲突,重试补偿");
}
// 4. 记录补偿日志
compensationLogDao.insert(txId, productId, "COMPENSATE_DEDUCT");
// 5. 更新库存状态
inventoryDao.updateStatus(productId, InventoryStatus.AVAILABLE);
return true;
}
实践建议: 补偿幂等性应作为 Saga 框架的基础能力,而非业务代码的额外负担。建议在框架层统一实现去重与状态校验逻辑。
3.2 补偿可逆性设计
补偿事务的本质是“撤销”正向操作的效果。可逆性要求补偿操作在逻辑上必须是正向操作的精确逆操作,且补偿后的系统状态应与正向操作从未发生时的状态完全一致。
3.2.1 可逆性设计原则
- 操作对称性: 正向操作与补偿操作应形成一对互逆函数。例如:
add(x)的补偿是subtract(x),而非subtract(x+1)。 - 数据完整性: 补偿操作不仅要恢复数值,还要恢复关联的元数据、日志、时间戳等。
- 顺序无关性: 补偿操作不应依赖其他未完成的补偿操作,应能独立执行并恢复局部状态。
3.2.2 可逆性对比表
| 正向操作 | 补偿操作 | 可逆性检查点 |
|---|---|---|
| 扣减账户余额 100 元 | 增加账户余额 100 元 | 余额、流水记录、账户状态均需恢复 |
| 创建订单(状态:待支付) | 取消订单(状态:已取消) | 订单状态、库存预占释放、优惠券回滚 |
| 发送短信通知 | 记录“短信已撤销”日志(不可逆时采用标记法) | 无法物理撤回短信,需通过业务标记补偿 |
| 上传文件至 OSS | 删除已上传的文件 | 文件元数据、CDN 缓存失效 |
注意: 并非所有操作都能实现物理可逆。例如“发送邮件”无法撤回,此时应采用“标记补偿”策略:记录补偿日志,并在业务层面展示“该操作已被撤销”的提示,而非试图物理删除已发送的邮件。
3.3 补偿空操作与悬挂处理
在 Saga 执行过程中,由于网络延迟、节点崩溃等原因,可能出现正向操作尚未执行,补偿操作却已触发的情况。这会导致两种异常模式:空操作(补偿执行时正向操作未发生)和悬挂(正向操作在补偿完成后才到达)。
3.3.1 空操作处理
空操作是指补偿事务执行时,对应的正向事务尚未执行或已失败回滚。此时补偿操作不应产生任何实际影响。
- 检测机制: 补偿执行前检查正向操作是否已成功提交。可通过事务日志、状态表或分布式缓存查询。
- 处理策略: 若正向操作不存在,则直接返回成功,并记录“空补偿”日志。
- 防重复: 空补偿日志同样需要幂等处理,避免重复记录。
3.3.2 悬挂处理
悬挂是指正向操作在补偿完成后才到达执行节点。此时正向操作应被拒绝执行,否则会导致数据不一致。
- 检测机制: 正向操作执行前,检查是否已有对应的补偿记录或 Saga 已标记为“补偿完成”。
- 处理策略: 若检测到悬挂,则直接丢弃该正向操作请求,并返回成功(避免触发重试)。
- 超时清理: 设置合理的 Saga 超时时间,超时后自动清理未完成的事务记录,防止悬挂操作长期占用资源。
3.3.3 空操作与悬挂处理流程
// 正向操作执行前的悬挂检测
public boolean executeForward(String txId, ForwardRequest request) {
// 1. 检查 Saga 状态
SagaStatus status = sagaLogDao.getStatus(txId);
if (status == SagaStatus.COMPENSATING || status == SagaStatus.COMPENSATED) {
// 已进入补偿阶段,拒绝正向操作(防悬挂)
logger.warn("检测到悬挂操作,丢弃请求 txId={}", txId);
return true; // 返回成功,避免上游重试
}
// 2. 检查是否已有补偿记录
if (compensationLogDao.existsByTxId(txId)) {
logger.warn("补偿已执行,正向操作被悬挂 txId={}", txId);
return true;
}
// 3. 执行正向业务逻辑
// ...
}
// 补偿操作前的空操作检测
public boolean executeCompensate(String txId, CompensateRequest request) {
// 1. 检查正向操作是否已执行
ForwardRecord record = forwardLogDao.getByTxId(txId);
if (record == null || record.getStatus() != ForwardStatus.SUCCESS) {
// 正向操作未执行或已失败,空操作处理
logger.info("空补偿:正向操作未执行 txId={}", txId);
compensationLogDao.insertEmpty(txId);
return true;
}
// 2. 执行补偿逻辑
// ...
}
最佳实践: 建议在 Saga 协调器层面统一管理空操作与悬挂检测,业务服务只需关注核心补偿逻辑。协调器通过事务状态机(TCC 模式的状态流转)自动判断当前操作是否合法。
3.4 补偿事务设计原则总览
3.5 本章小结
补偿事务设计是 Saga 模式落地的关键难点。通过本章的学习,应掌握以下核心要点:
- 幂等性是补偿操作的“安全网”,通过唯一键、状态机、乐观锁等机制确保多次执行结果一致。
- 可逆性要求补偿操作与正向操作形成精确的逆运算,无法物理可逆时采用标记补偿策略。
- 空操作与悬挂处理是应对分布式系统异步时序问题的必备手段,需要在正向和补偿操作中分别进行检测与防护。
实战提醒: 在压测场景中,补偿事务的幂等性和空操作处理是高频故障点。建议在压测脚本中专门构造重复补偿、延迟补偿等异常场景,验证补偿设计的健壮性。