冪等性
理解冪等性 (Idempotency) 的重要性,以及如何在支付系統中正確實作
冪等性 (Idempotency)
在支付系統中,冪等性是確保系統可靠性的核心設計原則。本文將深入探討什麼是冪等性、為什麼它如此重要,以及如何在你的系統中正確實作。
什麼是冪等性?
冪等性(Idempotency)是一個數學與電腦科學的概念:
一個操作執行一次或多次,產生的結果完全相同。
生活中的冪等操作
- 電梯按鈕:按一次或按十次,電梯只會來一次
- 開燈開關:已經開著的燈,再按「開」不會更亮
- 設定溫度:把冷氣設為 26°C,重複設定還是 26°C
非冪等操作
- 轉帳:執行兩次 = 轉兩次錢(災難!)
- 發送訊息:執行兩次 = 收到兩則相同訊息
- 計數器 +1:執行兩次 = 加了 2
在 API 設計中,GET、PUT、DELETE 通常是冪等的,而 POST 通常不是。
為什麼支付系統需要冪等性?
網路的不可靠性
在理想世界中,每個請求都會成功送達並收到回應。但現實是:
常見的失敗情境:
| 情境 | 發生了什麼 | 用戶看到 |
|---|---|---|
| 請求超時 | 伺服器可能已處理完成 | 「請稍後再試」 |
| 回應遺失 | 扣款成功但回應沒送達 | 「付款失敗」 |
| 網路斷線 | 不確定是否成功 | 轉圈圈後斷線 |
災難性的後果
當用戶看到「請稍後再試」,他們會怎麼做?再試一次。
如果你的系統沒有冪等性保護:
案例一:重複扣款
第一次請求:扣款 $1,000 ✓(但回應超時)
用戶:「奇怪,沒反應,再按一次」
第二次請求:扣款 $1,000 ✓
結果:用戶被扣了 $2,000案例二:儲值系統重複入帳
儲值型系統(如點數、遊戲幣、錢包餘額)面臨相反的風險:
Webhook 通知:用戶儲值 $500 成功
系統:餘額 +$500 ✓(但回應超時)
Webhook 重試:同一筆儲值通知再次送達
系統:餘額 +$500 ✓(沒有檢查是否處理過)
結果:用戶只付了 $500,卻得到 $1,000 餘額這種情況下,損失的是平台方。更糟的是,這類漏洞一旦被發現,可能被惡意利用:
攻擊者:故意讓 webhook 回應超時
系統:不斷重試、不斷入帳
結果:無限刷點數缺乏冪等性保護,輕則重複扣款引發客訴,重則成為系統漏洞被惡意利用。無論哪種情況,都會造成實質的財務損失和信任危機。
真實世界的重試來源
重複請求不只來自用戶手動重試:
- 前端重試邏輯:網路庫自動重試失敗請求
- 負載均衡器:認為後端無回應而重試
- Webhook 重送:目標伺服器回應太慢
- 佇列系統:消費者處理超時後重新派發
- 用戶行為:連點按鈕、重新整理頁面
Recur 的冪等性設計
Recur 在 API 和 Webhook 兩個層面都實作了冪等性保護。
API 層:Idempotency-Key Header
對於建立資源的 API(如建立結帳工作階段),Recur 採用類似 Stripe API v2 的設計:
- Idempotency-Key 是可選的:如果你不傳,系統會自動產生 UUID
- 所有 POST 請求預設冪等:無論是否傳入 key,相同請求不會重複建立資源
- 24 小時有效期:相同 key 在 24 小時內會返回相同的結果
# 方式一:讓系統自動產生 key(推薦)
curl -X POST https://api.recur.tw/v1/checkout/sessions \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{"product_id": "prod_xxx", "success_url": "https://example.com/success"}'
# Response headers 會包含自動產生的 key:
# Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
# 方式二:自行提供 key(適合需要追蹤的情境)
curl -X POST https://api.recur.tw/v1/checkout/sessions \
-H "Authorization: Bearer sk_test_xxx" \
-H "Idempotency-Key: order_12345_checkout" \
-H "Content-Type: application/json" \
-d '{"product_id": "prod_xxx", "success_url": "https://example.com/success"}'重複請求的行為:
| 情境 | 行為 |
|---|---|
| 相同 key + 相同參數 | 返回之前的結果(含 Idempotency-Replay: true header) |
| 相同 key + 不同參數 | 返回 400 錯誤(idempotency_key_mismatch) |
| 不同 key | 建立新的資源 |
目前支援 Idempotency-Key 的 API:
POST /v1/subscriptions/:id/completePOST /v1/portal/sessions
Webhook 層:Deterministic Event ID
每個 webhook 事件都有一個 確定性的 Event ID:
{
"id": "evt_a1b2c3d4e5f6...",
"type": "subscription.activated",
"data": { ... }
}這個 ID 是根據以下資訊計算出來的:
eventId = hash(eventType + resourceId + updatedAt)這代表:
- 相同的事件 + 相同的資源狀態 → 產生相同的
eventId - 即使 webhook 重送多次,
eventId不變 - 你可以用
eventId作為去重的依據
事件去重機制
Recur 的 webhook 基礎設施內建去重機制:
- 1 小時去重窗口:相同
eventId的事件在 1 小時內只會發送一次 - 自動重試:失敗的 webhook 會以指數退避策略重試(1, 2, 4, 8, 16 分鐘)
去重機制是「盡力而為」(best-effort) 的額外保護。你仍然應該在自己的系統中實作冪等性檢查。
你的系統如何實現冪等性
核心原則:先寫 DB,再呼叫外部 API
這是實作冪等性最重要的原則:
❌ 錯誤做法:
1. 呼叫外部 API(如發送 email)
2. 寫入資料庫記錄
→ 如果步驟 2 失敗,重試時會重複執行步驟 1
✓ 正確做法:
1. 寫入資料庫記錄(狀態:PENDING)
2. 呼叫外部 API
3. 更新資料庫記錄(狀態:COMPLETED)
→ 重試時,步驟 1 會發現記錄已存在,可以決定是否跳過Webhook 處理的正確模式
以下是處理 Recur webhook 的推薦模式:
async function handleWebhook(event: WebhookEvent) {
const { id: eventId, type, data } = event;
// 1. 檢查是否已處理過(冪等性檢查)
const existing = await db.webhookEvent.findUnique({
where: { eventId }
});
if (existing) {
if (existing.status === 'COMPLETED') {
// 已成功處理,直接返回成功
console.log(`Event ${eventId} already processed, skipping`);
return { success: true };
}
// 之前處理失敗,可以重試
console.log(`Event ${eventId} failed before, retrying`);
}
// 2. 先寫入 DB(標記為處理中)
await db.webhookEvent.upsert({
where: { eventId },
create: {
eventId,
eventType: type,
payload: data,
status: 'PROCESSING',
},
update: {
status: 'PROCESSING',
retryCount: { increment: 1 },
},
});
try {
// 3. 執行業務邏輯
await processEvent(type, data);
// 4. 標記為完成
await db.webhookEvent.update({
where: { eventId },
data: { status: 'COMPLETED' },
});
return { success: true };
} catch (error) {
// 5. 標記為失敗(下次可重試)
await db.webhookEvent.update({
where: { eventId },
data: {
status: 'FAILED',
lastError: error.message,
},
});
throw error; // 返回錯誤讓 webhook 系統知道要重試
}
}資料庫設計
確保你的資料表有適當的唯一約束:
CREATE TABLE webhook_events (
id SERIAL PRIMARY KEY,
event_id VARCHAR(255) UNIQUE NOT NULL, -- 冪等性關鍵!
event_type VARCHAR(100) NOT NULL,
payload JSONB,
status VARCHAR(20) DEFAULT 'PENDING',
retry_count INTEGER DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);
-- 確保 event_id 唯一
CREATE UNIQUE INDEX idx_webhook_events_event_id ON webhook_events(event_id);常見錯誤與反模式
反模式 1:只靠記憶體去重
// ❌ 錯誤:伺服器重啟後記憶體清空
const processedEvents = new Set();
if (processedEvents.has(eventId)) return;
processedEvents.add(eventId);反模式 2:處理完才記錄
// ❌ 錯誤:如果 recordEvent 失敗,下次會重複處理
await processEvent(event);
await recordEvent(eventId); // 可能失敗!反模式 3:忽略部分成功
// ❌ 錯誤:如果 sendEmail 成功但 updateDB 失敗,
// 下次重試會再寄一次 email
await sendEmail(user);
await updateDB(userId, { emailSent: true });狀態機思維
設計冪等系統時,把每個操作想成狀態轉換:
每個狀態轉換都應該是:
- 原子性的:要嘛完全成功,要嘛完全失敗
- 可追蹤的:記錄什麼時候、為什麼轉換
- 可重試的:從任何狀態都能安全地重試
檢查清單
在上線前,確認你的系統符合以下條件:
- Webhook 處理器會檢查
eventId是否已處理 - 使用資料庫(而非記憶體)儲存處理狀態
- 對
eventId欄位設置UNIQUE約束 - 先寫 DB 記錄,再執行業務邏輯
- 處理失敗時返回 5xx 錯誤(讓系統知道要重試)
- 處理成功時返回 2xx(避免不必要的重試)
延伸閱讀
想深入了解冪等性,推薦以下資源:
- Implementing Stripe-like Idempotency Keys in Postgres - Brandur Leach 的經典文章
- Stripe API: Idempotent Requests - Stripe 的官方文件
下一步
- Webhook 事件類型 - 了解所有可用的 webhook 事件
- Webhook 處理範例 - 完整的程式碼範例
- 錯誤處理 - 處理各種錯誤情境