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 類別以查看和發表留言。