前言
面對「請設計一個支付系統」這題,千萬別從「直接對接銀行底層網路」開始想。
在真實商業環境中,主流解法是依賴第三方支付服務商(PSP, Payment Service Provider),例如 Stripe、PayPal、綠界、藍新。
因此,系統設計的核心挑戰通常不是「如何跟銀行打交道」,而在於如何設計一套內部記帳、對帳,以及保證「絕不重複扣款(Double Charging)」的穩健系統。
這篇你可以帶走什麼
- 為什麼 Hosted Payment Page / SDK 是支付接入主流
- 如何用嚴格狀態機與 Idempotency Key 防止重複扣款
- Webhook 逾時時如何避免幽靈交易(Ghost Transaction)
- 為什麼支付資料要拆成 Order、Payment、Ledger 三層
- 指數退避 + Jitter + DLQ 在支付重試中的必要性
維度一、可直接觀察的事實:系統邊界與核心角色
支付系統中的核心角色包含:
- 使用者(持卡人)
- 商家(收款方)
- 第三方支付服務商(PSP)
- 我們自己的支付內部系統(訂單、支付狀態、帳本)
支付接入模式常見兩種:
- 自建伺服器對接(Server-to-Server):商家直接收集卡號。代價是必須承擔昂貴且嚴苛的 PCI-DSS 合規壓力。
- SDK / 託管支付頁面 (Hosted Payment Page):使用者點擊結帳後,跳轉到 PSP 的頁面,或利用 PSP 提供的 SDK 讓前端直接將卡號送到 PSP,換取一個一次性代幣(Token / Nonce)。商家伺服器只拿 Token 去跟 PSP 扣款。好處是完全不碰觸信用卡敏感資料,免除 PCI 稽核負擔。
後者是業界最常見做法,因為能大幅降低合規與資安風險。
重點:支付系統第一原則是「盡量不碰卡號」,把敏感資料處理責任交給 PSP。
維度二、條件檢查:確保一致性的最佳解與邊界
一般情況下的最佳解(嚴格狀態機 + 冪等性)
支付資料模型必須有明確狀態流轉,例如:
- Create -> Start -> Processing -> Success / Failed
同時,所有會改變金流狀態的 API(扣款、退款)都必須實作冪等性(Idempotency)。
典型做法是:
- 前端先向後端取得 Idempotency Key
- 後續重試都帶同一把 Key
- 後端與 PSP 看到同 Key 就視為同一筆交易,不重複扣款
邊界條件(網路超時與幽靈交易)
若我們向 PSP 發出扣款請求後遲遲收不到 Webhook 通知:
- 若系統直接判定失敗並讓使用者重試
- PSP 之後又處理成功
- 就會造成帳務不一致,甚至重複扣款
重點:支付系統最危險狀態不是失敗,而是「不知道成功或失敗」。
維度三、反證檢查:跳脫直覺的儲存與重試機制
盲點一:一有非同步,就一定要上 MQ 嗎?
直覺上,非同步處理就是要塞一個訊息佇列(MQ)。但在低到中流量的場景,若同時寫入 DB 與 MQ,反而會引入「雙重寫入不一致(Dual-write inconsistency)」的風險。
某些情況下,直接以資料庫作為任務來源並輪詢(Polling)狀態,架構反而更簡潔可控。
盲點二:API 沒回應,就立刻狂重試?
支付系統的重試必須非常克制。若無腦重試,極易引發系統雪崩效應(Thundering Herd Problem)。標準的做法是實作指數退避(Exponential Backoff),並加上隨機的 Jitter(抖動) 來打散請求時間點。若重試超過上限,則必須將訊息丟入死信佇列(Dead Letter Queue, DLQ),轉交人工或自動排程進行後續對帳。
盲點三:直接用 UPDATE 修改訂單狀態?
金融系統極度強調「歷史不可竄改」,傳統的覆蓋狀態非常不利於金融稽核。現代大型支付架構(如 Stripe, Coinbase)會捨棄傳統的更新操作,改採事件溯源(Event Sourcing)。使用只允許 Append-only 的資料庫(例如 AWS QLDB 或基於 Kafka 的 Event Store)來記錄每一個狀態的「變更事件」,再透過這些事件流推導出當前的資金帳本(Ledger),從根本上解決狀態被覆蓋與篡改的風險。
維度四、不確定性分析:帳務結構與對帳機制
觀察事實:支付系統需要同時管理訂單內容、交易狀態與資金流向。
經驗推論:絕不能把這些全塞在一張表裡。
核心假設是:系統遲早會出現資料不一致(網路延遲、軟體 bug、Callback 遺失)。
因此,資料庫必須做好「責任分離」,拆分成:
- Order Table:業務狀態(買了什麼)
- Payment Table:交易狀態(跟 PSP 的交互)
- Ledger:資金帳本(複式簿記)
並且需要固定執行對帳(Reconciliation):
- 每天批次拿內部紀錄與 PSP Settlement File 比對
- 有落差就觸發自動補償或人工稽核
維度五、最終結論與行動建議
針對開發者的實務建議:
- 前端整合:一律使用 PSP Hosted Page 或 SDK,絕對不要在自己的資料庫存放使用者的明碼卡號
- 核心 API:所有金流狀態變更 API 必須帶 Idempotency Key
- 狀態同步:依賴 PSP 的 Webhook(Callback)來非同步更新資料庫狀態,並在逾時未收到通知時,設計一套主動向 PSP 輪詢(Polling)的Fallback (備用機制)
- 帳務治理:Order / Payment / Ledger 分離 + 每日對帳
破關路線圖(信心度 95%)
- 先完成 PSP 接入與冪等性機制
- 建立支付狀態機與錯誤碼模型
- 補齊退避重試、Jitter、DLQ 與人工審核流程
- 打通 Webhook + Polling 雙保險狀態同步
- 實作每日對帳與補償任務
這套「冪等性 + 狀態機 + 重試控制 + 對帳」是目前支付整合的主流黃金法則。
脆弱點提示
若系統未來要承載高頻「內部虛擬錢包轉帳」(例如手遊幣在大量玩家間頻繁流動), 僅依賴外部 PSP 與每日對帳會不夠。
此時通常需要升級到:
- 更嚴格的關聯式資料庫交易(ACID Transactions)
- 分散式鎖保護帳戶併發更新
- 串流處理提升內部帳本吞吐與即時一致性
一句話總結
支付系統的核心不是把錢扣到就好,而是用冪等性、狀態機、事件化記錄與對帳機制,確保每一筆金流都可追蹤、可恢復、可稽核。