本章我們設計一個支付系統。近年來電子商務在全球蓬勃發展,而讓每一筆交易得以順利完成的,正是在背後運作的支付系統。一個可靠可擴展且具備彈性的支付系統至關重要。

什麼是支付系統?根據維基百科:「支付系統是任何用於透過貨幣價值轉移來結算金融交易的系統,包括使其能夠進行交換的機構、工具、人員、規則、程序、標準與技術」[1]。

支付系統表面上看似容易理解,但對許多開發者而言卻令人望而生畏。一個小失誤就可能造成巨大的營收損失,並摧毀使用者的信任。但別怕!本章我們將揭開支付系統的神秘面紗。

支付系統中的一個小失誤就可能造成巨大的營收損失,並摧毀使用者的信任。因此設計時必須特別謹慎處理失敗、重試與一致性。

Step 1 - Understand the Problem and Establish Design Scope#

「支付系統」對不同的人可能代表完全不同的東西。有些人會想到 Apple Pay 或 Google Pay 這類的數位錢包(digital wallet),有些人則會想到 PayPal 或 Stripe 這類處理支付的後端系統。在面試一開始就確定明確的需求非常重要。以下是一些可以詢問面試官的問題:

應徵者:我們要建構什麼樣的支付系統?

面試官:假設你正在為類似 Amazon.com 的電商應用建構一個支付後端。當顧客在 Amazon.com 下單時,支付系統處理所有與資金流動相關的事情。

應徵者:支援哪些付款方式?信用卡、PayPal、銀行卡等等?

面試官:在現實中支付系統應該支援所有這些選項。不過在這次面試中,我們以信用卡支付為例即可。

應徵者:信用卡支付處理是我們自己處理嗎?

面試官:不是,我們使用第三方支付處理商(payment processor),例如 Stripe、Braintree、Square 等。

應徵者:我們會在系統中儲存信用卡資料嗎?

面試官:由於非常嚴格的安全與合規要求,我們不會直接在系統中儲存卡號。我們依賴第三方支付處理商來處理敏感的信用卡資料。

應徵者:這個應用是全球性的嗎?我們需要支援不同貨幣與跨國付款嗎?

面試官:好問題。是的,這個應用是全球性的,但本次面試我們假設只使用一種貨幣。

應徵者:每天有多少筆支付交易?

面試官:每天 100 萬筆交易。

應徵者:我們需要支援付出(pay-out)流程嗎?也就是像 Amazon 這類電商網站每月支付給賣家的流程。

面試官:是的,我們需要支援這個。

應徵者:我想我已經收集了所有需求。還有什麼我應該注意的嗎?

面試官:有的。支付系統會與大量的內部服務(會計、分析等)以及外部服務(支付服務提供商)互動。當某個服務失敗時,我們可能會看到服務之間的狀態不一致。因此,我們需要進行對帳(reconciliation)並修正任何不一致。這也是一項需求。

透過這些問題,我們得到了功能性與非功能性需求的清晰樣貌。本次面試中,我們專注於設計一個支援以下功能的支付系統。

Functional requirements#

  • 收款(Pay-in)流程:支付系統代表賣家從顧客處收取金錢。
  • 付款(Pay-out)流程:支付系統將金錢匯給世界各地的賣家。

Non-functional requirements#

  • 可靠性與容錯能力。失敗的付款必須被謹慎處理。
  • 對帳流程。需要在內部服務(支付系統、會計系統)與外部服務(支付服務提供商)之間進行對帳流程。該流程以非同步方式驗證這些系統間的支付資訊是否一致。

Back-of-the-envelope estimation#

系統需要每天處理 100 萬筆交易,也就是 1,000,000 / 10^5 秒 = 每秒 10 筆交易(10 TPS)。對一般資料庫來說,10 TPS 並不是大數字。

本次系統設計面試的重點在於如何正確處理支付交易,而不是追求高吞吐量。

Step 2 - Propose High-Level Design and Get Buy-In#

從高層次來看,支付流程拆解為兩個步驟以反映資金流動方式:

  • 收款流程(Pay-in flow)
  • 付款流程(Pay-out flow)

以電商網站 Amazon 為例。買家下單後,資金流入 Amazon 的銀行帳戶,這就是收款流程。雖然錢在 Amazon 的銀行帳戶中,Amazon 並不擁有所有的錢。賣家擁有其中相當一部分,Amazon 只是代為保管並收取手續費。

之後,當商品送達且資金被釋出後,扣除手續費後的餘額再從 Amazon 的銀行帳戶流入賣家的銀行帳戶。這就是付款流程。簡化的收款與付款流程如圖 1 所示。

圖 1 簡化的收款與付款流程

Pay-in flow#

收款流程的高層次設計圖如圖 2 所示。讓我們看看系統的每個元件。

圖 2 收款流程

Payment service

支付服務(payment service)接受來自使用者的支付事件並協調支付流程。它通常做的第一件事是風險檢查(risk check),評估是否符合 AML/CFT [2] 等法規,並偵測洗錢或資助恐怖主義等犯罪活動的跡象。支付服務只處理通過此風險檢查的付款。通常,風險檢查服務會使用第三方提供商,因為這是一項非常複雜且高度專業化的工作。

Payment executor

支付執行器(payment executor)透過支付服務提供商(Payment Service Provider, PSP)執行單筆支付訂單(payment order)。一個支付事件可能包含多筆支付訂單。

Payment Service Provider (PSP)

PSP 將資金從帳戶 A 移動到帳戶 B。在這個簡化的範例中,PSP 將資金從買家的信用卡帳戶移出。

Card schemes

卡組織(card schemes)是處理信用卡操作的機構。知名的卡組織有 Visa、MasterCard、Discovery 等。卡組織的生態系統非常複雜 [3]。

Ledger

帳本(ledger)保留支付交易的金融紀錄。例如,當使用者向賣家支付 1 美元時,我們會記錄為從使用者借記(debit)1 美元,並貸記(credit)1 美元給賣家。帳本系統在支付後分析(例如計算電商網站的總營收或預測未來營收)中非常重要。

Wallet

錢包(wallet)保留商家的帳戶餘額。它也可能記錄某個使用者總共付了多少錢。

如圖 2 所示,典型的收款流程如下:

  1. 當使用者點擊「下單」按鈕時,會產生一個支付事件並送到支付服務。
  2. 支付服務將支付事件儲存到資料庫中。
  3. 有時,單一支付事件可能包含多筆支付訂單。例如,你可能在單次結帳流程中從多位賣家選購商品。如果電商網站把結帳拆成多筆支付訂單,支付服務會為每筆支付訂單呼叫支付執行器。
  4. 支付執行器將支付訂單儲存到資料庫。
  5. 支付執行器呼叫外部 PSP 來處理信用卡付款。
  6. 在支付執行器成功處理付款後,支付服務更新錢包以記錄某位賣家擁有多少錢。
  7. 錢包伺服器將更新後的餘額資訊儲存到資料庫。
  8. 在錢包服務成功更新賣家的餘額資訊後,支付服務呼叫帳本服務進行更新。
  9. 帳本服務將新的帳本資訊附加到資料庫。

APIs for payment service#

我們對支付服務採用 RESTful API 設計慣例。

POST /v1/payments

此端點執行一筆支付事件。如前所述,單一支付事件可能包含多筆支付訂單。請求參數列出如下:

欄位說明型別
buyer_info買家資訊json
checkout_id此次結帳的全域唯一 IDstring
credit_card_info可能是加密的信用卡資訊或支付 token,數值依 PSP 而定json
payment_orders支付訂單列表list

表 1 API 請求參數(執行支付事件)

payment_orders 看起來像這樣:

欄位說明型別
seller_account哪位賣家會收到款項string
amount此訂單的交易金額string
currency此訂單的貨幣string (ISO 4217 [4])
payment_order_id此付款的全域唯一 IDstring

表 2 payment_orders

請注意 payment_order_id 是全域唯一的。當支付執行器將支付請求送到第三方 PSP 時,payment_order_id 會被 PSP 用作去重 ID(deduplication ID),也稱為冪等性鍵(idempotency key)

你或許注意到「amount」欄位的資料型別是「string」而非「double」。Double 不是好的選擇,原因如下:

  1. 不同的協定、軟體與硬體在序列化與反序列化時可能支援不同的數值精度。這種差異可能導致非預期的捨入錯誤。
  2. 數字可能極大(例如 2020 年日本的 GDP 約為 5x10^14 日圓),或極小(例如比特幣的一個 satoshi 是 10^-8)。

建議在傳輸與儲存時將數字保持為字串格式。只有在顯示或計算時才解析為數字。

GET /v1/payments/{:id}

此端點根據 payment_order_id 回傳單筆支付訂單的執行狀態。

上述支付 API 與一些知名 PSP 的 API 類似。如果你想對支付 API 有更全面的了解,可以參考 Stripe 的 API 文件 [5]。

The data model for payment service#

支付服務需要兩張資料表:payment event(支付事件)與 payment order(支付訂單)。為支付系統挑選儲存方案時,效能通常不是最重要的因素。我們關注的是:

  1. 經過驗證的穩定性。儲存系統是否被其他大型金融公司使用多年(例如 5 年以上)並獲得正面回饋。
  2. 周邊工具的豐富程度,例如監控與調查工具。
  3. 資料庫管理員(DBA)人才市場的成熟度。能否招募到有經驗的 DBA 是非常重要的考量因素。

通常我們偏好支援 ACID 交易的傳統關聯式資料庫(relational database),勝過 NoSQL/NewSQL。

payment event 資料表包含詳細的支付事件資訊。它看起來像這樣:

名稱型別
checkout_idstring PK
buyer_infostring
seller_infostring
credit_card_info取決於發卡組織
is_payment_doneboolean

表 3 Payment event

payment order 資料表儲存每筆支付訂單的執行狀態。它看起來像這樣:

名稱型別
payment_order_idString PK
buyer_accountstring
amountstring
currencystring
checkout_idstring FK
payment_order_statusstring
ledger_updatedboolean
wallet_updatedboolean

表 4 Payment order

在深入這些資料表之前,先看一些背景資訊。

  • checkout_id 是外鍵。一次結帳產生一個支付事件,可能包含多筆支付訂單。
  • 當我們呼叫第三方 PSP 從買家信用卡扣款時,款項並不會直接轉給賣家,而是轉到電商網站的銀行帳戶。這個流程稱為收款(pay-in)。當付出條件被滿足(例如商品送達)時,賣家發起付款(pay-out),這時款項才會從電商網站的銀行帳戶轉到賣家的銀行帳戶。因此在收款流程中,我們只需要買家的卡片資訊,而不需要賣家的銀行帳戶資訊。

在 payment order 資料表(表 4)中,payment_order_status 是個列舉型別(enum),保存支付訂單的執行狀態。執行狀態包括 NOT_STARTED、EXECUTING、SUCCESS、FAILED。更新邏輯如下:

  1. payment_order_status 的初始狀態為 NOT_STARTED
  2. 當支付服務將支付訂單送至支付執行器時,payment_order_statusEXECUTING
  3. 支付服務根據支付執行器的回應將 payment_order_status 更新為 SUCCESSFAILED

一旦 payment_order_statusSUCCESS,支付服務會呼叫錢包服務以更新賣家餘額,並將 wallet_updated 欄位更新為 TRUE。這裡為了簡化設計,假設錢包更新永遠成功。

完成後,支付服務的下一步是呼叫帳本服務以更新帳本資料庫,並將 ledger_updated 欄位更新為 TRUE

當同一 checkout_id 下的所有支付訂單都成功處理時,支付服務在 payment event 資料表中將 is_payment_done 更新為 TRUE。通常會有一個排程任務以固定間隔監控進行中的支付訂單狀態。當某個支付訂單在門檻時間內未完成時,它會發出警報,讓工程師可以調查。

Double-entry ledger system#

帳本系統有一項非常重要的設計原則:複式記帳原則(double-entry principle,也稱 double-entry accounting/bookkeeping)[6]。複式記帳系統是任何支付系統的根本,也是準確記帳的關鍵。它將每筆支付交易記錄到兩個獨立的帳本帳戶中,金額相同,一個帳戶被借記,另一個帳戶以相同金額被貸記(表 5)。

AccountDebitCredit
buyer$1
seller$1

表 5 複式記帳系統

複式記帳系統規定所有交易分錄的總和必須為 0。少了一分錢就意味著有人多了一分錢。它提供端對端的可追溯性,並確保整個支付週期的一致性。

要進一步了解複式記帳系統的實作,可參考 Square 的工程部落格關於不可變複式記帳資料庫服務的文章 [7]。

Hosted payment page#

大多數公司不喜歡在內部儲存信用卡資訊,因為一旦這麼做,就必須處理複雜的法規,例如美國的支付卡產業資料安全標準(Payment Card Industry Data Security Standard, PCI DSS)[8]。為了避免處理信用卡資訊,公司會使用 PSP 提供的託管信用卡頁面(hosted credit card page)

對於網站,它是一個 widget 或 iframe;對於行動應用,可能是來自支付 SDK 的預建頁面。圖 3 顯示了與 PayPal 整合的結帳體驗範例。重點是 PSP 提供託管的支付頁面,直接擷取顧客的卡片資訊,而不是依賴我們的支付服務。

圖 3 託管的 PayPal 付款頁面

Pay-out flow#

付款流程的元件與收款流程非常相似。一個差別是,收款流程使用 PSP 將款項從買家的信用卡轉到電商網站的銀行帳戶;付款流程則使用第三方付款提供商,將款項從電商網站的銀行帳戶轉到賣家的銀行帳戶。

通常,支付系統會使用像 Tipalti [9] 這類第三方應付帳款提供商來處理付款。付款也涉及大量的記帳與法規要求。

Step 3 - Design Deep Dive#

本節中,我們專注於讓系統更快、更穩健、更安全。在分散式系統中,錯誤與故障不僅是無法避免的,而且很常見。例如,當顧客重複點擊「付款」按鈕時會發生什麼?他們會被重複扣款嗎?我們如何處理因網路連線不佳而導致的支付失敗?本節我們深入探討幾個關鍵主題。

  • PSP integration
  • Reconciliation
  • Handling payment processing delays
  • Communication among internal services
  • Handling failed payments
  • Exact-once delivery
  • Consistency
  • Security

PSP integration#

如果支付系統可以直接連接到銀行或卡組織(例如 Visa、MasterCard),就可以不透過 PSP 進行付款。但這類直接連線並不常見,且高度專業化,通常只保留給能合理化此類投資的大型公司。對大多數公司來說,支付系統會與 PSP 整合,整合方式有兩種:

  1. 如果公司可以安全地儲存敏感支付資訊並選擇這麼做,可以使用 API 整合 PSP。公司負責開發支付網頁、收集並儲存敏感支付資訊。PSP 負責連接到銀行或卡組織。
  2. 如果公司因為複雜的法規與安全顧慮而選擇不儲存敏感支付資訊,PSP 提供託管的支付頁面來收集卡片支付明細,並安全地儲存在 PSP 中。這是大多數公司採取的方式。

我們以圖 4 詳細說明託管支付頁面的運作方式。

圖 4 託管支付流程

為了簡化,我們在圖 4 中省略了支付執行器、帳本與錢包。由支付服務協調整個支付流程。

  1. 使用者在客戶端瀏覽器中點擊「結帳」按鈕。客戶端帶著支付訂單資訊呼叫支付服務。

  2. 收到支付訂單資訊後,支付服務向 PSP 發送支付註冊請求。此註冊請求包含支付資訊,例如金額、貨幣、支付請求的到期日,以及重新導向 URL(redirect URL)。因為一筆支付訂單只應註冊一次,所以有一個 UUID 欄位來確保「恰好一次」的註冊。此 UUID 也稱為 nonce [10]。通常,此 UUID 就是支付訂單的 ID。

  3. PSP 回傳一個 token 給支付服務。token 是 PSP 端的 UUID,唯一識別此次支付註冊。我們稍後可以使用此 token 檢查支付註冊與支付執行狀態。

  4. 支付服務在呼叫 PSP 託管的支付頁面之前,先將 token 儲存到資料庫。

  5. token 持久化後,客戶端會顯示 PSP 託管的支付頁面。行動應用通常使用 PSP 的 SDK 整合此功能。這裡我們以 Stripe 的網頁整合為例(圖 5)。Stripe 提供一個 JavaScript 函式庫,用於顯示支付 UI、收集敏感支付資訊,並直接呼叫 PSP 完成付款。敏感支付資訊由 Stripe 收集,永遠不會到達我們的支付系統。託管支付頁面通常需要兩個資訊:

    圖 5 Stripe 託管的支付頁面

    • 我們在第 4 步收到的 token。PSP 的 JavaScript 程式碼使用此 token 從 PSP 後端取得有關支付請求的詳細資訊,其中一項重要資訊是要收取多少錢。
    • 另一項重要資訊是 redirect URL。這是付款完成時要被呼叫的網頁 URL。當 PSP 的 JavaScript 完成付款後,它會把瀏覽器重新導向到 redirect URL。通常,redirect URL 是一個顯示結帳狀態的電商網頁。注意 redirect URL 與第 9 步的 webhook [11] URL 是不同的。
  6. 使用者在 PSP 的網頁上填入支付明細,例如信用卡號、持卡人姓名、到期日等,然後點擊付款按鈕。PSP 開始處理付款。

  7. PSP 回傳支付狀態。

  8. 網頁現在被重新導向到 redirect URL。第 7 步收到的支付狀態通常會被附加到 URL 中。例如完整的 redirect URL 可能是 [12]:https://your-company.com/?tokenID=JIOUIQ123NSF&payResult=X324FSa

  9. 非同步地,PSP 透過 webhook 帶著支付狀態呼叫支付服務。webhook 是支付系統端的一個 URL,在最初與 PSP 設定時就已向 PSP 註冊。當支付系統透過 webhook 收到支付事件時,它會擷取支付狀態並更新 Payment Order 資料表中的 payment_order_status 欄位。

到目前為止,我們解釋了託管支付頁面的快樂路徑(happy path)。實際上網路連線可能不可靠,上述 9 個步驟全都可能失敗。是否有系統化的方式來處理失敗情況?答案是對帳(reconciliation)

Reconciliation#

當系統元件以非同步方式溝通時,無法保證訊息會被送達,或回應會被回傳。這在支付業務中非常常見,因為支付業務經常使用非同步溝通以提升系統效能。外部系統(例如 PSP 或銀行)也偏好非同步溝通。那麼在這種情況下我們如何確保正確性?

答案是對帳。對帳是一種定期比較相關服務之間狀態的做法,以驗證它們是否一致。

對帳通常是支付系統的最後一道防線

每天晚上,PSP 或銀行會送結算檔案(settlement file)給他們的客戶。結算檔案包含銀行帳戶餘額,以及當天在此銀行帳戶中發生的所有交易。對帳系統會解析結算檔案,並將細節與帳本系統比對。圖 6 顯示對帳流程在系統中的位置。

圖 6 對帳

對帳也用於驗證支付系統內部一致性。例如,帳本與錢包中的狀態可能會出現分歧,我們可以使用對帳系統來偵測任何差異。

要修正在對帳期間發現的不一致,我們通常依賴財務團隊進行人工調整。不一致與調整通常分為三類:

  1. 不一致是可分類的,調整可以自動化。在此情況下,我們知道不一致的原因,知道如何修正,且寫程式自動化調整具有成本效益。工程師可以將不一致的分類與調整都自動化。
  2. 不一致是可分類的,但我們無法自動化調整。在此情況下,我們知道不一致的原因與如何修正,但寫自動調整程式的成本太高。不一致會被放入工作佇列,由財務團隊手動修正。
  3. 不一致無法分類。在此情況下,我們不知道不一致是怎麼發生的。不一致會被放入特殊的工作佇列,由財務團隊手動調查。

Handling payment processing delays#

如前所述,端對端的支付請求會流經許多元件,並涉及內部與外部多方。雖然大多數情況下支付請求會在數秒內完成,但有時支付請求會卡住,並需要數小時甚至數天才會被完成或拒絕。以下是一些支付請求可能花比平常更久時間的例子:

  • PSP 認為某筆支付請求風險高,需要人工審核。
  • 信用卡需要額外保護,例如 3D Secure Authentication [13],需要持卡人提供額外資訊以驗證購買。

支付服務必須能處理這些需要長時間處理的支付請求。如果購買頁面是由外部 PSP 託管(這在現今相當常見),PSP 會以下列方式處理這些長時間運行的支付請求:

  • PSP 會回傳 pending 狀態給我們的客戶端。我們的客戶端會把它顯示給使用者,並提供一個頁面讓顧客檢視目前的支付狀態。
  • PSP 代我們追蹤待處理的付款,並透過支付服務向 PSP 註冊的 webhook 通知支付服務任何狀態更新。

當支付請求最終完成時,PSP 呼叫上述已註冊的 webhook。支付服務更新其內部系統並完成出貨給顧客。

或者,有些 PSP 不會透過 webhook 更新支付服務,而是把責任放在支付服務上,讓支付服務輪詢 PSP 來取得任何待處理支付請求的狀態更新。

Communication among internal services#

內部服務溝通有兩種模式:同步與非同步。下面分別說明。

Synchronous communication

像 HTTP 這類同步溝通在小規模系統中運作良好,但隨著規模增加,其缺點變得明顯。它建立了一個依賴許多服務的長請求-回應週期。這種方法的缺點:

  • 效能低。如果鏈中任何一個服務表現不佳,整個系統都會受到影響。
  • 失敗隔離不佳。如果 PSP 或任何其他服務失敗,客戶端將無法收到回應。
  • 緊耦合。請求發送者需要知道接收者。
  • 難以擴展。沒有使用佇列作為緩衝,就不容易擴展系統以支援突發流量。

Asynchronous communication

非同步溝通可分為兩類:

  • 單一接收者:每個請求(訊息)由一個接收者或服務處理。通常透過共享訊息佇列實作。訊息佇列可以有多個訂閱者,但訊息一旦被處理,就會從佇列中移除。讓我們看一個具體例子。在圖 7 中,服務 A 與服務 B 都訂閱一個共享訊息佇列。當 m1 與 m2 分別被服務 A 與服務 B 消費時,兩個訊息都會從佇列中移除,如圖 8 所示。

    圖 7 訊息佇列

    圖 8 每則訊息的單一接收者

  • 多個接收者:每個請求(訊息)由多個接收者或服務處理。Kafka 在此情境下表現很好。當消費者收到訊息時,訊息不會從 Kafka 中移除,同一則訊息可以被不同的服務處理。這種模型非常適合支付系統,因為同一個請求可能觸發多個副作用,例如發送推播通知、更新財務報告、分析等。圖 9 展示了一個範例,支付事件被發布到 Kafka 並被不同的服務消費,例如支付系統、分析服務與帳單服務。

    圖 9 同一則訊息的多個接收者

一般來說,同步溝通在設計上更簡單,但不允許服務具備自治性。隨著依賴圖的擴大,整體效能會受影響。非同步溝通以設計簡單性與一致性為代價,換取可擴展性與失敗韌性。對於業務邏輯複雜且有大量第三方依賴的大規模支付系統,非同步溝通是更好的選擇

Handling failed payments#

每個支付系統都必須處理失敗的交易。可靠性與容錯能力是關鍵需求。我們審視一些應對這些挑戰的技術。

Tracking payment state#

在支付週期的任何階段都能有明確的支付狀態至關重要。每當失敗發生時,我們可以判斷支付交易的當前狀態,並決定是否需要重試或退款。支付狀態可以儲存在僅可附加(append-only)的資料庫表中。

Retry queue and dead letter queue#

為了優雅地處理失敗,我們利用重試佇列(retry queue)與死信佇列(dead letter queue),如圖 10 所示。

  • 重試佇列:可重試的錯誤(例如暫時性錯誤)會被路由到重試佇列。
  • 死信佇列 [14]:如果一則訊息反覆失敗,它最終會落入死信佇列。死信佇列對於除錯與隔離有問題的訊息以判斷它們為何沒有被成功處理非常有用。

圖 10 處理失敗的付款

  1. 檢查失敗是否可重試。

    1a. 可重試的失敗會被路由到重試佇列。

    1b. 對於不可重試的失敗(例如無效輸入),錯誤會被儲存到資料庫中。

  2. 支付系統從重試佇列消費事件,並重試失敗的支付交易。

  3. 如果支付交易再次失敗:

    3a. 如果重試次數未超過門檻,事件會被路由回重試佇列。

    3b. 如果重試次數超過門檻,事件會被放入死信佇列。這些失敗的事件可能需要被調查。

如果你想看實際使用這些佇列的例子,可參考 Uber 的支付系統如何利用 Kafka 來達成可靠性與容錯需求 [16]。

Exactly-once delivery#

支付系統可能遇到的最嚴重問題之一就是對顧客重複扣款。在我們的設計中,必須保證支付系統「恰好一次」(exactly-once)執行支付訂單 [16]。

乍看之下,exactly-once delivery 似乎很難解決,但如果我們把問題拆成兩部分,就容易許多。在數學上,一個操作是 exactly-once 執行的,如果:

  1. 它至少執行一次(at-least-once)。
  2. 同時,它最多執行一次(at-most-once)。

我們會說明如何透過重試來實作 at-least-once,並透過**冪等性檢查(idempotency check)**來實作 at-most-once。

Retry#

偶爾,由於網路錯誤或逾時,我們需要重試支付交易。重試提供 at-least-once 保證。例如,如圖 11 所示,客戶端嘗試支付 10 美元,但由於網路連線不佳,支付請求一直失敗。在此例中,網路最終恢復,請求在第四次嘗試時成功。

圖 11 重試

決定重試之間的適當時間間隔很重要。以下是幾種常見的重試策略。

  • 立即重試(Immediate retry):客戶端立即重新發送請求。
  • 固定間隔(Fixed intervals):在失敗付款與新的重試嘗試之間等待固定的時間。
  • 遞增間隔(Incremental intervals):客戶端在首次重試前等待短暫時間,然後在後續重試時逐步增加等待時間。
  • 指數退避(Exponential backoff) [17]:每次失敗重試後將等待時間加倍。例如,當請求第一次失敗時,我們在 1 秒後重試;如果第二次失敗,我們在 2 秒後重試;如果第三次失敗,我們在 4 秒後重試。
  • 取消(Cancel):客戶端可以取消請求。當失敗是永久性的,或重複的請求不太可能成功時,這是常見做法。

決定適當的重試策略很困難,沒有「一體適用」的解決方案。一般指引是,如果網路問題不太可能在短時間內解決,請使用指數退避。過於積極的重試策略會浪費運算資源,並可能造成服務過載。一個好做法是提供一個帶有 Retry-After header 的錯誤代碼。

重試的潛在問題是雙重付款(double payment)。讓我們看兩個情境。

情境 1:支付系統使用託管支付頁面與 PSP 整合,而客戶端點了兩次付款按鈕。

情境 2:付款已被 PSP 成功處理,但回應因網路錯誤而未能到達我們的支付系統。使用者再次點擊「付款」按鈕,或客戶端重試付款。

為了避免雙重付款,付款必須以 at-most-once 執行。這個 at-most-once 保證也稱為冪等性(idempotency)

Idempotency#

冪等性是確保 at-most-once 保證的關鍵。根據維基百科:「冪等性是數學與電腦科學中某些操作的性質,這些操作可以被多次套用而不改變超出初次套用後的結果」[18]。從 API 的角度來看,冪等性意味著客戶端可以重複呼叫同一個請求並產生相同的結果。

對於客戶端(網頁與行動應用)與伺服器之間的通訊,冪等性鍵(idempotency key)通常是由客戶端產生並在一段時間後過期的唯一值。UUID 通常被用作冪等性鍵,並被許多科技公司推薦,例如 Stripe [19] 與 PayPal [20]。要執行冪等的支付請求,會在 HTTP header 加上冪等性鍵:<idempotency-key: key_value>

既然我們了解冪等性的基礎,讓我們看看它如何協助解決前面提到的雙重付款問題。

情境 1:如果顧客快速按了兩次「付款」按鈕呢?

在圖 12 中,當使用者點擊「付款」時,冪等性鍵會作為 HTTP 請求的一部分被送到支付系統。在電商網站中,冪等性鍵通常是結帳前購物車的 ID。

對於第二個請求,因為支付系統已經看過此冪等性鍵,所以會被當成重試處理。當我們在請求 header 中包含先前指定的冪等性鍵時,支付系統會回傳前一個請求的最新狀態。

圖 12 冪等性

如果偵測到多個並發請求帶有相同的冪等性鍵,只有一個請求會被處理,其他的會收到 「429 Too Many Requests」 狀態碼。

要支援冪等性,我們可以使用資料庫的唯一鍵約束(unique key constraint)。例如,將資料庫表的主鍵作為冪等性鍵。它的運作方式如下:

  1. 當支付系統收到一筆付款時,它會嘗試在資料庫表中插入一列。
  2. 成功插入意味著我們之前沒見過這筆支付請求。
  3. 如果插入失敗(因為相同的主鍵已存在),意味著我們之前已經看過這筆支付請求,第二個請求將不會被處理。

情境 2:付款已被 PSP 成功處理,但回應因網路錯誤而未能到達我們的支付系統。然後使用者再次點擊「付款」。

如圖 4 所示(步驟 2 與 3),支付服務發送 nonce 給 PSP,PSP 回傳對應的 token。nonce 唯一代表支付訂單,token 唯一對應到 nonce。因此,token 唯一對應到支付訂單。

當使用者再次點擊「付款」按鈕時,支付訂單相同,因此送到 PSP 的 token 也相同。由於 token 在 PSP 端被作為冪等性鍵,所以它能識別出雙重付款並回傳前一次執行的狀態。

Consistency#

支付執行中會呼叫多個有狀態服務:

  1. 支付服務保存與支付相關的資料,例如 nonce、token、支付訂單、執行狀態等。
  2. 帳本保存所有會計資料。
  3. 錢包保存商家的帳戶餘額。
  4. PSP 保存支付執行狀態。
  5. 資料可能在不同的資料庫複本之間複製,以提升可靠性。

在分散式環境中,任何兩個服務之間的溝通都可能失敗,造成資料不一致。讓我們看看一些在支付系統中解決資料不一致的技術。

要在內部服務之間維持資料一致性,確保 exactly-once 處理非常重要。

要在內部服務與外部服務(PSP)之間維持資料一致性,我們通常依賴冪等性與對帳。如果外部服務支援冪等性,我們應該對支付重試操作使用相同的冪等性鍵。即使外部服務支援冪等 API,仍然需要對帳,因為我們不應假設外部系統永遠是對的。

如果資料被複製,複製延遲(replication lag)可能在主資料庫與複本之間造成資料不一致。一般有兩種解法:

  1. 只從主資料庫提供讀取與寫入。這種方法容易設置,但明顯的缺點是擴展性差。複本被用於確保資料可靠性,但不服務任何流量,浪費資源。
  2. 確保所有複本始終保持同步。我們可以使用 Paxos [21] 與 Raft [22] 等共識演算法,或使用基於共識的分散式資料庫,例如 YugabyteDB [23] 或 CockroachDB [24]。

Payment security#

支付安全非常重要。在本系統設計的最後部分,我們簡單介紹幾種對抗網路攻擊與卡片盜用的技術。

問題解決方案
請求/回應竊聽使用 HTTPS
資料竄改強制加密與完整性監控
中間人攻擊使用搭配憑證綁定(certificate pinning)的 SSL
資料遺失跨多區域的資料庫複製,並對資料拍攝快照
分散式阻斷服務攻擊(DDoS)速率限制與防火牆 [25]
卡片盜用Tokenization。使用 token 取代真實卡號進行儲存與付款
PCI 合規PCI DSS 是針對處理品牌信用卡組織的資訊安全標準
詐欺地址驗證、卡片驗證碼(CVV)、使用者行為分析等 [26] [27]

表 6 支付安全

Step 4 - Wrap Up#

本章中,我們研究了收款流程與付款流程,深入探討了重試、冪等性與一致性。本章末也涵蓋了支付錯誤處理與安全。

支付系統極為複雜。儘管我們涵蓋了許多主題,仍然有更多值得一提的內容。以下是一份具代表性但非詳盡的相關主題清單。

  • 監控(Monitoring)。監控關鍵指標是任何現代應用的關鍵部分。透過完善的監控,我們可以回答諸如「特定支付方式的平均接受率是多少?」、「我們伺服器的 CPU 使用率是多少?」等問題。我們可以建立並在儀表板上顯示這些指標。
  • 警報(Alerting)。當異常發生時,及時警示值班的工程師非常重要,讓他們能迅速回應。
  • 除錯工具。「為什麼一筆支付會失敗?」是常見問題。為了讓工程師與客服更容易除錯,開發工具讓人員能審視支付交易的狀態、處理伺服器歷史、PSP 紀錄等非常重要。
  • 貨幣兌換(Currency exchange)。為國際使用者群設計支付系統時,貨幣兌換是一個重要的考量。
  • 地理區域。不同區域可能有完全不同的支付方式組合。
  • 現金支付(Cash payment)。在印度、巴西等國家,現金支付非常普遍。Uber [28] 與 Airbnb [29] 撰寫了詳細的工程部落格,說明他們如何處理基於現金的支付。
  • Google/Apple Pay 整合。有興趣可參閱 [30]。

恭喜你看到這裡!給自己一個鼓勵吧,做得好!

Chapter Summary#

圖示為一個階層式樹狀圖,描繪 Payment Service 的設計。根節點…