經典架構拆解 · 03 — Stripe API 冪等性設計
前兩篇拆解了「規模怎麼撐」(Netflix)與「即時配對怎麼做」(Uber),這一篇換個主題:正確性。在支付系統裡,少扣一次是客訴,多扣一次是災難。Stripe 沒有把這件事推給每個串接它的工程師各自解決,而是直接在 API 設計這一層就處理掉——只要你做過任何跟錢有關的系統,這個案例都值得看一遍。
重送這件事,你躲不掉
先講一個分散式系統裡很反直覺的事實:「請求只會送達一次」幾乎不可能保證。網路抖一下、客戶端逾時重試、負載平衡器等不到回應自己重發,任何一個都會讓同一個請求被送出兩次。你寫的 API 不管多乾淨,總有一天會收到重複的呼叫。
Stripe 的態度很務實:既然擋不住重送,那就讓重送變得安全。同一個請求送十次,結果跟送一次一模一樣——這就是冪等性(idempotency)。聽起來像個小技巧,但它其實是一整條寫入路徑的設計原則,從 API 一路貫到資料庫、下游訊息、webhook,少顧哪一段哪一段就會出事。
對 PCA 考生來說,這個案例打中的考點很集中:at-least-once 投遞下的消費者冪等、webhook(或非同步事件)的可靠重試、以及金流級的強一致 ledger 選型。考題裡只要出現「Pub/Sub 消費者收到重複訊息怎麼辦」「事件處理不能重複計費」,底層要你想的,就是 Stripe 這套邏輯。
📝 考場提點
這類題的 trap 幾乎都長同一張臉:題幹給你一個**至少投遞一次(at-least-once)**的管線——通常是 Pub/Sub,偶爾是 Cloud Tasks 或外部 webhook——然後問你下游怎麼避免重複處理。錯誤選項會引誘你去「保證只送一次」,例如硬要把 Pub/Sub 調成 exactly-once、或加一堆鎖。記住關鍵字對應:看到 at-least-once,答案是「消費端做冪等」,不是「想辦法只送一次」。
實作層面再記一條:冪等的標準作法是用業務上唯一的 ID 去重(事件 ID、訂單 ID、Stripe 那種
Idempotency-Key),把處理結果記下來,重複進來就回放舊結果。所以選項裡只要看到「為每個請求帶一個冪等鍵 / 用event.id去重」,通常就是它。
商業規模與壓力
根據 Stripe 於 2024 年公開的年度信件(Stripe’s 2024 Annual Letter),Stripe 在 2024 年處理超過 1.4 兆美元的總支付金額(Total Payment Volume),且其平台成為多家大型企業的金流基礎設施。根據 Stripe Engineering Blog 於 2017 年的公開文章《Designing robust and predictable APIs with idempotency》,Stripe API 每天處理的 API 請求數級別在億次以上,webhook 也以類似等級對外派送。
把這個量級攤開來看就懂為什麼冪等性是生死問題了:在這種規模下,只要有一個不具冪等性的 API 呼叫被重試一次,就可能變成一次重複扣款、一筆建錯的訂閱、一次重複寄出的通知。概念上,億分之一的機率乘上每天億次請求,這種事就不是「會不會發生」,而是「每天發生幾次」。每一件都是客服成本,也是財報風險。
架構演進簡史
| 年份 | 里程碑 | 意義 |
|---|---|---|
| 2011 | Stripe API 初版上線 | 極度簡單的 REST 設計,成為當年「開發者友善 API」的代名詞 |
| 2013+ | 正式引入 Idempotency-Key header | 讓同一個 key 在 24 小時內重試任意次,結果都一樣 |
| 2017 | 公開發表冪等性設計文章 | 把內部的「upsert with fencing」寫法公開,成為產業標準作法之一 |
| 2017+ | webhook retry 策略公開 | 明確告訴客戶:webhook 會以指數退避(exponential backoff)重試達 3 天 |
| 2020+ | Stripe Workbench、可觀測性(observability)優先 | 所有 API 請求可 replay、追蹤請求 ID,debug 成為產品功能 |
這張表有個值得停下來想的地方:冪等性是 2013 年就加進去的,但「把它寫成公開文章、變成產業標準」是 2017 年的事。中間那幾年,這套設計是邊踩坑邊長出來的——這也很符合我的觀察,真正可靠的冪等機制很少是一開始就設計對,多半是被重複扣款的客訴逼出來的。
用一張圖看「重送也安全」這件事在系統裡怎麼接起來:
Stripe 冪等流程:API 帶 Idempotency-Key,先過冪等層查重、通過才執行業務,落到強一致 Ledger 帳本;Webhook 以 at-least-once 重試送達商戶。
核心技術決策
| 決策 | 為何這樣選 | 替代方案與為何沒選 |
|---|---|---|
所有寫入 API 支援 Idempotency-Key header | 客戶端只要在重試時帶同一個 key,Stripe 就能辨識是重送還是新請求 | 讓客戶端自己做 dedup:每個串接方都重造一次輪子,錯誤率不可控 |
| key 的有效期為 24 小時 | 夠長,涵蓋得了大多數重試情境;又夠短,不用永久佔著儲存 | 永久保留:儲存成本和查詢延遲都會爆掉 |
| webhook 採 at-least-once + 指數退避重試 | 接收端偶爾會掛,不重試等於漏事件;at-least-once 比 at-most-once 安全得多 | 只送一次(at-most-once):任何接收端暫時失敗都會掉事件 |
| 金流狀態以 state machine 明確建模 | PaymentIntent 有明確的 requires_payment_method → requires_confirmation → succeeded 等狀態,每一步可查核 | 用 boolean flag 集合:狀態組合爆炸、無法稽核 |
| observability 視為產品功能 | 客戶自己可以在 Dashboard 看到每個請求、每次 webhook 的完整 trace | 只做內部 log:客戶遇到問題只能開 ticket,支援成本高 |
這幾個決策裡,最容易被低估的是把 Idempotency-Key 做成伺服器端的責任而不是丟給客戶端。你想想,如果 Stripe 說「重送請自己去重」,那全世界幾十萬個串接方就要各自寫一遍 dedup,寫對的沒幾個——錯誤率根本不可控。把它收進 API 合約,等於用一次設計,幫所有客戶把這個坑填了。
如果用 GCP 重新蓋
把這套設計搬到 GCP,大致對應如下:
- 冪等性快取(idempotency cache): Firestore 或 Memorystore 存
Idempotency-Key → response映射,TTL 設 24 小時。Firestore 適合跨 region 強一致;Redis 適合超低延遲。 - 支付帳本(ledger): Spanner 存 PaymentIntent 與交易紀錄,利用強一致與 multi-region 能力確保同一 key 的第二次寫入會被拒絕(透過唯一約束或 transactional read-modify-write)。
- webhook 派送: Cloud Tasks 最適合 —— 它原生支援指數退避、最長重試期間(maxRetryDuration)、可設定最大重試次數(maxAttempts),完全符合 Stripe 的 3 天重試模型。失敗用盡重試的任務需自行實作 dead-letter 模式(Cloud Tasks 沒有內建 DLQ)。
- 事件主幹線: Pub/Sub 做內部事件解耦(付款成功 → 通知 / 發票 / 風控),記得消費者端要自行實作 dedup(Pub/Sub 原生支援 dead-letter topic 處理無法消費的訊息)。
- 可觀測性: Cloud Logging + Cloud Trace,搭配 BigQuery 做長期查詢介面給客戶 Dashboard 用。
📝 考場提點
GCP 服務選型題很愛在這裡設兩個分岔,記熟就送分:
- 「金流 ledger 要強一致、還要跨 region」→ Spanner。 干擾項通常是 Cloud SQL(單區、撐不住跨 region 強一致)或 Firestore(拿來當交易帳本火力不對)。考點背後的道理是:錢的帳本不能有「最終一致」的空窗,那一瞬間就是重複扣款的破口。
- 「非同步、要重試、要指數退避」→ Cloud Tasks。 它原生有 retry、退避、最大重試期間,對得上 webhook 那種「對方掛了等一下再送」的模型。如果題目強調的是多個下游子系統解耦、廣播一個事件給很多消費者,那才是 Pub/Sub。一句話分辨:點對點、要重試節奏 → Cloud Tasks;一對多、要扇出 → Pub/Sub。
兩者都記得補一句「消費端自己做冪等」,因為它們都是 at-least-once。Pub/Sub 的 dead-letter topic、Cloud Tasks 的(需自行實作的)dead-letter 模式,是用來收「重試到死還是失敗」的訊息,別跟去重搞混。
一個小很多的版本:我自己踩過的去重坑
Stripe 的量級我們大多碰不到,但「重複寫入」這個坑,做過任何下單或扣款流程的人應該都不陌生。我自己接過一個小電商的訂單服務,當初的 bug 就很經典:使用者在結帳頁網路卡住,瀏覽器自動重送了一次 POST,後端老老實實建了兩筆訂單。我們第一版的修法,就是犯了下面要講的那個最常見錯誤——只加了一個 UNIQUE 索引,結果第二次請求撞到衝突直接 500,前端以為失敗、又叫使用者重試,反而更亂。
後來才把它補成完整的冪等:前端在進結帳頁時就先要一個 request token 帶上來,後端用這個 token 當去重鍵,第二次進來直接回放第一次的結果(同一張訂單),而不是報錯。概念上跟 Stripe 的 Idempotency-Key 是同一件事,只是我們是被客訴逼著、土法煉鋼補出來的小型版。這段經驗讓我對 Stripe「把冪等做進 API 合約」這個決定特別有感——他們是把我們踩過的坑,一次性幫所有客戶填平。
(聲明一下:上面是我自己專案的故事,Stripe 內部到底怎麼實作 fencing、key 怎麼存,以他們 2017 那篇公開文章為準,我沒有任何內部資訊。)
常見誤解
- 「冪等性就是加一個 UNIQUE 索引」 —— 這只做了一半,也是我上面那個坑的根源。完整的冪等性還得「記住上一次的 response 並回放」,不然第二次請求會撞到 UNIQUE 衝突報錯,客戶端以為失敗又重試,就卡進死循環了。
- 「webhook 接收端不用做 dedup」 —— 錯。at-least-once 代表同一個事件可能送達 N 次,接收端一定要拿
event.id去重,不然會把同一筆重複寫進自己的資料庫。 - 「冪等性只是 API 層的技巧」 —— 其實它是整條寫入路徑的設計原則。從 API → 資料庫 → 下游訊息 → webhook,每一段都得想清楚「重送會不會出錯」,少顧一段就會漏。
來源與延伸閱讀
- Stripe API Reference — Idempotent Requests — 官方冪等性 header 規格,公開文件可直接引用。
- Stripe Engineering — Designing robust and predictable APIs with idempotency — 2017 年公開文章,解釋 fencing、key TTL 等設計。
- Stripe Docs — Webhooks best practices — webhook 重試、簽章驗證、冪等消費的官方指引。
- Stripe 2024 Annual Letter — 年度總支付金額引用來源。
- Google Cloud — Cloud Tasks retry configuration — GCP 對應 webhook retry 模型的原生服務。
這篇結束了本系列上半部。下一篇換系列第 4 篇接手:Slack 的即時訊息架構,看他們怎麼用 WebSocket 加 fanout 模型撐起數千萬同時在線的使用者。
🎯 換你練習