9.1 角色数据模型
到第 9 章,我们终于要碰数据库了。前面八章把 aegis 从单服推成了集群,但有一个问题一直被有意绕开:玩家的数据到底长什么样、存在哪、怎么读写。缓存(第 8 章)只是数据的副本,内存(卷二)只是数据的投影,真正的权威副本必须在某个持久化存储里,而这个存储的形状决定了后面所有章节的复杂度。
一个满级角色的数据维度多得吓人:账号与昵称、等级与经验、地图坐标、金币与钻石、背包里几十件物品、任务进度、动态属性、公会与好友关系、邮件……如果天真地把它们塞进一张 player 表,会得到一张又宽又热的大行。它的麻烦不在「存不下」,而在每次读都要全读、每次写都要全写。
这里的关键词是访问模式错配。同一份角色数据里,坐标每秒变好几次,背包半小时才动一次;等级几天升一级,邮件可能永远不看。它们共享同一个 player_id,却有着完全不同的读写节奏。把它们绑在同一行,就等于让最热的那部分去拖动最冷的那部分——高频写被迫带上全部冷数据,低频读又要为大字段付出额外的 I/O。数据模型设计,本质就是按访问模式给数据分组。
本节要回答的是最基础也最容易被做错的一个问题:这些字段该怎么拆表。我们会讲清「热字段进主表、大字段与冷字段进扩展表」这条原则背后的三个物理原因,给出可直接在 Postgres 上执行的建表 DDL,并用一个真连数据库的测试证明:一次登录只读一张窄表,而背包、任务、属性各自独立。
与站内 practice 文章的分工
本书从零写代码、每一段都能真跑;站内既有的 practice 文章讲的是架构方案与取舍。本章对应的是下面两篇,建议对照阅读:
| 站内文章 | 它负责回答 | 本书这一章负责 |
|---|---|---|
/game-server-shard-rebalancing-architecture/ | 分片集群怎么扩容、再均衡期间怎么保证读写正确 | 把「分片键」落成代码,让 9.3 的搬迁能真跑并断言不丢数据 |
/zone-sharding-and-server-splitting-for-online-games/ | 分区、分服、合服的业务形态与运营取舍 | 讲清「一个角色的数据该拆成几张表」这个更底层的模型问题 |
这两篇解决的是「集群怎么摆、运营怎么做」,本章解决的是「一个角色的数据在库里长什么样、怎么读写」。
主表与扩展表怎么分
拆表的依据不是「字段属于哪个业务」,而是访问模式。同一个角色身上,不同数据的读写频率、单角色基数、更新粒度差了几个数量级:
| 数据 | 归属 | 访问频率 | 单角色基数 | 更新粒度 |
|---|---|---|---|---|
| 账号、昵称、等级、坐标、货币 | player(主表) | 每次移动 tick 都写坐标 | 1 | 单行整体更新 |
| 背包物品 | player_item | 拾取 / 使用 / 整理时 | 1:N(几十上百) | 单件物品更新 |
| 任务进度 | player_quest | 任务事件触发时 | 1:N | 单任务更新 |
| 动态属性 | player_attr | 属性变化时 | 1:N | 单键更新 |
把这张表读一遍就能看出拆分的三条理由:
第一,行宽决定 I/O。Postgres 按 8KB 页存储,一行越宽,一页装的行越少,读一行要拖更多页进缓存。坐标每 tick 都变,如果它和背包放在同一行,那么每次移动都会把整个背包重写一遍——写的量级和读的热度完全错配。
第二,大字段会触发 TOAST。Postgres 对超过约 2KB 的变长字段会外置(TOAST),读的时候要 detoast。如果背包序列化成一个 JSONB 塞进主表,每次读坐标都可能把整块背包解压出来。把它拆到 player_item,主表就永远是一行小记录。
第三,独立更新与独立版本。背包、任务、属性的更新时机完全不同:背包在拾取时变,任务在事件触发时变。拆开后,改背包不需要碰任务行,9.2 的「脏标记 + 选择性 flush」才有落点——只重写变了的表。
这里有一个容易被忽略的边界:主键与外键都用 player_id。所有扩展表都以 player_id 作为分区键的一部分,这样 9.3 按角色分片时,一个角色的全部数据会落在同一个分片里,跨分片查询被彻底消除。
字段归属的判定清单
落到具体字段,可以用一张清单快速判断它该进主表还是扩展表。四条里只要有一条指向「扩展」,就应该拆出去:
| 判断 | 指向主表 | 指向扩展表 |
|---|---|---|
| 单角色是 1 份还是 N 份? | 1 份 | N 份 |
| 读它的频率 | 每次登录 / 每 tick | 用到才读 |
| 它会不会单独变? | 跟着角色整体变 | 独立事件触发 |
| 字段大小 | 小(几十字节) | 可能很大(列表 / 文本) |
映射到 Go 侧,主表是一个窄结构体,扩展表各自是带 PlayerID 的行类型:
// snippet: internal/store/model.go 中的主表与扩展表类型
type Player struct {
ID uint64
Account string
Name string
Level int
PosX float64
PosY float64
Gold int64
Version int64 // 9.2 的乐观锁载体,先埋下
}
type Item struct {
InstID uint64
PlayerID uint64
ItemID uint32
Count int32
}
Player 刻意保持字段少而窄:它对应 player 表的一行,会被完整读进内存,每次移动 tick 都可能被整体写回,所以它的宽度直接决定了高频写的成本。Item 则是 player_item 的一行,PlayerID 既是归属外键,也是 9.3 的分片键。
一个常见误区是「反正都要加载,不如一次查全」。问题在于加载的时机不一样:角色登录必须立刻拿到坐标和等级(否则进不了世界),但背包只在打开时才有用。如果把它们绑在一行,每次登录都得把背包一起拖出来,读放大会在在线人数上升时线性暴露。拆开之后,登录只读一行主表,背包什么时候用什么时候读,两者的容量与热点也能各自独立优化。
建表 DDL
下面是本章用到的全部建表语句,标记为 L2,因为它会被真的执行到 Postgres 上。player 是窄的热表,另外三张是按角色挂载的扩展表:
CREATE SCHEMA IF NOT EXISTS aegis_test_ch09;
CREATE TABLE IF NOT EXISTS aegis_test_ch09.player (
player_id BIGINT PRIMARY KEY,
account TEXT NOT NULL,
name TEXT NOT NULL,
level INT NOT NULL DEFAULT 1,
pos_x DOUBLE PRECISION NOT NULL DEFAULT 0,
pos_y DOUBLE PRECISION NOT NULL DEFAULT 0,
gold BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS aegis_test_ch09.player_item (
inst_id BIGINT PRIMARY KEY,
player_id BIGINT NOT NULL,
item_id INT NOT NULL,
count INT NOT NULL
);
CREATE INDEX IF NOT EXISTS player_item_player_idx
ON aegis_test_ch09.player_item (player_id);
CREATE TABLE IF NOT EXISTS aegis_test_ch09.player_quest (
player_id BIGINT NOT NULL,
quest_id INT NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (player_id, quest_id)
);
CREATE TABLE IF NOT EXISTS aegis_test_ch09.player_attr (
player_id BIGINT NOT NULL,
key TEXT NOT NULL,
value BIGINT NOT NULL,
PRIMARY KEY (player_id, key)
);
几个设计点值得单独说。player 上带一个 version 列,它是 9.2 乐观锁的载体,现在先埋下。player_item 的主键是物品实例 ID(inst_id)而不是配置表 ID,因为同一个配置物品可以在背包里出现多份,必须靠实例 ID 区分;player_id 上建了索引,用于按角色拉整包。player_quest 与 player_attr 用 (player_id, 子键) 复合主键,天然幂等——同一任务进度、同一属性键重复写只会覆盖。所有表都建在独立 schema aegis_test_ch09 下,收尾 DROP SCHEMA ... CASCADE 一次清干净,不污染共享库。
再补一句索引口径。player_item 的查询永远是「按 player_id 取整包」,所以索引建在 player_id 上;而 player_quest / player_attr 的复合主键 (player_id, 子键) 本身就是一个以 player_id 打头的索引,按角色取整包时直接走它,不需要额外建索引。这也是拆表的一个附带好处:每张扩展表的索引都短而专,不像宽表那样要为主表的每个查询维度各建一条。
真跑验证:一次加载完整角色
光有 DDL 不算数,得证明这套模型真的能读写。下面这段是 L2,连的是本机 127.0.0.1:25432 的独立 Postgres 容器(不是用户自己的 15432 / 15434 / 15435),它会建表、写入一个角色加背包、任务、属性,再逐张读回来断言:
// file: internal/store/model_test.go
// tier: L2
// verify: go test ./internal/store/ -run TestPlayerDataModel -v
package store
import (
"context"
"testing"
)
func TestPlayerDataModel(t *testing.T) {
pool := testPool(t)
defer pool.Close()
resetSchema(t, pool)
ctx := context.Background()
p := &Player{ID: 10001, Account: "acc-10001", Name: "晨光",
Level: 12, PosX: 128, PosY: 64, Gold: 5000, Version: 1}
if err := SavePlayer(ctx, pool, p); err != nil {
t.Fatal(err)
}
if err := SaveItems(ctx, pool, []Item{
{InstID: 9001, PlayerID: p.ID, ItemID: 1001, Count: 1},
{InstID: 9002, PlayerID: p.ID, ItemID: 2005, Count: 20},
{InstID: 9003, PlayerID: p.ID, ItemID: 3007, Count: 3},
}); err != nil {
t.Fatal(err)
}
if err := SaveQuests(ctx, pool, []Quest{
{PlayerID: p.ID, QuestID: 1, State: 2},
{PlayerID: p.ID, QuestID: 7, State: 0},
}); err != nil {
t.Fatal(err)
}
if err := SaveAttrs(ctx, pool, []Attr{
{PlayerID: p.ID, Key: "atk", Value: 120},
{PlayerID: p.ID, Key: "hp", Value: 3000},
}); err != nil {
t.Fatal(err)
}
got, err := LoadPlayer(ctx, pool, p.ID)
if err != nil {
t.Fatal(err)
}
if got.Level != 12 || got.Gold != 5000 || got.PosX != 128 || got.Name != "晨光" {
t.Fatalf("hot row mismatch: %+v", got)
}
gi, _ := LoadItems(ctx, pool, p.ID)
gq, _ := LoadQuests(ctx, pool, p.ID)
ga, _ := LoadAttrs(ctx, pool, p.ID)
if len(gi) != 3 || len(gq) != 2 || len(ga) != 2 {
t.Fatalf("extension counts: items=%d quests=%d attrs=%d", len(gi), len(gq), len(ga))
}
// The split is the point: one hot row per player, one row per item. The
// hot table stays narrow no matter how big the bag grows.
nc, _ := CountRows(ctx, pool, "player")
ni, _ := CountRows(ctx, pool, "player_item")
if nc != 1 || ni != 3 {
t.Fatalf("player rows=%d (want 1), player_item rows=%d (want 3)", nc, ni)
}
t.Logf("model ok: 1 hot row + 3 items + 2 quests + 2 attrs")
}
它断言的核心不是「数据存进去了」,而是拆分本身成立:player 表里这个角色只有 1 行,而 player_item 里同样这个角色有 3 行。这个 1 : N 的对比就是整个数据模型的意义——热表恒定窄,扩展表随业务增长。SavePlayer / LoadPlayer / SaveItems 等函数是完整的读写实现,落在 internal/store/model.go,用的都是 github.com/jackc/pgx/v5,没有任何 ORM。
数据模型速查
| 项 | 取值 | 说明 |
|---|---|---|
| 热表 | player | 每角色 1 行:账号 / 等级 / 坐标 / 货币 |
| 扩展表 | player_item / player_quest / player_attr | 每角色 1:N |
| 主键口径 | player_id | 全表一致,兼作 9.3 的分片键 |
| 背包主键 | inst_id(实例 ID) | 同一配置物品可在背包出现多份 |
| 复合主键 | (player_id, 子键) | 写入天然幂等 |
| 乐观锁列 | version | 9.2 使用,先埋下 |
| 测试 schema | aegis_test_ch09 | 收尾 DROP SCHEMA ... CASCADE |
实测结论
- 本节代码在本机 Postgres 17(
127.0.0.1:25432)真跑通过:go test ./internal/store/ -run TestPlayerDataModel -v输出model ok: 1 hot row + 3 items + 2 quests + 2 attrs。 - 拆分口径:热字段(账号 / 等级 / 坐标 / 货币)进主表,大字段与 1:N 数据(背包 / 任务 / 属性)进扩展表,主键与外键统一用
player_id。 - 主表保持「每角色一行」,是为了让坐标这类高频写永远只重写一行小记录,而不是拖着整包数据。
- 扩展表用
(player_id, 子键)复合主键,写入天然幂等;player_id索引保证按角色拉整包是一次有序扫描。 - 所有表建在独立 schema
aegis_test_ch09下,测试收尾DROP SCHEMA ... CASCADE清理,绝不碰用户自己的库。 - 模型定下来之后,下一个问题立刻浮现:这些行在什么时候写回、多个写者同时改一行怎么办——那是 9.2 的脏写防护。
这套模型是后面两节的地基:9.2 的 Flush 就是围绕「主表 + 扩展表 + version 列」设计的,9.3 的分片键正是这里的 player_id。把表拆对了,持久化的并发控制与分库分表才有落脚点;拆错了,后面每一个优化都会在错误的形状上打补丁。
阅读导航:上一节:8.3 读写分离与读模型 · 下一节:9.2 落库与脏写防护 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。