每個交易系統都必須回答一個問題:
若相同的請求送達兩次,資料庫中會有一筆記錄還是兩筆?
重複請求可能因多種原因發生——而雙擊只是最明顯的其中一種:
- 在儲存按鈕上雙擊
- 瀏覽器在逾時後重試
- 網路中斷與重新連線
- 反向代理或負載平衡器重試
- 行動端在失去訊號後重新發送
- 前端錯誤導致請求發出兩次
- 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,重複記錄的錯誤將永久消失。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.