冪等與重試
網路會逾時,重試就可能讓同一件事做兩次:用冪等鍵讓同一個請求不管送幾次結果都一樣,重試才安全。
冪等鍵
10%
10%
3 次
程式碼路徑
實際扣款/付款
2,433 / 2,000
重複扣款
433
使用者看到失敗
19
其中其實已扣款
19
存著的冪等紀錄
0
前十筆需要重試的付款,每一次嘗試的結果(✓ = 用戶端及時收到這次的回應)
- #7扣款扣款 ✓
- #10扣款扣款 ✓
- #13扣款扣款扣款 ✓
- #15扣款扣款扣款 ✓
- #17請求遺失扣款 ✓
- #25扣款扣款 ✓
- #26請求遺失扣款扣款
- #30扣款扣款 ✓
- #32扣款扣款扣款 ✓
- #41扣款扣款扣款 ✓
扣款重複扣款重播存好的收據還在處理請求遺失
沒有冪等鍵時,每一次送到伺服器的重試都是一筆新的扣款:2,000 筆付款裡有 367 筆被扣了不只一次,總共多扣 433 次。伺服器分不出這是重試,還是使用者真的又買了一次。
不同付款每 100 秒到達一筆,模擬跨越 2.3 天。紀錄保留 24 小時;數字已扣除過期的鍵,包括從未重試的鍵。閒置時可用計時器呼叫 expire(now) 回收。
假設:用戶端等 1 秒沒回應就放棄這次嘗試,每 1.2 秒用同一個請求重試;3% 的請求在路上遺失;扣款通常花 0.1–0.4 秒,「處理超時」的那部分要 1.5–3 秒。
亮起來的是這一步執行的程式碼
type Entry = { state: "in-progress" | "done"; response?: string; expiresAt: number }; class IdempotencyStore { private entries = new Map<string, Entry>(); private expiry: { key: string; entry: Entry }[] = []; // min-heap by expiresAt constructor(private ttlSec: number) {} get size(): number { return this.entries.size; } private put(key: string, entry: Entry): void { this.entries.set(key, entry); this.expiry.push({ key, entry }); for (let i = this.expiry.length - 1; i > 0;) { const parent = (i - 1) >> 1; if (this.expiry[parent].entry.expiresAt <= entry.expiresAt) break; [this.expiry[i], this.expiry[parent]] = [this.expiry[parent], this.expiry[i]]; i = parent; } } // Run on arrivals and from a timer during idle periods, not only on key reuse. expire(now: number): void { while (this.expiry.length && this.expiry[0].entry.expiresAt <= now) { const first = this.expiry[0], last = this.expiry.pop()!; if (this.expiry.length) { this.expiry[0] = last; for (let i = 0;;) { let child = 2 * i + 1; if (child >= this.expiry.length) break; if (child + 1 < this.expiry.length && this.expiry[child + 1].entry.expiresAt < this.expiry[child].entry.expiresAt) child++; if (this.expiry[i].entry.expiresAt <= this.expiry[child].entry.expiresAt) break; [this.expiry[i], this.expiry[child]] = [this.expiry[child], this.expiry[i]]; i = child; } } if (this.entries.get(first.key) === first.entry) this.entries.delete(first.key); } } // In production this is one atomic insert-if-absent // (Redis SET NX, or INSERT ... ON CONFLICT DO NOTHING). begin(key: string, now: number): { kind: "new" | "busy" | "replay"; response?: string } { this.expire(now); const entry = this.entries.get(key); if (entry && entry.expiresAt <= now) this.entries.delete(key); else if (entry?.state === "done") return { kind: "replay", response: entry.response }; else if (entry) return { kind: "busy" }; this.put(key, { state: "in-progress", expiresAt: now + this.ttlSec }); return { kind: "new" }; } finish(key: string, response: string, now: number): void { this.expire(now); this.put(key, { state: "done", response, expiresAt: now + this.ttlSec }); } release(key: string): void { this.entries.delete(key); }} function handlePayment(store: IdempotencyStore, key: string, now: number, charge: () => string) { const claim = store.begin(key, now); if (claim.kind === "replay") return { status: 200, body: claim.response }; if (claim.kind === "busy") return { status: 409, body: "in progress" }; try { const body = charge(); store.finish(key, body, now); return { status: 200, body }; } catch (error) { store.release(key); throw error; }} // One key per payment, reused by every retry of that payment.function pay(send: (key: string) => number, newKey: () => string, maxAttempts: number): boolean { const key = newKey(); for (let attempt = 0; attempt < maxAttempts; attempt++) { if (send(key) === 200) return true; } return false;}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 固定逾時與重試規則、種子化遺失;以同一 key 辨識同一請求。未包含多區域併發與真正付款 provider 的副作用。
什麼時候用
- 有副作用、而且呼叫方會重試的 API:付款、下單、寄信、建立資源。逾時的那一刻,呼叫方不知道事情做了沒有。
- 消費訊息佇列:佇列多半是「至少送一次」,同一則訊息可能被處理兩次。
- 本來就冪等的操作(設定某個值、刪除某筆資料)不需要額外的鍵;「加一」「扣款」這種才需要。
和其他主題的關係
- 延伸閱讀
- 訊息佇列
出現在這些架構裡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 第一次請求:佔位+存回應 雜湊表寫入+最小堆排程;最壞先清 k 筆過期紀錄 | O(log k) | O(k log k) |
| 重試:重播或回「處理中」 查詢平均 O(1),另需清除已到期的紀錄 | O(1) | O(k log k) |
| 清除 e 筆過期紀錄 從最小堆移除;即使原鍵沒再出現也會清除 | O(e log k) | O(k log k) |
空間:O(k),k 是 TTL 內的請求與到期排程數;每筆付款存一份回應,佔位與完成各新增一筆到期事件
Big O 實測:n 變大時步數怎麼長
數的是:TTL 內 n 筆付款(重試 3 次、有冪等鍵)
| Big O | n = 500 | n = 5,000 | n = 50,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 每次送達的查表次數 | O(1) | 1.0 | 1.0 | 1.0 | ×1.0 (×1.0) |
| 存著的冪等紀錄 | O(n) | 500 | 5,000 | 49,996 | ×100 (×100) |
此表每 0.1 秒一筆,全部付款都在 TTL 內。每次送達查一次雜湊表;另用最小堆排程到期時間,新增紀錄花 O(log k),到期時逐筆回收。記憶體和 TTL 內的付款數成正比。
和其他做法比
| 使用者看到失敗 | 其中已扣款 | 重複扣款 | 總嘗試次數 | 存著的紀錄 | |
|---|---|---|---|---|---|
| 不重試 | 428 | 368 | 0 | 2,000 | 0 |
| 重試 3 次,沒有冪等鍵 | 19 | 19 | 433 | 2,506 | 0 |
| 重試 3 次+冪等鍵 | 91 | 91 | 0 | 2,648 | 864 |
| 重試 5 次+冪等鍵 | 3 | 3 | 0 | 2,755 | 864 |
同樣 2,000 筆付款、10% 的回應遺失、10% 的扣款超時。重試把錯誤從 428 壓到 19,但沒有冪等鍵就多扣了 433 次。加上冪等鍵,重複扣款歸零;不過同樣重試 3 次,看到錯誤的反而變成 91 人——還在處理時到的重試只拿到「處理中」——要多重試幾次(5 次剩 3 人)或把間隔拉長。代價是每筆付款在 TTL(Time To Live)內多存一筆紀錄。
真實世界裡的它
- Stripe 的
Idempotency-Key標頭:同一個鍵 24 小時內重送,回傳第一次的結果。 - AWS 許多 API 的
ClientToken、Google Cloud 的requestId。 - Kafka 的 idempotent producer:broker 依序號丟掉重送的訊息。
取捨與陷阱
- 每次重試都產生新的鍵,等於沒有鍵:鍵要在第一次送出前就決定好,所有重試共用。
- 查鍵和佔位要是同一個原子操作;先查再寫的話,兩個同時到的重試都會以為自己是第一個。
- 同一個鍵卻帶著不同的內容(金額改了),應該拒絕而不是重播舊結果;實務上會把請求內容的雜湊一起存起來比對。