把单体应用拆成多个服务后,一个下单动作可能要同时调用订单服务、库存服务、营销服务和支付服务。每个服务都有独立的数据库,本地事务只能保证自己库内的ACID,跨服务调用链却没有任何统一提交或回滚能力。如果库存扣减失败,订单已经落库,营销券已经核销,这时直接返回错误会把不一致的数据留在线上。要解决这类问题,常见做法就是引入Saga模式:把一个长业务拆成一系列有固定顺序的本地事务,每个事务结束后记录状态,当某个事务失败时,按相反顺序执行已经成功步骤的补偿逻辑。这样做的目标不是强一致,而是让系统在有限时间内恢复一致,也就是最终一致性。本文将结合Node.js实现一套可落地的Saga编排器。

相比两阶段提交和TCC,Saga更贴近微服务长事务场景。两阶段提交会锁定资源直到全局提交,不适合高并发和长流程;TCC要求每个参与者都实现Try、Confirm、Cancel,接口复杂且业务侵入明显。Saga把补偿策略下沉到业务逻辑中,开发量更可控,也更适合订单、库存这类可以通过反向操作抵消的服务。但它也有代价:补偿可能失败、业务可能出现中间状态、需要额外的状态机和日志保障。因此实现Saga不能只写一个循环调用,必须把状态持久化、幂等、重试和超时一起考虑进去。
一、Saga模式的核心机制与两种协调方式
Saga把全局事务划分成多个步骤,每个步骤都包含一个正向操作和一个补偿操作。正向操作例如创建订单,补偿操作就是取消订单;正向操作例如扣减库存,补偿操作就是回补库存。执行时,协调器按顺序调用每个步骤的正向操作,并记录每个步骤是否成功。如果某个步骤抛出异常,协调器不会继续执行后续步骤,而是从当前步骤的前一个步骤开始,倒序执行补偿操作,把已经完成的正向操作撤销掉。这里的关键是,补偿操作不是数据库回滚,而是业务层的反向操作,因此它的设计必须能抵消正向操作产生的副作用。
Saga有两种主流协调方式:编排式(Orchestration)和协同式(Choreography)。编排式由一个中心协调器掌握整个流程,由它依次调用各个服务,并根据结果决定继续还是补偿。协同式没有中心节点,每个服务完成自身事务后发布事件,下一个服务监听事件并继续执行,失败时再发布补偿事件。两种方式各有优劣:编排式流程清晰、易测试、易维护,适合步骤固定、业务顺序明确的场景;协同式服务之间完全解耦,适合流程经常变化、团队边界多的场景,但排错和追踪困难。Node.js生态中,如果团队规模不大、业务步骤清晰,推荐优先使用编排式,因为用一个Saga编排器就能把复杂流程收敛到一处。
在真正写代码之前,还需要明确Saga的一些前提。每个正向操作和补偿操作都必须是幂等的,因为网络超时、服务重启、重试等因素可能导致同一个操作被执行多次。状态必须持久化,不能只放在内存里,否则协调器重启后不知道哪些步骤已经成功、哪些需要补偿。还需要定义超时策略,如果一个步骤长时间没有返回,协调器应能主动标记失败并触发补偿。下面给出的Node.js示例会基于这些前提实现一个可运行的最小模型。
二、Node.js编排式Saga的代码骨架
先定义Saga步骤的结构。每个步骤包含名称、正向操作和补偿操作,正向操作返回一个上下文对象的一部分,供后续步骤使用;补偿操作接收相同的上下文,以便知道该撤销哪些数据。为了演示方便,这里不直接绑定具体数据库和消息中间件,而是用内存对象模拟订单、库存、支付三个服务,这样可以把重点放在编排器本身。
// saga.definition.js
const orderSaga = {
steps: [
{
name: 'createOrder',
execute: async function (ctx) {
ctx.orderId = 'ORD-' + Date.now();
console.log('创建订单成功', ctx.orderId);
return ctx;
},
compensate: async function (ctx) {
ctx.orderStatus = 'CANCELED';
console.log('补偿:取消订单', ctx.orderId);
return ctx;
}
},
{
name: 'deductInventory',
execute: async function (ctx) {
const stock = await inventoryService.getStock(ctx.productId);
if (stock < ctx.quantity) {
throw new Error('库存不足');
}
await inventoryService.deduct(ctx.productId, ctx.quantity);
console.log('扣减库存成功');
return ctx;
},
compensate: async function (ctx) {
await inventoryService.restore(ctx.productId, ctx.quantity);
console.log('补偿:回补库存');
return ctx;
}
},
{
name: 'chargePayment',
execute: async function (ctx) {
await paymentService.charge(ctx.userId, ctx.amount);
console.log('支付扣款成功');
return ctx;
},
compensate: async function (ctx) {
await paymentService.refund(ctx.userId, ctx.amount);
console.log('补偿:退款');
return ctx;
}
}
]
};
module.exports = orderSaga;
上面的定义方式非常直观,每个步骤的execute和compensate成对出现,即使业务人员也能大致看懂流程。实际项目中,库存服务、支付服务可能跑在不同的进程或服务中,这里的inventoryService.deduct可以替换成HTTP调用或RPC调用。关键在于每个步骤不应自行吞掉异常,而应把错误抛给协调器,让协调器统一处理补偿。
定义好步骤后,接下来实现一个SagaRunner,它负责按顺序执行正向操作,记录已成功步骤,并在某个步骤失败时倒序执行补偿。为了简单,我们把执行状态保存在内存变量中,这适合单机演示;多实例部署时,需要把这个状态替换为Redis或数据库中的Saga日志。
// saga.runner.js
class SagaRunner {
constructor(steps) {
this.steps = steps;
}
async run(initialContext) {
const completedSteps = [];
const ctx = { ...initialContext };
try {
for (const step of this.steps) {
console.log('执行步骤:', step.name);
await step.execute(ctx);
completedSteps.push(step);
}
console.log('Saga执行完成');
return { status: 'SUCCESS', ctx };
} catch (err) {
console.error('步骤失败:', err.message, '开始补偿');
for (let i = completedSteps.length - 1; i >= 0; i--) {
const step = completedSteps[i];
try {
await step.compensate(ctx);
} catch (compensateErr) {
console.error('补偿失败:', step.name, compensateErr.message);
// 实际场景应写入死信或人工介入队列
}
}
return { status: 'COMPENSATED', error: err.message, ctx };
}
}
}
module.exports = SagaRunner;
在这个执行器中,正向操作会改变ctx对象的属性,例如createOrder写入ctx.orderId,后续扣库存和扣款步骤可以读取这些属性。出错时,catch块从最后一个成功步骤开始向前遍历,依次调用compensate。需要注意,这里反向遍历的是completedSteps,而不是所有步骤;当前失败步骤本身的正向操作如果没有提交成功,就不需要补偿,但如果失败步骤已经产生了部分副作用,则需要在compensate中识别并处理,这是业务层需要额外注意的地方。
三、补偿函数与幂等控制
补偿操作比正向操作更容易出问题,因为补偿可能在网络抖动、服务重启后重复执行。如果取消订单的接口被调用两次,第一次把订单状态改为CANCELED,第二次可能覆盖成异常状态或重复发通知。因此,补偿函数必须满足幂等:重复执行多次,最终效果与执行一次相同,且不会产生额外副作用。实现幂等可以从几个层面入手,例如给每次Saga分配全局唯一的sagaId,用sagaId + 步骤名作为幂等键写入Redis或数据库,执行前先判断是否已经处理过。
对于库存回补这类操作,如果正向操作是扣减库存,补偿操作就是增加库存。但如果回补操作没有幂等控制,重复执行会导致库存虚增。常见的做法是记录一条库存变更流水,每次操作都插入一条带有唯一键的记录,键为sagaId:deductInventory:compensate,数据库唯一约束会阻止重复插入。下面是一个使用Redis实现幂等锁的示例,实际生产可以替换为数据库唯一索引。
// idempotent-inventory.js
async function compensateDeductInventory(ctx) {
const lockKey = 'saga:' + ctx.sagaId + ':inventory:compensated';
const locked = await redis.set(lockKey, '1', 'EX', 60, 'NX');
if (locked !== 'OK') {
console.log('库存补偿已执行过,跳过');
return;
}
await db.query(
'UPDATE inventory SET stock = stock + $1 WHERE product_id = $2',
[ctx.quantity, ctx.productId]
);
console.log('库存回补成功');
}
这里的Redis命令SET key value EX 60 NX只有在键不存在时才会设置成功,返回OK,否则返回空。通过这个幂等锁可以避免并发或重试导致重复回补。除了Redis,数据库唯一键、状态字段条件更新(例如UPDATE ... WHERE status = 'ACTIVE')也都是常用手段。幂等设计不仅是Saga的要求,也是任何分布式系统的基础约束。
另外,正向操作可能存在部分成功的情况,比如HTTP调用已经到达服务端并完成业务,但响应丢失,协调器误以为失败而触发补偿。此时正向操作和补偿操作可能都会执行。为应对这种情况,正向操作也要支持幂等。给每次请求传入sagaId,服务端收到相同sagaId时直接返回已有结果,不再重复处理。这样做可以很大程度上减少分布式事务中的重复副作用。
四、执行器、超时与消息队列结合落地
上面的执行器把所有状态放在内存中,适合演示和单机任务。但在真实生产环境,协调器可能会重启,Saga可能运行几秒甚至几分钟,内存状态不足以支撑可靠执行。此时需要把Saga的执行日志持久化到数据库,记录每个步骤的状态:PENDING、SUCCEEDED、COMPENSATING、COMPENSATED、FAILED。协调器在启动时加载未完成的Saga,根据状态继续执行或补偿。每次状态变更都写入日志,这样即使进程挂掉,也能从数据库恢复现场。
异步场景下,步骤执行可能通过消息队列触发。一种常见做法是:协调器更新Saga状态为PENDING并发送命令消息到队列,对应服务消费消息后执行正向操作,再发布完成事件。协调器收到完成事件后继续下一步;如果在一定时间内没收到事件,则进入超时分支,触发补偿。消息队列本身需要保证至少一次投递,配合幂等消费能实现最终一致。Node.js中可以使用RabbitMQ的确认机制或Kafka的消费者组来搭建这个流程。
超时处理是Saga容易忽略但非常重要的一环。假设支付服务调用后迟迟不返回,协调器不能无限等待,否则后续流程无法推进。可以在步骤执行时设置一个定时器,超过阈值就认为该步骤失败,开始补偿。但是要注意,超时不代表正向操作没有执行,所以补偿函数仍然必须能处理正向操作已成功、未成功以及执行中三种情况。通常会在步骤执行前记录状态为PENDING,超时后先把状态标记为UNCERTAIN并尝试查询业务侧结果,如果不能确定再补偿,补偿通过幂等保证安全。
最后看一个完整的运行示例,把前面定义的Saga和执行器串起来。执行时故意让支付步骤失败,观察补偿是否按取消订单、回补库存的顺序执行。
// index.js
const orderSaga = require('./saga.definition');
const SagaRunner = require('./saga.runner');
async function main() {
const runner = new SagaRunner(orderSaga.steps);
const result = await runner.run({
sagaId: 'SAGA-' + Date.now(),
productId: 'PROD-1001',
quantity: 2,
userId: 'USER-200',
amount: 198
});
console.log('最终状态:', result.status);
console.log('上下文:', result.ctx);
}
main().catch(console.error);
如果支付步骤抛出异常,运行后会看到创建订单、扣减库存先后成功,支付失败后倒序执行退款、回补库存、取消订单。这说明Saga的补偿路径已经跑通。把日志和状态落到数据库、接入消息队列后,这套逻辑可以平滑扩展到多个Node.js服务实例。只要坚持每个步骤有明确的补偿动作,并且所有写操作都具备幂等控制,分布式事务补偿就不会变成难以维护的黑洞。