結論先講
微服務裡不要用分散式 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-Driven | Choreography 起步容易 |
| 需要清楚的流程控制 | 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 用戶能不能接受