分散式交易
一個操作橫跨好幾個服務時,沒辦法用單一資料庫交易包起來:Saga 用補償步驟把做到一半的事退回去,Outbox 確保寫資料庫和送訊息不會只成功一半。
看哪一段
讓這步失敗
1/5
訂單服務
待處理
金流服務
沒扣款
庫存服務
沒保留
- 做 建立訂單
- 做 扣款
- 失敗 保留庫存
- 撤銷 扣款
- 撤銷 建立訂單
完成進行中失敗已撤銷
建立訂單:完成。協調者記下這一步,萬一後面失敗要回頭補償。
亮起來的是這一步執行的程式碼
interface Step { name: string; run(): boolean; compensate(): void } function runSaga(steps: Step[], log: string[]): boolean { const done: Step[] = []; for (const step of steps) { if (step.run()) { log.push("run " + step.name); done.push(step); continue; } log.push("fail " + step.name); for (const previous of done.reverse()) { previous.compensate(); log.push("compensate " + previous.name); } return false; } return true;} type Event = { id: number };interface Db { transaction(work: (tx: Db) => void): void; insert(table: string, row: object): void; unsent(): Event[]; markSent(id: number): void;}interface Broker { publish(event: Event): void } // The bug: two systems, two writes, and a crash can land between them.function placeOrderDualWrite(db: Db, broker: Broker, order: Event) { db.insert("orders", order); broker.publish({ id: order.id });} // The fix: the event is a row, committed together with the order.function placeOrder(db: Db, order: Event) { db.transaction((tx) => { tx.insert("orders", order); tx.insert("outbox", { id: order.id, sent: false }); });} // Runs in the background; after a crash it simply runs again.function relayOnce(db: Db, broker: Broker) { for (const event of db.unsent()) { broker.publish(event); db.markSent(event.id); }} // Delivery is at least once, so the consumer remembers what it has done.class Consumer { private seen = new Set<number>(); handle(event: Event, apply: (event: Event) => void): boolean { if (this.seen.has(event.id)) return false; this.seen.add(event.id); apply(event); return true; }}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 有限步驟的 saga 和 transactional outbox;補償依範例規則執行。真實補償可能失敗,且不能抹除已被外界觀察的副作用。
什麼時候用
- 一個業務動作要改好幾個服務各自的資料庫:下單同時要扣款、扣庫存、建立出貨單。
- 寫完資料庫還要通知別人(發事件、更新搜尋索引、寄信):用 Outbox,不要直接在程式裡接著送。
- 能放進同一個資料庫的,就用一般的資料庫交易:比任何分散式做法都簡單可靠。
和其他主題的關係
- 被這些用到
- 案例:金流系統
- 延伸閱讀
- 一致性與 Quorum
出現在這些架構裡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| Saga:n 個步驟 失敗時再加最多 n − 1 次補償 | O(n) | O(n) |
| Outbox:每筆寫入 同一個交易多寫一列 | O(1) | O(1) |
| Relay:每個事件 讀出、送出、標記;當機會讓它多送幾次 | O(1) | O(1) |
| 消費者去重 查一次處理過的事件編號 | O(1) | O(1) |
空間:O(e),e 是還沒送出、或還在去重視窗內的事件數
Big O 實測:n 變大時步數怎麼長
數的是:n 個服務的 Saga,最後一步失敗時
| Big O | n = 4 | n = 16 | n = 64 | n = 256 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 往前的呼叫 | O(n) | 4 | 16 | 64 | 256 | ×64 (×64) |
| 補償呼叫 | O(n) | 3 | 15 | 63 | 255 | ×85 (×64) |
最差情況是最後一步才失敗,前面每一步都要補償一次。所以 Saga 會把最可能失敗的步驟(例如扣款)排在前面,最難撤銷的排在最後。
和其他做法比
| 遺失的事件 | 幽靈事件 | 重複送達 | 去重後仍重複處理 | |
|---|---|---|---|---|
| 先寫資料庫再送訊息 | 511 | 0 | 0 | 0 |
| 先送訊息再寫資料庫 | 0 | 511 | 0 | 0 |
| Outbox+去重 | 0 | 0 | 540 | 0 |
10,000 筆訂單、5% 當機率。兩種雙寫各錯一邊;Outbox 用「可能重複」換掉「可能遺失」,再由消費者依事件編號去重。兩階段提交(2PC)也能讓兩邊一起成功或一起失敗,但協調者在中途掛掉時,參與者會鎖著資料等它回來,而且訊息系統多半不支援,所以實務上很少用在跨服務的場合。
真實世界裡的它
- Temporal、AWS Step Functions、Camunda 這類工作流程引擎,就是現成的 Saga 協調者。
- Debezium 讀資料庫的變更紀錄(CDC,Change Data Capture)把 outbox 表送進 Kafka,取代自己寫 relay。
- 訂房、訂機票:先保留、付款後確認、逾時自動釋出,本身就是 Saga。
取捨與陷阱
- 補償不等於回滾:信已經寄出、錢已經轉到別的帳戶,補償只能「再做一件抵銷的事」,而且補償本身也可能失敗,要能重試、要冪等。
- Saga 執行期間,別人看得到做到一半的狀態(訂單「待處理」、庫存已保留);畫面和報表要能處理這種中間狀態。
- Outbox 保證的是「至少送一次」,不是「剛好一次」:消費者一定要去重,否則重送就會重複出貨或重複寄信。