跳轉到主要內容

冪等鍵(Idempotency Key)是什麼?支付接口如何安全重試、不重複扣款

冪等鍵是什麼、支付接口為什麼需要它、Idempotency-Key 請求頭如何重放響應,以及如何寫出永遠不會重複扣款的重試邏輯。

發佈於 約 5 分鐘閲讀

手繪插畫:三張帶着相同 Idempotency-Key 的請求卡片進入同一個服務器,只返回一個 201 響應,旁邊有一把鑰匙和一個雙手抱臂的人
本文目錄
  1. 支付接口為什麼需要冪等鍵?
  2. 冪等鍵是如何工作的?
  3. KukoPay 上的 Idempotency-Key 請求頭
  4. 冪等鍵和 out_trade_no,兩個都需要嗎?
  5. 如何寫一個安全的重試邏輯
  6. 使用冪等鍵的常見錯誤
  7. 接收端的冪等
  8. 如何在沙箱裏測試冪等
  9. 總結

冪等鍵(idempotency key)是客戶端隨請求發送的一個唯一值,讓服務端能認出同一個請求的重試,並返回第一次的結果,而不是再執行一遍。在支付接口裏,它通常以 Idempotency-Key 請求頭的形式發送,存在的理由只有一個:請求超時的時候,你無法知道付款、訂單或退款到底有沒有創建,盲目重試就可能創建兩次。

這篇文章説明支付接口為什麼需要冪等鍵、Idempotency-Key 請求頭在 KukoPay 上如何工作、它和你自己的訂單號有什麼區別,以及如何寫出永遠不會重複扣款的重試邏輯。

支付接口為什麼需要冪等鍵?

在分佈式系統裏,「沒有收到響應」不等於「沒有執行」。你發出創建訂單的請求後連接超時了,可能是:

  • 請求根本沒到,什麼都沒發生;
  • 訂單已經創建了,只是響應在回程中丟了。

客戶端無法區分這兩種情況。不加保護地重試,可能產生第二筆訂單或第二筆退款;不重試,又可能丟掉一筆。冪等鍵解決了這個兩難:用同一個鍵重試,服務端要麼第一次執行這個請求,要麼把已經產生的結果原樣交還給你。

冪等鍵是如何工作的?

  1. 每個操作只生成一次鍵,通常用 UUID v4,並在你自己的系統裏和這個操作一起保存。
  2. 隨請求一起發送,這個請求的每一次重試也都帶上它。
  3. 服務端把第一次的響應存在這個鍵下,同時存下請求的指紋。
  4. 帶着同一個鍵的重試拿回存下的響應,什麼都不會執行兩次。

一個鍵對應的是「做一件事的一次嘗試」。不同的操作,比如另一筆訂單、另一筆退款,要用新的鍵。

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_noIdempotency-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 一起使用;
  • 一個操作一個鍵、每次重試請求體相同、嘗試之間要退避。

在沙箱裏把它跑通

註冊商戶後即可申請測試密鑰,不需要等待資質審核。