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