經典架構拆解 · 03 — Stripe API 冪等性設計
前兩篇談的是影片傳送和即時派單。這次換個問題:付款請求送出去後,網路逾時了,客戶端該重試嗎?如果第一次其實已經成功,重試就可能造成重複付款。Stripe 把冪等鍵設計進 API,讓同一筆操作有辦法被辨認。

冪等不是只加 UNIQUE constraint:伺服器還要把 key、請求指紋、處理狀態與第一次回應綁在一起,讓相同請求重送時回放結果,而不同內容重用同一 key 時拒絕。Webhook 的 at-least-once 與退避解決漏送,接收端仍須用 Event ID 去重;API 與事件兩段都要各自保護副作用。
重送這件事,你躲不掉
先講一個分散式系統裡很反直覺的事實:「請求只會送達一次」幾乎不可能保證。網路抖一下、客戶端逾時重試、負載平衡器等不到回應自己重發,任何一個都會讓同一個請求被送出兩次。你寫的 API 不管多乾淨,總有一天會收到重複的呼叫。
Stripe 的態度很務實:既然擋不住重送,那就讓重送變得安全。同一個請求送十次,結果跟送一次一模一樣——這就是冪等性(idempotency)。聽起來像個小技巧,但它其實是一整條寫入路徑的設計原則,從 API 一路貫到資料庫、下游訊息、webhook,少顧哪一段哪一段就會出事。
對準備 PCA 的人來說,這個案例可以練三件事:at-least-once 投遞下的消費者冪等、webhook(或非同步事件)的可靠重試、以及金流級的強一致 ledger 選型。遇到「Pub/Sub 消費者收到重複訊息怎麼辦」「事件處理不能重複計費」這類情境,可以用 Stripe 這套邏輯來想。
流程圖暫時無法顯示。可先查看下方的流程與節點文字,或重新整理頁面後再試。
查看流程與節點文字
箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。
sequenceDiagram participant C as Client participant I as Idempotency layer participant P as Payment service participant L as Strongly consistent ledger C->>I: POST payment+Key K1+Request A I->>I: 原子建立 K1+fingerprint+processing I->>P: 執行付款 P->>L: 寫入一次交易 L-->>P: committed P-->>I: status+response I->>I: 保存完成結果 I-->>C: 回應途中網路 timeout C->>I: 以 K1 重送 Request A I-->>C: 直接 replay 原 response C->>I: 以 K1 傳入不同 Request B I-->>C: 拒絕 fingerprint 不一致
📝 考場提點
遇到至少投遞一次(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 重試送達商戶。

客戶端逾時後重送時沿用同一把 key;伺服器只有第一次會執行付款並保存結果。之後相同請求只重播既有回應,同 key 卻換參數則拒絕,避免把兩個操作錯當成同一件事。
核心技術決策
| 決策 | 為何這樣選 | 替代方案與為何沒選 |
|---|---|---|
所有寫入 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 合約,等於用一次設計,幫所有客戶把這個坑填了。
流程圖暫時無法顯示。可先查看下方的流程與節點文字,或重新整理頁面後再試。
查看流程與節點文字
箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。
stateDiagram-v2 [*] --> RequiresPaymentMethod RequiresPaymentMethod --> RequiresConfirmation: 取得付款方式 RequiresConfirmation --> Processing: confirm Processing --> Succeeded: 金流完成 Processing --> RequiresAction: 需要額外驗證 RequiresAction --> Processing: 驗證完成 Processing --> RequiresPaymentMethod: 付款失敗,可更換方式 RequiresPaymentMethod --> Cancelled: 取消 RequiresConfirmation --> Cancelled: 取消 Succeeded --> Refunded: 追加退款事件 Cancelled --> [*] Refunded --> [*]

至少一次投遞接受事件可能重複,換取暫時失敗時不輕易遺失。發送端靠退避重試提高可靠度;接收端先檢查
event_id,確保同一副作用只執行一次。
流程圖暫時無法顯示。可先查看下方的流程與節點文字,或重新整理頁面後再試。
查看流程與節點文字
箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。
sequenceDiagram
participant S as Webhook sender
participant Q as Retry scheduler
participant R as Receiver
participant D as Dedup store
S->>R: Event E1+signature
R--xS: timeout/5xx
S->>Q: 排入指數退避重試
Q->>R: 再送 Event E1
R->>R: 驗證 signature
R->>D: 原子檢查並記錄 event_id E1
alt 第一次處理
D-->>R: new
R->>R: 執行一次副作用
else 已處理
D-->>R: duplicate
R->>R: 跳過副作用
end
R-->>S: 2xx 如果用 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 用。
流程圖暫時無法顯示。可先查看下方的流程與節點文字,或重新整理頁面後再試。
查看流程與節點文字
箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。
flowchart TB
A["需要非同步傳遞"] --> B{"一個工作要交給<br/>指定端點並控制重試節奏?"}
B -->|"是"| C["Cloud Tasks<br/>schedule、backoff、<br/>max attempts"]
B -->|"否"| D{"同一事件要讓多個<br/>獨立消費者收到?"}
D -->|"是"| E["Pub/Sub<br/>fan-out 與解耦"]
D -->|"否"| F["重新確認同步呼叫<br/>或工作流需求"]
C --> G["接收端仍需冪等"]
E --> G
G --> H["重試用盡後進入人工或 dead-letter 流程"] 📝 考場提點
把需求拆開看,會比較容易決定訊息和資料該交給哪種服務:
- 跨區域交易帳本:如果需要關聯式交易與跨區域部署,可評估 Spanner,並確認交易範圍、成本與故障處理。強一致性本身不能取代冪等鍵和重複請求防護。
- 非同步重試與事件分發:要控制單一 HTTP 目標的重試節奏,可評估 Cloud Tasks;要讓多個下游各自收到事件,可評估 Pub/Sub。兩者的投遞語意都要求消費端處理重複請求。
Dead-letter 處理的是反覆失敗的訊息,冪等處理的是重複投遞造成的副作用;兩件事需要分開設計。
小型訂單系統也會遇到重複請求
即使是小型電商,也可能碰到這個情境:使用者結帳時網路卡住,瀏覽器重送 POST,後端卻建立了兩筆訂單。只加 UNIQUE 索引可以擋住第二筆寫入,但如果第二次請求直接回 500,前端仍不知道第一筆訂單是否成功。
比較完整的做法,是讓前端帶上 request token,後端用它辨認同一筆操作。請求重送時,回傳第一次建立的那張訂單與結果,而不是再寫入一次或直接報錯。這與 Stripe Idempotency-Key 想解決的是同一類問題。
常見誤解
- 「冪等性就是加一個 UNIQUE 索引」 —— 這只擋住重複寫入。還要讓重送的請求取得前一次的結果,避免客戶端把衝突錯誤誤認為整筆交易失敗。
- 「webhook 接收端不用做去重」 —— 同一事件可能重送;接收端應記下已處理的事件 ID,讓重複通知不會再次觸發扣款或寫入。也要考慮同一業務動作是否可能由不同事件代表,不能只靠單一欄位解決所有重複副作用。
- 「冪等性只要放在 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 模型撐起數千萬同時在線的使用者。
🎯 換你練習
讀完後,留一個學習紀錄
試著用自己的話說出一個重點,以及你會如何運用。實作文章請先核對結果與清理資源,再標記為讀完。
文章進度以你確認讀完的標記為準,可隨時取消。舊版造訪紀錄保留,但不計入讀完;這份自評不代表已掌握內容。