跳轉到

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_totalidempotency_cleanup_batch_duration_secondsidempotency_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 |


本概念頁面由 Semi-Brain Wiki 系統自動維護

最後更新: 2026-04-14