題目與適用情境
請設計一個電商付款處理系統。買家提交訂單後,系統透過外部付款服務商完成一次性銀行卡付款;庫存 確認後再扣款,訂單取消時釋放授權,扣款後支援多次部分退款,累計退款不得超過已扣款金額。付款方式 可能要求 3DS 等額外驗證,最終結果也可能晚於瀏覽器回傳。
本題假設每天有 500 萬次付款嘗試,平均約 58 TPS,尖峰 500 TPS;建立付款 API 的 p99 小於 300 毫秒,付款狀態查詢的 p99 小於 200 毫秒,本機 API 月可用性目標為 99.99%。這個可用性目標不承諾 外部服務商一定完成付款。金額使用最小貨幣單位的整數表示;一筆付款只有一種幣別。外部服務商、網路 和本機程序都可能失敗。數字與時限是面試約束,不代表任何付款產品的承諾。
範圍包括付款建立、額外驗證、授權、扣款、取消授權、部分或全額退款、資金帳本、服務商回呼與對帳。 拒付、風控模型、換匯、商家結算、稅務和完整 PCI 合規專案不展開,但候選人應說明邊界。付款卡號由 服務商託管頁面或權杖化元件收集,本系統保存付款方式權杖,不接收或記錄原始卡號與安全碼。
面試官考察什麼
第一項是能否區分業務意圖、外部嘗試和資金事實。一個購物車對應一個內部 payment_id,但它可能有 多次驗證或服務商嘗試;一次請求逾時也不代表付款失敗。高品質回答不會用一欄 paid=true 覆蓋所有 過程,而會分別保存付款聚合狀態、每次操作及其未知結果、服務商引用和帳本分錄。
第二項是能否準確處理分散式邊界。資料庫交易不能和外部付款服務商組成同一個原子交易。服務商已經 扣款但回應遺失、回呼先於同步回應抵達、程序在本機提交後崩潰,都可能發生。答案要用呼叫方冪等鍵、 內部操作 ID、服務商冪等鍵、交易 outbox、回呼去重和狀態查詢,讓重複執行成為可收斂流程,不能 籠統承諾端到端 exactly-once。
第三項是付款狀態機是否具備單調、可稽核的轉換。授權和扣款是不同資金階段;REQUIRES_ACTION 不 等於失敗,服務商逾時產生 UNKNOWN 操作,也不能直接把付款標成失敗。退款必須引用已扣款付款,使用 精確金額,並在並行下守住「已退款 + 處理中退款不超過已扣款」的不變量。
第四項是帳本與業務狀態的職責。付款表回答使用者目前能做什麼;追加寫帳本回答資金為什麼得到這個餘額。 已入帳事實不能原地改寫,退款和修正使用新的反向或補償分錄。每個 journal 的借貸總額、幣別和業務 引用都要在交易中驗證,之後再與服務商報告和銀行入帳做對帳。
最後是安全和可證偽性。候選人應縮小卡片資料範圍、驗證回呼簽章、保護服務商金鑰、限制權限與日誌內容, 並用回應遺失、重複回呼、亂序回呼、並行扣款/退款、帳本不平和對帳差異做故障注入。只畫服務框圖而 沒有不變量、復原路徑和驗證方法,不能證明系統正確。
回答前要確認的問題
- 誰負責收集付款卡資料? 本題使用付款服務商託管頁面或權杖化元件。業務後端只接收付款方式權杖,
不保存原始卡號、磁軌、PIN 或安全碼;仍需由合規團隊確認實際 PCI 範圍。
- 授權與扣款何時發生? 下單時授權,庫存預留成功後扣款;庫存失敗或授權到期前取消授權。不同付款
方式是否支援延遲扣款,要透過能力表判斷,不能假設全部一致。
- 成功以什麼為準? 瀏覽器跳轉只提供使用者體驗,不能單獨觸發出貨。權威狀態來自經過驗證的服務商
回應、回呼或主動查詢,並由本機狀態機接受後才能產生業務動作。
- 退款契約是什麼? 支援多次部分退款和全額退款,不允許累計超過已扣款金額;不做沒有原付款的獨立
退款。退款可能非同步完成,也可能被服務商拒絕。
- 是否需要多服務商路由? 首版只接一家服務商,但介面保存內部操作 ID 和服務商引用。結果未知時
禁止自動換另一家重試,否則可能在兩家都扣款。
- 帳本涵蓋什麼? 本題記錄服務商應收與商家應付,不展開服務商費用、商家撥款和稅務。每種幣別
獨立平衡,不在帳本中使用浮點數或隱含匯率換算。
- 資料保留與稽核要求是什麼? 付款、操作、回呼收據和帳本引用依監管與公司政策保留;敏感負載
需要最小化、加密和存取稽核。PCI SSC 明確禁止授權後保存敏感驗證資料,即使已加密。
- 可用性和一致性如何取捨? 服務商不可用時可接受並顯示「處理中」,但不能顯示虛假成功。資金
正確性和可追溯性優先於立即給出終態。
30 秒回答框架
「我會用穩定 payment_id 表達一次付款意圖,把付款狀態與 authorize、capture、refund 操作分開。 建立和資金操作都用呼叫方冪等鍵原子保存請求摘要、操作與 outbox;工作程序重用操作 ID 呼叫服務商。 逾時進入 UNKNOWN,靠驗簽回呼、查詢和對帳收斂,不換 ID 盲目重試。權威結果透過單調狀態機寫入, 同一交易追加平衡 journal 和業務事件。退款條件預占剩餘可退金額,避免並行超額;最後再把內部帳本、 服務商報告和銀行入帳對齊。卡片資料由服務商權杖化,瀏覽器成功頁不能觸發出貨。」
分步深入分析
第一步:從容量、不變量和所有權開始
500 萬除以 86,400 秒約為 58 TPS,500 TPS 的尖峰不要求一開始就切分所有資料表。設計重點是正確性、 稽核與外部故障復原。付款 API 可以水平擴充,關聯式資料庫保存權威狀態;按 payment_id 路由的持久 佇列吸收尖峰並隔離服務商延遲。先寫下不變量:
amount > 0
currency is immutable after the first provider attempt
captured_amount <= authorized_amount
refunded_amount + pending_refund_amount <= captured_amount
for every journal: sum(debits) == sum(credits), per currency
one merchant + one idempotency_key describes one immutable request intent
one successful business operation produces at most one journal reference在這個量級,單寫區域的關聯式資料庫搭配故障切換,比主動—主動多區域寫入更容易守住冪等鍵、狀態版本和 帳本順序。主動—主動能降低區域切換時間,卻要解決同鍵跨區域並行和資金衝突,只有明確的區域可用性需求 才值得採用。完整事件溯源也能保留歷史,但會把狀態重建、版本遷移和查詢複雜度擴散到整個付款流程; 本題只讓資金 journal 追加寫,工作流程聚合使用帶版本的目前狀態,稽核收益和維運複雜度更匹配。
訂單服務擁有庫存與履約;付款服務擁有付款狀態、操作和資金引用;服務商擁有銀行卡網路側狀態;帳本擁有 內部資金事實。訂單不能直接寫付款表,付款回呼也不能直接把訂單標成已出貨。付款服務發布穩定業務事件, 訂單服務按 payment_id 冪等消費。
第二步:定義 API、冪等工作階段和資料模型
核心介面可以是:
POST /payments create one payment for an order
POST /payments/{id}/capture capture an authorized amount
POST /payments/{id}/cancel cancel an uncaptured authorization
POST /payments/{id}/refunds request a partial or full refund
GET /payments/{id} return current status and allowed next actions
POST /provider/webhooks persist a verified provider event所有改變資金的呼叫都需要呼叫方提供冪等鍵。(merchantid, operationtype, idempotency_key) 建立唯一 約束,記錄規範化請求摘要、處理中狀態和可重播回應。首次請求在同一資料庫交易中建立 payments、 payment_operations 與 outbox。相同鍵和相同摘要重播已知回應;相同鍵但金額、幣別、付款或操作類型 不同,回傳衝突。Amazon 的一手工程說明也強調:由呼叫方明確請求識別碼,並把識別碼記錄和業務變更 放在同一個 ACID 邊界;同識別碼不同意圖應被拒絕。
建議分開儲存:
payments:訂單引用、金額、幣別、聚合狀態、已授權/已扣款/已退款金額、版本;payment_operations:類型、操作 ID、請求金額、狀態、服務商、服務商引用、嘗試次數與未知原因;provider_events:服務商事件 ID、簽章驗證結果、接收時間、負載加密引用和處理狀態;journal_entries:journal ID、帳戶、借/貸、金額、幣別、操作引用;outbox_events:本機交易已提交、等待發布的業務事件。
內部操作 ID 可以作為服務商冪等鍵。Adyen 文件明確說明付款逾時可用同一鍵安全重試,並指出鍵有作用域、 保存期和跨區域邊界;因此系統必須了解具體服務商契約,不能把一個「UUID」當成永久全球保證。
第三步:把付款和操作建成兩個狀態機
付款聚合面向訂單和使用者:
CREATED -> REQUIRES_ACTION -> AUTHORIZED -> CAPTURED
\-> FAILED \-> CANCELED
CAPTURED -> PARTIALLY_REFUNDED -> REFUNDED每次 authorize、capture、cancel 或 refund 還有獨立操作狀態:
PENDING -> SUCCEEDED | FAILED | UNKNOWN
UNKNOWN -> SUCCEEDED | FAILED (after query, webhook, or reconciliation)FAILED 只用於收到權威失敗且該次操作不會再成功;網路逾時、5xx 或程序失去回應都是 UNKNOWN。 付款聚合只接受合法的單調轉換。回呼報告舊狀態時保留收據但不回退聚合;回呼與主動查詢同時抵達時, 透過資料列版本或條件更新只提交一次轉換。Stripe 文件說明一個付款意圖涵蓋從建立到結帳的生命週期, 並可能要求額外驗證;人工扣款會先進入可扣款狀態,再執行 capture。這支援把付款建成長生命週期資源, 而非把 HTTP 請求結果當成付款本身。
下單授權與庫存確認解耦。庫存成功事件觸發 capture;庫存失敗觸發 cancel。兩個操作可能並行,資料庫 條件更新只允許一個從 AUTHORIZED 認領目前版本。服務商操作回傳前仍可收到回呼,所以同步回應和回呼 都進入同一個狀態套用函式,不能各寫一套轉換邏輯。
第四步:接受外部非原子性,並讓流程收斂
本機交易把操作狀態與 outbox 一起提交,relay 至少一次發布,工作程序按操作 ID 認領。呼叫服務商時 重用該操作 ID 作為冪等鍵,並設定有界連線、請求和總截止時間。若回傳確定成功或失敗,保存服務商引用 和原始結果摘要;若回應遺失,標為 UNKNOWN,排入狀態查詢。絕不能建立新操作或換服務商盲目重試。
有三個關鍵崩潰窗口:
- 本機交易提交前崩潰:沒有可見操作,呼叫方可用同一冪等鍵重試;
- outbox 已提交但發布確認遺失:relay 會重複發布,工作程序認領相同操作;
- 服務商成功但回應遺失:相同服務商冪等鍵重試、查詢服務商引用或等待回呼,最終收斂。
這提供的是可重試、可稽核的「同一意圖」,不是跨公司交易。服務商冪等保存期過後仍未知時,不能繼續 猜測;凍結該操作的自動重試,透過服務商報告和人工處置確認結果。
第五步:安全接收非同步回呼並容忍亂序
回呼端點先保留原始請求本文,使用目前與輪替期舊金鑰驗證簽章和時間窗口;驗證失敗直接拒絕。按 (provider, providereventid) 唯一保存收據,成功持久化後儘快回傳 2xx,再由佇列非同步套用。日誌 只記錄事件 ID、服務商引用和原因碼,不記錄完整敏感負載或金鑰。
回呼可能重複、延遲和亂序。Stripe 文件明確說明正式事件會自動重試,且不保證產生順序。處理器因此 不能假設「授權事件一定先於扣款事件」。它可以透過事件中的物件引用查詢服務商目前資源,或把外部狀態 映射為允許的本機轉換。任何事件都使用同一個操作 ID 或服務商引用去重,已經入帳的 capture 不能再次 入帳,舊的 authorization 也不能把 CAPTURED 回退為 AUTHORIZED。
瀏覽器只輪詢或訂閱本機付款狀態。即使回傳 URL 帶有「成功」參數,也不能出貨;用戶端也不能提交 CAPTURED。當本機收到權威成功並提交帳本後,透過 outbox 發布 PaymentCaptured,訂單服務用事件 ID 冪等履約。
第六步:用追加寫帳本表達資金事實
付款狀態是操作檢視,帳本是資金稽核記錄。本題簡化為兩個帳戶:服務商應收是資產,商家應付是負債。 扣款 100.00 元(假設貨幣最小單位為分)可以寫:
journal capture-<operation_id>, CNY
debit processor_receivable 10000
credit merchant_payable 10000退款 30.00 元寫新的反向 journal:
journal refund-<operation_id>, CNY
debit merchant_payable 3000
credit processor_receivable 3000每個 journal 至少兩筆分錄,借貸金額按幣別相等;journal_id 和業務操作引用唯一。狀態從權威結果轉換 到 CAPTURED 或退款成功時,同一交易寫入 journal 和 outbox。手續費、拒付、商家結算若納入範圍, 增加明確帳戶與新分錄,不改寫舊分錄。餘額由分錄彙總或由可重建投影加速,投影不能成為資金真源。
並行退款先在付款資料列上做條件認領:只有 capturedamount - refundedamount - pendingrefundamount 足夠時才建立退款操作並增加處理中金額。成功後把處理中移到已退款;確定失敗則釋放;未知繼續占用, 避免另一次退款超過上限。服務商也可能限制超額退款,但本機不變量不能依賴外部兜底。
第七步:用對帳發現靜默不一致並安全修復
即時路徑無法證明永遠沒有遺漏。第一層對帳處理 UNKNOWN 操作:按服務商引用或冪等鍵查詢,比較金額、 幣別、操作類型和終態。第二層每日拉取不可變服務商交易或結算報告,對齊內部 payment、operation、 journal 與訂單引用。第三層把服務商結算批次與銀行實際入帳匹配,區分未結算、費用、退款和拒付。
差異分類為:外部有內部無、內部成功外部無、金額或幣別不同、狀態落後、重複引用、帳本不平、結算批次 缺項。高風險差異阻止相關商家餘額釋放並告警。修復器必須冪等:補錄服務商已經確認的操作時寫新的 journal 與稽核原因;錯誤帳本用補償分錄,不能 UPDATE 歷史金額。Stripe 的報告文件把餘額交易描述為 不可變,並用新的退款交易抵銷原事實,也說明了付款、結算批次和銀行入帳需要分別匹配。
指標分層記錄 API 成功率與 p99、付款狀態分布、操作 UNKNOWN 數量和年齡、服務商錯誤與限流、回呼 驗簽失敗/重複/延遲、授權到扣款耗時、退款餘額占用、outbox/佇列積壓、journal 不平衡拒絕、對帳差異 數量和最大未解決時間。業務報表還要把授權率、扣款率、退款率和結算率分開,不能把「請求已受理」算成 「資金已入帳」。
第八步:縮小敏感面並做故障注入
卡片資料由服務商權杖化元件直接收集;後端只保存無法還原卡號的服務商權杖、品牌和末四碼等獲准欄位。 用戶端 secret 不進入 URL 或日誌,服務商金鑰放入受控金鑰系統並輪替。回呼金鑰獨立,正式與測試環境 隔離。讀取付款、發起退款、查看帳本和人工修復使用不同權限,所有高風險操作帶操作者與理由稽核。
PCI SSC 明確禁止授權後保存卡片驗證碼、PIN/PIN block 等敏感驗證資料,即使加密也不允許。本題採用 託管收集來縮小範圍,但是否符合合規要求仍需正式評估,不能在面試中聲稱「用了 token 就不受 PCI」。
驗收涵蓋:用戶端同鍵重試和同鍵改金額;服務商成功後遺失回應;回呼先於 API 回應;重複、亂序和延遲 回呼;outbox 重複發布;授權與取消並行;兩筆退款並行爭奪剩餘金額;部分退款後全額退款;服務商冪等 過期;回呼簽章輪替;帳本單邊寫入被交易拒絕;對帳發現外部單邊交易;修復程序重複執行;佇列與服務商 長時間停機後的追趕。每個情境都斷言付款狀態、操作狀態、journal 數量、訂單副作用和告警結果。
高品質示範回答
「我會把付款設計成長期業務資源,不是一次 HTTP 呼叫。一張訂單對應穩定 payment_id,付款聚合保存 金額、幣別和授權/扣款/退款狀態;每次 authorize、capture、cancel、refund 使用獨立操作 ID 與 PENDING、SUCCEEDED、FAILED、UNKNOWN 狀態。外部逾時只進入 UNKNOWN,不能直接失敗或換服務商重試。
建立和資金操作 API 都要求呼叫方冪等鍵。商家、操作類型和鍵唯一,首次請求原子保存請求摘要、付款或 操作和 outbox;相同請求回傳原結果,同鍵不同金額或幣別回報衝突。工作程序用內部操作 ID 作為服務商 冪等鍵。outbox、佇列和工作程序都按至少一次設計,相同操作只套用一次權威轉換。
服務商同步回應、經過驗簽的回呼和主動查詢進入同一個狀態套用函式。回呼原始負載驗簽,事件 ID 去重, 先持久化再快速回傳 2xx;處理器容忍重複和亂序,舊事件不能讓付款狀態回退。瀏覽器只顯示本機狀態, 不能觸發出貨。付款扣款權威成功並在同一交易寫入平衡 journal 與業務 outbox 後,訂單服務才冪等履約。
扣款 journal 借記服務商應收、貸記商家應付;退款寫新的反向 journal,歷史分錄不可修改。退款建立時 用條件更新預占剩餘可退金額,保證已退款加處理中退款不超過已扣款。資金金額用最小單位整數,幣別不能 在首次嘗試後變更,各幣別獨立平衡。
復原分三層:未知操作重用服務商冪等鍵或查詢狀態;每日服務商交易報告與 payment、operation、journal 對帳;結算批次再與銀行入帳匹配。差異隔離並告警,修復只追加冪等補錄或補償分錄。卡片資料由服務商 託管元件收集,後端不保存卡號或安全碼。最後用回應遺失、重複/亂序回呼、並行扣款退款、服務商停機、 帳本不平和對帳單邊交易做故障注入,證明每個外部結果最終只產生一份可稽核資金事實。」
常見錯誤
- 把瀏覽器成功頁當作付款成功 → 跳轉可被偽造,付款也可能仍在等待驗證或非同步確認 → **只讓權威
服務商結果透過本機狀態機觸發履約。**
- 用一欄
paid保存全部狀態 → 無法表達額外驗證、授權、扣款、部分退款和未知結果 → **拆分付款
聚合與操作嘗試狀態機。**
- 逾時後換新 ID 或換服務商重試 → 第一次可能已經扣款,產生雙扣 → **重用同一操作和服務商冪等
鍵,查詢或對帳未知結果。**
- 同鍵請求只比較 key,不比較參數 → 呼叫方誤用同一個鍵改金額時會回傳錯誤業務結果 → **保存
規範請求摘要,同鍵不同意圖回報衝突。**
- 依賴回呼順序 → 服務商可能重試、亂序或延遲 → 事件去重,查詢目前資源或只套用合法單調轉換。
- 付款表餘額就是帳本 → 原地更新會遺失資金變更原因,也無法獨立對帳 → **追加平衡 journal,餘額
只做可重建投影。**
- 並行退款先查再寫 → 兩個請求都看到足夠餘額並一起超額 → **在交易中條件預占可退金額並唯一綁定
操作。**
- 只監控 API 200 → 已受理、已授權、已扣款和已結算含義不同 → **按狀態階段、未知年齡和對帳差異
分層監控。**
- 認為權杖化自動消除 PCI 責任 → 頁面、日誌、腳本和維運流程仍可能進入範圍 → **最小化卡片資料並
由合規團隊確認實際邊界。**
- 修復差異時修改歷史分錄 → 稽核鏈斷裂且歷史報表無法重播 → 追加帶原因的補錄或補償 journal。
追問及應對
追問一:服務商扣款成功,但同步回應和回呼都遺失,怎麼辦?
操作保持 UNKNOWN,占用對應授權或退款額度,禁止建立同意圖操作。先用原服務商冪等鍵重試支援冪等的 查詢或呼叫,再按服務商引用主動查詢;仍無法確定時等待交易報告對帳。確認成功後透過同一狀態套用函式 寫入 journal 和 outbox,確認失敗才釋放占用。超過服務商冪等保存期仍無證據時進入人工佇列,不能憑 時間推斷失敗。
追問二:兩筆 60 元退款同時請求一筆已扣款 100 元的付款,如何阻止超額?
在付款資料列或專門的退款餘額資料列上執行條件更新:只有剩餘可退金額至少為 60 元時才把 pendingrefundamount 增加 60 元並建立操作。兩筆交易爭用同一版本,只有一筆成功;另一筆重新讀取 後回傳可退餘額不足。未知退款繼續占用,確定失敗釋放,成功從 pending 移到 refunded。服務商限制只是 第二道防線。
追問三:回呼先收到 CAPTURED,之後才收到 AUTHORIZED,怎麼處理?
兩份收據都保存並去重。CAPTURED 若簽章、金額、幣別和服務商引用均匹配,就完成扣款轉換與入帳; 之後的 AUTHORIZED 是舊事實,狀態機不允許回退,只更新回呼稽核和延遲指標。若回呼負載不含足夠版本 資訊,就查詢服務商目前付款資源,不按接收順序覆蓋本機狀態。
追問四:為什麼同時需要付款表和帳本?
付款表是工作流程聚合,適合回答是否還能扣款、取消或退款;帳本是追加寫資金事實,適合回答餘額如何形成、 每次變化對應哪項業務。付款狀態可以從 CAPTURED 進入 PARTIALLY_REFUNDED,帳本則保留原扣款和每次 退款的獨立平衡 journal。兩者用唯一操作引用連接,並在同一交易提交權威轉換,職責不能互相替代。
追問五:怎樣驗證對帳修復器不會重複補帳?
為每筆外部報告記錄建立穩定來源鍵,例如服務商、報告類型和交易引用;修復動作再綁定差異 ID,並對 (sourcekey, repairtype) 建立唯一約束。第一次交易追加 journal、標記差異和寫 outbox;崩潰重跑會 命中同一修復記錄並回傳原結果。測試在交易提交前、提交後和事件發布後分別終止程序,斷言 journal 數量、 餘額和下游事件都不增加第二次。
追問六:如果以後接入第二家服務商,何時允許故障切換?
只有在第一家明確表示操作未建立或確定失敗,且本機操作尚未產生資金事實時,才可建立綁定第二家的 操作。連線逾時、5xx 和未知結果都不符合條件。路由決策、服務商能力、金額、幣別和原因要進入稽核; 兩家結果都可能存在時先凍結履約與餘額釋放,透過查詢和對帳消除雙扣,不能用自動退款掩蓋未知狀態。