一、字段按生命周期分五层
玩家表最容易被写成“角色面板的数据库副本”:客户端显示什么,表里就加什么。结果 buff、临时坐标、战斗目标全进来了,字段越加越多,每个系统都能改同一行,最后没人说得清哪一列是事实源。正确做法是按生命周期分层,每层只回答一个问题。
| 层 | 字段 | 回答的问题 | 变化频率 |
|---|---|---|---|
| 身份 | player_id、account_id、region | 这是谁、属于哪个区 | 创建后不变 |
| 展示 | name、avatar | 别人怎么看到它 | 低频 |
| 进度 | level、exp | 成长到什么程度 | 中频 |
| 状态 | online、banned | 当前能不能玩 | 高频或临时 |
| 审计 | created_at、updated_at、version | 谁在什么时候改过 | 每次写 |
身份字段必须不可变。player_id 是区服内的角色主键,account_id 跨区稳定,region 标明归属;三者组合决定了数据路由。展示字段可以改名,但要有唯一性约束和敏感词校验。进度字段是存档的核心。状态字段里只有 banned 值得持久化,online 只是路由提示。审计字段不是“顺便加的”,它们是并发控制和排障的依据。
二、静态配置、动态存档、运行期状态
三类数据的读写者、生命周期和恢复方式完全不同,不能塞进同一张表。
| 类别 | 例子 | 正确存放 | 重启后 |
|---|---|---|---|
| 静态配置 | 等级经验表、装备定义、地图刷新点 | 配置表或版本化配置文件 | 不变 |
| 动态存档 | 等级、经验、背包、任务进度 | 玩家库,按角色分片 | 必须恢复 |
| 运行期状态 | buff、临时坐标、战斗目标、在线连接 | 内存或 Redis,按会话持有 | 允许丢失并重建 |
把 buff 写进存档表是典型错误:buff 的剩余时间依赖服务器时钟,写入数据库后既要定时清理,又会在维护后出现“离线期间 buff 还在走”的争议。临时坐标同理,它只在当前场景有意义,换线或重登就该重置。运行期状态应由场景服或会话层持有,进程退出即丢弃,玩家重新登录时从存档重建。
三、类型、默认值与 NULL
字段类型是契约,不是省空间的技巧。下面几条在本项目里是硬规则:
| 语义 | 推荐类型 | 说明 |
|---|---|---|
| 时间 | DATETIME(3),统一 UTC | 不要存本地时间,也不要依赖数据库默认时区 |
| 枚举 | TINYINT UNSIGNED + 代码常量 | 不要用字符串,改文案不应改数据 |
| 金额 | BIGINT,最小货币单位 | 人民币用分,避免 FLOAT/DOUBLE 的舍入误差 |
| 布尔 | TINYINT(1) NOT NULL DEFAULT 0 | 三态需求另开状态列,不要用 NULL 表示“未知” |
| ID | BIGINT UNSIGNED | 不要用用户名、邮箱或设备号当主键 |
| 名称 | VARCHAR(64) utf8mb4 | 长度按字符校验,唯一性按区服约束 |
NOT NULL 加显式默认值是默认选择。零值本身有语义:exp = 0 表示新角色,banned = 0 表示正常。只有“确实没有值”才允许 NULL,例如可选的封禁截止时间。不要把“空字符串”“0”“NULL”混用成同一种状态,否则查询条件会漏数据。
四、一张可落库的玩家表
下面的 DDL 刻意没有 online 列。在线状态属于会话层,写进玩家表只会带来“宕机后全员在线”的脏数据。
CREATE TABLE `player` (
`player_id` BIGINT UNSIGNED NOT NULL,
`account_id` BIGINT UNSIGNED NOT NULL,
`region` VARCHAR(32) NOT NULL,
`name` VARCHAR(64) NOT NULL,
`avatar` VARCHAR(255) NOT NULL DEFAULT '',
`level` INT UNSIGNED NOT NULL DEFAULT 1,
`exp` BIGINT NOT NULL DEFAULT 0,
`banned` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
`version` BIGINT UNSIGNED NOT NULL DEFAULT 1,
PRIMARY KEY (`player_id`),
UNIQUE KEY `uk_region_name` (`region`, `name`),
KEY `idx_account` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
version 必须存在。玩家可能同时被登录服、场景服、运营后台修改;没有版本号,后写入的整行会覆盖先写入的字段,形成丢失更新。version 还是审计线索:配合 updated_at 可以定位是哪次写入把等级改回了旧值。跨服读取角色快照时,它也用来判断副本是否过期。
五、Go 结构体与乐观锁
结构体只承载可持久化字段,运行期数据不放进来。Save 要求调用方传入读取时的版本,版本不一致就返回冲突,由上层决定重试还是提示“数据已变化”。
// file: ch06/model/player.go
// tier: L1
// verify: go test ./ch06/model/
package model
import (
"errors"
"strings"
"time"
"unicode/utf8"
)
const (
maxNameRunes = 32
maxLevel = 200
)
var (
ErrInvalidPlayer = errors.New("player: invalid field")
ErrPlayerNotFound = errors.New("player: not found")
ErrVersionConflict = errors.New("player: version conflict")
)
// Player is persisted mutable state. Buffs, temporary coordinates and battle
// state are runtime-only and must not appear here.
type Player struct {
PlayerID uint64 // 区服内角色主键
AccountID uint64 // 跨区稳定账号 ID
Region string
Name string
Avatar string
Level uint32
Exp int64
Online bool // 仅路由提示,登录态以会话表为准
Banned bool
CreatedAt time.Time
UpdatedAt time.Time
Version uint64 // 乐观锁令牌
}
func (p *Player) Validate() error {
switch {
case p.PlayerID == 0:
return errors.Join(ErrInvalidPlayer, errors.New("player_id"))
case p.AccountID == 0:
return errors.Join(ErrInvalidPlayer, errors.New("account_id"))
case strings.TrimSpace(p.Region) == "":
return errors.Join(ErrInvalidPlayer, errors.New("region"))
case strings.TrimSpace(p.Name) == "":
return errors.Join(ErrInvalidPlayer, errors.New("name"))
case utf8.RuneCountInString(p.Name) > maxNameRunes:
return errors.Join(ErrInvalidPlayer, errors.New("name too long"))
case p.Level == 0 || p.Level > maxLevel:
return errors.Join(ErrInvalidPlayer, errors.New("level out of range"))
case p.Exp < 0:
return errors.Join(ErrInvalidPlayer, errors.New("negative exp"))
case p.CreatedAt.IsZero() || p.UpdatedAt.IsZero():
return errors.Join(ErrInvalidPlayer, errors.New("timestamps"))
case p.UpdatedAt.Before(p.CreatedAt):
return errors.Join(ErrInvalidPlayer, errors.New("updated_at before created_at"))
}
return nil
}
// PlayerStore stands in for a transactional repository.
type PlayerStore struct {
players map[uint64]Player
now func() time.Time
}
func NewPlayerStore(now func() time.Time) *PlayerStore {
if now == nil {
now = time.Now
}
return &PlayerStore{players: make(map[uint64]Player), now: now}
}
// Create inserts a player and assigns version 1.
func (s *PlayerStore) Create(p Player) (Player, error) {
if _, exists := s.players[p.PlayerID]; exists || p.Version != 0 {
return Player{}, ErrVersionConflict
}
now := s.now().UTC()
p.CreatedAt, p.UpdatedAt, p.Version = now, now, 1
if err := p.Validate(); err != nil {
return Player{}, err
}
s.players[p.PlayerID] = p
return p, nil
}
// Save is compare-and-swap. Online comes from the session layer, so a save
// payload never overwrites it.
func (s *PlayerStore) Save(p Player, expectedVersion uint64) (Player, error) {
current, ok := s.players[p.PlayerID]
if !ok {
return Player{}, ErrPlayerNotFound
}
if current.Version != expectedVersion {
return Player{}, ErrVersionConflict
}
p.Online, p.CreatedAt = current.Online, current.CreatedAt
p.UpdatedAt, p.Version = s.now().UTC(), current.Version+1
if err := p.Validate(); err != nil {
return Player{}, err
}
s.players[p.PlayerID] = p
return p, nil
}
func (s *PlayerStore) Get(playerID uint64) (Player, error) {
p, ok := s.players[playerID]
if !ok {
return Player{}, ErrPlayerNotFound
}
return p, nil
}
Create 在写入前统一转成 UTC 并赋版本 1;Save 保留数据库里的 created_at,忽略调用方传来的 online。这样即使某个业务把在线标记误塞进存档对象,也污染不了持久层。
六、用测试固定语义
测试覆盖非法字段、重复创建、过期版本写入和在线标记不落库。表驱动用例让新增约束时有统一入口。
// file: ch06/model/player_test.go
// tier: L1
// verify: go test ./ch06/model/
package model
import (
"errors"
"testing"
"time"
)
func testClock() time.Time { return time.Date(2026, 11, 4, 1, 0, 0, 0, time.UTC) }
func validPlayer() Player {
return Player{PlayerID: 1001, AccountID: 77, Region: "s1", Name: "雾港旅人", Level: 12, Exp: 3400}
}
func TestPlayerValidateRejectsBadFields(t *testing.T) {
tests := []struct {
name string
mutate func(*Player)
}{
{"zero player id", func(p *Player) { p.PlayerID = 0 }},
{"zero account id", func(p *Player) { p.AccountID = 0 }},
{"blank region", func(p *Player) { p.Region = " " }},
{"blank name", func(p *Player) { p.Name = "" }},
{"name too long", func(p *Player) { p.Name = string(make([]rune, 33)) }},
{"zero level", func(p *Player) { p.Level = 0 }},
{"negative exp", func(p *Player) { p.Exp = -1 }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := validPlayer()
tc.mutate(&p)
if err := p.Validate(); !errors.Is(err, ErrInvalidPlayer) {
t.Fatalf("Validate() = %v, want ErrInvalidPlayer", err)
}
})
}
}
func TestPlayerStoreCreateAssignsVersionAndUTC(t *testing.T) {
loc := time.FixedZone("UTC+8", 8*3600)
store := NewPlayerStore(func() time.Time { return testClock().In(loc) })
p, err := store.Create(validPlayer())
if err != nil {
t.Fatal(err)
}
if p.Version != 1 || p.CreatedAt.Location() != time.UTC {
t.Fatalf("version=%d created_at=%v", p.Version, p.CreatedAt)
}
if _, err := store.Create(p); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("duplicate create error=%v", err)
}
}
func TestPlayerStoreSaveRejectsStaleVersion(t *testing.T) {
store := NewPlayerStore(testClock)
created, err := store.Create(validPlayer())
if err != nil {
t.Fatal(err)
}
first := created
first.Level = 13
updated, err := store.Save(first, created.Version)
if err != nil || updated.Version != 2 {
t.Fatalf("update=%+v err=%v", updated, err)
}
stale := created
stale.Level = 99
if _, err := store.Save(stale, created.Version); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale save error=%v, want ErrVersionConflict", err)
}
got, _ := store.Get(created.PlayerID)
if got.Level != 13 || got.Version != 2 {
t.Fatalf("stale write leaked: %+v", got)
}
}
func TestPlayerStoreDoesNotPersistOnlineFlag(t *testing.T) {
store := NewPlayerStore(testClock)
created, err := store.Create(validPlayer())
if err != nil {
t.Fatal(err)
}
created.Online = true
created.Exp = 4000
updated, err := store.Save(created, created.Version)
if err != nil {
t.Fatal(err)
}
if updated.Online || updated.Exp != 4000 {
t.Fatalf("save result=%+v", updated)
}
}
七、运行验证
cd .local/checks/gs-practice-lab && gofmt -l ch06/model && go vet ./ch06/model/ && go test ./ch06/model/ -count=1
ok gspractice/ch06/model 0.259s
八、失败场景:漂移、零值与时间
某项目最初把 level 定义为账号等级,后来改成角色等级,但老数据没有迁移。运营按“账号等级”发奖励时,把同一账号下多个角色的等级重复计算;客服看到玩家截图与后台不一致,排查了两天才发现字段语义已经变了。修复不能只改代码:先冻结写入,给旧数据打标记,新增 level_scope 字段并双写一段时间,确认所有读取方切换后再删旧逻辑。字段含义一旦进入生产,就必须按协议对待,改名、改单位、改口径都要走迁移,而不是悄悄改注释。
NULL 与零值混用同样隐蔽。某次运营后台把 avatar 的空值写成 NULL,而客户端只判断空字符串,结果所有未上传头像的玩家显示裂图;另一个报表用 exp IS NULL 统计新玩家,漏掉了 exp = 0 的真实新号。约定应该是:可选文本统一空字符串,数值统一 0,只有“确实未知”才用 NULL,并在写入层做一次归一。
时区问题通常延迟爆发。created_at 用数据库本地时间写入,服务器迁到另一个时区后,新老数据的先后关系颠倒,按时间补偿的脚本把老玩家当成新注册。所有时间在入口转 UTC、落库用 DATETIME(3)、展示层再按玩家时区转换;不要让数据库默认值替你做决定。
小结
玩家表的核心不是字段多,而是边界清楚:身份不可变,展示可改,进度是存档,在线是运行期状态,version 负责并发。静态配置、动态存档、临时状态分三层存放;类型上时间统一 UTC、金额用整数最小单位、布尔用 NOT NULL 默认值。把这些约定写进 DDL 和结构体,后续加系统才不会互相踩踏。
常见错误与边界
- 用用户名或邮箱当主键,改名或换绑后所有外键失效。
- 金额用
FLOAT存储,结算时出现 0.01 的误差,长期累积对不上账。 - 把
online持久化却从不清理,服务器宕机后全员显示在线。 - 用 NULL 表示“未设置”,又用 0 表示“没有”,查询时两种状态都漏。
- 时间字段存本地时间,跨时区部署或夏令时切换后顺序错乱。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。