案例:金流系統
錢不能多扣也不能少記:冪等鍵擋住重複付款、複式記帳讓每一筆帳都對得起來,和外部銀行對帳則抓出兩邊的不一致。
流程
金流系統的每一跳都不是為了快,而是為了不出錯:重試、當機、回應遺失,都不能讓錢多扣或少記。選「逾時後重試」或「扣款後當機」,看冪等鍵怎麼擋住重複扣款。
亮起來的是這一步執行的程式碼
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):事件不會比帳先送出,也不會因為當機而只寫了一半。
- 要求的是正確而不是快:寫入要等多數副本確認,寧可慢一點也不能遺失。
和其他主題的關係
- 延伸閱讀
- 一致性與 Quorum
時間與空間複雜度(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 O | n = 500 | n = 1,000 | n = 2,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 先把帳本建成雜湊表 | O(n) | 500 | 1,000 | 2,000 | ×4.0 (×4.0) |
| 每筆都掃一遍帳本 | O(n²) | 742,500 | 2,970,000 | 11,880,000 | ×16 (×16) |
結算檔每天都有幾百萬筆;逐筆掃描在這個規模下根本跑不完,所以對帳一定是先建索引(或兩邊排序後合併)。
和其他做法比
| 被重複扣款的客戶 | 多扣的金額 | 對帳抓到 | 帳本借貸差額 | |
|---|---|---|---|---|
| 不用冪等鍵 | 566 | $32,020 | 110 | $0 |
| 只在自家伺服器記鍵 | 103 | $5,561 | 103 | $0 |
| 鍵一路傳到金流商 | 0 | $0 | 0 | $0 |
10,000 筆付款,請求遺失 2.0%、扣款後當機 1.0%、回應遺失 5.0%,三種做法跑同一組故障。帳本是複式記帳,不管哪種做法借貸都平衡;平衡只代表帳是一致的,不代表沒有多扣。
真實世界裡的它
- Stripe、Adyen 的 API 都接受 Idempotency-Key 標頭,同一把鍵重送會拿到第一次的結果。
- 金流、電子錢包、記帳軟體的帳本幾乎都是複式記帳:每一筆都有借有貸,總和永遠為零。
- 收單機構每天提供結算檔,商家和金流平台據此和自己的紀錄對帳。
取捨與陷阱
- 未完成的付款意圖不等於沒有人正在處理。原請求仍在執行時要回「處理中」;確認原執行者停止後,才可重新取得執行權。示範以本機集合與鎖保護;正式系統需原子資料庫佔位、交易及唯一鍵,跨執行者恢復另需 fencing token 防止舊執行者寫入。
- 帳本平衡不代表沒有多扣:沒有冪等鍵時,重複扣款在帳本裡是好幾筆「正常」的付款,借貸照樣平衡,對帳也對得上。
- 冪等鍵要存得夠久(常見是 24 小時以上),而且同一把鍵配上不同的金額要拒絕,不能當成重試。
- 金額用整數的最小單位(分)存,不要用浮點數:0.1 + 0.2 在浮點數裡不等於 0.3。
- 「先寫資料庫、再送訊息」兩步之間當機,訊息就永遠不見了;這正是 Outbox 要解決的。