在微服务架构中,订单服务和库存服务各自拥有独立数据库,本地事务只能保证单库的原子性。一次下单操作可能先写订单表,再通过 HTTP 调用库存服务扣减库存。如果库存扣减成功,订单服务在提交本地事务前崩溃,就会出现订单未生成但库存已扣减;反过来,如果订单提交成功、扣减调用超时,实际远端可能已经扣减,订单却显示失败。类似问题无法仅靠调用方捕获异常解决,因为超时并不代表业务失败。为了让多个服务在异常情况下仍能保持一致,分布式事务方案被引入。TCC 和最大努力通知是两种常见落地方式:TCC 通过预占资源与阶段确认实现较短时间窗的强一致,最大努力通知则通过消息重试与幂等消费实现最终一致。

一、从下单扣库存看分布式事务的难点
很多团队习惯在订单服务里写一个数据库事务,然后在事务内部调用库存服务。代码如下所示:
await db.transaction(async (tx) => {
await orderRepo.insert(order, tx);
await inventoryClient.deduct(order.sku, order.qty);
});
这段代码看似把本地插入和远程调用放在了一个事务里,但数据库事务只会回滚本地插入,已经发出的 HTTP 请求无法撤回。如果库存服务已经成功扣减,而订单表本地事务最终回滚了,库存就会凭空减少;如果库存服务调用失败,本地回滚没有问题,但真实业务中调用失败也可能只是响应丢失,实际远端已经执行成功。
因此,跨服务的一致性不能依靠数据库事务,而需要由业务层引入协调机制。分布式事务的目标不是在任意时刻都保持所有节点完全一致,而是让系统在成功路径上尽快一致,在失败路径上通过补偿或重试恢复到可接受状态。TCC 更偏向同步的强一致,最大努力通知则更偏向异步的最终一致,两者在实现代价和业务侵入性上有明显差异。
二、TCC 事务模式:原理与 Node.js 协调器实现
TCC 把一次跨服务调用拆成三个动作:Try、Confirm、Cancel。Try 阶段只预留资源,不做真正的业务提交;Confirm 阶段把预留资源转为正式结果;Cancel 阶段释放预留资源。以库存服务为例,Try 时冻结库存,防止其他订单抢用;Confirm 时把冻结库存真正扣减;Cancel 时解冻库存。订单服务可以 Try 创建预下单、Confirm 标记有效、Cancel 标记取消。
协调器先依次调用所有分支的 Try,全部成功后再依次调用 Confirm;如果任意分支 Try 失败,或者 Confirm 阶段出现异常,协调器会对已经执行 Try 成功的分支调用 Cancel 进行补偿。下面是一个简化的 Node.js 协调器实现:
class TCCCoordinator {
constructor() {
this.txStore = new Map();
}
async execute(txId, branches) {
const done = [];
try {
for (const branch of branches) {
await branch.try(txId);
done.push(branch);
}
for (const branch of done) {
await branch.confirm(txId);
}
this.txStore.set(txId, 'confirmed');
} catch (err) {
for (const branch of done) {
try {
await branch.cancel(txId);
} catch (cancelErr) {
console.error('cancel branch failed', cancelErr);
}
}
this.txStore.set(txId, 'cancelled');
throw err;
}
}
}
class InventoryBranch {
async try(txId) {
console.log('Try: 冻结库存', txId);
}
async confirm(txId) {
console.log('Confirm: 正式扣减冻结库存', txId);
}
async cancel(txId) {
console.log('Cancel: 解冻库存', txId);
}
}
(async () => {
const coordinator = new TCCCoordinator();
await coordinator.execute('tx-1001', [new InventoryBranch()]);
})();
这个示例使用内存 Map 保存事务状态,只适合演示。生产环境中协调器必须把事务状态、分支调用结果和当前阶段持久化到 MySQL 或 Redis,否则协调器在 Confirm 中途宕机后,无法知道哪些分支已经确认、哪些还需要补偿。同时需要增加超时扫描任务,比如某些事务长时间停留在 Try 完成但未 Confirm 的阶段,定时器要负责继续推进或触发回滚。
TCC 落地时最容易踩到空回滚和悬挂问题。空回滚指的是分支服务没有收到过 Try,却收到了 Cancel。这通常发生在 Try 请求超时后,协调器直接发起 Cancel。分支服务如果直接执行 Cancel 释放资源,可能产生错误;正确做法是记录一个回滚标记,表示这个事务只允许回滚,后续 Try 到达时直接拒绝。悬挂则是 Cancel 先到、Try 后到的情况,如果 Try 不识别回滚标记,就会重新冻结资源,导致数据不一致。下面的代码演示了基本的防护逻辑:
async function tryPhase(txId) {
const tx = await db.findOne({ txId });
if (tx && tx.status === 'rollback_only') {
throw new Error('transaction already marked rollback only');
}
if (!tx) {
await db.insert({ txId, status: 'frozen' });
}
return 'try success';
}
async function cancelPhase(txId) {
const tx = await db.findOne({ txId });
if (!tx) {
await db.insert({ txId, status: 'rollback_only' });
return 'cancel recorded with no try';
}
if (tx.status === 'frozen') {
await db.update({ txId }, { status: 'released' });
}
return 'cancel success';
}
除了状态判断,分支服务的三个接口都必须幂等。Try 重复调用不应重复冻结,Confirm 重复调用不应重复扣减,Cancel 重复调用不应重复释放。实际开发中可以通过数据库唯一约束加状态机保证幂等,同时把分支事务执行日志独立出来,便于排查和重试。
三、最大努力通知:基于本地消息表与重试的最终一致性
最大努力通知不要求所有服务都实现预占和确认接口,它的核心思路是发起方把本地业务和通知消息放在同一个本地事务里。订单服务创建订单的同时,往 outbox 消息表插入一条待发送记录,事务提交后再异步发送这条消息。如果发送失败,后台任务会定时扫描待发送消息并按退避策略重试,直到发送成功或达到最大重试次数。消费方必须实现幂等处理,因为重试会导致重复投递。
下面是一个 Node.js 本地消息表加定时重试的示例:
async function createOrderWithNotify(order) {
await db.insert('orders', order);
await db.insert('outbox', {
orderId: order.id,
payload: JSON.stringify(order),
status: 'pending',
nextRetry: Date.now()
});
}
async function notifyRemoteService(message) {
const response = await fetch('http://localhost:4001/notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: message.payload
});
return response.ok;
}
async function maxEffortWorker() {
const dueMessages = await db.query(
"SELECT * FROM outbox WHERE status = 'pending' AND next_retry <= $1",
[Date.now()]
);
for (const msg of dueMessages) {
const success = await notifyRemoteService(msg);
if (success) {
await db.update('outbox', { ...msg, status: 'sent' });
} else {
const retries = (msg.retries || 0) + 1;
const delay = Math.min(1000 * Math.pow(2, retries), 60000);
await db.update('outbox', {
...msg,
retries,
nextRetry: Date.now() + delay
});
}
}
}
setInterval(maxEffortWorker, 5000);
这段代码中,createOrderWithNotify 在同一个本地事务里插入订单和消息,避免了订单已创建但消息丢失的问题。maxEffortWorker 每隔五秒扫描一次到期消息,发送成功后把状态改为 sent,失败时根据重试次数做指数退避。实际项目中可以把 setInterval 替换为 Agenda、BullMQ 或云函数定时触发器,以获得更可靠的调度能力。
如果使用 RabbitMQ 或 Kafka,发布方可以开启 publisher confirm,消费方使用手动 ACK,但这并不等于绝对可靠。confirm 只代表 Broker 收到消息,不代表下游消费成功;手动 ACK 也可能因为进程崩溃而丢失。因此本地消息表仍然是最大努力通知的重要兜底,尤其在网络抖动和下游服务发布时,持续重试能显著降低不一致窗口。
四、TCC 与最大努力通知的对比与选型建议
两种方案并不互斥,它们解决的是不同强度的一致性问题。TCC 会锁定资源,参与方必须实现三个接口,协调器要维护事务状态机,开发和测试成本较高,但一旦流程结束,各参与方状态就是一致的。最大努力通知侵入性小,主流程只多了一张消息表,发送失败可以持续重试,缺点是存在短暂不一致,如果重试始终失败,需要人工介入或进入死信队列。
下表从关键维度对比了两种方案:
| 维度 | TCC | 最大努力通知 |
|---|---|---|
| 一致性 | 强一致,事务完成后各分支一致 | 最终一致,存在短暂不一致 |
| 性能 | 需要预留资源,有一定锁竞争 | 主流程异步,吞吐较高 |
| 侵入性 | 每个资源需实现三个接口 | 只需发送方记录消息,消费方幂等 |
| 适用场景 | 资金扣减、库存、积分 | 支付回调、通知、数据同步 |
选型时优先看业务对一致性窗口的容忍度。核心交易链路,例如资金扣减、库存锁定,如果要求调用结束后立即一致,适合使用 TCC;而通知类、日志同步、积分发放等允许延迟的场景,使用最大努力通知能获得更好的吞吐和更低的实现复杂度。很多系统会组合使用,比如库存扣减走 TCC,订单完成后的短信通知走最大努力通知。
在 Node.js 中落地分布式事务,无论选择哪种方案,都需要把事务状态持久化、接口幂等、超时重试和监控告警作为基础能力。分布式事务不是框架可以直接隐藏的复杂度,它要求开发者明确数据流向和失败路径。只要补偿动作、重试机制和状态查询足够清晰,Node.js 同样可以稳定承载高并发的跨服务事务场景。
Node.js分布式事务TCC最大努力通知修改时间:2026-09-02 17:39:57