---
title: 冪等性
description: 理解冪等性 (Idempotency) 的重要性，以及如何在支付系統中正確實作
---

# 冪等性 (Idempotency)

在支付系統中，**冪等性**是確保系統可靠性的核心設計原則。本文將深入探討什麼是冪等性、為什麼它如此重要，以及如何在你的系統中正確實作。

## 什麼是冪等性？

**冪等性**（Idempotency）是一個數學與電腦科學的概念：

> 一個操作執行一次或多次，產生的結果完全相同。

### 生活中的冪等操作

- **電梯按鈕**：按一次或按十次，電梯只會來一次
- **開燈開關**：已經開著的燈，再按「開」不會更亮
- **設定溫度**：把冷氣設為 26°C，重複設定還是 26°C

### 非冪等操作

- **轉帳**：執行兩次 = 轉兩次錢（災難！）
- **發送訊息**：執行兩次 = 收到兩則相同訊息
- **計數器 +1**：執行兩次 = 加了 2

在 API 設計中，`GET`、`PUT`、`DELETE` 通常是冪等的，而 `POST` 通常不是。

## 為什麼支付系統需要冪等性？

### 網路的不可靠性

在理想世界中，每個請求都會成功送達並收到回應。但現實是：

```mermaid
sequenceDiagram
    participant U as 用戶
    participant N1 as 網路
    participant S as 伺服器
    participant N2 as 網路

    U->>N1: 發送請求
    Note over N1: ❌ 可能失敗（超時、斷線）
    N1->>S: 請求送達
    S->>S: 處理請求
    S->>N2: 發送回應
    Note over N2: ❌ 可能失敗（回應遺失）
    N2->>U: 回應送達
```

常見的失敗情境：

| 情境 | 發生了什麼 | 用戶看到 |
|------|-----------|---------|
| 請求超時 | 伺服器可能已處理完成 | 「請稍後再試」 |
| 回應遺失 | 扣款成功但回應沒送達 | 「付款失敗」 |
| 網路斷線 | 不確定是否成功 | 轉圈圈後斷線 |

### 災難性的後果

當用戶看到「請稍後再試」，他們會怎麼做？**再試一次。**

如果你的系統沒有冪等性保護：

**案例一：重複扣款**

```
第一次請求：扣款 $1,000 ✓（但回應超時）
用戶：「奇怪，沒反應，再按一次」
第二次請求：扣款 $1,000 ✓
結果：用戶被扣了 $2,000
```

**案例二：儲值系統重複入帳**

儲值型系統（如點數、遊戲幣、錢包餘額）面臨相反的風險：

```
Webhook 通知：用戶儲值 $500 成功
系統：餘額 +$500 ✓（但回應超時）
Webhook 重試：同一筆儲值通知再次送達
系統：餘額 +$500 ✓（沒有檢查是否處理過）
結果：用戶只付了 $500，卻得到 $1,000 餘額
```

這種情況下，損失的是平台方。更糟的是，這類漏洞一旦被發現，可能被惡意利用：

```
攻擊者：故意讓 webhook 回應超時
系統：不斷重試、不斷入帳
結果：無限刷點數
```

缺乏冪等性保護，輕則重複扣款引發客訴，重則成為系統漏洞被惡意利用。無論哪種情況，都會造成實質的財務損失和信任危機。

### 真實世界的重試來源

重複請求不只來自用戶手動重試：

1. **前端重試邏輯**：網路庫自動重試失敗請求
2. **負載均衡器**：認為後端無回應而重試
3. **Webhook 重送**：目標伺服器回應太慢
4. **佇列系統**：消費者處理超時後重新派發
5. **用戶行為**：連點按鈕、重新整理頁面

## Recur 的冪等性設計

Recur 在 API 和 Webhook 兩個層面都實作了冪等性保護。

### API 層：Idempotency-Key Header

對於建立資源的 API（如建立結帳工作階段），Recur 採用類似 Stripe API v2 的設計：

- **Idempotency-Key 是可選的**：如果你不傳，系統會自動產生 UUID
- **所有 POST 請求預設冪等**：無論是否傳入 key，相同請求不會重複建立資源
- **24 小時有效期**：相同 key 在 24 小時內會返回相同的結果

```bash
# 方式一：讓系統自動產生 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/complete`
- `POST /v1/portal/sessions`

### Webhook 層：Deterministic Event ID

每個 webhook 事件都有一個 **確定性的 Event ID**：

```json
{
  "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 的推薦模式：

```typescript
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 系統知道要重試
  }
}
```

### 資料庫設計

確保你的資料表有適當的唯一約束：

```sql
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);
```

### 常見錯誤與反模式

```typescript
// ❌ 錯誤：伺服器重啟後記憶體清空
const processedEvents = new Set();

if (processedEvents.has(eventId)) return;
processedEvents.add(eventId);
```

```typescript
// ❌ 錯誤：如果 recordEvent 失敗，下次會重複處理
await processEvent(event);
await recordEvent(eventId); // 可能失敗！
```

```typescript
// ❌ 錯誤：如果 sendEmail 成功但 updateDB 失敗，
// 下次重試會再寄一次 email
await sendEmail(user);
await updateDB(userId, { emailSent: true });
```

## 狀態機思維

設計冪等系統時，把每個操作想成**狀態轉換**：

```mermaid
flowchart TD
    START(("收到請求")) --> CHECK{已處理過?}
    CHECK -->|是 - COMPLETED| RETURN[直接返回成功]
    CHECK -->|是 - FAILED| RETRY[允許重試]
    CHECK -->|否| PENDING[PENDING]

    PENDING --> PROCESSING[PROCESSING]
    RETRY --> PROCESSING

    PROCESSING -->|成功| COMPLETED[COMPLETED]
    PROCESSING -->|失敗| FAILED[FAILED]

    COMPLETED --> END(("結束"))

    style RETURN fill:#d4edda,stroke:#28a745
    style COMPLETED fill:#d4edda,stroke:#28a745
    style FAILED fill:#f8d7da,stroke:#dc3545
```

每個狀態轉換都應該是：
1. **原子性的**：要嘛完全成功，要嘛完全失敗
2. **可追蹤的**：記錄什麼時候、為什麼轉換
3. **可重試的**：從任何狀態都能安全地重試

## 檢查清單

在上線前，確認你的系統符合以下條件：

- [ ] Webhook 處理器會檢查 `eventId` 是否已處理
- [ ] 使用資料庫（而非記憶體）儲存處理狀態
- [ ] 對 `eventId` 欄位設置 `UNIQUE` 約束
- [ ] 先寫 DB 記錄，再執行業務邏輯
- [ ] 處理失敗時返回 5xx 錯誤（讓系統知道要重試）
- [ ] 處理成功時返回 2xx（避免不必要的重試）

## 延伸閱讀

想深入了解冪等性，推薦以下資源：

- [Implementing Stripe-like Idempotency Keys in Postgres](https://brandur.org/idempotency-keys) - Brandur Leach 的經典文章
- [Stripe API: Idempotent Requests](https://stripe.com/api/idempotent_requests) - Stripe 的官方文件

## 下一步

- [Webhook 事件類型](/guides/webhooks/events) - 了解所有可用的 webhook 事件
- [Webhook 處理範例](/guides/examples/webhook-handling) - 完整的程式碼範例
- [錯誤處理](/guides/checkout/error-handling) - 處理各種錯誤情境
