本地事务,全局结果
订单流程可能包括预留库存、扣款、创建发货单。每个服务有自己的数据库,无法与其他服务参与同一个 ACID 事务。Saga 把流程显式化:预留库存、扣款、创建发货单;如果发货失败,就退款并释放库存。每一步本地提交,Saga 负责顺序和补偿逻辑。
有两种协调方式。编排式由一个中心执行器调用各服务并决定下一步;协同式由每个服务响应事件并发布下一个事件,没有组件拥有完整流程。复杂流程用编排式更容易推理和观测;协同式降低耦合,但服务增多后全局流程更难看清。
type Step = {
name: string;
run: () => Promise<void>;
compensate: () => Promise<void>;
};
export async function runSaga(steps: Step[]): Promise<void> {
const completed: Step[] = [];
try {
for (const step of steps) {
await step.run();
completed.push(step);
}
} catch (error) {
for (const step of completed.reverse()) {
await step.compensate();
}
throw error;
}
}
这个最小执行器不足以生产使用。调用下一步前要持久化流程状态和已完成步骤,使进程崩溃后可以恢复或补偿。每个 run 和 compensate 都应使用由 saga id 和步骤名派生的幂等键。增加超时和可运维的最终失败状态。
设计中转状态
Saga 会暴露中间状态:订单可能在支付成功前存在。要显式建模 pending_payment、reserved、cancelled,并决定哪些对用户可见。语义锁把记录标记为正在被 Saga 修改,阻止其他操作把它当作最终状态。这不是隔离,但能防止最危险的并发更新。
补偿不总是原操作的逆操作。退款是带手续费和延迟的新交易;释放预留可能无法恢复原库存位置,因为其他订单已经消耗。应把补偿设计成业务操作,在部分失败下测试,并使其可观测。
何时使用
当工作流跨服务、运行时间长,并且可以接受带显式中转状态的最终一致性时,使用 Saga。优点是不用持有跨网络调用的全局锁,每个服务保留自己的事务边界;缺点是必须显式设计并运维补偿、幂等和中间状态。与分布式事务相比,Saga 放弃隔离,换取可用性和独立演进。不要用它来回避单个服务内的事务边界设计;本地 ACID 事务更简单、更强。如果流程要求严格隔离或立即全局一致,Saga 就是错误的抽象,需要改变服务边界或业务流程。