每個交易系統都必須回答一個問題:

若相同的請求送達兩次,資料庫中會有一筆記錄還是兩筆?

重複請求可能因多種原因發生——而雙擊只是最明顯的其中一種:

  • 在儲存按鈕上雙擊
  • 瀏覽器在逾時後重試
  • 網路中斷與重新連線
  • 反向代理或負載平衡器重試
  • 行動端在失去訊號後重新發送
  • 前端錯誤導致請求發出兩次
  • API 使用者重試失敗的呼叫
  • Webhook 提供者重新發送事件

你無法阻止重複的請求。你只能阻止重複的記錄。答案是由資料庫強制執行的冪等性

閱讀本文時,請牢記一個區別:Django 負責驗證,PostgreSQL 則保證正確性。我們將以發票模組為例實作此模式,但此模式適用於任何建立端點。

問題

使用者儲存一張發票。請求變慢後被使用者、瀏覽器或網路重試——現在存在兩張相同的發票。報表、庫存與會計全都出錯。同樣的失敗也可能出現在訂單、付款、註冊或其他任何會建立資料的模組。

為什麼僅靠客戶端防護不夠

在請求進行中停用儲存按鈕是良好的使用者體驗,也能阻止大多數意外的雙擊:

<button :disabled="saving" @click="save">Save</button>

Enter fullscreen mode Exit fullscreen mode

但這對網路重試、重新整理、代理或其他 API 使用者毫無作用。前端可以減少重複,但永遠無法保證任何事情。

前端真正能做的,是為每個操作賦予身份。在操作開始時(而非請求發送時)產生一個冪等性金鑰,並在每次嘗試時重複使用它:

data() {
  return {
    invoice: {},
    idempotencyKey: crypto.randomUUID(), // once per operation
  };
},
methods: {
  async save() {
    await axios.post('/api/invoices/', this.invoice, {
      headers: { 'X-Idempotency-Token': this.idempotencyKey },
    });
  },
  newInvoice() {
    this.invoice = {};
    this.idempotencyKey = crypto.randomUUID(); // reset only here
  },
},

Enter fullscreen mode Exit fullscreen mode

一次操作,一把金鑰。如果你是在每次請求時產生金鑰(例如在全域 Axios 攔截器中),那麼每次重試看起來就像是一次新的操作,伺服器就無法進行去重。

為什麼先檢查再插入行不通

直覺的後端做法是在插入前先檢查:

if not Invoice.objects.filter(idempotency_key=token).exists():
    Invoice.objects.create(idempotency_key=token, ...)

Enter fullscreen mode Exit fullscreen mode

在並行環境下,這會失敗:

Request A arrives
    ↓
exists() → false
                        Request B arrives
                            ↓
                        exists() → false
A inserts row
                        B inserts row   ← duplicate

Enter fullscreen mode Exit fullscreen mode

兩個請求都在插入前通過了檢查。這種「先檢查後動作」的競爭條件,同樣適用於基於快取的去重(先 cache.get()cache.set())。任何應用層級的檢查都無法解決此問題,因為檢查與插入是兩個獨立的步驟——而另一個請求可能在這兩個步驟之間執行。

關於 get_or_create() 的說明:許多 Django 開發者在此使用它,但它無法取代冪等性。其內部仍然是先查詢再插入,且只有在查詢欄位已代表業務唯一性時才有幫助。冪等性金鑰則不同:它識別單一的客戶端操作,而非記錄的內容。

讓 PostgreSQL 保證唯一性

解決方法是將保證移到資料庫中:

class Invoice(models.Model):
    idempotency_key = models.UUIDField(unique=True, db_index=True)
    # ... your fields

Enter fullscreen mode Exit fullscreen mode

為什麼這在應用程式檢查失敗時仍有效?因為 PostgreSQL 將唯一性檢查與插入作為資料庫引擎內部的單一原子操作執行。兩個並行交易無法同時插入相同的唯一值——一個成功,另一個則以 IntegrityError 失敗。檢查與插入之間沒有間隙,因此不可能發生競爭條件。

因此,我們不應將 IntegrityError 視為失敗,而是視為工作已完成的訊號:

from django.db import IntegrityError, transaction

def create(self, request):
    token = request.headers.get("X-Idempotency-Token")
    if not token:
        return Response({"detail": "Idempotency token required."}, status=400)

    serializer = InvoiceSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)

    try:
        with transaction.atomic():
            invoice = serializer.save(idempotency_key=token)except IntegrityError:
        invoice = Invoice.objects.get(idempotency_key=token)return Response(InvoiceSerializer(invoice).data, status=200)return Response(InvoiceSerializer(invoice).data, status=201)

Enter fullscreen mode Exit fullscreen mode

第一次請求:201 Created。任何重播:200 OK 並回傳相同記錄。Django 驗證請求;PostgreSQL 保證正確性。

新增業務約束

一層保護很好;兩層更好。大多數記錄本身也有自然的唯一性規則。將其作為第二個約束來強制執行:

class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["terminal_id", "number"],
            name="unique_invoice_per_terminal",
        )]

Enter fullscreen mode Exit fullscreen mode

即使未來的錯誤繞過了冪等性金鑰,PostgreSQL 仍會拒絕重複。約束成本為零,並能保護你免受尚未撰寫的錯誤影響。

可重複使用的架構

可在任何模組中重複使用的完整流程:

Client
    ↓  generate one idempotency key per operation
Django
    ↓  validate the request
PostgreSQL
    ↓  unique constraint enforces exactly-once insert
IntegrityError
    ↓  caught by Django
Return the existing resource (200 OK)

Enter fullscreen mode Exit fullscreen mode

你系統中的每個建立端點都可以遵循此確切模式。

何時應該使用此方法?

請對任何重複會造成損害的操作使用冪等性

  • 發票、訂單與採購單
  • 付款與資金轉帳
  • 庫存移動與出貨
  • 票券建立與註冊

請跳過本質上可安全重複的操作:

  • 搜尋端點
  • 報表與分析
  • 任何唯讀 API

效能

唯一索引檢查是一次極快的單一查找——只需數微秒。約束不會對插入造成明顯負擔,且 PostgreSQL 能在高寫入量下處理此模式,而無需佇列、分散式鎖定或任何特殊基礎設施。對於絕大多數應用程式而言,效能根本不是問題。

重點摘要

交易系統絕不應依賴時序、客戶端行為或網路狀況來維持資料完整性——這些因素都不可預測。資料庫是唯一能在並行環境下保證唯一性的元件。每個操作產生一把冪等性金鑰,以 PostgreSQL 唯一約束強制執行,並捕捉 IntegrityError 來回傳現有記錄。圍繞此保證建構你的 API,重複記錄的錯誤將永久消失。