《MMO 服务器开发》9.1 角色数据模型

一个满级角色的数据散落在账号、等级、坐标、货币、背包、任务、属性七八个维度上,全塞进一张宽表会让每次位置更新都重写整行。本节讲清主表与扩展表的拆分原则:什么进热表、什么进冷表、大字段为什么必须分出去,并给出在 Postgres 上真跑的建表 DDL 与读写验证。

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, 子键)写入天然幂等
乐观锁列version9.2 使用,先埋下
测试 schemaaegis_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 落库与脏写防护 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标