跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 分散式交易

分散式交易

一個操作橫跨好幾個服務時,沒辦法用單一資料庫交易包起來:Saga 用補償步驟把做到一半的事退回去,Outbox 確保寫資料庫和送訊息不會只成功一半。

看哪一段
讓這步失敗
1/5
訂單服務
待處理
金流服務
沒扣款
庫存服務
沒保留
  1. 做 建立訂單
  2. 做 扣款
  3. 失敗 保留庫存
  4. 撤銷 扣款
  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,不要直接在程式裡接著送。
  • 能放進同一個資料庫的,就用一般的資料庫交易:比任何分散式做法都簡單可靠。

和其他主題的關係

被這些用到
案例:金流系統

出現在這些架構裡

時間與空間複雜度(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 On = 4n = 16n = 64n = 256成長倍數:實測(理論)
往前的呼叫O(n)41664256×64 (×64)
補償呼叫O(n)31563255×85 (×64)

最差情況是最後一步才失敗,前面每一步都要補償一次。所以 Saga 會把最可能失敗的步驟(例如扣款)排在前面,最難撤銷的排在最後。

和其他做法比

遺失的事件幽靈事件重複送達去重後仍重複處理
先寫資料庫再送訊息511000
先送訊息再寫資料庫051100
Outbox+去重005400

10,000 筆訂單、5% 當機率。兩種雙寫各錯一邊;Outbox 用「可能重複」換掉「可能遺失」,再由消費者依事件編號去重。兩階段提交(2PC)也能讓兩邊一起成功或一起失敗,但協調者在中途掛掉時,參與者會鎖著資料等它回來,而且訊息系統多半不支援,所以實務上很少用在跨服務的場合。

真實世界裡的它

  • Temporal、AWS Step Functions、Camunda 這類工作流程引擎,就是現成的 Saga 協調者。
  • Debezium 讀資料庫的變更紀錄(CDC,Change Data Capture)把 outbox 表送進 Kafka,取代自己寫 relay。
  • 訂房、訂機票:先保留、付款後確認、逾時自動釋出,本身就是 Saga。

取捨與陷阱

  • 補償不等於回滾:信已經寄出、錢已經轉到別的帳戶,補償只能「再做一件抵銷的事」,而且補償本身也可能失敗,要能重試、要冪等。
  • Saga 執行期間,別人看得到做到一半的狀態(訂單「待處理」、庫存已保留);畫面和報表要能處理這種中間狀態。
  • Outbox 保證的是「至少送一次」,不是「剛好一次」:消費者一定要去重,否則重送就會重複出貨或重複寄信。