问题:一笔业务事务、五个服务、没有 ACID
一位旅客预订一次旅行。航班、酒店、租车、机场接送、旅行保险这五项,分别住在不同的服务里、不同的数据库里、不同的归属下。从旅客的视角看,这是一笔事务:要么这五段全都订到了,要么这笔订单不存在。从数据库的视角看,没有任何原子事务能跨过五个连接池。
这不是疏漏。这是把单体拆成微服务之后的结构性后果。ACID 保证只在一个数据库、一次连接、一个信任边界内有效。一笔工作只要跨过服务边界——哪怕是非常小的边界——全局 ACID 事务就崩了。两阶段提交能让多个数据库保持步调一致,但没法让多个服务保持步调一致:调一次第三方航司 API 的 PREPARE 是一个尽力而为的异步事件,不是一张事务性的投票。
双写问题
一种朴素的做法是写两次:一次写进每个服务的本地事务,一次写到一个「全局」反规范化存储。每次写入翻倍,第二次写入可能独立失败,两次写入还会赛跑。这份反规范化成了记录系统,意味着真相的来源被劈成了两半,意味着之后每一次查询都要协调两条时间线。这比当初想要解决的问题还要糟。
于是 Saga 模式回答的问题就变得很务实:**当单一 ACID 事务不可能时,对这个问题做最小的改动,使得一笔业务结果仍然可能?**答案是:把业务事务拆成一连串本地事务,每一笔都 能 被撤销,并接受「撤销」本身也是一笔本地事务。
这个模式最早由 Hector Garcia-Molina 和 Kenneth Salem 在他们 1987 年的论文 Sagas 中形式化,背景是长运行数据库事务。微服务时代的复兴——本文要讲的——来自 Chris Richardson 的 microservices.io 以及那些在生产里把它真用起来的系统:AWS Step Functions、Camunda、Temporal。模式本身有四十岁了,应用场景是新的。
解决方案预览:Saga 是一次有回程计划的航行
一次航行有几段航程。每一段都是一个有目的地的动作——一次航班、一次酒店、一次租车——而每一段都有一份 回程:如果后面任何一段失败,把这次旅行拨回原点的对应动作。Saga 模式把这种航行的结构,作为业务工作的单元。
如果说 舱壁模式 是通过把资源分到独立的池来隔离故障,那么 Saga 模式就是在没有共同数据库的服务之间协调恢复。舱壁挡住爆炸,Saga 写出把碎片拼回去的剧本。
形式上,Saga 是一连串 本地事务 ,每一笔都配一个 补偿事务 。前向进度从左到右进行;在第一次失败时,Saga 对每一笔已经完成的 反向运行对应的补偿。补偿 是一项真实的动作——CancelFlight、RefundPayment、ReleaseSeat——不是数据库回滚。Saga 协调的是数据库事务做不到的事。
本文覆盖两种顶层变体——编排(由一个中央协调器告诉每个服务做什么)和协同(每个服务对总线上的事件做出反应)——外加三种实现形态、一节权衡讨论、一个决策矩阵。文末的相关模式一节会指向 Circuit Breaker(断路器) 和 Outbox Pattern(发件箱模式)——这是每一个真正在用的 Saga 最终都需要的两个模式。
两种变体:编排 vs 协同
同一种形状,两种控制逻辑。
编排式 Saga。 有一个服务——编排器——知道 Saga 的步骤。它依次调用 flight.reserve、hotel.reserve、car.reserve。每一步向编排器返回成功或失败,编排器决定下一步。任何失败发生时,编排器对已经完成的步骤反向发出补偿调用。编排器维护一份 Saga 日志:一份 per-Saga 的、仅追加的日志,记录哪些步骤已经完成、哪些已经补偿;这样一旦崩溃,可以重放日志来恢复。
协同式 Saga。 没有编排器。每个服务在消息代理上发出事件(FlightReserved、HotelReserved),并监听其它服务的事件。Saga 的前向进度,就是事件在系统中走过的路径;补偿则是监听 TripBookingFailed 并做出反应的那些服务。Saga 的状态住在代理的 offset 和每个参与者自己的本地存储里。没有单一的日志;每个参与者拥有自己那一段。
没有 Saga vs 有 Saga(问题框架)
单体是没问题的。微服务版本是把单体拆分之后才会出现的情况——拆分的理由很充分:独立部署、团队边界、扩缩策略。Saga 不是一笔更糟糕的事务,它是一种不同形状的工作,有不同的失败模式和不同的运维面。
编排:一条有向序列
编排器是 Saga 状态的唯一所有者。每一步、每一次补偿、每一次重试、每一次超时,都是它的职责。参与服务对更大的 Saga 一无所知——flight.reserve 和从任何调用者那里收到的请求是同一个调用。从参与者一侧解耦,是编排器的好处。
协同:一次事件级联
没有中心服务拥有任何东西。Saga 的前向进度,就是代理投递出来的事件序列。补偿流是 另一串 事件,往回走。脆弱之处藏在跨服务的事件契约里:TripBookingRequested 一改 schema,下游的三个消费者就要同步迁移。
「谁拥有这个 Saga?」是正确的问题
编排式 Saga 由编排器拥有。协同式 Saga 由 每一个处理相关事件的消费者 拥有——等团队图全了,「拥有者」就是代理挂掉那一刻恰好还醒着的那位工程师。挑那个你能撑到凌晨三点的归属故事的变体。
补偿事务:Saga 的真正工作
模式的名字暗示的是前向进度。真正的工作是 按需的反向进度。对每笔前向事务 ,Saga 必须定义一个补偿事务 ,在语义上撤销 ——不是数据库回滚,是一项动作。
在 microservices.io 经典的旅行预订例子中,Saga 是这样的:
| 步骤 | 前向 () | 补偿 () |
|---|---|---|
| 1 | ReserveFlight | CancelFlightReservation |
| 2 | ReserveHotel | CancelHotelReservation |
| 3 | ReserveCar | CancelCarReservation |
| 4 | ReserveAirportTransfer | CancelAirportTransfer |
| 5 | ActivateTravelInsurance | CancelTravelInsurance |
这些补偿不是 DELETE FROM booking WHERE id = ?。它们是 业务动作,用来撤销前向步骤的业务效果:调航司 API 取消订座,把房间归还酒店的库存,把车重新标记为可订。一笔补偿本身也会失败——这是 Saga 最被忽视的失败模式。
补偿对战的难题
想象 CancelFlightReservation 本身也失败了。Saga 正在清理时,航司 API 返回 503。这时 Saga 卡在半补偿状态:航司系统里航班订座还在(钱已付),酒店和租车都取消了(OK),编排器(或协同的消费者)在补偿上死循环重试。
应对这个问题的模式是:
- 幂等补偿。 每一笔补偿都带一个
idempotency_key,由(saga_id, step_id, "compensation")派生。第一次调用有效;后续用同一 key 的重试都是 no-op。补偿的效果,无论是第一次就成功,还是第一百零一次才成功,都一样。 - Saga 层的退避重试。 编排器(或协同的消费者)按计划重试失败的补偿——指数退避加一个最大间隔,一直重试到补偿成功,或人为超时中止 Saga。
- 为 Saga 的日志再套一层 Saga。 当连补偿日志都不靠谱时,升级到一个 meta-saga,监控主 Saga 的卡死补偿并施加运维干预(人工补偿、部分退款),把自动化解决不了的案例暴露给人。
- 参与者层面的幂等。 参与者服务是最后一道防线:如果它收到两次带相同幂等 key 的
CancelFlightReservation,第二次就是 no-op。Saga 没有参与者层面的幂等就玩不转。
一笔没有补偿的前向步骤,是一种设计异味
如果你在 Saga 里发现一笔前向步骤没有干净的补偿,那这步大概不属于这个 Saga——「我们没法撤销」对应的失败模式不是「加大力度补偿」,而是「在不可逆不再是问题之前,不要执行那个不可逆的动作」。真实例子:ActivateInsurancePolicy 步骤有一个补偿(CancelPolicy),但前提是激活之后还有一个 pending 窗口。过了窗口就没有补偿。修法是把激活保持在一个有受控延迟的状态机里,而不是围绕不可逆的情形写一段聪明的 Saga。
部分补偿的边界情况——退款减去取消费、部分反转、不可逆副作用步骤——本身就是一个丰富的话题,有不能干净归入上述「完全撤销」模型的失败模式。本系列会有后续文章专门讲补偿语义——具体的位置见文末相关模式一节。完全撤销假设是当下理解 Saga 实现的承重假设;部分补偿是它的严格超集,需要更丰富的状态模型。
实现策略:三种形态
模式是形状,不是产品。下面这三种形态是生产代码里会看到的——Temporal、Camunda、AWS Step Functions 都落在第三种;前两种是手写的。
形态 1:手写编排器
最简单、能上生产的实现是一个编排器服务,它把 Saga 的步骤图跑在真实的参与者服务之上。Saga 日志是编排器数据库里的一张表。步骤失败触发反向运行的补偿。
// saga/orchestrator.ts —— 一个 90 行的 Saga 编排器,含重试、退避、
// 幂等和 Saga 日志。生产代码会把 saga_log 持久化到数据库;这里的
// 形状是承重的部分,不是线缆格式。
type SagaStep = {
readonly id: string;
execute: () => Promise<{ ok: true; result: unknown } | { ok: false; reason: string }>;
compensate: () => Promise<{ ok: true } | { ok: false; reason: string }>;
};
type SagaLog = {
saga_id: string;
completed: string[]; // 按完成顺序排列的步骤 id
compensations_run: string[];
state: 'started' | 'forward' | 'compensating' | 'completed' | 'failed';
};
async function runSaga(
steps: readonly SagaStep[],
log: SagaLog,
retry: { maxAttempts: number; backoffMs: (n: number) => number } = {
maxAttempts: 5,
backoffMs: n => Math.min(30_000, 500 * 2 ** n),
},
): Promise<{ ok: true } | { ok: false; failedStep: string }> {
for (const step of steps) {
let attempt = 0;
let result: Awaited<ReturnType<SagaStep['execute']>>;
for (;;) {
result = await step.execute();
if (result.ok) break;
attempt++;
if (attempt >= retry.maxAttempts) {
// 标记进入补偿并落穿。
log.state = 'compensating';
await compensateInReverse(steps, log, retry);
return { ok: false, failedStep: step.id };
}
await new Promise(r => setTimeout(r, retry.backoffMs(attempt)));
}
log.completed.push(step.id);
}
log.state = 'completed';
return { ok: true };
}
async function compensateInReverse(
steps: readonly SagaStep[],
log: SagaLog,
retry: SagaParameters['retry'],
): Promise<void> {
for (const step of [...log.completed].reverse().map(id => steps.find(s => s.id === id)!)) {
let attempt = 0;
for (;;) {
const r = await step.compensate();
if (r.ok) { log.compensations_run.push(step.id); break; }
attempt++;
if (attempt >= retry.maxAttempts) {
log.state = 'failed';
return; // saga-for-the-saga
}
await new Promise(r => setTimeout(r, retry.backoffMs(attempt)));
}
}
log.state = 'failed';
}
type SagaParameters = Parameters<typeof runSaga>[2];
// 用法:
const steps: SagaStep[] = [
{ id: 'flight', execute: () => flight.reserve(...), compensate: () => flight.cancel(...) },
{ id: 'hotel', execute: () => hotel.reserve(...), compensate: () => hotel.cancel(...) },
{ id: 'car', execute: () => car.reserve(...), compensate: () => car.cancel(...) },
];
const log: SagaLog = { saga_id: 'trip-001', completed: [], compensations_run: [], state: 'started' };
const result = await runSaga(steps, log);
形状是:一个步骤迭代器、每步一个重试/退避循环、一份由 Saga 日志驱动的补偿循环,以及日志本身的一台状态机。真正的实现会把 log 放进有乐观并发的数据库行;这段代码是概念骨架,不是生产文件。加上 Saga 日志持久化、错误落到死信队列、再加一层给卡死补偿的 meta-Saga,你就有一个能上生产的手写编排器。
形态 2:事件驱动的协同
协同这种形态是 没有编排器。每个参与者服务拥有 Saga 的自己那段,对消息代理上的事件做出反应。Saga 的状态是分布式的。
// flight-service.ts —— 协同式预订 Saga 中的航班参与者。
// 订阅 TripBookingRequested;发出 FlightReserved 或
// FlightReservationFailed。
import { EventEmitter } from 'node:events';
type TripBookingRequested = {
saga_id: string;
leg_id: string;
flight_offer: { airline: string; flight_no: string; iso: string };
trace: string; // 用于分布式追踪的关联 id
};
type SagaEvent =
| { type: 'TripBookingRequested'; payload: TripBookingRequested }
| { type: 'FlightReserved'; payload: { saga_id: string; conf: string } }
| { type: 'FlightReservationFailed'; payload: { saga_id: string; reason: string } }
| { type: 'TripBookingCancelled'; payload: { saga_id: string } };
const bus = new EventEmitter();
bus.on('TripBookingRequested', async (evt) => {
if (evt.payload.leg_id !== 'flight') return;
try {
const conf = await flightProvider.reserve(evt.payload.flight_offer, evt.payload.trace);
bus.emit('saga', { type: 'FlightReserved', payload: { saga_id: evt.payload.saga_id, conf } } satisfies SagaEvent);
} catch (err) {
bus.emit('saga', {
type: 'FlightReservationFailed',
payload: { saga_id: evt.payload.saga_id, reason: String(err) },
} satisfies SagaEvent);
}
});
bus.on('TripBookingCancelled', async (evt) => {
const log = await readCompensationLog(evt.payload.saga_id);
if (!log.flightReserved) return;
try {
await flightProvider.cancel(log.conf, evt.payload.saga_id /* idempotency key */);
bus.emit('saga', { type: 'FlightReservationCancelled', payload: { saga_id: evt.payload.saga_id } } satisfies SagaEvent);
} catch (err) {
// 重试由代理的 redelivery + 死信队列处理。
throw err;
}
});
emit('FlightReserved', { saga_id, conf });
这里的承重形状是:事件先向前流动,然后补偿事件向回流动。每个消费者有自己一套幂等故事。所谓「Saga 状态」,是任何一个消费者能从自己看到的事件里推出来的部分;不存在全局视图。
// hotel-service.ts —— 对 FlightReserved 和 CarReservationFailed
// 做出反应的酒店参与者。这是从第二步内部看到的协同式 Saga 长这样。
bus.on('FlightReserved', async (evt) => {
const saga = await readSaga(evt.payload.saga_id);
if (saga.legs.includes('hotel') && !saga.hotelReserved) {
try {
const conf = await hotelProvider.reserve(saga.hotelOffer, evt.payload.saga_id);
bus.emit('saga', { type: 'HotelReserved', payload: { saga_id: evt.payload.saga_id, conf } } satisfies SagaEvent);
} catch (err) {
bus.emit('TripBookingCancelled', { payload: { saga_id: evt.payload.saga_id, reason: String(err) } } satisfies never as never as any);
// 生产里还要发 FlightReservationFailed,让航班补偿器也能跑。
}
}
});
协同的人体工学代价恰好就在这里:每个参与者都要知道要对哪些事件反应、对哪些事件忽略,而 Saga 里每加一步,都要改其它每个消费者的逻辑。编排器把这表达成一个列表;协同把它表达成一张图。
形态 3:工作流引擎(Temporal、Camunda、Step Functions)
工作流引擎把编排器外化出来。引擎持久化 Saga 日志、调度重试、并开箱即用地提供对运行中 Saga 的可观测性。团队写 Saga 的逻辑;引擎负责跑。
// saga.workflow.ts —— 同一个预订 Saga 的 Temporal workflow。
// 注意:函数体就是一个普通的 async;workflow 抛出时,
// Temporal 会自动处理重试、Saga 日志、超时和补偿调用。
import { proxyActivities, ApplicationFailure } from '@temporalio/workflow';
import type * as acts from './activities';
const { reserveFlight, cancelFlight, reserveHotel, cancelHotel, reserveCar, cancelCar } =
proxyActivities<typeof acts>({
startToCloseTimeout: '30s',
retry: { maximumAttempts: 5, backoffCoefficient: 2 },
});
export async function bookTripSaga(legs: TripLegs): Promise<Trip> {
const conf: Partial<Trip> = {};
try {
conf.flight = await reserveFlight(legs.flight);
conf.hotel = await reserveHotel(legs.hotel);
conf.car = await reserveCar(legs.car);
return conf as Trip;
} catch (err) {
// Temporal 的 Saga 补偿 API 负责反向调用。
// 在补偿模式下,fail() 不会被重试——引擎
// 把这次补偿记成 Saga 的一步。
if (ApplicationFailure.hasType(err, 'CarReservationFailed')) {
await Promise.allSettled([
conf.hotel && cancelHotel(conf.hotel),
conf.flight && cancelFlight(conf.flight),
]);
}
throw err;
}
}
这是和形态 1 一样的形状,只是编排器的「Saga 日志」是 Temporal 的状态存储,「重试」是 activity 的重试策略,「补偿」是 workflow 的 catch 块。生产团队在发现手写版本那些失败模式(Saga 日志持久化、可观测性、跨部署存活的长 Saga)开始比引擎本身还贵时,就会转向引擎。
| 形态 | 什么时候伸手拿它 | 权衡 |
|---|---|---|
| 手写编排器 | 一个团队,流程简单,并且想读懂 Saga 代码的每一行 | Saga 日志的持久化、可观测性、恢复故事都得自己扛 |
| 协同 | 参与者由不同团队拥有;Saga 天生就是松耦合 | 可观测性成本;跨团队的事件 schema 协调 |
| 工作流引擎 | 长运行 Saga(数小时到数天)、Saga 变体数量大、需要扛过部署的持久执行 | 增加一个供应商 / 自托管依赖;引擎特定概念 |
权衡与可观测性
Saga 模式不是免费的。三条权衡、三个可观测面。
权衡
- 运维复杂度。 Saga 每一步要监控三件事(前向成功、补偿成功、卡死补偿)、每一步要有一份重试策略、Saga 日志还得扛住进程重启和数据库迁移。单体有一笔事务;Saga 有一套运维流程。
- 最终一致性窗口。 在 Saga 前向步骤成功的那一刻,到所有下游服务看到结果状态之间,Saga 是 不一致的。窗口可以短到毫秒级(同步编排器、快的代理),也可以长到分钟级(协同、人工介入)。业务必须能对横跨窗口的查询进行推理——「航班订上了吗?」 这个问题的答案,要看酒店服务是否已经看到这次 Saga。
- 耦合权衡。 编排把 编排器 跟每个参与者的契约耦合在一起;协同把 每个参与者 跟其它每个参与者的事件 schema 耦合在一起。挑你想站的那一边。
可观测性
决定 Saga 在生产里生死的三个观测面:
- 每一步的幂等 key。 每次前向调用和每次补偿都带
idempotency_key = hash(saga_id, step_id, "compensation"|"forward")。按幂等 key 搜索就能看到完整的 Saga 状态,即使 Saga 日志残缺不全。 - 关联 ID。 Saga 步骤的每一条日志、追踪和指标,都带
saga_id和trace_id。单个查询应该能从日志和追踪里重建出 Saga 的历史,不需要去查 Saga 日志。 - Saga 状态仪表盘。 按状态(
started、forward、compensating、completed、failed、stuck)对 Saga 计数,看时间趋势。卡死补偿是最重要的指标——它就是需要人介入的那种。
如果你的监控里看不到卡死 Saga 状态计数,你就没有 Saga 可观测性
一份没暴露在 Runbook 里的 Saga 日志是不可见的。最简单的仪表盘是两个计数器:前向进度(最近一小时 started → completed)和补偿进度(最近一小时 compensating → compensated)。第二个数字飙起来说明有问题;第一个数字走平说明有更大的问题。
决策矩阵
伸手拿那个形状合得上的变体。八条规则,每条一条试金石。
决策矩阵——编排 vs 协同,八条规则
规则 1 —— 当 Saga 的逻辑归一个团队所有时,伸手拿编排。 如果有一个团队同时拥有工作流的所有步骤 和 每一个参与者服务,编排器把 Saga 表达成一个列表,团队能读懂它。耦合成本是局部的;可观测性在一处。
规则 2 —— 当参与者归多个团队所有时,伸手拿协同。 如果航班服务、酒店服务、租车服务由各自独立部署节奏的不同团队所有,没有编排器属于它们中的任何一个。协同的事件契约是唯一诚实的 API 表面。
规则 3 —— 当 Saga 状态必须全局可观测时,伸手拿编排。 如果你需要用一次仪表盘查询回答 「现在有多少 Saga 卡在补偿?」,编排的 Saga 日志免费送你这个答案。协同给你的是一道分布式拼装题。
规则 4 —— 当 Saga 没有天然的「指挥者」服务时,伸手拿协同。 有些 Saga 没有明显的编排器候选——加一个就得写一个新服务并负责运维。协同起步便宜,让参与者各自独立演进。
规则 5 —— 当 Saga 跑数小时到数天时,伸手拿工作流引擎。 手写编排器扛不住部署。引擎(Temporal、Camunda、Step Functions)把 Saga 日志持久化过部署、集群故障、操作失误;引擎成本靠你不用再处理的事故赚回来。
规则 6 —— 只有当你的事件带全状态时,才伸手拿协同。
协同式 Saga 的 FlightReserved 事件必须带足酒店服务做它的事 而不回呼航班服务 所需的信息。协同 + 多回呼 = 换了皮的编排,而且更糟。
规则 7 —— 当补偿很常见时,伸手拿编排。 如果补偿路径在生产里跑得比前向路径还多,「每个消费者对事件做出反应」的成本就会变成每次改动的税。编排把补偿路径变成一次函数调用。
规则 8 —— 当你的团队已经在事件溯源上投入时,伸手拿协同。 协同 天然 建立在事件溯源系统之上——每一个状态变化都已经是事件,每个消费者从同一份日志读。在没有事件的有状态数据库上做协同,是别扭且慢的。
规则 0(承重的那条)—— 当单一 ACID 事务能罩住时,两个都不要拿。 如果一笔预订的五段都能住在一个数据库、一次连接里,那 Saga 就是过度设计。Saga 给你换一种能力;用不到的时候别为它付费。
读一遍,存到团队的 wiki 里,你下一次范围里的 Saga 特性就能一眼找到它该走的变体。
决策的两轴图
流程图先看前三个问题向下读;如果没有清晰信号就默认「两种都行」。单团队信号是最强的;Saga 时长信号压过可观测性,因为没有哪个手写编排器能跨部署活过一笔多日 Saga。
结语
Saga 模式就是当 ACID 跨不过服务边界时协调工作长成的样子。两个变体——编排和协同——是同一种形状的两种控制逻辑:一个说「做这个,做这个,再做这个」;另一个说「对这个反应,对那个反应」。它们承担相同的重量:前向事务配上补偿,持久化在一份能恢复的 Saga 日志里,靠关联 ID 和卡死 Saga 计数器观测,前面接一套根据约束挑变体的决策程序。
和 舱壁模式 合在一起——它隔离故障——Saga 模式协调恢复。两个模式是同一道答案的两半:舱壁挡住爆炸,Saga 写出剧本。本系列会有后续文章专门讲补偿语义,包括部分补偿和不可逆副作用步骤;留意 saga-compensation-semantics 这一篇。
相关模式
每一个真正在用的 Saga,最终都至少还需要本系列里的另外两个模式。舱壁是架构上的前置条件;Circuit Breaker(断路器) 和 Outbox Pattern(发件箱模式) 是运维原语。
- 舱壁模式 —— 反向引用。舱壁隔离故障,Saga 协调恢复。把它们配对:舱壁放在每个 Saga 步骤的线程池里;Saga 跨 Saga 的各个服务。
- Circuit Breaker 模式(断路器模式) —— 每一个调外部服务的 Saga 步骤都需要断路器,这样某个坏掉的参与者不会把 Saga 的重试拖入死亡螺旋。
- Outbox Pattern(发件箱模式) (向前引用) —— 每一个协同式 Saga 的事件发布都需要发件箱,这样本地事务和事件发布不会失同步。
- saga-compensation-semantics(Saga 补偿语义) (向前引用) —— 部分补偿、退款窗口、不可逆副作用步骤——经典「完全撤销」模型没能覆盖的情况。留给另一篇单独的文章。
评论
请接受“功能性”Cookie 类别以查看和发表评论。
评论加载失败。您可以重试,或前往 GitHub 查看讨论。
在 GitHub 上查看