Mutation Pipeline DB-backed Idempotency Backstop¶
概念概覽
核心發現:CAS 不保護 Sequential Replay¶
核心知識¶
核心發現:CAS 不保護 Sequential Replay¶
Redis SETNX 掛掉時,UseItem/SellItem 的 runWithVersionRetry 讀到的是**已更新後的** version(42→43),下一次重送讀到 43,CAS 不衝突,直接雙重執行。CAS 只防 concurrent lost update,不防 sequential replay。
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);
- unconditional claim:每個 tx 都執行,不管 Redis 狀態,避免 Redis healthy/down 切換期的 race
- marker-only:不存 response payload,DB 層保 correctness,Redis 保 replay UX
- key 不加 rpc_method scope:與 Redis
idem:{userID}:{requestID}保持同一 namespace,cross-RPC reuse 視為 client bug - PostgreSQL RANGE partition 不可行:PK 必須包含 partition key,
PARTITION BY RANGE(created_at)+PRIMARY KEY(user_id, request_id)是無效 DDL,改用普通表 + batched cleanup with SKIP LOCKED - TTL = 1h:定義 Redis outage 期間成功請求的最終 replay protection window(對齊 Redis completed TTL)
Error Contract 三層區分¶
| 情境 | 回傳 | Client 行為 |
|---|---|---|
| Redis pending | Aborted |
可安全 retry |
| Redis completed | cached response | 直接使用 |
| DB marker duplicate | AlreadyExists |
禁止 retry mutation,直接 re-fetch state |
AlreadyExists 語義強保證:INSERT ON CONFLICT 衝突 row 未決時會**等待**,rollback 後由後續 request 接手,不會誤判 in-flight 為 committed。
Cleanup Worker¶
- 每個 pod 獨立跑(非 singleton),
SKIP LOCKED避免 DELETE 競爭 - 5s interval,50K batch,continuous loop
- 監控:
idempotency_cleanup_rows_total、idempotency_cleanup_batch_duration_seconds、idempotency_oldest_expired_age_seconds
經驗教訓¶
-
CAS(optimistic locking)只防 concurrent write,不防 sequential replay,兩者是完全不同的問題
-
DB backstop 必須是 unconditional,conditional claim 在 Redis 切換期間有 race window
-
PostgreSQL partitioned table 的 PK 必須包含 partition key,RANGE partition + 全域唯一 PK 是無效 DDL
-
TTL 設定不只是容量決策,它同時定義了 degraded-mode 的 replay protection window,必須明文寫成 contract
常見陷阱¶
-
誤以為 Redis fail-open 只是效能退化,實際上是 request-level idempotency 完全缺口
-
PARTITION BY RANGE(created_at) 配合 PRIMARY KEY(user_id, request_id) 在 PostgreSQL 是無效 DDL
-
cleanup 吞吐量公式錯誤:20K inserts/sec × 60s = 1.2M expired rows/min,每 60s 刪 10K 會讓表無限增長
-
DB duplicate 不等於 in-flight:PostgreSQL 的 INSERT ON CONFLICT 在衝突 row 未決時會 block,rollback 後後續 request 會接手執行
最佳實踐¶
-
idempotency claim 放在同一個 tx 的第一步,rollback 時 marker 一起消失,不卡死重試
-
DB backstop 只用於 externally replayable 的 public mutation RPC(有 request_id 的路徑),不要對所有 internal mutation 一刀切
-
cleanup worker 用 SKIP LOCKED 支援 multi-pod 部署,避免 exclusive lock 競爭
-
error code 語義必須明確區分 Aborted(in-flight)vs AlreadyExists(committed),client 行為截然不同
相關概念¶
來源 Sessions¶
| 日期 | Session | 貢獻摘要 |
|---|---|---|
| 2026-04-14 | a7a343a3-2a2d-4fde-8057-503f23ef22e2 | 透過六輪架構辯論確立:CAS + Redis-only 不足以保護 sequential replay,必須在 tx 第一步做 DB marker unconditional claim |