結論先講

微服務裡不要用分散式 Transaction(2PC)——用 Saga Pattern。 2PC 需要所有服務同時在線、同時鎖住資源,一個服務掛了全部卡住。Saga 把大 transaction 拆成一連串小 transaction,每個都有對應的補償操作。失敗時不是 rollback,是「做反向操作」。


為什麼分散式 Transaction 不 work

單體:一個 DB Transaction 搞定

async function createOrder(userId, productId, amount) {
  const t = await db.transaction();
  try {
    // 三個操作,同一個 DB,同一個 transaction
    await Order.create({ userId, productId, amount }, { transaction: t });
    await Product.decrement('stock', { where: { id: productId }, transaction: t });
    await Payment.create({ userId, amount, status: 'paid' }, { transaction: t });
    await t.commit();  // 全成功
  } catch (e) {
    await t.rollback(); // 全失敗
  }
}

ACID 保證:要嘛三個都成功,要嘛三個都沒發生。人生美好。

微服務:三個 DB,Transaction 跨不過去

訂單服務(OrderDB)  →  庫存服務(StockDB)  →  付款服務(PaymentDB)
    ↓                     ↓                      ↓
  自己的 DB             自己的 DB               自己的 DB

三個服務、三個 DB instance。PostgreSQL 的 BEGIN ... COMMIT 只能管自己那個 DB——你沒辦法用一個 transaction 橫跨三個 DB。

2PC(Two-Phase Commit)為什麼不行

理論上,2PC 可以:

Phase 1(Prepare):
  Coordinator → 訂單服務: 「你能建訂單嗎?」 → 「能」
  Coordinator → 庫存服務: 「你能扣庫存嗎?」 → 「能」
  Coordinator → 付款服務: 「你能扣款嗎?」   → 「能」

Phase 2(Commit):
  Coordinator → 全部: 「都 commit 吧」

問題在實務上:

問題說明
資源鎖定Prepare 到 Commit 之間,所有參與者的資源被鎖住。1 秒延遲 = 鎖 1 秒
單點故障Coordinator 掛了,所有參與者卡在 Prepare 狀態,不知道要 commit 還是 rollback
效能殺手需要所有服務同步等待,延遲是所有服務中最慢那個
可用性低任何一個服務不可用,整個 transaction 失敗

這在 第 28 篇第 29 篇 都提過——跨服務的強一致性是微服務最大的坑。


Saga Pattern:用補償取代 Rollback

Saga 的核心概念:

傳統 Transaction: 全部成功 → Commit / 任一失敗 → Rollback

Saga: 一系列 Local Transaction + 補償操作
  T1 → T2 → T3(全成功 → 完成)
  T1 → T2 → T3 失敗 → C3 → C2 → C1(反向補償)

訂單流程的 Saga

正常流程:
  T1: 訂單服務 — 建立訂單(狀態: PENDING)
  T2: 庫存服務 — 扣庫存
  T3: 付款服務 — 扣款
  T4: 訂單服務 — 更新訂單(狀態: CONFIRMED)

T3 失敗時的補償流程:
  C2: 庫存服務 — 恢復庫存(+1)
  C1: 訂單服務 — 更新訂單(狀態: CANCELLED)

注意:T3 失敗了但 T1 和 T2 已經 commit 了——不是 rollback,是「做反向操作」把狀態修正回來。


兩種 Saga 實作方式

Choreography(編舞):事件驅動,無中央協調

訂單服務 ──publish──→ [order.created]
                           ↓
庫存服務 ←─subscribe─┘
庫存服務 ──publish──→ [stock.reserved]
                           ↓
付款服務 ←─subscribe─┘
付款服務 ──publish──→ [payment.completed]
                           ↓
訂單服務 ←─subscribe─┘
訂單服務: 更新狀態為 CONFIRMED

失敗時

付款服務 ──publish──→ [payment.failed]
                           ↓
庫存服務 ←─subscribe─┘
庫存服務: 恢復庫存
庫存服務 ──publish──→ [stock.released]
                           ↓
訂單服務 ←─subscribe─┘
訂單服務: 更新狀態為 CANCELLED

優點

  • 完全去中心化,服務之間沒有直接依賴
  • 第 35 篇 的 Event-Driven 就能做

缺點

  • 流程散落在各服務,沒人知道完整的 flow
  • 新增一個步驟要改多個服務
  • debug 困難(要追蹤 event 鏈)
  • 容易出現循環依賴

Orchestration(編排):有中央協調者

Saga Orchestrator(訂單 Saga)
  ├── Step 1: 呼叫訂單服務 → 建立訂單
  ├── Step 2: 呼叫庫存服務 → 扣庫存
  ├── Step 3: 呼叫付款服務 → 扣款
  └── Step 4: 呼叫訂單服務 → 確認訂單

失敗時 Orchestrator 負責反向執行:
  ├── 呼叫庫存服務 → 恢復庫存
  └── 呼叫訂單服務 → 取消訂單
// Orchestrator 範例(簡化版)
class OrderSaga {
  constructor() {
    this.steps = [
      {
        execute: (data) => orderService.create(data),
        compensate: (data) => orderService.cancel(data.orderId),
      },
      {
        execute: (data) => stockService.reserve(data.productId, data.quantity),
        compensate: (data) => stockService.release(data.productId, data.quantity),
      },
      {
        execute: (data) => paymentService.charge(data.userId, data.amount),
        compensate: (data) => paymentService.refund(data.paymentId),
      },
    ];
  }
 
  async run(data) {
    const completedSteps = [];
 
    for (const step of this.steps) {
      try {
        const result = await step.execute(data);
        data = { ...data, ...result };
        completedSteps.push(step);
      } catch (error) {
        // 反向補償已完成的步驟
        for (const completed of completedSteps.reverse()) {
          await completed.compensate(data);
        }
        throw new SagaFailedError(error);
      }
    }
  }
}

優點

  • 流程集中在一個地方,容易理解和維護
  • 新增步驟只改 Orchestrator
  • debug 容易(Orchestrator 有完整的狀態)

缺點

  • Orchestrator 是單點,要做好高可用
  • 服務之間有間接耦合(透過 Orchestrator)

怎麼選

場景建議
2-3 個服務的簡單流程Choreography
4+ 個服務的複雜流程Orchestration
團隊已經用 Event-DrivenChoreography 起步容易
需要清楚的流程控制Orchestration

我的建議:大部分團隊用 Orchestration。 Choreography 在小規模很優雅,但規模一大就變成分散在各處的 event spaghetti,沒人畫得出完整的流程圖。


補償操作的設計原則

1. 補償必須是 Idempotent(冪等)

// 錯誤:重複呼叫會多退錢
async function refund(paymentId) {
  const payment = await Payment.findById(paymentId);
  await wallet.credit(payment.userId, payment.amount);
}
 
// 正確:檢查狀態,重複呼叫不會多退
async function refund(paymentId) {
  const payment = await Payment.findById(paymentId);
  if (payment.status === 'refunded') return; // 已退過
  await wallet.credit(payment.userId, payment.amount);
  await payment.update({ status: 'refunded' });
}

為什麼?因為網路不可靠——補償操作可能被呼叫兩次(重試機制)。

2. 有些操作沒辦法補償

  • 送出的 Email 收不回來
  • 發出的推播通知收不回來
  • 呼叫的第三方 API(例如物流出貨)可能無法取消

解法:把不可逆的操作放在 Saga 的最後一步。

T1: 建立訂單(可補償 → 取消)
T2: 扣庫存(可補償 → 恢復)
T3: 扣款(可補償 → 退款)
T4: 通知用戶(不可補償 → 放最後)

3. 訂單狀態要反映 Saga 進度

PENDING       → 訂單已建立,等待庫存確認
STOCK_RESERVED → 庫存已扣,等待付款
CONFIRMED     → 付款完成,訂單成立
CANCELLED     → Saga 失敗,已補償
COMPENSATION_FAILED → 補償也失敗了(需要人工介入)

使用者看到的是 PENDING / CONFIRMED / CANCELLED,但你內部需要更細的狀態來追蹤 Saga 進度。


實務上的難題

1. 補償失敗怎麼辦

Saga 的 T3 失敗了,開始補償。但 C2(恢復庫存)也失敗了——庫存服務剛好在重啟。

解法:
1. 重試(指數退避)
2. 重試次數耗盡 → 寫入 Dead Letter Queue
3. 定期處理 DLQ 裡的補償(人工 + 自動)
4. 告警通知 on-call 人員

這是微服務最難的部分——你的錯誤處理邏輯比業務邏輯還複雜。

2. Saga 執行到一半 Orchestrator 掛了

Orchestrator 必須持久化 Saga 的狀態:

// 每一步完成後都寫入 DB
await SagaState.update({
  sagaId: 'saga-123',
  currentStep: 2,
  completedSteps: ['create_order', 'reserve_stock'],
  data: { orderId: 456, productId: 789 },
});
 
// Orchestrator 重啟後,從上次的狀態繼續
const saga = await SagaState.findById('saga-123');
resumeSaga(saga);

3. 用戶體驗:「我的訂單到底成功了沒」

Saga 是非同步的——使用者按下「送出訂單」,可能要等幾秒才知道結果。

方案 A(推薦):立刻回 PENDING,前端 polling 或 WebSocket 等結果
方案 B:同步等待(但 timeout 風險高)
方案 C:樂觀 UI,先顯示成功,失敗再通知

下一篇

資料一致性(二):Eventual Consistency 用戶能不能接受 — Saga 讓你不需要分散式 Transaction,但代價是「資料不是立刻一致的」。庫存扣了但訂單還在 PENDING,這段時間用戶看到的資料是不一致的。哪些場景可以接受?哪些場景不行?


本系列文章

完整 68 篇目錄見 系列首頁

← 上一篇:可觀測性(三):監控 Dashboard 該看什麼指標 → 下一篇:資料一致性(二):Eventual Consistency 用戶能不能接受