Saga 模式:跨微服务协调长生命周期事务

问题:一笔业务事务、五个服务、没有 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 对每一笔已经完成的 反向运行对应的补偿。补偿 是一项真实的动作——CancelFlightRefundPaymentReleaseSeat——不是数据库回滚。Saga 协调的是数据库事务做不到的事。

本文覆盖两种顶层变体——编排(由一个中央协调器告诉每个服务做什么)和协同(每个服务对总线上的事件做出反应)——外加三种实现形态、一节权衡讨论、一个决策矩阵。文末的相关模式一节会指向 Circuit Breaker(断路器)Outbox Pattern(发件箱模式)——这是每一个真正在用的 Saga 最终都需要的两个模式。

两种变体:编排 vs 协同

同一种形状,两种控制逻辑。

编排式 Saga。 有一个服务——编排器——知道 Saga 的步骤。它依次调用 flight.reservehotel.reservecar.reserve。每一步向编排器返回成功或失败,编排器决定下一步。任何失败发生时,编排器对已经完成的步骤反向发出补偿调用。编排器维护一份 Saga 日志:一份 per-Saga 的、仅追加的日志,记录哪些步骤已经完成、哪些已经补偿;这样一旦崩溃,可以重放日志来恢复。

协同式 Saga。 没有编排器。每个服务在消息代理上发出事件(FlightReservedHotelReserved),并监听其它服务的事件。Saga 的前向进度,就是事件在系统中走过的路径;补偿则是监听 TripBookingFailed 并做出反应的那些服务。Saga 的状态住在代理的 offset 和每个参与者自己的本地存储里。没有单一的日志;每个参与者拥有自己那一段。

没有 Saga vs 有 Saga(问题框架)

flowchart TB subgraph "没有 Saga" U1[旅客] --> M[单体<br/>单一 ACID 事务<br/>覆盖五段航程] M --> R1[(DB)] end subgraph "有 Saga" U2[旅客] --> API[预订 API] API --> O[编排器<br/>或<br/>事件流] O --> F[航班服务] O --> H[酒店服务] O --> C[租车服务] O --> T[接送服务] O --> I[保险服务] F --> DB1[(航班 DB)] H --> DB2[(酒店 DB)] C --> DB3[(租车 DB)] end style M fill:#c8e6c9 style R1 fill:#c8e6c9 style O fill:#fff9c4 style API fill:#fff9c4 style DB1 fill:#e1f5ff style DB2 fill:#e1f5ff style DB3 fill:#e1f5ff

单体是没问题的。微服务版本是把单体拆分之后才会出现的情况——拆分的理由很充分:独立部署、团队边界、扩缩策略。Saga 不是一笔更糟糕的事务,它是一种不同形状的工作,有不同的失败模式和不同的运维面。

编排:一条有向序列

sequenceDiagram participant U as 旅客 participant O as 编排器 participant F as 航班服务 participant H as 酒店服务 participant C as 租车服务 U->>O: bookTrip(legs) activate O O->>F: ReserveFlight(leg1) activate F F-->>O: Reserved / Conf# deactivate F O->>H: ReserveHotel(leg2) activate H H-->>O: Reserved / Conf# deactivate H O->>C: ReserveCar(leg3) activate C C--xO: 503 OutOfCars deactivate C Note over O: 步骤失败。<br/>补偿已完成的步骤。 O->>H: CancelHotelReservation(leg2) activate H H-->>O: Cancelled deactivate H O->>F: CancelFlightReservation(leg1) activate F F-->>O: Cancelled deactivate F O-->>U: Booking Failed(补偿已完成) deactivate O

编排器是 Saga 状态的唯一所有者。每一步、每一次补偿、每一次重试、每一次超时,都是它的职责。参与服务对更大的 Saga 一无所知——flight.reserve 和从任何调用者那里收到的请求是同一个调用。从参与者一侧解耦,是编排器的好处。

协同:一次事件级联

sequenceDiagram participant U as 旅客 participant API as 预订 API participant B as 代理 participant F as 航班服务 participant H as 酒店服务 participant C as 租车服务 U->>API: bookTrip(legs) API->>B: emit TripBookingRequested B-->>F: 投递 TripBookingRequested F->>B: emit FlightReserved / Conf# B-->>H: 投递 FlightReserved H->>B: emit HotelReserved / Conf# B-->>C: 投递 HotelReserved C->>B: emit CarReservationFailed Note over B: 补偿流:<br/>每个消费者各自反应。 B-->>H: 投递 CarReservationFailed H->>B: emit HotelReservationCancelled B-->>F: 投递 HotelReservationCancelled F->>B: emit FlightReservationCancelled API->>U: 通知最终状态

没有中心服务拥有任何东西。Saga 的前向进度,就是代理投递出来的事件序列。补偿流是 另一串 事件,往回走。脆弱之处藏在跨服务的事件契约里:TripBookingRequested 一改 schema,下游的三个消费者就要同步迁移。

💡「谁拥有这个 Saga?」是正确的问题

编排式 Saga 由编排器拥有。协同式 Saga 由 每一个处理相关事件的消费者 拥有——等团队图全了,「拥有者」就是代理挂掉那一刻恰好还醒着的那位工程师。挑那个你能撑到凌晨三点的归属故事的变体。

补偿事务:Saga 的真正工作

模式的名字暗示的是前向进度。真正的工作是 按需的反向进度。对每笔前向事务 ,Saga 必须定义一个补偿事务 ,在语义上撤销 ——不是数据库回滚,是一项动作。

microservices.io 经典的旅行预订例子中,Saga 是这样的:

步骤前向 ()补偿 ()
1ReserveFlightCancelFlightReservation
2ReserveHotelCancelHotelReservation
3ReserveCarCancelCarReservation
4ReserveAirportTransferCancelAirportTransfer
5ActivateTravelInsuranceCancelTravelInsurance

这些补偿不是 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_idtrace_id。单个查询应该能从日志和追踪里重建出 Saga 的历史,不需要去查 Saga 日志。
  • Saga 状态仪表盘。 按状态(startedforwardcompensatingcompletedfailedstuck)对 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 特性就能一眼找到它该走的变体。

决策的两轴图

flowchart TD Q1{一个团队<br/>拥有所有<br/>服务?} Q2{Saga 跑<br/>数小时<br/>或数天?} Q3{需要<br/>Saga 状态<br/>可观测?} Q4{多个团队<br/>拥有<br/>参与者?} Q1 -->|是| ORC[编排器] Q1 -->|否| Q4 Q4 -->|是| CHORE[协同] Q4 -->|否| Q2 Q2 -->|是| ENGINE[工作流引擎] Q2 -->|否| Q3 Q3 -->|是| ORC Q3 -->|否| BOTH{两种都行} style ORC fill:#c8e6c9,stroke:#2f9e44 style CHORE fill:#fff9c4,stroke:#f59f00 style ENGINE fill:#e1f5ff,stroke:#1971c2 style BOTH fill:#f5f5f5,stroke:#757575

流程图先看前三个问题向下读;如果没有清晰信号就默认「两种都行」。单团队信号是最强的;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 类别以查看和发表评论。