跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 冪等與重試

冪等與重試

網路會逾時,重試就可能讓同一件事做兩次:用冪等鍵讓同一個請求不管送幾次結果都一樣,重試才安全。

冪等鍵
10%
10%
3 次
程式碼路徑
實際扣款/付款
2,433 / 2,000
重複扣款
433
使用者看到失敗
19
其中其實已扣款
19
存著的冪等紀錄
0
前十筆需要重試的付款,每一次嘗試的結果(✓ = 用戶端及時收到這次的回應)
  1. #7扣款扣款 ✓
  2. #10扣款扣款 ✓
  3. #13扣款扣款扣款 ✓
  4. #15扣款扣款扣款 ✓
  5. #17請求遺失扣款 ✓
  6. #25扣款扣款 ✓
  7. #26請求遺失扣款扣款
  8. #30扣款扣款 ✓
  9. #32扣款扣款扣款 ✓
  10. #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 On = 500n = 5,000n = 50,000成長倍數:實測(理論)
每次送達的查表次數O(1)1.01.01.0×1.0 (×1.0)
存著的冪等紀錄O(n)5005,00049,996×100 (×100)

此表每 0.1 秒一筆,全部付款都在 TTL 內。每次送達查一次雜湊表;另用最小堆排程到期時間,新增紀錄花 O(log k),到期時逐筆回收。記憶體和 TTL 內的付款數成正比。

和其他做法比

使用者看到失敗其中已扣款重複扣款總嘗試次數存著的紀錄
不重試42836802,0000
重試 3 次,沒有冪等鍵19194332,5060
重試 3 次+冪等鍵919102,648864
重試 5 次+冪等鍵3302,755864

同樣 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 依序號丟掉重送的訊息。

取捨與陷阱

  • 每次重試都產生新的鍵,等於沒有鍵:鍵要在第一次送出前就決定好,所有重試共用。
  • 查鍵和佔位要是同一個原子操作;先查再寫的話,兩個同時到的重試都會以為自己是第一個。
  • 同一個鍵卻帶著不同的內容(金額改了),應該拒絕而不是重播舊結果;實務上會把請求內容的雜湊一起存起來比對。