你的服務在處理 POST /charge 請求到一半時當機了。客戶端看到超時並重試。現在你有兩筆扣款。客戶很生氣。資料庫是一致的。你的商業邏輯不是。
這不是邊緣案例。這是分散式系統的預設行為。網路會丟封包。container 會在請求處理到一半時被 OOM-killed。負載平衡器會對已經抵達後端的請求回傳 502。如果你的 API 假設「我收到錯誤,所以這個操作肯定沒發生」,那你正在建造一間 bug 工廠。
你無法逃脫的 at-least-once 現實
HTTP 預設就是 at-least-once。TCP 會重試遺失的封包。你的 HTTP client 會在超時時重試。你的基礎設施會在 5xx 時重試。每一層都假設前一層可能會失敗並重新傳送。
問題不在重試。問題是非idempotent(non-idempotent)的操作。
GET 可以安全重試,因為讀兩次跟讀一次是一樣的。POST /charge 或 POST /orders 則不是。執行兩次會建立兩個資源。執行零次會遺失一筆 transaction。在不可靠的網路上,你無法選擇「exactly once」。你只能選擇:搭配 deduplication 的「at least once」,或者伴隨資料損壞的「可能是零次,可能是兩次」。
Idempotency 就是讓「at least once」變得安全的方法。
Idempotency keys 實際上如何運作
Stripe 推廣了這個模式,但這個概念其實更古老。客戶端產生一個唯一的 key(一個 UUID),並透過 header 傳送:Idempotency-Key: <uuid>。伺服器在執行 side effect 之前,先儲存 tuple (key, request_body, response)。如果同樣的 key 再次送達,伺服器就回傳已儲存的 response,而不重新執行操作。
核心洞察:伺服器必須在做工作「之前」儲存 key,而不是之後。如果你先寫入 charge row,然後在儲存 key 之前當機,重試就會建立第二筆扣款。idempotency key 的儲存與商業邏輯的變更必須是原子的(atomic)。
實務上,這代表兩種做法之一:
-
idempotency store 與 business store 共享同一個資料庫 transaction。 你在同一個
BEGIN ... COMMIT中插入idempotency_keys和charges。如果 commit 成功,兩者都存在。如果失敗,兩者都不存在。 -
idempotency store 就是 business store。 你的
charges表格有一個client_idempotency_key欄位,帶有UNIQUE限制條件。重試時 unique check 會失敗,你就回傳現有的 row。
選項 2 比較簡單,也是大多數團隊應該從這裡開始的。
使用 atomic storage 的實作範例
這裡有一個最小但完整的 Python 伺服器,使用 SQLite。idempotency key 與商業邏輯變更位於同一個 transaction 中。如果伺服器在 COMMIT 之後當機,重試會命中快取。如果它在 COMMIT 之前當機,什麼都不會被持久化,重試可以安全地重新執行。
import sqlite3
import json
import uuid
DB_PATH = "/data/inventory.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS reservations (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
quantity INTEGER NOT NULL,
idempotency_key TEXT UNIQUE NOT NULL,
created_at INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS idempotency_responses (
key TEXT PRIMARY KEY,
response_body TEXT NOT NULL,
created_at INTEGER
)
""")
conn.commit()
return conn
def reserve_inventory(conn, idempotency_key, item_id, quantity):
# Step 1: Check for an existing response.
cursor = conn.execute(
"SELECT response_body FROM idempotency_responses WHERE key = ?",
(idempotency_key,)
)
row = cursor.fetchone()
if row:
return json.loads(row[0])
# Step 2: Do the work inside a single transaction.
conn.execute("BEGIN")
# Deduct inventory atomically.
conn.execute(
"UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
(quantity, item_id, quantity)
)
if conn.total_changes == 0:
conn.rollback()
return {"error": "insufficient inventory"}
# Record the reservation.
reservation_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
(reservation_id, item_id, quantity, idempotency_key)
)
# Cache the response for retries.
response = {"reservation_id": reservation_id, "status": "reserved"}
conn.execute(
"INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
(idempotency_key, json.dumps(response))
)
conn.commit()
return response
idempotency_responses 表格就是安全網。第一個請求執行變更、commit 結果,並快取 response。之後每個帶有相同 key 的請求都會跳過工作,直接回傳已快取的 JSON。reservation 與快取條目寫在同一個 transaction 中,所以它們要嘛同時可見,要嘛同時不存在。
這在哪裡會失效:side-effect 的邊界
Idempotency keys 處理來自同一個客戶端的重複請求。它們不處理來自不同客戶端的並發重複請求。如果有兩個使用者同時點擊庫存最後一件商品的「購買」,你仍然需要 pessimistic locking 或 optimistic concurrency control。上面範例中的 available >= ? 檢查是一種原始形式,但真正的庫存系統需要更多機制。
更大的問題是transaction 外部的 side effects。如果你透過 Stripe 刷卡、透過 SendGrid 寄信、同時寫入資料庫,idempotency key 只保護資料庫的部分。郵件可能會寄出兩次。如果 Stripe 自己的 idempotency window 過期了,卡片可能會被扣款兩次。真正的安全需要每個下游系統都參與。
這就是為什麼 Stripe 在建立扣款時接受它自己的 Idempotency-Key。他們在自己的層級做 deduplication。你也應該在自己的層級這樣做。把相同的 key 傳遞給任何支援 idempotency 的下游服務。對於不支援的服務,把呼叫包裝在本地 transaction 中,或者接受風險。
Key 碰撞、TTL 與其他維運陷阱
UUID4 有 122 bits 的隨機性。對於任何現實規模的用量,碰撞機率都可以忽略。不要使用遞增整數、時間戳記或雜湊過的 request body 作為 key。client-generated UUID 是業界標準,這是有原因的。
除非你讓舊條目過期,否則 key 儲存空間會永遠增長。設定一個 TTL:24 小時是標準。之後刪除舊 key。如果客戶端在 TTL 之後重試,他們會得到重複結果。把這件事記錄下來。重試窗口與 TTL 是商業合約,不是技術細節。
idempotency store 的可用性必須至少跟 API 一樣高。如果你的 Redis cache 當機了,你無法驗證 key。有些團隊會退回到「假設是新請求」,這會在停機期間產生重複。其他團隊會拒絕請求,這比較安全但會創造不同的失敗模式。這裡沒有免費的午餐。
客戶端同樣重要
如果客戶端不發送,伺服器端的 idempotency keys 就沒用。每個會改變狀態的請求都應該在呼叫點產生一個 key,並在超時時用相同的 key 重試:
import uuid
import requests
def safe_post(url, payload, max_retries=3):
key = str(uuid.uuid4())
for attempt in range(max_retries):
try:
resp = requests.post(
url,
json=payload,
headers={"Idempotency-Key": key},
timeout=10,
)
return resp
except requests.Timeout:
if attempt == max_retries - 1:
raise
# Retry with the SAME key. The server deduplicates.
key 必須為每個邏輯操作只產生一次,而不是每個 HTTP 嘗試都產生一次。如果你每次重試都產生新的 UUID,你就搞錯重點了。這個 key 就是把所有重試綁在一起的合約。
FAQ
如果客戶端是瀏覽器,而使用者重新整理頁面呢?
頁面重新整理會建立新的 JavaScript context。新的請求會拿到新的 idempotency key,除非你將 key 持久化到 localStorage 或 URL。大多數團隊對於非關鍵流程不會這樣做。對於付款流程,你應該要這樣做。
GET 請求應該使用 idempotency keys 嗎?
不。根據 HTTP 語義,GET 本來就是安全的。idempotency keys 是給會改變狀態的方法用的:POST、PUT、PATCH、DELETE。
我可以用 request body 的雜湊值作為 idempotency key 嗎?
只有當 body 是確定性的,且不含時間戳記或隨機值時才可以。實務上,client-generated UUID 更簡單且更可靠。
我應該保留 idempotency keys 多久?
比你最長的客戶端重試窗口還要久。如果客戶端重試 60 秒,就保留 key 24 小時。如果客戶端可能因為batch 作業而在明天重試,就保留一週。
今天就讓你的第一個請求變得idempotent
為每個會改變狀態的端點加上 Idempotency-Key header。從 24 小時快取開始。使用 UUID4。讓儲存與你的商業 transaction原子化。當第一次有 container 在請求處理到一半時死掉、而客戶端重試時,你會很高興你這樣做了。另一種選擇,是向你的財務團隊解釋為什麼單一客戶有十七筆一模一樣的扣款。