API 冪等性(Idempotency)與重試設計

Chance Lu · 學習文章 · 2026-09-09 21:06
Chance Lu

在 API 的世界裡,重試是常態:網路會斷、伺服器會忙,遇到失敗「再送一次」幾乎是每個 client 的本能。但只要會重試,就有一個躲不掉的問題——「同一個請求被執行兩次」怎麼辦?

💸

想像你在 App 按下「付款」,畫面轉圈圈轉了十秒,然後跳出「連線逾時」。錢到底扣了沒?你不知道,App 也不知道。這時候「再送一次」安不安全,就取決於今天的主角:冪等性(Idempotency)

🎯

讀完這篇,你要能用自己的話回答:什麼是冪等性、為什麼「網路不可靠」讓它變成必修、哪些 HTTP 方法天生冪等、以及怎麼用 Idempotency-Key 讓「付款」這種 POST 也能放心重試。

就愛考你

這幾題就是本文的重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,然後帶著它們往下讀——遇到答案所在的段落就特別留意;讀完再回來點「看答案」對照。

  1. 用自己的話說:什麼叫「冪等」?「執行多次結果一樣」的『結果』指的是什麼?看答案收合
    同一個請求做一次和做很多次,對伺服器造成的「效果」相同(RFC 9110 §9.2.2)。
    • 比較的是伺服器上的狀態,不是每次的回應內容——DELETE 第二次可能回 404,但「資源已刪除」這個狀態沒變,仍算冪等。
    • 冪等 ≠ 沒有副作用:伺服器照樣可以為每次請求寫 log。
  2. Client 送出請求後沒收到回應。為什麼這時「直接重送」對付款 API 是危險的?看答案收合
    因為 client 分不出「請求沒送到」和「處理完了、只是回應在半路斷掉」這兩種情況。
    • 前者重送沒事;後者重送就是再扣一次款
    • Stripe 把失敗分成三類:連線就失敗/處理到一半斷/處理成功但回應遺失——client 看到的都一樣:沒有回應。
  3. POST 和 PUT 都會改伺服器狀態,為什麼一個不冪等、一個冪等?看答案收合
    差在語意:POST 是「新增一筆」,做十次就多十筆;PUT 是「把這個位置整個蓋成這份內容」,蓋十次結果都一樣。
    • 關鍵不是「會不會寫入」,而是重複執行會不會累積效果
    • 這是 HTTP 規格的「語意約定」——伺服器實作要自己遵守,HTTP 不會自動幫你做到。
  4. Idempotency-Key 模式中,伺服器為什麼要存「整份回應(status code + body)」,而不是只記一個「這個 key 做過了」的標記?看答案收合
    因為重送進來時,伺服器要能「把第一次的結果原封不動再回一次」,client 才拿得到它錯過的答案。
    • 只記「做過了」,重送時你只能回一個空泛的錯誤,client 還是不知道訂單編號、扣款結果。
    • Stripe 的做法:把第一次的 status code + body 存起來,之後同 key 一律回放——連 500 錯誤也照樣回放
  5. 同一個 key 的第二發請求進來時,第一發「還在處理中」——伺服器該怎麼辦?看答案收合
    不能再執行一次,也還沒有結果可以回放——所以回一個「衝突」錯誤,請 client 稍後再試。
    • IETF 草案建議回 409 Conflict;Stripe 的做法是不儲存結果、回錯誤讓你重試。
    • 這就是為什麼寫入 key 要用原子操作(例如 DB unique constraint 或 Redis SET NX)搶「處理權」。
  6. 哪些回應該重試、哪些不該?重試的間隔又該怎麼安排?看答案收合
    該重試:連線錯誤/timeout、408、429、5xx;不該重試:其他 4xx(是你的請求有問題,重送一百次也一樣)。
    • 間隔用 exponential backoff(每次失敗等待時間翻倍)+ jitter(加隨機值),避免大家同一秒一起重試把伺服器打死。
    • 這是 Google Cloud 與 Stripe 都採用的標準做法。
  7. 綜合題:你要設計一個「建立訂單」API,讓 client 可以安全重試。從 client 到 server 你會做哪些事?看答案收合
    Client 端:產一個 UUID 當 Idempotency-Key,整輪重試共用同一個 key,只對 timeout/408/429/5xx 重試,用 backoff + jitter。
    • Server 端:用原子寫入搶 key → 沒見過就執行並把 status + body 跟 key 存在一起
    • 同 key 已完成 → 回放存好的結果;同 key 還在跑 → 回 409;同 key 但參數不同 → 回 422。
    • Key 設個存活期(例如 24 小時)再清掉。

1冪等性是什麼:做一次和做十次,結果一樣

一句話:一個操作不管執行一次還是重複執行很多次,對伺服器造成的效果都相同,它就是冪等的。這是 RFC 9110(HTTP 語意標準)§9.2.2 的定義:「多個相同請求的預期效果,跟單一請求相同」。

兩個生活比喻,感受一下差別:

🛗
電梯按鈕=冪等

按一次是「叫電梯」,狂按十次還是「叫電梯」——電梯不會來十台。

🥤
販賣機投幣=不冪等

投一次扣十元,投十次扣一百元——每做一次,效果就累積一次。

有兩個常見誤解,先拆掉:

🧹

「結果一樣」指的是伺服器狀態,不是回應內容。DELETE 同一個資源兩次,第一次回 200、第二次回 404——回應不同,但伺服器狀態相同(東西就是被刪了),所以 DELETE 仍是冪等的(MDN 的範例正是這個)。

🧾

冪等 ≠ 沒有副作用。RFC 9110 明講:伺服器照樣可以為每次請求各寫一筆 log、留版本紀錄——冪等只管「使用者要求的那個效果」不重複累積。

💡

這段一句話:冪等問的是「重複執行,效果會不會疊加」——會疊加(多扣一次錢、多一筆訂單)就不冪等。

2為什麼需要它:網路不可靠,重試是常態

核心痛點:請求失敗時,client 根本分不出「失敗在哪一步」。Stripe 在它的工程文章裡把失敗分成三類:

① 連線就沒建起來

請求根本沒到伺服器,什麼都沒發生。

→ 重送完全安全 ✓
② 處理到一半斷了

伺服器可能做了一半,狀態不明。

→ 狀態不明 ⚠
③ 做完了,回應沒回來

伺服器其實處理成功,只是回應在回程斷線——你不知道。

→ 重送=做第二次 ✗

糟糕的地方在於:這三種情況,client 看到的長得一模一樣——「沒有回應」。拿支付當例子(我們每天面對的場景):

怎麼都不對。唯一的出路是:把 API 做成冪等的,然後放心重試到成功為止。這也是 Stripe 給的結論:冪等之後,client 可以「安全地一直重試,直到拿到明確的成功」。

再往上拉一層:只要系統裡任何一方會重送(client 重試、訊息佇列重投、webhook 重發),接收端看到的世界就是 at-least-once——同一個操作「至少送達一次、可能很多次」。想得到「效果只發生一次」(exactly-once 的效果),公式就是:

at-least-once 投遞 接收端冪等 exactly-once 的效果

所以冪等性不是支付 API 的專利——所有會被重試打到的端點(webhook receiver、MQ consumer)都適用同一招。

下面這張動畫把整個故事演一遍:同一筆 $100 付款,回應在半路斷線、App 重送——上半部沒有 Idempotency-Key,下半部有。盯著右邊「銀行帳戶」的差別看:

A|沒有 Idempotency-Key:重送一次,就多扣一次 App(client) 支付 API(server) 銀行帳戶(真的錢) ① 付款 $100(POST /pay) − $100(第 1 次扣款) ② 回應在半路斷線 ③ 沒收到回應 → 原封重送 − $100(又扣了一次!) 合計被扣 $200 —— 重複扣款 B|有 Idempotency-Key:重送幾次,都只扣一次 App(client) 支付 API(server) 伺服器記住的 key: abc-123 → 200 OK(連回應一起存) 銀行帳戶(真的錢) ① 付款 $100 + key: abc-123 − $100(第 1 次扣款) ② 回應在半路斷線 ③ 原封重送(key 不變) ④ 認得這個 key → 不重扣,回放存好的結果 合計被扣 $100 —— 正確 ✓
💡

這段一句話:client 永遠分不出「沒做」還是「做了但我不知道」,所以安全的重試必須靠伺服器端的冪等來兜底

3HTTP 方法的天生冪等性

HTTP 規格已經替每個方法約定好「應不應該冪等」(RFC 9110 §9.2.2)。整理成一張表:

方法安全(唯讀)冪等白話理由
GET / HEAD✓ 冪等只是「看」,看幾次都不會改變什麼
PUT✓ 冪等「把這個位置整個蓋成這份內容」——蓋十次結果一樣
DELETE✓ 冪等刪掉的東西再刪一次還是不存在(第二次可能回 404,但狀態沒變)
POST✗ 不冪等「新增一筆」——送十次就多十筆
PATCH✗ 不保證部分修改,像「餘額 −100」這種相對操作重複做就會疊加(PATCH 定義在 RFC 5789,規格沒有要求冪等)

這個約定有實際後果:RFC 9110 說冪等方法在連線失敗時,client 可以自動重試。這就是為什麼瀏覽器重新整理一個 POST 過的頁面會跳「確定要重新提交表單嗎?」——瀏覽器不敢替你自動重送 POST。

兩個提醒:

📜

冪等是「語意約定」,不是 HTTP 自動施展的魔法。你把 handler 寫成 PUT 但內部做「累加」,它就不冪等——規格只是要求你「應該」把 PUT 實作成冪等。

💳

而我們最需要冪等的操作——付款、下單、轉帳——偏偏都是 POST。天生不冪等的方法要怎麼安全重試?這就是下一節的主角。

💡

這段一句話:GET / PUT / DELETE 天生可重試;POST 天生不行——所以需要人工幫它裝上冪等。

4Idempotency-Key:讓 POST 也能安全重試

一句話:client 替「這一次操作」發一張唯一的號碼牌(key),伺服器對同一張號碼牌只真正執行一次,之後都直接回放第一次的結果。這就是 Stripe 帶紅、現在幾乎是業界標準的 Idempotency-Key header 模式。

運作流程(三步)

Client 產 key

對「這一次業務操作」產一個唯一字串(建議 UUID v4),放進 Idempotency-Key header。key 代表「這次付款」,不是「這次 HTTP 請求」——重試時 key 不能換

Server 第一次看到

正常執行(扣款、建單),然後把「key → 回應的 status code + body」存起來。

Server 再看到同一個 key

不再執行業務邏輯,直接回放存好的那份回應。client 拿到的就像第一次沒斷線一樣。

伺服器端的完整判斷流程長這樣:

收到請求,讀出 Idempotency-Key
key 沒看過 → 執行
  1. 原子地寫入「處理中」(DB unique constraint / Redis SET NX
  2. 執行業務(扣款)
  3. 把 status code + body 跟 key 存在一起
  4. 回應 client
key 看過、已有結果 → 比參數

參數跟第一次一樣 → 直接回放存好的 status + body(不再執行)。
參數不一樣 → 回 422:同一個 key 不准配不同內容。

key 看過、第一發還在跑 → 擋下

409:請稍後再試——不能執行第二次,也還沒有結果可回放。

實務細節(踩過坑才知道的部分)

標準化進度(誠實說明)Idempotency-Key header 有一份 IETF 草案(draft-ietf-httpapi-idempotency-key-header,2025-10 出到第 07 版),但目前仍是過期的 draft、還不是正式 RFC。實務上大家照著 Stripe 的模式做,行為大同小異,但細節(狀態碼、過期時間)各家可能不同,接第三方 API 時要看它自己的文件。

實際長什麼樣:Stripe 的例子

對 Stripe 建立一筆客戶資料,帶上 key。這條指令重跑幾次,都只會建立一個 customer:

curl https://api.stripe.com/v1/customers \
  -u sk_test_xxx: \
  -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324" \
  -d description="第一筆測試客戶"
💡

這段一句話:Idempotency-Key =「client 發號碼牌、server 記帳本」——同一張牌只做一次事,之後都回放第一次的答案

5重試設計:該重試什麼、怎麼重

有了冪等性兜底,重試才敢做。但重試本身也有章法——亂重試比不重試更危險(把已經在掙扎的伺服器打得更死)。三個原則:

原則一:只重試「暫時性」的失敗

判斷標準很簡單:只有「等一下可能就會好」的失敗,重試才有意義。配上 Google Cloud 的重試清單,整理成一張表:

拿到什麼重試?理由
連線錯誤 / timeout(沒有回應)✓ 要典型的暫時性故障;也是最需要 Idempotency-Key 的情況——你不知道對面做了沒
408(請求逾時)、429(被限流)✓ 等一下再試429 記得尊重 Retry-After header
5xx(500 / 502 / 503 / 504)✓ 要伺服器那邊的問題,等一下可能就好了
其他 4xx(400 / 401 / 403 / 404 / 422…)✗ 不要是你的請求有問題——同一份東西重送一百次,答案還是一樣

原則二:exponential backoff + jitter

原則三:整輪重試共用同一個 key

把三個原則拼起來,client 端的完整寫法(可直接拿去改):

async function payWithRetry(order) {
  const key = crypto.randomUUID();   // ★ 只產一次:整輪重試共用同一個 key
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      const res = await fetch("https://api.example.com/v1/charges", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": key,    // ★ 重試時 key 不變,server 才認得出是同一筆
        },
        body: JSON.stringify(order),
      });
      if (res.ok) return await res.json();               // 成功,收工
      if (res.status < 500 && res.status !== 408 && res.status !== 429) {
        throw new FatalError(await res.text());          // 其他 4xx:重試沒意義
      }
      // 408 / 429 / 5xx:掉下去走重試
    } catch (e) {
      if (e instanceof FatalError) throw e;              // 連線錯誤 / timeout:走重試
    }
    const wait = 500 * 2 ** attempt + Math.random() * 400;  // backoff + jitter
    await new Promise(r => setTimeout(r, wait));
  }
  throw new Error("重試多次仍失敗:標記待確認,交給對帳/排程處理");
}
💡

這段一句話:重試的紀律=只重試暫時性錯誤、越退越久加隨機、key 從頭用到尾

6容易踩的雷

⚠️

重試時換了新 key。最常見也最致命——server 每次都當新請求,等於完全沒做冪等。key 綁「這次業務操作」,不綁「這次 HTTP 請求」。

⚠️

只記「key 用過了」,沒存回應。重送進來你回不出第一次的結果,client 還是不知道訂單長怎樣,等於沒解決問題。status code + body 要一起存。

⚠️

寫 key 不是原子操作。兩發併發重送同時查到「key 不存在」→ 兩發都執行 → 照樣扣兩次。要用 DB unique constraint 或 Redis SET NX 這類原子寫入搶執行權。

⚠️

key 存活期短於重試窗口。key 被清掉之後的重送會被當新請求再執行一次。存活期要蓋過「client 最久可能隔多久重試」(Stripe 用 24 小時當底線)。

⚠️

「存結果」和「做業務」不在同一個交易裡。扣款成功但存 key 前程序掛掉 → 重送時 key 查不到 → 再扣一次。理想上兩者要原子地一起完成;這塊要做到滴水不漏還有不少細節(例如跨外部服務時),可以延伸讀 Brandur 的 Implementing Stripe-like Idempotency Keys in Postgres

⚠️

對不冪等的操作無腦自動重試。Google Cloud 明確把「未加條件的重試不冪等操作」列為反模式——先確認冪等,再開自動重試。

一句話帶走

網路一定會斷,所以重試一定會發生;重試一定會發生,所以每個會改狀態的端點都要能「被重複打而不重複生效」。GET / PUT / DELETE 靠語意天生冪等,POST 靠 Idempotency-Key 後天補上——client 發同一張號碼牌重試到底,server 只做一次、回放到底。

📚參考來源