在 API 的世界裡,重試是常態:網路會斷、伺服器會忙,遇到失敗「再送一次」幾乎是每個 client 的本能。但只要會重試,就有一個躲不掉的問題——「同一個請求被執行兩次」怎麼辦?
想像你在 App 按下「付款」,畫面轉圈圈轉了十秒,然後跳出「連線逾時」。錢到底扣了沒?你不知道,App 也不知道。這時候「再送一次」安不安全,就取決於今天的主角:冪等性(Idempotency)。
讀完這篇,你要能用自己的話回答:什麼是冪等性、為什麼「網路不可靠」讓它變成必修、哪些 HTTP 方法天生冪等、以及怎麼用 Idempotency-Key 讓「付款」這種 POST 也能放心重試。
這幾題就是本文的重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,然後帶著它們往下讀——遇到答案所在的段落就特別留意;讀完再回來點「看答案」對照。
SET NX)搶「處理權」。一句話:一個操作不管執行一次還是重複執行很多次,對伺服器造成的效果都相同,它就是冪等的。這是 RFC 9110(HTTP 語意標準)§9.2.2 的定義:「多個相同請求的預期效果,跟單一請求相同」。
兩個生活比喻,感受一下差別:
按一次是「叫電梯」,狂按十次還是「叫電梯」——電梯不會來十台。
投一次扣十元,投十次扣一百元——每做一次,效果就累積一次。
有兩個常見誤解,先拆掉:
「結果一樣」指的是伺服器狀態,不是回應內容。DELETE 同一個資源兩次,第一次回 200、第二次回 404——回應不同,但伺服器狀態相同(東西就是被刪了),所以 DELETE 仍是冪等的(MDN 的範例正是這個)。
冪等 ≠ 沒有副作用。RFC 9110 明講:伺服器照樣可以為每次請求各寫一筆 log、留版本紀錄——冪等只管「使用者要求的那個效果」不重複累積。
這段一句話:冪等問的是「重複執行,效果會不會疊加」——會疊加(多扣一次錢、多一筆訂單)就不冪等。
核心痛點:請求失敗時,client 根本分不出「失敗在哪一步」。Stripe 在它的工程文章裡把失敗分成三類:
請求根本沒到伺服器,什麼都沒發生。
→ 重送完全安全 ✓伺服器可能做了一半,狀態不明。
→ 狀態不明 ⚠伺服器其實處理成功,只是回應在回程斷線——你不知道。
→ 重送=做第二次 ✗糟糕的地方在於:這三種情況,client 看到的長得一模一樣——「沒有回應」。拿支付當例子(我們每天面對的場景):
怎麼都不對。唯一的出路是:把 API 做成冪等的,然後放心重試到成功為止。這也是 Stripe 給的結論:冪等之後,client 可以「安全地一直重試,直到拿到明確的成功」。
再往上拉一層:只要系統裡任何一方會重送(client 重試、訊息佇列重投、webhook 重發),接收端看到的世界就是 at-least-once——同一個操作「至少送達一次、可能很多次」。想得到「效果只發生一次」(exactly-once 的效果),公式就是:
所以冪等性不是支付 API 的專利——所有會被重試打到的端點(webhook receiver、MQ consumer)都適用同一招。
下面這張動畫把整個故事演一遍:同一筆 $100 付款,回應在半路斷線、App 重送——上半部沒有 Idempotency-Key,下半部有。盯著右邊「銀行帳戶」的差別看:
這段一句話:client 永遠分不出「沒做」還是「做了但我不知道」,所以安全的重試必須靠伺服器端的冪等來兜底。
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 天生不行——所以需要人工幫它裝上冪等。
一句話:client 替「這一次操作」發一張唯一的號碼牌(key),伺服器對同一張號碼牌只真正執行一次,之後都直接回放第一次的結果。這就是 Stripe 帶紅、現在幾乎是業界標準的 Idempotency-Key header 模式。
對「這一次業務操作」產一個唯一字串(建議 UUID v4),放進 Idempotency-Key header。key 代表「這次付款」,不是「這次 HTTP 請求」——重試時 key 不能換。
正常執行(扣款、建單),然後把「key → 回應的 status code + body」存起來。
不再執行業務邏輯,直接回放存好的那份回應。client 拿到的就像第一次沒斷線一樣。
伺服器端的完整判斷流程長這樣:
參數跟第一次一樣 → 直接回放存好的 status + body(不再執行)。
參數不一樣 → 回 422:同一個 key 不准配不同內容。
回 409:請稍後再試——不能執行第二次,也還沒有結果可回放。
標準化進度(誠實說明):Idempotency-Key header 有一份 IETF 草案(draft-ietf-httpapi-idempotency-key-header,2025-10 出到第 07 版),但目前仍是過期的 draft、還不是正式 RFC。實務上大家照著 Stripe 的模式做,行為大同小異,但細節(狀態碼、過期時間)各家可能不同,接第三方 API 時要看它自己的文件。
對 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 記帳本」——同一張牌只做一次事,之後都回放第一次的答案。
有了冪等性兜底,重試才敢做。但重試本身也有章法——亂重試比不重試更危險(把已經在掙扎的伺服器打得更死)。三個原則:
判斷標準很簡單:只有「等一下可能就會好」的失敗,重試才有意義。配上 Google Cloud 的重試清單,整理成一張表:
| 拿到什麼 | 重試? | 理由 |
|---|---|---|
| 連線錯誤 / timeout(沒有回應) | ✓ 要 | 典型的暫時性故障;也是最需要 Idempotency-Key 的情況——你不知道對面做了沒 |
408(請求逾時)、429(被限流) | ✓ 等一下再試 | 429 記得尊重 Retry-After header |
5xx(500 / 502 / 503 / 504) | ✓ 要 | 伺服器那邊的問題,等一下可能就好了 |
其他 4xx(400 / 401 / 403 / 404 / 422…) | ✗ 不要 | 是你的請求有問題——同一份東西重送一百次,答案還是一樣 |
把三個原則拼起來,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 從頭用到尾。
重試時換了新 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 只做一次、回放到底。