在單體架構中,資料庫的本地 ACID 事務(Local Transaction)透過寫入鎖與 WAL 日誌,讓「跨表更新」具備完美的原子性(Atomicity)與隔離性(Isolation)。
然而,當系統拆分為微服務後,原本單一的資料庫操作演化為跨越「訂單服務」、「庫存服務」、「支付服務」與「物流服務」的多個獨立跨網路 RPC/訊息呼叫。此時,傳統的本地事務徹底失效。
若採用強一致性的兩階段提交(2PC),嚴苛的全局鎖定會讓微服務的高吞吐量與可用性崩潰。為此,Saga 模式(Saga Pattern) 成為了處理分散式長事務(Long-Running Distributed Transactions)的工業標準。
本文將從 Saga 的基本原理出發,深度剖析 協同式(Choreography) 與 編排式(Orchestration) 兩大架構形態、補償事務設計原則,以及如何應對 Saga 缺乏隔離性帶來的業務風險。
1. Saga 模式基本原理:本地事務鏈與補償機制
Saga 模式將一個大型的分散式全域事務拆分為一系列有序的本地事務(Local Transactions)序列:T_1, T_2, ..., T_n。
- 每個服務在執行本地事務時,僅鎖定本服務的本地資料庫,執行完成立即提交事務釋放鎖。
- 正向執行成功:當
T_1 -> T_2 -> ... -> T_n全部順利完成,全域 Saga 宣告成功。 - 局部步驟失敗與補償:若第
k步本地事務T_k執行失敗(例如庫存不足或扣款失敗),Saga 必須依相反順序觸發一組補償事務(Compensating Transactions)C_{k-1}, ..., C_1,將先前已提交的歷史變更在業務語義上「撤銷/沖正」,達到最終一致性。
✅ 正向成功鏈 (Forward Path)
1. T1 (訂單):建立訂單並提交。
2. T2 (庫存):預扣庫存並提交。
3. T3 (支付):扣款成功並提交。
4. T4 (物流):生成運單並成功完成。
❌ 失敗回滾鏈 (Compensation Path)
1. T3 失敗:支付帳戶餘額不足,中止正向流程。
2. C2 補償:呼叫庫存服務釋放已凍結之庫存(沖正)。
3. C1 補償:呼叫訂單服務更新狀態為已取消。
補償交易須遵循冪等性與重試必定成功原則,達成全鏈路最終一致性。
2. 兩大架構拓撲:協同式 (Choreography) vs. 編排式 (Orchestration)
| 評估維度 | Choreography (事件協同式) | Orchestration (集中編排式) |
|---|---|---|
| 控制中心 | 去中心化,完全依賴事件廣播 | 集中式 Saga 協調器 (Orchestrator) |
| 服務間偶合度 | 極低,服務只需監聽對應領域事件 | 服務需依賴協調器的統一呼叫排程 |
| 業務流程可視化 | 困難 (散落在各個 Consumer 代碼中) | 極佳 (由狀態機集中定義執行圖) |
| 循環依賴風險 | 高 (容易形成複雜網狀事件迴路) | 零 (單向調度拓撲) |
| 適用複雜度 | 簡單流程 (2~4 個服務節點) | 複雜商業鏈條 (5 個以上跨服務長事務) |
2.1 協同式(Choreography):去中心化的事件發布/訂閱
服務之間沒有中央協調者,各服務透過發布與訂閱領域事件(Domain Events via Kafka/RabbitMQ)自主推動下一步:
- 訂單服務建立訂單,發布
OrderCreated事件。 - 庫存服務監聽
OrderCreated,扣減庫存後發布InventoryReserved事件。 - 支付服務監聽
InventoryReserved,執行扣款。若扣款失敗,發布PaymentFailed事件。 - 庫存服務與訂單服務各自監聽
PaymentFailed事件,分別執行補償回滾。
2.2 編排式(Orchestration):基於狀態機的中央調度器
由專屬的 Saga Orchestrator(協調器,如 Temporal、AWS Step Functions、Cadence) 集中管理全域事務狀態機:
- 協調器向訂單服務發送「建立訂單」指令,等待回執。
- 協調器向庫存服務發送「扣減庫存」指令,等待回執。
- 若某一步拋出異常,協調器根據狀態機定義,依序主動發起「釋放庫存」、「取消訂單」等補償指令。
3. 補償事務的四大工程鐵律
補償事務並不是簡單的 ROLLBACK,而是透過一條全新的 UPDATE 或 INSERT 進行業務沖正。在設計補償動作時,必須嚴格遵守以下四條原則:
- 冪等性(Idempotence):由於網路抖動或重試,補償事務可能被重複呼叫多次。每次補償操作必須依賴唯一的
SagaId + StepId保證重複執行結果完全一致。 - 絕對不允許失敗(Cannot Fail):補償事務一旦觸發,必須透過死循環重試、本地持久佇列或死信佇列(DLQ)保證最終必定執行成功;若遇致命系統故障,需報警交由人工介入對帳。
- 不可逆操作的順序後置(Pivot Step):
- 可補償事務(Compensatable Transactions):能夠被撤銷的操作(如預扣庫存、凍結資金)。
- 樞紐事務(Pivot Transaction):一旦成功執行即不可逆的操作(如真實向第三方銀行發起信用卡扣款)。
- 可重試事務(Retriable Transactions):在樞紐事務之後執行的操作,保證必定會成功(如發送確認 Email、列印出貨單)。
- 設計原則:樞紐事務必須放在所有可補償事務之後執行!
- 防懸掛與空補償處理:若正向請求因網路延遲尚未到達服務端,而協調器已因逾時提前發送了補償請求,服務端必須記錄該 Saga 的取消狀態,當遲到的正向請求抵達時直接拒絕執行。
4. Saga 的致命短板:缺乏隔離性 (ACID 中的 I)
Saga 滿足了 ACD(原子性、一致性、持久性),但完全放棄了 ACID 中的 I(隔離性)。因為在整個 Saga 尚未結束前,中間步驟產生的本地資料變更已經被 COMMIT 到了資料庫中,對外完全可見!
這會引發嚴重的併發異常:
- 髒讀(Dirty Read):Saga A 扣減了庫存,此時 Saga B 讀取到剩餘庫存為 0 並拒絕了其他客戶;隨後 Saga A 支付失敗回滾了庫存。
- 更新丟失(Lost Update):Saga A 將庫存從 10 改為 8;在 Saga A 尚未結束時,Saga B 讀取到 8 並將其改為 5;若此時 Saga A 發生失敗補償,將庫存加回 2 改為 7,導致 Saga B 的扣減被覆蓋丟失。
4.1 解決隔離性缺失的防禦對策
- 語義鎖(Semantic Lock / Soft State):
- 不要直接扣減庫存,而是將商品狀態標記為
PENDING_RESERVED。 - 任何其他交易看到
PENDING狀態時,必須等待或拒絕操作,直到 Saga 結束轉為CONFIRMED。
- 不要直接扣減庫存,而是將商品狀態標記為
- 悲觀防禦(Pessimistic View):
- 讀取資料時假設所有進行中的 Saga 都會成功,計算可用配額時主動扣除
Pending總量。
- 讀取資料時假設所有進行中的 Saga 都會成功,計算可用配額時主動扣除
- 操作可交換性(Commutative Updates):
- 將扣減操作設計為可交換的數學算式(如
UPDATE account SET balance = balance - 100),避免覆蓋絕對值。
- 將扣減操作設計為可交換的數學算式(如
5. 架構選型總結
- 流程簡單、服務個數少於 3 個:選用 Choreography(協同式),透過 Kafka 事件串聯,開發成本最低。
- 核心金融、訂單履約、長達數小時的審批流:強烈推薦引入 Orchestration(編排式)(如 Temporal),透過程式碼定義狀態機,具備全鏈路可觀測性、精確重試與超時補償能力。
