Mutation Pipeline DB-backed Idempotency Backstop¶
概念概覽
核心洞察:CAS ≠ Request-level Idempotency¶
核心知識¶
核心洞察:CAS ≠ Request-level Idempotency¶
CAS(data_version 樂觀鎖)只能防止**並發**寫入的 lost update,無法防止**sequential replay**。當 Redis 掛掉、Request 2 重送進來時,它讀到的是 Request 1 已更新後的 version(例如 43),CAS 完全不衝突,mutation 會再次執行。Redis SETNX 是唯一的 request-level idempotency 機制,Redis 故障 = 冪等保護完全失效。
三層防禦模型¶
| 層 | 機制 | 保護範圍 | Redis 故障行為 |
|---|---|---|---|
| L1 | Redis SETNX fast-path | pending/completed dedup + response replay | 失效 |
| L2 | DB marker (INSERT ... ON CONFLICT DO NOTHING) |
sequential replay correctness | 保持有效 |
| L3 | CAS data_version |
concurrent lost update | 保持有效 |
DB Backstop 設計關鍵決策¶
CREATE TABLE player_mutation_idempotency (
user_id TEXT NOT NULL,
request_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, request_id)
);
CREATE INDEX idx_pmi_created_at ON player_mutation_idempotency (created_at);
- marker-only(不存 response payload):DB 層保 correctness,Redis 保 replay UX,分層乾淨
- key 不加 rpc_method scope:維持與 Redis key
idem:{userID}:{requestID}語義一致;cross-RPC reuse 視為 client bug - unconditional claim:不能用條件式(Redis 掛才走 DB),切換期有 race window;必須永遠作為 tx Step 0
- PostgreSQL partitioning 陷阱:
PARTITION BY RANGE (created_at)+PRIMARY KEY (user_id, request_id)是無效 DDL,partition key 必須包含在 PK 內。採用普通表 + index + batched cleanup
TTL 語義(1 小時)¶
TTL 不只是容量問題,而是語義決策。Redis outage 期間成功的 request,Redis 恢復後不會自動補回 completed key。因此 DB marker TTL 實際上定義了 degraded-mode 下的 replay protection window。應與 Redis completed TTL 對齊(1h),而非只 cover failover window(15min)。
錯誤契約(三種 duplicate 場景)¶
| 場景 | 錯誤 | Client 語義 |
|---|---|---|
| Redis pending | Aborted |
正在執行中,可重試 |
| Redis completed | 直接回 cached response | 已完成且有 replay |
| DB marker duplicate | AlreadyExists |
前一個 request 已 commit,禁止重送 mutation,直接 re-fetch state |
INSERT ... ON CONFLICT DO NOTHING 在衝突 row 未決時會等待;rollback 後後續 request 接手,不會誤回 duplicate。因此 AlreadyExists = 前一 request 確定 commit 完成,語義可以寫強。
Cleanup Worker 設計¶
DELETE FROM player_mutation_idempotency
WHERE ctid IN (
SELECT ctid FROM player_mutation_idempotency
WHERE created_at < now() - interval '1 hour'
ORDER BY created_at
LIMIT 50000
FOR UPDATE SKIP LOCKED
)
- 每個 pod 都跑 worker(不是單例):
SKIP LOCKED避免刪除競爭 - cleanup 吞吐必須對齊過期速率:若 20K inserts/sec,過期速率 = 1.2M rows/min,10K rows/60s 會直接失衡(差 120x)
- 觀測 metrics:
idempotency_cleanup_rows_total、idempotency_cleanup_batch_duration_seconds、idempotency_oldest_expired_age_seconds
經驗教訓¶
-
CAS 樂觀鎖只防 concurrent write,不防 sequential replay;兩者是完全不同的問題,不能混用
-
DB idempotency marker 必須作為 tx Step 0 且 unconditional,conditional 切換會引入 race window
-
PostgreSQL partitioned table 的 PK/unique key 必須包含 partition key,RANGE partition + 非 partition key PK 是無效 DDL
-
Cleanup 吞吐量要與過期速率同級計算,不能只寫「每 N 秒刪 M 行」而不對齊 insert 速率假設
-
TTL 不只是容量參數,要明確定義它的語義(degraded-mode replay protection window)
-
duplicate error 要區分三種場景(in-flight / completed / DB-marker),語義不同導致 client retry 決策完全不同
常見陷阱¶
-
把 Redis fail-open 誤判為「只是效能退化」而非 request-level idempotency 缺口
-
在 idempotency key 加 rpc_method scope 造成 Redis/DB 行為不一致(Redis 擋、DB 不擋 cross-RPC reuse)
-
PostgreSQL RANGE partition + non-partition PK 的 DDL 錯誤
-
cleanup 吞吐計算只寫 delete batch size 不計算 insert 速率,導致表無限增長
-
過早把 DB backstop 抽象成 middleware/decorator framework 而非單純 repo method
最佳實踐¶
-
DB backstop 用 marker-only,不存 response payload,分層職責:DB 保 correctness,Redis 保 replay UX
-
scope 用規則定義(所有呼叫 tryAcquireIdempotency 的 public mutation RPC),不要手寫 endpoint 清單
-
每個 pod 都跑 cleanup worker + SKIP LOCKED,避免單點與競爭
-
request_id mandatory 升級走兩階段:Phase 1 opt-in + telemetry gate(idempotency_request_id_empty_total counter),Phase 2 才強制
相關概念¶
來源 Sessions¶
| 日期 | Session | 貢獻摘要 |
|---|---|---|
| 2026-04-13 | a7a343a3-2a2d-4fde-8057-503f23ef22e2 | 本 session 完整設計並驗證了三層冪等防禦模型,釐清 CAS 無法防止 sequential replay 的根本缺陷,並收斂出 marker-only DB backstop 的落地 DDL 與錯誤契約。 |