跳轉到

Mutation Pipeline 三層冪等防禦模型與 DB Backstop 設計(融合完整版 v4)

文檔資訊

  • 分類: architecture
  • 難度: advanced
  • 預估閱讀時間: 25 分鐘
  • 標籤: idempotency, mutation-pipeline, redis-fail-open, db-backstop, lock-ordering, postgresql, game-server, review-methodology, error-contract, implementation-status, distributor-atomization, step6d, wire-gen, plan-execute-separation

摘要

針對 MatchRPG Server mutation-pipeline 的多輪(8+次)架構 Review 與實作追蹤。核心發現是 Redis fail-open 並非「效能退化」而是 request-level idempotency 完全失效:CAS 只防並發更新,不防 sequential replay。設計收斂到三層防禦模型,補入 DB-backed marker 作為 correctness backstop。v4 新增實作進度:Steps 1-6c 全部完成並 commit,Step 3.5 scaffolding 被第三方 reviewer 確認為「地基打好但 P0 gap 尚未關閉」(直至 Step 6a 接入 useItem 才真正生效)。Step 6d(Distributor 原子化)設計文檔已覆蓋,import cycle 疑慮解除。Step 6e(全模組 writer lock coverage)與 Step 7-9 待實作。

關鍵學習

  • CAS(樂觀鎖)只防止 concurrent lost-update,不防 sequential replay;Redis 掛掉時已完成 request 可被重送並再次執行

  • 三層冪等防禦:L1 Redis SETNX(fast-path dedup)→ L2 DB marker(correctness backstop)→ L3 CAS version check(concurrent safety)

  • DB backstop 必須是 tx 內第一步(unconditional INSERT ON CONFLICT),conditional 在切換期間存在 race window;rollback 時 marker 一起消失,不會卡死重試

  • PostgreSQL RANGE partition 限制:PRIMARY KEY 必須包含 partition key,(user_id, request_id) + RANGE(created_at) 是無效 DDL;改用 plain table + SKIP LOCKED

  • Multi-pod cleanup worker 用 SKIP LOCKED 避免 DELETE 競爭,不需 leader election

  • 錯誤契約三路分層:Redis pending → Aborted(重試安全)、Redis completed → cached response(replay)、DB marker duplicate → AlreadyExists(不得 retry mutation,應 re-fetch state)

  • TTL 語義:DB marker TTL 定義 degraded-mode 最終 replay protection window(1h 對齊 Redis completed TTL)

  • scaffolding 完成(interface + postgres impl 存在)≠ P0 gap 已關閉;Claim() 必須真正接入 mutation 寫路徑才算生效

  • DB backstop scope 用規則式定義(所有呼叫 tryAcquireIdempotency 的 public mutation RPC),不要手寫清單——實際有 4 個 call site 包含 UnlockHeroStage(player_twirp.go:638)

  • PlanItemEffects 必須在 CAS retry loop 外執行(pure function),ExecuteMutationPlanTx 在 loop 內執行——這是防 CAS retry random drift 的關鍵

  • pkg/reward/distributor.go 沒有 internal module imports,Step 6d PlayerVersionBumper interface 定義在 pkg/reward/ 不會有 import cycle

  • wire_gen.go 需手動 regenerate:NewPlayerService signature 增加 IdempotencyRepository 參數後,直到重新執行 wire gen 前 build 會失敗

技術細節

問題根源

原設計的 tryAcquireIdempotency 在 Redis error 時 fail-open(直接放行)。

Sequential replay 路徑:Request 1 成功將 version 42 → 43。Redis 故障。Request 2(同 request_id)進來,讀到 currentVersion=43(已更新後的值),CAS 不衝突,再次執行 mutation。runWithVersionRetry 只在 version conflict 時重試,不辨識「這是不是同一個 request」。

三層防禦模型

機制 Redis 失效時
L1 Redis SETNX 失效
L2 DB marker INSERT ON CONFLICT 仍有效
L3 CAS data_version 仍有效(但不保冪等)

DB Backstop DDL

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);

不用 RANGE partition,因 PostgreSQL PK 必須包含 partition key。key 不加 rpc_method scope,維持 (user_id, request_id) 全域唯一。

Lock Ordering

Step 0: player_mutation_idempotency (INSERT ON CONFLICT) — idempotency claim
Step 1: players row (FOR UPDATE) — per-user serialization
Step 2: player_inventory / resources / pools
Step 3: audit log

容量估算

  • Worst case 20K inserts/sec → 72M rows steady state(TTL=1h)
  • 過期速率 = 20K * 3600s = 72M rows/hr
  • Cleanup 需 continuous batched loop(5s interval, 50K batch),靜態「每 60s 刪 10K」會失衡 120x

錯誤契約(三路分層)

情境 錯誤 語義 Client 行為
Redis pending 存在 Aborted 正在執行中 可重試
Redis completed 存在 已完成有 replay 回原 response
DB marker duplicate AlreadyExists 前一個 request 已 commit 不得 retry mutation,re-fetch state

AlreadyExists 的強語義保證:INSERT ON CONFLICT 在衝突 row 未決時會等待,rollback 後後續 request 接手,所以能拿到 AlreadyExists 代表前一個 tx 確實 committed。

Plan/Execute 分離

  • PlanItemEffects:pure function,在 CAS retry loop **外**執行,用固定 seed RNG 避免 retry 時 random drift
  • ExecuteMutationPlanTx:DB 寫入,在 CAS retry loop **內**執行,tx 第一步 claim idempotency marker

Step 6d(Distributor 原子化)設計要點

  • Distribute → unexported distribute
  • 新增 DistributeAndSync(ctx, userID, idempotencyKey, source string, rewards []ResolvedReward, versionBumper PlayerVersionBumper) (*MutationSummary, error)
  • PlayerVersionBumper interface 定義在 pkg/reward/(不引入 internal,無 import cycle)
  • 3 個 caller 遷移:Dungeon distributeRewards、Hero Unlock、Teach
  • Re-entrant tx:Teach 有自己的 RunInTx,pkg/db/postgres/tx.go 的 TxManager 會重用

What Changed

v3 → v4 核心演化(實作大幅推進)

v4 相比 v3 最重要的更新是 Steps 1-6c 全部完成並 commit。v3 時 Step 4 剛啟動,v4 時已經完成了完整的 Plan/Execute separation、三層冪等防禦接入、pool guard、Distributor 原子化設計確認。

實作進度更新(v4 新增)

Steps 1-3:完成。go test ./internal/modules/player/... 和 go test ./cmd/twirpserver 通過。

Step 3.5:scaffolding 完成(IdempotencyRepository interface + PostgresIdempotencyRepository + IdempotencyCleanupWorker + wire binding),但第三方 reviewer 確認「P0 gap 尚未關閉」——直至 Step 6a 將 Claim() 接入 useItem 寫路徑才真正生效。

Step 4:完成。新增 AcquireRowLock(player_repository_postgres.go ~line 510)、CheckSeasonRowsExist(player_inventory_repository_postgres.go ~line 677)、MockIdempotencyRepository(test/player_mocks.go),6 個 NewPlayerService call site 更新加入 IdempotencyRepository 參數。wire_gen.go build failure 是 pre-existing(需 regenerate)。

Steps 5-6c:完成並 commit。pool guard、useItem plan/execute 遷移、GrantItems 遷移、playerRewardGranter 遷移(commit 1f97315)。

Step 6d:設計文件已完整覆蓋(00_README.md RD-1 lines 227-290),import cycle 疑慮解除,實作進行中。

關鍵 Bug 修正記錄

  • mergeGrantSpecs 重排序 bug:GrantItems 呼叫 mergeGrantSpecs 後 stackable/non-stackable 順序改變,破壞位置型 test assertion。修正:直接移除不必要的 mergeGrantSpecs 呼叫。
  • TestGrantItems_NonStackable_WithMetadata panic:planner 只計算 config-based metadata,不保留 caller 提供的 EnhanceLevel/Quality。修正:有 explicit Metadata 時 bypass planItemGrant 直接建 GrantSpec。
  • cleanup 容量 bug(初版):初版寫「每 60s 刪 10K」,實際過期速率 1.2M rows/min,差距 120x。修正為 continuous batched loop(5s/50K)。

So What

這份設計揭示了三個常被忽視的架構陷阱,並提供了從 6+ 輪 Review 到完整實作的完整路徑。

Redis fail-open 在 cache 場景合理,在 idempotency 場景致命。 很多系統把 Redis 當作多功能層,但 cache 的 fail-open 邏輯不能直接套用到 idempotency;兩者的失效語義根本不同。

CAS/樂觀鎖 ≠ 冪等保護。 CAS 防 concurrent lost-update,不防 sequential replay。在 Redis 可用時這個漏洞被掩蓋,一旦 Redis 故障才暴露,是典型的隱藏型 P0。

scaffolding ≠ feature complete。 介面和實作存在,不代表 correctness gap 已關閉。要接入實際呼叫路徑並有測試保護,才算真正修復。這個區分在 code review 和 sign-off 時至關重要,且已由第三方 reviewer 在本 session 中獨立佐證。

Review 方法論的教訓:P0 嚴重度要明確定義「對什麼阻擋」,否則 5 個 P0 讓人無法判斷是否能開始實作,實際上只有 2 個真正阻擋 sign-off。

Trade-offs

  • marker-only vs 存 response payload:marker-only 寫放大小,Redis down 時無法 replay response,client 需 re-fetch。選 marker-only,因為 response payload 寫放大太貴,且 at-most-once correctness 比 deterministic replay 更重要
  • 全域唯一 key vs 加 rpc_method scope:加 scope 防 cross-RPC 碰撞,但 Redis/DB 語義不一致(Redis key 不含 scope)。選全域唯一,cross-RPC reuse 視為 client bug,行為一致性優先
  • TTL 15min vs 1h:15min 不夠覆蓋 app 背景化後的合理 retry window。選 1h 對齊 Redis completed TTL,row 數 steady state 約 72M(20K inserts/sec)
  • RANGE partition vs plain table:range partition 方便清理但 DDL 複雜(PK 必須包含 partition key)。選 plain table + SKIP LOCKED 簡單可靠
  • 每 pod 都跑 cleanup vs 單例 leader:leader election 複雜且單點。選每 pod 都跑 + SKIP LOCKED,自然分散,ops 更簡單
  • cleanup lag 超過時縮小 scope vs 告警:縮 scope = 拿掉 idempotency 保護換效能,方向錯誤。選告警並人工介入,correctness 不能用作 ops 旋鈕
  • Phase 1 opt-in vs 直接 mandatory request_id:直接 mandatory 太激進,現有 caller 可能沒有帶 request_id。Phase 1 先讓帶 request_id 的 caller 受保護,透過 telemetry gate 確認 empty rate 降至 0 後再 Phase 2 收緊

Try It Fast

// DB backstop claim — tx 內第一步,unconditional
// 返回 (true, nil) = 成功 claim,可繼續執行 mutation
// 返回 (false, nil) = duplicate detected,應回 AlreadyExists
func (r *PostgresIdempotencyRepository) Claim(
    ctx context.Context, userID, requestID string,
) (bool, error) {
    query := `INSERT INTO player_mutation_idempotency (user_id, request_id)
              VALUES ($1, $2)
              ON CONFLICT (user_id, request_id) DO NOTHING`
    ct, err := postgres.TxOrPool(ctx, r.pool).Exec(ctx, query, userID, requestID)
    if err != nil {
        return false, err
    }
    return ct.RowsAffected() > 0, nil
}

// Cleanup worker — 每個 pod 都跑,SKIP LOCKED 避免競爭
// 建議 5s interval, 50K batch,保持 cleanup rate >= insert rate
func cleanupExpiredMarkers(ctx context.Context, pool *pgxpool.Pool) error {
    _, err := pool.Exec(ctx, `
        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
        )
    `)
    return err
}

// 驗證實作接入狀態
// grep -n 'Claim' internal/modules/player/player_twirp.go
// grep -n 'AcquireRowLock' internal/modules/player/player_repository_postgres.go
// grep -n 'CheckSeasonRowsExist' internal/modules/player/player_inventory_repository_postgres.go
// go test ./internal/modules/player/... -run TestIdempotency
// go test ./test/... -run TestMutationExecutor

Recommendation

  1. 凡帶 request_id 的 public mutation RPC,tx 第一步做 DB backstop claimidemRepo.Claim(txCtx, userID, requestID)),不管 Redis 狀態,unconditional 執行——這是 P0 sign-off 的必要條件
  2. scaffolding 完成後必須驗證接入點grep -n 'idemRepo.Claim' internal/modules/player/player_twirp.go 若無結果,P0 gap 未關閉
  3. 明確區分 Aborted vs AlreadyExists:Aborted = in-flight 可 retry;AlreadyExists = 已 commit 應 re-fetch,不得 retry mutation
  4. PlanItemEffects 必須在 CAS retry loop 外呼叫,否則 retry 時 random 結果不同,violation determinism invariant
  5. review idempotency 設計時必問「Redis 掛掉時 sequential replay 行為是什麼」,而非只問 concurrent 衝突
  6. PostgreSQL partition 前確認 PK 約束:RANGE partition 需要 partition key 在 PK 中,否則 DDL 無效
  7. cleanup 吞吐量要與過期速率對齊:insert rate = R,TTL = T,steady-state rows = R×T,cleanup 需能持續處理相同速率;靜態低頻刪除很容易失衡(差距 120x)
  8. P0 嚴重度要明確定義對什麼阻擋:「阻擋開始實作」與「阻擋 production sign-off」是不同標準,混用導致決策癱瘓
  9. wire_gen.go 需 regenerate:NewPlayerService signature 更新後必須重新執行 wire gen 才能消除 build failure
  10. DB backstop scope 用規則式定義(所有呼叫 tryAcquireIdempotency 的 public mutation RPC),不要手寫 endpoint 清單——目前 4 個 call site 包含 UnlockHeroStage(player_twirp.go:638)
  11. pkg/reward/ 定義 interface 可避免 import cycle:distributor atomization 的 PlayerVersionBumper interface 定義在 pkg/reward/ 而非 internal/modules/player/,這是解決跨層依賴的標準模式

本文檔由 Semi-Brain 自動生成

Session ID: a7a343a3-2a2d-4fde-8057-503f23ef22e2

分析信心度: 96%