回到部落格
2026年3月31日4 分鐘閱讀

系統設計08:支付系統(Payment System)

從 PSP 整合、冪等性、狀態機到對帳機制,拆解支付系統如何避免重複扣款並維持帳務一致。

《從 Web 到 AI-Native:一個新鮮人的系統設計五維度推演筆記》 系列學習筆記系統設計

前言

面對「請設計一個支付系統」這題,千萬別從「直接對接銀行底層網路」開始想。

在真實商業環境中,主流解法是依賴第三方支付服務商(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)
  • 分散式鎖保護帳戶併發更新
  • 串流處理提升內部帳本吞吐與即時一致性

一句話總結

支付系統的核心不是把錢扣到就好,而是用冪等性、狀態機、事件化記錄與對帳機制,確保每一筆金流都可追蹤、可恢復、可稽核。