冪等鍵(Idempotency Key)是什麼?支付接口如何安全重試、不重複扣款
冪等鍵是什麼、支付接口為什麼需要它、Idempotency-Key 請求頭如何重放響應,以及如何寫出永遠不會重複扣款的重試邏輯。
本文目錄
冪等鍵(idempotency key)是客戶端隨請求發送的一個唯一值,讓服務端能認出同一個請求的重試,並返回第一次的結果,而不是再執行一遍。在支付接口裏,它通常以 Idempotency-Key 請求頭的形式發送,存在的理由只有一個:請求超時的時候,你無法知道付款、訂單或退款到底有沒有創建,盲目重試就可能創建兩次。
這篇文章説明支付接口為什麼需要冪等鍵、Idempotency-Key 請求頭在 KukoPay 上如何工作、它和你自己的訂單號有什麼區別,以及如何寫出永遠不會重複扣款的重試邏輯。
支付接口為什麼需要冪等鍵?
在分佈式系統裏,「沒有收到響應」不等於「沒有執行」。你發出創建訂單的請求後連接超時了,可能是:
- 請求根本沒到,什麼都沒發生;
- 訂單已經創建了,只是響應在回程中丟了。
客戶端無法區分這兩種情況。不加保護地重試,可能產生第二筆訂單或第二筆退款;不重試,又可能丟掉一筆。冪等鍵解決了這個兩難:用同一個鍵重試,服務端要麼第一次執行這個請求,要麼把已經產生的結果原樣交還給你。
冪等鍵是如何工作的?
- 每個操作只生成一次鍵,通常用 UUID v4,並在你自己的系統裏和這個操作一起保存。
- 隨請求一起發送,這個請求的每一次重試也都帶上它。
- 服務端把第一次的響應存在這個鍵下,同時存下請求的指紋。
- 帶着同一個鍵的重試拿回存下的響應,什麼都不會執行兩次。
一個鍵對應的是「做一件事的一次嘗試」。不同的操作,比如另一筆訂單、另一筆退款,要用新的鍵。
KukoPay 上的 Idempotency-Key 請求頭
在任何寫接口上加上這個請求頭即可:
curl -X POST "https://api.kukopay.com/v1/orders" \
-H "X-Api-Key: $KUKOPAY_API_KEY" \
-H "Idempotency-Key: 8f14e45f-ea2a-4c6f-9d3b-1b7c2a55e901" \
-H "Content-Type: application/json" \
-d '{
"out_trade_no": "ORDER_20261104_0001",
"amount": 2990,
"currency": "USD",
"subject": "Pro 年度訂閲"
}'| 規則 | 説明 |
|---|---|
| 取值 | 不超過 255 個可打印 ASCII 字符,推薦 UUID v4 |
| 作用範圍 | 調用密鑰所屬的應用和環境。鍵屬於它第一次使用的接口:換一個接口再用,會像請求體變了一樣被拒絕 |
| 保留時長 | 24 小時,之後這個值可以重新使用 |
命中重放時
用同一個鍵、同樣的請求體重試,會得到第一次響應的狀態碼和響應體,並額外帶上 Idempotent-Replayed: true。request_id 也是第一次的,所以你日誌裏的兩條記錄指向同一次真實執行。
HTTP/1.1 201 Created
X-Request-Id: req_9f2c1a7b4e8d0356c1ab77de90f4b215
Idempotent-Replayed: true同一個鍵配不同的請求體
用同一個鍵發送不同的請求體,或者在另一個接口上使用它,會返回 400 idempotency_error。這一定意味着調用方有 bug,常見原因是重試時重新生成了請求體裏的時間戳、隨機數或簽名。如果請求體確實變了,那就是一個新請求,需要新的鍵。
同時發送同一個鍵
第一次請求還在處理中時,第二個帶同一個鍵的請求會立即得到 409 idempotency_in_progress,而不是排隊等待。稍後用同一個鍵重試,就能拿到最終結果。
哪些響應會被保存
| 第一次的響應 | 重試時會怎樣 |
|---|---|
2xx | 在 24 小時內重放 |
4xx | 同樣重放:請求已經有了確定的結論,重試不會改變它 |
5xx | 不保存,鍵被釋放,用它重試會真正再執行一次 |
最後一行很重要:服務端錯誤意味着結果未知,所以平台讓你的重試去找出答案,而不是重放一次失敗。
冪等鍵和 out_trade_no,兩個都需要嗎?
KukoPay 有兩層冪等,解決的是不同的問題:
out_trade_no / out_refund_no | Idempotency-Key | |
|---|---|---|
| 位置 | 請求體 | 請求頭 |
| 覆蓋範圍 | 創建訂單和退款 | 所有寫接口 |
| 有效期 | 永久 | 24 小時 |
| 相同的值、不同的內容 | 409 conflict,請先查詢原訂單 | 400 idempotency_error |
out_trade_no 是業務級的保證:同一個訂單號配同樣的內容,無論重試多晚,返回的都是同一筆訂單。Idempotency-Key 是傳輸級的保證,覆蓋所有寫接口,並原樣重放響應。兩個都用:訂單號保證一筆訂單永遠不會存在兩次,冪等鍵讓每一次重試都安全,並返回相同的 request_id。退款用 out_refund_no 也是一樣,見 部分退款怎麼操作。
如何寫一個安全的重試邏輯
async function createOrder(payload) {
// 每個業務操作生成一個鍵,它的每次重試都複用這個值
const idempotencyKey = crypto.randomUUID()
const delays = [1000, 2000, 4000, 8000]
for (let attempt = 0; ; attempt++) {
const res = await fetch("https://api.kukopay.com/v1/orders", {
method: "POST",
headers: {
"X-Api-Key": process.env.KUKOPAY_API_KEY,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify(payload), // 每次嘗試都發送同樣的請求體
})
const body = await res.json()
console.info("kukopay", res.status, body.request_id) // 反饋問題時平台靠它定位
if (res.ok) return body.data
// 409 表示同一個鍵仍在處理中;5xx 表示結果未知。兩者都可以原樣重試
const retriable = res.status === 409 || res.status >= 500
if (!retriable || attempt >= delays.length) throw new Error(body.message)
await new Promise((resolve) => setTimeout(resolve, delays[attempt]))
}
}保證它正確的三條規則:
- 每個操作只生成一次鍵,放在循環外面;如果操作可能比進程活得更久,就把鍵存起來。
- 每次嘗試都發送完全相同的請求體:只構造一次,不要重新加時間戳。
- 兩次嘗試之間要退避,遇到重試解決不了的錯誤就停下。錯誤裏的
type會告訴你是哪一種:api_error和api_connection_error可以安全重試,invalid_request_error需要先修正。見 錯誤碼。
使用冪等鍵的常見錯誤
- 每次重試都生成新鍵。 這樣鍵什麼也保護不了,每次嘗試都是一個新請求。
- 所有請求共用一個鍵,比如用客戶 id。不同的操作會相互碰撞,返回彼此的響應。
- 重試時改了請求體,比如新的時間戳或隨機數,讓一次安全的重試變成
400 idempotency_error。 - 把超時當作失敗,向買家顯示錯誤,而訂單很可能已經存在了。應該用同一個鍵重試,或者用
out_trade_no查詢訂單。
接收端的冪等
冪等鍵保護的是你發出的請求。你收到的 Webhook 需要對應的另一面:一條支付通知可能到達不止一次,所以要在同一個事務裏,用唯一約束記錄事件 id 並完成履約。這個做法見 Webhook 重複通知怎麼辦;為什麼判斷訂單已支付要靠通知而不是回跳,見 return_url 和 notify_url 的區別。
如何在沙箱裏測試冪等
用 kuko_test_ 密鑰,以同一個 Idempotency-Key 把同一個下單請求發兩次,檢查是否帶有 Idempotent-Replayed: true、request_id 是否相同;然後改一個字段,確認返回 400;再用同一個 out_trade_no、不帶請求頭重複下單,確認返回的是同一筆訂單。這些和其他沙箱場景一起列在 支付測試卡號大全 裏。
總結
- 冪等鍵讓服務端認出重試,返回第一次的結果,而不是執行兩次;
- 在 KukoPay,任何寫接口都可以帶
Idempotency-Key:響應保留 24 小時,重放時帶Idempotent-Replayed: true; - 同一個鍵配不同請求體是
400 idempotency_error,鍵仍在處理中是409 idempotency_in_progress,5xx結果不保存; - 和永久保護訂單與退款的
out_trade_no/out_refund_no一起使用; - 一個操作一個鍵、每次重試請求體相同、嘗試之間要退避。
在沙箱裏把它跑通
註冊商戶後即可申請測試密鑰,不需要等待資質審核。