跳到主要內容

系統設計

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

主題 · 案例:金流系統

案例:金流系統

錢不能多扣也不能少記:冪等鍵擋住重複付款、複式記帳讓每一筆帳都對得起來,和外部銀行對帳則抓出兩邊的不一致。

流程

金流系統的每一跳都不是為了快,而是為了不出錯:重試、當機、回應遺失,都不能讓錢多扣或少記。選「逾時後重試」或「扣款後當機」,看冪等鍵怎麼擋住重複扣款。

亮起來的是這一步執行的程式碼
type Entry = { paymentId: string; account: string; debit: number; credit: number };
interface Provider {
// Charges once per key, however often it is asked.
charge(key: string, amount: number): void;
}
class PaymentService {
private intents = new Map<string, { amount: number; paymentId?: string }>();
private running = new Set<string>(); // live claims, separate from persisted intents
ledger: Entry[] = [];
outbox: { paymentId: string; sent: boolean }[] = [];
constructor(private provider: Provider) {}
pay(key: string, amount: number): string {
const intent = this.intents.get(key);
if (intent && intent.amount !== amount) throw new Error("idempotency key used with a different amount");
if (intent?.paymentId) return intent.paymentId;
if (this.running.has(key)) throw new Error("payment in progress");
// Production: an atomic DB claim; ledger and outbox also have unique payment IDs.
this.intents.set(key, intent ?? { amount });
this.running.add(key);
try {
const paymentId = "pay_" + key;
this.provider.charge(key, amount);
const fee = Math.round(amount * 0.029) + 30;
// One database transaction: ledger lines, outbox row, stored result.
this.ledger.push(
{ paymentId, account: "psp_clearing", debit: amount, credit: 0 },
{ paymentId, account: "merchant_payable", debit: 0, credit: amount - fee },
{ paymentId, account: "fee_revenue", debit: 0, credit: fee },
);
this.outbox.push({ paymentId, sent: false });
this.intents.set(key, { amount, paymentId });
return paymentId;
} finally {
this.running.delete(key); // failed attempt stopped; preserve its intent for recovery
}
}
relay(publish: (paymentId: string) => void): void {
for (const row of this.outbox) {
if (row.sent) continue;
publish(row.paymentId);
row.sent = true;
}
}
}
function reconcile(ledger: Entry[], settlement: { paymentId: string; amount: number }[]): string[] {
const booked = new Map<string, number>();
for (const e of ledger) {
if (e.account === "psp_clearing") booked.set(e.paymentId, (booked.get(e.paymentId) ?? 0) + e.debit);
}
const captured = new Map<string, number>();
for (const s of settlement) captured.set(s.paymentId, (captured.get(s.paymentId) ?? 0) + s.amount);
const problems: string[] = [];
for (const [paymentId, amount] of captured) {
if (booked.get(paymentId) !== amount) problems.push(paymentId);
}
return problems;
}
重試保護
5%
1%

10,000 筆付款,逾時就重試,最多再試 3 次。2.0% 的請求在送來的路上遺失,另外兩種故障由拉桿設定;三種做法遇到的故障完全相同。

被重複扣款的客戶
103
多扣的金額
$5,561
對帳抓到的不一致
103
帳本借貸差額
$0

回應遺失後的重試,現在會拿到存好的結果。但「扣款後、寫帳前」當機時,鍵還沒完成,重試又扣了一次款,因為金流商分不出這是同一筆:103 位客戶被扣兩次。對帳以金額不符抓到了這 103 筆。

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 付款狀態、重試與帳務不變量是教學模型;沒有金融網路、實際清算、風控或正式支付產品的交易保證。

什麼時候用

  • 冪等鍵由呼叫的一方產生(通常是訂單編號),每次重試都帶同一把;伺服器在動手之前先記下它。
  • 鍵要一路傳到金流商:只在自家伺服器擋,擋不住「扣款後、寫帳前」當機的重試。
  • 帳、事件和鍵的結果在同一筆交易裡寫(Outbox):事件不會比帳先送出,也不會因為當機而只寫了一半。
  • 要求的是正確而不是快:寫入要等多數副本確認,寧可慢一點也不能遺失。

和其他主題的關係

時間與空間複雜度(Big O)

操作平均最差
付款(查鍵、扣款、寫帳)
鍵和帳本都在 B-tree 索引上;最慢的其實是呼叫金流商的網路往返
O(log n)O(log n)
重試(鍵已完成)
只查一次鍵
O(log n)O(log n)
對帳(雜湊比對)O(n)O(n)
對帳(逐筆掃描)O(n²)O(n²)

空間:O(n),每筆付款三行帳、一把鍵、一筆 outbox

Big O 實測:n 變大時步數怎麼長

數的是:對帳時的比對次數(n 是結算檔裡的付款數)

Big On = 500n = 1,000n = 2,000成長倍數:實測(理論)
先把帳本建成雜湊表O(n)5001,0002,000×4.0 (×4.0)
每筆都掃一遍帳本O(n²)742,5002,970,00011,880,000×16 (×16)

結算檔每天都有幾百萬筆;逐筆掃描在這個規模下根本跑不完,所以對帳一定是先建索引(或兩邊排序後合併)。

和其他做法比

被重複扣款的客戶多扣的金額對帳抓到帳本借貸差額
不用冪等鍵566$32,020110$0
只在自家伺服器記鍵103$5,561103$0
鍵一路傳到金流商0$00$0

10,000 筆付款,請求遺失 2.0%、扣款後當機 1.0%、回應遺失 5.0%,三種做法跑同一組故障。帳本是複式記帳,不管哪種做法借貸都平衡;平衡只代表帳是一致的,不代表沒有多扣。

真實世界裡的它

  • Stripe、Adyen 的 API 都接受 Idempotency-Key 標頭,同一把鍵重送會拿到第一次的結果。
  • 金流、電子錢包、記帳軟體的帳本幾乎都是複式記帳:每一筆都有借有貸,總和永遠為零。
  • 收單機構每天提供結算檔,商家和金流平台據此和自己的紀錄對帳。

取捨與陷阱

  • 未完成的付款意圖不等於沒有人正在處理。原請求仍在執行時要回「處理中」;確認原執行者停止後,才可重新取得執行權。示範以本機集合與鎖保護;正式系統需原子資料庫佔位、交易及唯一鍵,跨執行者恢復另需 fencing token 防止舊執行者寫入。
  • 帳本平衡不代表沒有多扣:沒有冪等鍵時,重複扣款在帳本裡是好幾筆「正常」的付款,借貸照樣平衡,對帳也對得上。
  • 冪等鍵要存得夠久(常見是 24 小時以上),而且同一把鍵配上不同的金額要拒絕,不能當成重試。
  • 金額用整數的最小單位(分)存,不要用浮點數:0.1 + 0.2 在浮點數裡不等於 0.3。
  • 「先寫資料庫、再送訊息」兩步之間當機,訊息就永遠不見了;這正是 Outbox 要解決的。