遊戲後端資源系統重構 + 角色解鎖系統架構設計(1M CCU)¶
文檔資訊
- 分類: architecture
- 難度: advanced
- 預估閱讀時間: 12 分鐘
- 標籤:
game-backend,postgresql,grpc-proto,hero-unlock,resource-system,1M-CCU,database-schema,vertical-table,idempotency,cas,luban,migration
摘要¶
將原本 player_currencies 扁平表重構為語義明確的兩張垂直資源表(player_resources / player_resource_pools),並在此基礎上設計支撐 1M CCU 的角色多階段解鎖系統。涵蓋資料庫設計決策、Proto 設計原則(pool 資源用 repeated 結構而非硬編欄位)、DungeonToken 等核心貨幣的處理策略,以及 BooleanAutoOpen 獨立議題記錄。Phase 1(player_resources table + ResourceRepository)已開始實作。
關鍵學習¶
-
player_currencies 混用導致語義不清:純餘額型資源(Gold/Diamond/DungeonToken)與池型資源(Stamina 有上限+恢復計時)不應放同一張表
-
垂直資源表(player_resources)以 type 欄位區分資源種類,新增 ResourcesType 8/9/10 完全不需要改 code 或做 migration
-
DungeonToken 屬於核心遊戲貨幣(關卡產出 → 角色解鎖消耗),應歸入 player_resources 垂直表以支援 CAS 防超扣和 CHECK 約束
-
Proto 欄位號(field number)一旦使用就不可重用,廢棄欄位必須加 reserved 宣告避免 wire format 衝突
-
Proto 命名不需要對應 DB table 命名,應反映業務語義(PlayerCurrency 已無意義,改用 PlayerResourceState)
-
池型資源(Stamina 等)的 proto 絕對不能硬編 stamina_max / stamina_recover_at,必須設計為 repeated PoolResource 結構以利未來擴充
-
角色解鎖操作必須加 idempotency key(player_id + role_id + unlock_stage),防止網路重試造成重複扣款
-
BooleanAutoOpen(道具/禮包領取後是否自動打開)屬於獨立功能議題,不在角色解鎖系統範疇內
-
調研程式碼時必須讀取最新狀態,不可依賴快取或先前的讀取結果——這是被用戶明確糾正的重要工作習慣
-
Luban 生成的 GameRoleConfig Go struct 已包含 RoleUnlockCondition 和 RoleUnlockReward,企劃配置已完整更新
技術細節¶
資源表重構決策¶
原始 player_currencies 表混用了兩種性質完全不同的資源:
- 純餘額型(Gold, Diamond, DungeonToken):只有加減操作,需要 CAS 防超扣
- 池型(Stamina):有
max上限 + 時間恢復計時器,需要額外欄位
重構後拆成兩張語義明確的表(兩張表都需要清楚的 COMMENT 說明差異):
-- 純餘額型資源(只有加減,無上限無恢復)
-- 新增資源類型只需插入一行,無需 ALTER TABLE
CREATE TABLE player_resources (
player_id BIGINT NOT NULL,
type INT NOT NULL, -- ResourcesType enum
amount BIGINT NOT NULL DEFAULT 0,
CHECK (amount >= 0),
PRIMARY KEY (player_id, type)
);
-- 池型資源(有上限 + 恢復機制,如 Stamina)
-- 未來若有其他池型資源也進此表,type 欄位區分
CREATE TABLE player_resource_pools (
player_id BIGINT NOT NULL,
type INT NOT NULL,
current INT NOT NULL DEFAULT 0,
max_val INT NOT NULL,
recover_at BIGINT, -- unix timestamp, nullable
PRIMARY KEY (player_id, type)
);
Proto 設計關鍵決策¶
Proto message 命名應反映業務語義,PlayerCurrency 已無意義(因為資源不再只是 currency),改用 PlayerResourceState。
Pool 型資源絕對不能硬編欄位,否則未來新增池型資源就會有 wire format 不相容問題:
// 錯誤設計(硬編 stamina 欄位)
message PlayerResourceState {
int32 stamina = 2;
int32 stamina_max = 3;
int64 stamina_recover_at = 4; // 未來加第二種 pool 型資源就爆炸
}
// 正確設計(repeated 結構)
message PlayerResourceState {
reserved 7, 8, 9, 10; // 舊版 extra_currencies 相關欄位,已廢棄
repeated ResourceEntry resources = 1;
repeated PoolResourceEntry pool_resources = 2;
}
message ResourceEntry {
int32 type = 1; // ResourcesType enum
int64 amount = 2;
}
message PoolResourceEntry {
int32 type = 1;
int32 current = 2;
int32 max_val = 3;
int64 recover_at = 4; // unix timestamp
}
Luban Go Struct 確認狀態¶
Luban 生成的 GameRoleConfig struct 已包含所有必要欄位(企劃配置已完整更新):
type GameRoleConfig struct {
ID int64
DataDescription string
RoleUnlockCondition []interface{} // 每階段解鎖條件
RoleUnlockReward []*Beans_RewardData // 完成全部解鎖的獎勵
RoleFavoriteGift []int64
}
角色解鎖系統設計¶
多階段解鎖流程:玩家消耗 DungeonToken 逐步解鎖角色進度,全部解鎖後獲得 RoleUnlockReward。
解鎖操作需原子性:扣除資源 + 更新進度在同一個 transaction,加 idempotency key(player_id + role_id + unlock_stage)防重複扣款。
實作進度(Phase 順序)¶
- Phase 1(已開始):建立 player_resources table + ResourceRepository
- Phase 2:遷移 Gold/Diamond 進 player_resources
- Phase 3:實作角色解鎖 API
What Changed¶
將混用的 player_currencies 重構為語義明確的兩張垂直資源表:player_resources(純餘額型)與 player_resource_pools(池型含恢復機制)。這個命名組合在多次討論後確認為最清晰的方案,並要求在 schema 加清楚的 COMMENT。
Proto 設計從最初草案(硬編 stamina 欄位)修正為 repeated PoolResource 結構,確保未來新增池型資源時不會有 wire format 不相容問題。同時確認 proto 命名應反映業務語義(PlayerResourceState),不需要對應 DB table 命名。廢棄欄位 field ⅞/9/10 已加 reserved 宣告。
新增本次對話的重要發現:Luban GameRoleConfig struct 已完整更新(含 RoleUnlockCondition / RoleUnlockReward),企劃配置無缺漏。Phase 1 實作已啟動(player_resources table + ResourceRepository)。BooleanAutoOpen 確認為獨立功能議題,不納入此次設計範疇。
So What¶
資源垂直表設計是整個遊戲資源系統的基礎。用 type 欄位區分資源種類,未來新增任何 ResourcesType 都不需要改 code 或做 migration,極大降低了長期維護成本。
Proto 的 repeated PoolResource 設計確保了 wire format 向後相容性,避免未來 client/server 版本不一致時的靜默資料損壞。
角色解鎖系統是核心玩法之一,idempotency key 設計是 1M CCU 環境下防止重複扣款的生產級必要設計。
Trade-offs¶
- 垂直資源表 vs 橫向加欄位:垂直表彈性高、不用改 code,但 JOIN 查詢複雜度略增;橫向欄位查詢直覺但每次新增資源都要 ALTER TABLE
- JSONB extra_currencies vs 實體欄位:JSONB 無須 migration 但無法做 CAS 和 CHECK 約束,核心高頻貨幣(DungeonToken)不適用
- Proto repeated PoolResource vs 硬編欄位:repeated 結構彈性高,但 client 解析複雜度略增;硬編欄位直覺但日後擴充有 wire format 風險
- 分 Phase 遷移策略:降低風險但需要過渡期維護雙表寫入,增加過渡期複雜度
Try It Fast¶
-- CAS 防超扣:原子性扣除資源,amount 不足時直接 reject
UPDATE player_resources
SET amount = amount - $3
WHERE player_id = $1
AND type = $2
AND amount >= $3
RETURNING amount;
-- 若影響 0 rows → 餘額不足,上層回傳 INSUFFICIENT_RESOURCE error
-- 新增資源類型只需插入一行,不需要 migration
INSERT INTO player_resources (player_id, type, amount)
VALUES ($1, $new_type, 0)
ON CONFLICT (player_id, type) DO NOTHING;
// proto 廢棄欄位保留 field number 避免 wire 衝突
message PlayerResourceState {
reserved 7, 8, 9, 10; // 舊版 extra_currencies 相關欄位,已廢棄
repeated ResourceEntry resources = 1;
repeated PoolResourceEntry pool_resources = 2;
}
message ResourceEntry {
int32 type = 1; // ResourcesType enum
int64 amount = 2;
}
message PoolResourceEntry {
int32 type = 1;
int32 current = 2;
int32 max_val = 3;
int64 recover_at = 4; // unix timestamp
}
Recommendation¶
- 所有新增的遊戲資源預設進
player_resources垂直表(type 欄位區分),評估後才考慮 pool 型 - 核心高頻貨幣(每局都會扣除的,如 DungeonToken)必須走實體欄位 + CAS,不可用 JSONB
- Proto 每次廢棄欄位都要在 message 層加
reserved <field_number>宣告,納入 code review checklist - Pool 型資源的 proto 設計用 repeated/map 結構,不要硬編每種資源的獨立欄位
- Proto message 命名反映業務語義,不需要與 DB table 命名一致
- 角色解鎖操作加 idempotency key(player_id + role_id + unlock_stage),防止網路重試造成重複扣款
- 遷移現有 player_currencies 資料時分 Phase 執行,每個 Phase 保留雙寫以便 rollback
- BooleanAutoOpen(道具/禮包領取後自動打開)作為獨立功能議題,後續單獨設計實作
- 調研程式碼時每次都必須重新讀取最新檔案狀態,不可依賴對話中先前讀取的快取結果