跳轉到

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)
  • 觀測 metricsidempotency_cleanup_rows_totalidempotency_cleanup_batch_duration_secondsidempotency_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 與錯誤契約。 |


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

最後更新: 2026-04-13