Hero Image
- Mark

支付系統設計:訂單、支付、對帳的一致性與冪等

支付系統設計:訂單、支付、對帳的一致性與冪等

這篇文章是看了 s09g 在 YouTube 上發的「system design — payment system」(19:47)之後的整理筆記。

我必須先承認一件事:我在寫金流的時候,第一次看這種題目也是先翻 Stripe 文件、把 SDK 串起來、看到 payment_intent.succeeded 就覺得「啊我會接金流了」。但 s09g 影片一開始就潑了一盆冷水——你沒有金融牌照,你拿什麼去划帳?

金流系統設計的本質,不是「怎麼呼叫 PSP」,而是「怎麼把一筆支付的事實記下來,並且保證它永遠對」。這才是後端工程師真正要扛的事。

這篇文會順著影片的章節走:為什麼要透過 PSP、整合的 4 種方案、資料模型怎麼切、冪等怎麼做、狀態機怎麼設計、對帳、event sourcing、風控。每一段我都會加一點自己的觀察(也就是後端工程師半夜被叫醒時會想知道的那種)。

為什麼不是「呼叫 PSP 就好」

很多人以為支付系統就是「前端收信用卡 → 打到 Stripe → 收 webhook → 訂單完成」。但如果你真的畫出這個流程圖,會發現你只畫了 1/10 的參與者。

完整的支付參與者至少有:持卡人(customer)、發卡行(issuer bank)、卡組織(Visa、Mastercard、JCB 這些 card network)、收單行(acquirer bank)、商戶(merchant)。光是把這些角色對齊就已經頭昏了——使用者可能拿著 30 家不同銀行的卡,商戶也對接 5 家收單行,如果你要一家一家去接,工作量跟合規負擔會讓你直接關掉筆電。

所以實務上商戶會找一個 PSP(Payment Service Platform),像 Stripe、Adyen、綠界、藍新。PSP 給你一組乾淨的 API,你呼叫就好,剩下的路由、轉發、跨行清算,他們處理。當然,他們會抽 0.5%–1% 的手續費,這是合理的代價。

系統邊界與責任分離

影片裡 s09g 強調一件事:你的瓶頸不在你自己這裡,在外部 PSP。因為你只是呼叫 API,吞吐量被外部限制住,所以 scalability 暫時不用列為非功能性需求。

這是一個很重要的思維翻轉——大多數後端面試題會要求你談 QPS、談 sharding、談 cache,但支付題目反過來:你先別想 scale,先想合規、可靠性、一致性、正確性。後面三個是金融業務的命根子。

責任分離長這樣:

  • 商戶後端:發起支付請求、記帳、通知使用者
  • PSP:路由、轉發、風控初篩、與銀行對接
  • 銀行 / 卡組織:最終的扣款與清算

商戶負責「事實記錄」,PSP 負責「通路執行」,銀行負責「資金移動」。這個三段式的分工,後面所有的設計都是圍繞它展開的。

支付整合的 4 種方案

影片列了 4 種把使用者付費資料送到 PSP 的方式,我用台灣的脈絡重述並補充:

方案 1:Server-to-server(自建 PSP)

前端把卡號、CVV、到期日送到商戶後端,後端再轉給銀行或卡組織。

這條路等於你自己開一家 PSP。你需要拿到支付機構牌照、需要通過 PCI-DSS 完整稽核、需要保存敏感的 PAN(primary account number)。只有大型企業才會這樣做(像京東、淘寶早期、AWS 自己接的那種等級)。好處是通過 PCI 之後自主權大、能自訂分期分帳、可以省通道費。但初期投入與合規成本太高,一般新創絕對不要走這條路。

方案 2:JavaScript SDK + iframe + 客戶端加密(CSE)

這是 Stripe.js、TapPay、綠界 SDK 的標準做法。PSP 提供 JS library,商戶嵌入到自己的網站,付費時 SDK 把卡號直接送到 PSP(甚至不需要經過商戶伺服器),回傳一個不可逆的 token。商戶拿到 token 再去打 PSP 的扣款 API,永遠不接觸原始卡號

但 SDK 仍然是跑在商戶頁面上的 JS,所以有兩個風險:

  1. XSS 注入:攻擊者在你的頁面注入腳本,攔截使用者輸入
  2. MageCart 攻擊:供應鏈攻擊,在第三方 SDK 裡偷卡號

所以前端必須做 CSP(Content Security Policy)SRI(Sub-resource Integrity)、限制 script 來源、驗 hash。

進一步,現代 SDK 會在底層塞一個 iframe(hosted field),讓卡號輸入框實際上是 PSP 網域的子頁面,再用 PSP 的公鑰做 CSE(client-side encryption) 把卡號加密起來 post 出去。這樣即使商戶頁面被攻破,攻擊者也拿不到明文卡號。

方案 3:Hosted Redirect(託管頁面)

最簡單的方案:使用者點「結帳」→ 302 跳到 PayPal / Stripe Checkout / 綠界託管頁 → 付完跳回來。

PCI 負擔最低、運維最簡單、PSP 故障時全靠他們兜底。自由度也最低——UI 是別人的、品牌是別人的、轉換率會掉(多一次跳出)。台灣很多小店早期就是這樣接藍新、TapPay 的。

取捨總結

s09g 影片最後給了一句非常到位的結論:「可客製化越高,成本越高——不僅是技術成本,還有 PCI 責任和安全管控成本。」台灣的中小型電商我會建議從 方案 2 + iframe hosted field 開始,視需要再升到方案 1。千萬不要為了「接觸卡號比較帥」就直接走 server-to-server,那是把團隊推入合規深坑。

資料模型:三張表把責任切開

影片提到一個非常關鍵的設計:業務狀態、交易狀態、資金狀態,三種狀態應該放三張表

原因很直接——支付資料是合規驅動,不可變更、必須可審計。如果你把「訂單狀態」跟「扣款狀態」混在同一張表,後端寫程式時會很容易不小心 UPDATE 錯欄位,導致審計人員追蹤時資料前後矛盾。

理論上至少要這三張表:

order table(業務狀態)

  • 訂單 ID、商品、金額(業務幣別)、使用者、狀態(待付款 / 已付款 / 已取消 / 已退款)

payment table(交易狀態)

  • charge_id(PSP 端 ID)
  • order_id
  • payment_method(信用卡 / ATM / LINE Pay)
  • psp(Stripe / 綠界 / 藍新)
  • status(state machine:created / start / processing / success / failed)
  • amountcurrency
  • created_atupdated_at
  • idempotency_key重點

ledger(資金狀態,append-only)

  • 帳戶、借方金額、貸方金額、交易 ID、時間戳
  • 永遠 append,不 UPDATE 不 DELETE

三張表讀寫模式完全不同:order table 是 CRUD;payment table 是「以 idempotency_key 為錨點的有限狀態機」;ledger 是純 append-only 的事件流。

ledger 為什麼是 append-only

金融審計的硬要求:「這筆帳寫入後就不能改」。如果你允許 UPDATE,理論上就有人可以掩蓋錯誤。所以 ledger 必須是 append-only。在 PostgreSQL 上你可以用觸發器或權限設定強制,在雲端可以用專用服務——AWS 有 QLDB(Quantum Ledger Database),阿里巴巴開源過 LedgerDB,都提供 SQL 接口但底層專為不可變特性優化。

雙式記帳

影片提到一個會計學名詞:double-entry bookkeeping(雙式記帳)。每筆交易至少影響兩個帳號、產生一組「金額相等、方向相反」的紀錄。借方 100、貸方 100,加總永遠是 0。這是會計幾百年來的鐵律——任何單邊記帳最終都會出錯。

實務上你可以在 ledger table 用兩筆 row 來表達一筆交易:

-- 持卡人帳戶 -100
INSERT INTO ledger (account_id, amount, txn_id, direction) VALUES ('user:42', 100, 'txn:abc', 'debit');
-- 商戶帳戶 +100
INSERT INTO ledger (account_id, amount, txn_id, direction) VALUES ('merchant:1', 100, 'txn:abc', 'credit');

只要加總為 0,內部就一定自洽。

冪等性:exactly-once 的真正意思

金流最怕的事:使用者被扣兩次錢

這個事故怎麼發生的?想像一下:使用者在結帳頁面狂點按鈕(手機網路 lag、按鈕沒 disable、前端重試邏輯有 bug),結果伺服器收到 5 個相同的請求,每個都去 PSP 扣款一次,使用者崩潰、客服崩潰、法務也崩潰。

要避免這個,必須做到 exactly-once——同一筆邏輯交易,不管被打幾次,只會被處理一次

idempotency key 的生命週期

s09g 的影片把這個流程講得很清楚:

  1. 使用者打開 checkout 頁面
  2. 伺服器生成一把 idempotency_key(例如 UUID),回傳給前端
  3. 前端把這個 key 放在後續所有 payment 請求裡
  4. 伺服器收到請求時,先去 payment table 用 key 查重
  5. 如果已經有 record,就回傳上次結果;如果沒有,就開始處理

這個 key 是 exactly-once 的核心。它讓「重試」變成安全操作,因為重試會拿到跟第一次相同的結果

內部一致性 vs 外部一致性

影片把這兩個拆得很乾淨:

  • 內部一致性:你的系統內部(例如 order 跟 payment 的狀態對齊)—— 用關聯式資料庫的交易就能搞定
  • 外部一致性:你的系統跟 PSP 之間的狀態對齊—— 必須靠 idempotency

因為你不能預期 PSP 會乖乖地「只執行一次」。網路會抖動、PSP 會重試、你的 callback 會丟失。所以你的 idempotency key 必須穿透到 PSP,跟他們說:「這個 key 我已經處理過了,請不要再扣一次」。Stripe 文件裡的 Idempotency-Key header 就是這個意思。

狀態機與非同步通訊

PSP 是非同步的。你送出一個扣款請求,PSP 會回你「我收到了」(200 OK),但實際處理結果可能要幾秒甚至幾分鐘才會回。所以 payment 的狀態機至少要有:

created → start → processing → success
                          ↓
                        failed

每個狀態轉換都要落 payment table,並且只能往「終態」前進,不能倒退。

retry 的四要素

當 PSP 沒回應時,你要重試。但不能無腦重試,必須有紀律:

  1. timeout:超過多久才重試
  2. exponential backoff:1s → 2s → 4s → 8s,每次翻倍
  3. jitter:每次加 ±15% 隨機抖動,避免雪崩——多個 client 同時重試時打散流量
  4. dead letter queue (DLQ):超過最大重試次數就丟進 DLQ,不要再撞牆

jitter 這件事 s09g 講得特別細:如果你只有 backoff 沒有 jitter,很多 client 會在同一時間點同步 retry,瞬間把 PSP 打死。jitter 是保護對方的設計,不是保護你自己

callback + 輪詢

PSP 處理完會呼叫你給的 callback URL(webhook)。但 callback 會丟——網路會斷、你的 server 會重啟、防火牆會擋。所以 callback 不能當唯一的事實來源。

實務上兩條路並行:

  • Webhook:PSP 主動通知
  • Polling:超過 N 秒沒收到 callback,主動打 PSP 查詢

兩個都收到時,用 idempotency_key 去重,不會出錯。

狀態機跳變

還有一個討厭的 race condition:你的 server 已經 timeout 把狀態寫成 failed,結果 PSP 後來處理成功、callback 送過來說「其實是 success」。怎麼辦?

兩個解法:

  • 單調狀態機:進入 failed 後不再允許改成 success
  • 樂觀鎖 / 版本號:每次更新帶 version,UPDATE 時檢查 WHERE version = ?

最壞的情況,就是等日終對帳時由 settlement file 兜底。所以對帳才是金流的最終仲裁,不是 webhook、不是 polling。

對帳(Reconciliation)

為什麼還需要對帳?不是已經有 webhook + polling 了嗎?

因為 任何線上鏈路都可能遇到 bug。PSP 可能漏發 webhook、你的 server 可能丟訊息、網路可能 silent drop。一筆交易可能被記成 success 但其實銀行沒收到錢;也可能被記成 failed 但其實已經扣款。

這時候就要靠日終對帳:銀行(或 PSP)每天早上會送一份 settlement file 給你,列出昨天所有交易的最終狀態。你拿這份檔案跟自己的 payment table 比對——

  • 在自己這邊成功、settlement 也成功:沒事
  • 在自己這邊失敗、settlement 卻成功:使用者被扣款但訂單沒成立 → 自動退款或人工補單
  • 在自己這邊成功、settlement 卻失敗:可能是 PSP bug → 人工審核
  • 完全沒出現在 settlement:可能是交易被 PSP 風控擋下但忘了通知你

對帳出來的差異要觸發告警、自動補償、或轉人工審核。這是一條獨立的 batch job,不能跟線上即時流程混在一起,否則你會把 production 弄掛。

Event Sourcing 與風控

s09g 影片最後兩段是「加分題」:如果你還有時間,可以秀一下 event sourcing 跟 risk engine。

為什麼互聯網公司用 event sourcing

Stripe、Coinbase、Square 都用過 event store(Kafka / Pulsar) 作為 source of truth:

  • 所有支付事件(payment.created / payment.captured / payment.refunded)都 append 到 event store
  • projection / view 從事件流計算出查詢用的 view(加速前端查詢)
  • 任何糾錯都以「反向事件」或「補償事件」寫入,不會覆蓋歷史

這樣做的代價是系統複雜度高、開發與運維成本高、團隊要同時熟悉流系統與金融系統。但好處是:拿到金融正確性 + 互聯網可擴展性——可以重放事件、可以對帳時重新計算、可以接多個下游 consumer。

削峰填谷

如果把 MQ 放到最前面,Payment Service 變成 pure broadcaster,把每個事件投放到不同 DB:

  • 一份進 payment table(線上查詢用)
  • 一份進 ledger(金融記錄用)
  • 一份進下游分析系統(BI 用)

每次對帳時,可以在 MQ 前端做事件重放,重新計算所有 view。這對偶發的資料錯誤非常有救。

Risk Engine

風控放在 Payment Service 前面。影片列出 4 種等級:

  1. 規則引擎:黑名單、白名單、規則樹——最簡單、合規友善、可解釋
  2. 統計 / ML:LR、XGBoost 之類的反詐欺模型——反應慢、需要標註資料
  3. 無監督學習:解決冷啟動問題——會有誤報、需人工介入
  4. 圖模型 / GNN:捕捉複雜詐騙網路——維運成本高、開發難

台灣中小型電商我會建議從規則引擎 + 統計模型起步,等到交易量到一定規模(單日 10 萬筆以上)再考慮上 GNN。風控模型最大的坑是誤報成本——擋下太多正常訂單會直接傷害營收,所以一開始寧可寬鬆再調嚴,也不要一開始就過度嚴格。

結語:給金流工程師的一句話

把整支影片看完,我最大的感觸是:金流系統設計的難度不在於「技術棧很新」,而在於「你的每一行程式碼都在碰真實的金錢」。

當你寫一般的後端服務時,bug 通常只是「使用者看到錯誤畫面」。當你寫支付系統時,bug 可能是「使用者被扣款但沒拿到商品」、「錢被轉到錯的帳戶」、「帳務報表對不上」——每一個都是會上新聞的等級。

所以金流工程師的核心素養,不是會串 SDK,而是對 合規邊界、資金一致性、非同步協作、可審計性 的直覺。

s09g 這支影片是個很好的起點,但他講得很保守(給面試用)。實際上線你還要對 PCI-DSS、PSP SLA、當地法規(台灣還有洗錢防制法、金管會的電子支付機構管理條例)。如果你是金流工程師,下一個值得挑戰自己的題目是:怎麼把對帳與金流觀測做成 dashboard,讓 ops 半夜被叫醒時不用翻 SQL。

金流這條路很硬,但相對地也比較少人走得深。值得走。

Other Related Posts:

Log System 系統設計:面試官逐點拆解的五個關鍵取捨

Log System 系統設計:面試官逐點拆解的五個關鍵取捨

從 sidecar 模式、Ingestion 架構到倒排索引,一次搞懂集中式日誌系統設計的面試重點


最近看了一支 s09g 的系統設計 Mock Interview,受面者設計「集中式日誌系統」,面試官在 45 分鐘內從 Clarify 一路 DeepDive 到查詢層,把每一層的設計決定都翻過一遍。這類「面試官逐點 feedback」的影片,對準備系統設計面試的人特別有價值——比起「如何設計一個 log system」的教學文,它揭露的是真正的 trade-off 與面試官在意什麼

這篇整理影片裡面試官挑出的五個關鍵設計決定,包含他自己的「我會怎麼問」與「為什麼這樣...

17th Aug 2026 - Mark