一、加载策略先定,保存策略才有意义
角色数据不是一个对象,而是角色表、背包、任务、邮件、冷却等多个存储的聚合。登录时是把它们一次读全,还是用到再读,决定了首登延迟、内存占用和后续保存的复杂度。
| 策略 | 首登延迟 | 常驻内存 | 一致性 | 适用 |
|---|---|---|---|---|
| 一次加载全部 | 高,随数据量增长 | 高 | 简单,进场景时已完整 | 角色数据小、玩法频繁交叉访问 |
| 按模块懒加载 | 低 | 低 | 需要处理加载失败与并发 | 大背包、邮件、长尾系统 |
| 一次加载核心 + 懒加载外围 | 中 | 中 | 核心强一致,外围最终一致 | 大多数 MMORPG |
不要用“登录时全表扫描”代替设计。按账号查全部角色、再按角色查全部背包,在角色多、背包大的区服会把登录峰值放大成数据库峰值。查询必须带主键或覆盖索引,并且限制单次返回量。
二、部分失败不能带病进场景
一次加载多个模块时,任何一个失败都只有三种处理:整体失败让玩家重试、降级到只读或安全默认值、跳过非核心模块并记录待补。最危险的是忽略错误继续进入场景,内存里一半是旧数据、一半是零值,下线保存时再把零值覆盖回数据库。
核心数据(角色基础、货币、关键任务)必须强一致,加载失败就拒绝登录;外围数据(邮件、图鉴、统计)可以降级,但要在玩家对象上标记“未加载”,禁止在未加载状态下执行会写回该模块的操作。
三、保存时机与丢数据窗口
保存不是越频繁越好。每次保存都有序列化、网络和磁盘成本,过于频繁会把逻辑线程拖慢;过于稀疏则崩溃时丢数据。
| 时机 | 代价 | 崩溃时丢数据窗口 | 说明 |
|---|---|---|---|
| 关键操作后同步保存 | 高,阻塞操作 | 几乎为零 | 充值、交易、抽卡等不可丢操作 |
| 定时快照 | 中,可分摊 | 一个快照周期 | 适合等级、经验、普通背包 |
| 下线保存 | 低 | 从上次保存到下线 | 必须做,但不能作为唯一手段 |
| 停服 flush | 集中 IO 峰值 | 取决于排空速度 | 需要限流与超时 |
“关键操作后保存”要和业务确认哪些算关键。把所有操作都当关键,等于把数据库变成同步写放大器;只在下线保存,则一次进程崩溃可能丢掉整段在线时长。
四、全量、增量与字段级更新
全量保存实现简单,整行覆盖,适合数据量小且写入不频繁的角色;增量保存只写变化字段,节省带宽和锁竞争,但需要维护脏字段集合,漏标一个字段就是静默丢档。
| 方式 | 写放大 | 冲突风险 | 实现复杂度 | 适用 |
|---|---|---|---|---|
| 整行覆盖 | 高 | 低(配合版本号) | 低 | 小对象、低频写 |
| 字段级 UPDATE | 低 | 中,需合并并发修改 | 中 | 大对象、热点字段 |
| 事件追加 + 快照 | 低 | 低 | 高 | 可审计、可回放的资产流水 |
字段级更新必须带版本条件,例如 UPDATE player SET gold = ?, version = version + 1 WHERE id = ? AND version = ?。影响行数为零就说明期间有人改过,不能当成功。
五、版本一致性
加载时记录 version,保存时把它作为乐观锁条件。第一次保存成功后版本加一,旧会话再保存就会冲突。冲突不是错误日志,而是必须处理的业务分支:重新加载、合并可合并字段,或者拒绝并让玩家重新登录。
版本号只能单调递增,不能回退,也不能由客户端提供。跨服、跨进程写同一角色时,版本校验是最后一道防线;如果使用缓存,缓存里的版本必须与数据库一致,不能各自维护一套。
六、失败场景:保存失败只打日志
最常见的丢档不是数据库宕机,而是代码把保存错误吞掉:if err != nil { log.Error(err) },然后继续让玩家在线。玩家下线后数据仍是旧的,客服查日志只能看到一行错误,无法恢复。
正确做法是把保存失败向上传递:关键保存失败要阻止后续不可逆操作,下线保存失败要保留角色对象并重试,停服时要把失败的角色写入本地补偿文件。日志必须带 player_id、version、save_id 和影响字段,便于对账。
七、Go 实现
Repository 用 expectedVersion 做乐观锁,用 saveID 做重复提交幂等;MemoryRepository 在锁内完成校验和写入,任何校验失败都不修改旧行,模拟真实事务的回滚语义。
// file: ch06/save/loadsave.go
// tier: L1
// verify: go test ./ch06/save/
package save
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
var (
ErrRecordNotFound = errors.New("record not found")
ErrVersionConflict = errors.New("version conflict")
ErrInvalidData = errors.New("invalid player data")
)
type Asset struct {
ItemID int32
Count int32
}
type PlayerData struct {
PlayerID uint64
Version uint64
Name string
Level int
Gold int64
Items []Asset
UpdatedAt time.Time
}
type Repository interface {
Load(ctx context.Context, playerID uint64) (PlayerData, error)
Save(ctx context.Context, data PlayerData, expectedVersion uint64, saveID string) (PlayerData, error)
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service { return &Service{repo: repo} }
func (s *Service) Load(ctx context.Context, playerID uint64) (PlayerData, error) {
data, err := s.repo.Load(ctx, playerID)
if err != nil {
return PlayerData{}, fmt.Errorf("load player %d: %w", playerID, err)
}
return data, nil
}
func (s *Service) Save(ctx context.Context, data PlayerData, saveID string) (PlayerData, error) {
saved, err := s.repo.Save(ctx, data, data.Version, saveID)
if err != nil {
return PlayerData{}, fmt.Errorf("save player %d: %w", data.PlayerID, err)
}
return saved, nil
}
func ValidatePlayerData(data PlayerData) error {
if data.PlayerID == 0 {
return fmt.Errorf("%w: player id required", ErrInvalidData)
}
if data.Level < 1 {
return fmt.Errorf("%w: level must be positive", ErrInvalidData)
}
if data.Gold < 0 {
return fmt.Errorf("%w: gold must not be negative", ErrInvalidData)
}
for _, asset := range data.Items {
if asset.ItemID <= 0 || asset.Count <= 0 {
return fmt.Errorf("%w: bad asset %d x %d", ErrInvalidData, asset.ItemID, asset.Count)
}
}
return nil
}
type MemoryRepository struct {
mu sync.Mutex
rows map[uint64]PlayerData
applied map[uint64]string
now func() time.Time
}
func NewMemoryRepository() *MemoryRepository {
return &MemoryRepository{rows: map[uint64]PlayerData{}, applied: map[uint64]string{}, now: time.Now}
}
func (m *MemoryRepository) Seed(data PlayerData) {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[data.PlayerID] = clonePlayer(data)
}
func (m *MemoryRepository) Load(_ context.Context, playerID uint64) (PlayerData, error) {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.rows[playerID]
if !ok {
return PlayerData{}, fmt.Errorf("%w: player %d", ErrRecordNotFound, playerID)
}
return clonePlayer(row), nil
}
func (m *MemoryRepository) Save(_ context.Context, data PlayerData, expectedVersion uint64, saveID string) (PlayerData, error) {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.rows[data.PlayerID]
if !ok {
return PlayerData{}, fmt.Errorf("%w: player %d", ErrRecordNotFound, data.PlayerID)
}
if saveID != "" && m.applied[data.PlayerID] == saveID {
return clonePlayer(row), nil
}
if row.Version != expectedVersion {
return PlayerData{}, fmt.Errorf("%w: stored=%d expected=%d", ErrVersionConflict, row.Version, expectedVersion)
}
if err := ValidatePlayerData(data); err != nil {
return PlayerData{}, err
}
data.Version = row.Version + 1
data.UpdatedAt = m.now().UTC()
m.rows[data.PlayerID] = clonePlayer(data)
if saveID != "" {
m.applied[data.PlayerID] = saveID
}
return clonePlayer(data), nil
}
func clonePlayer(data PlayerData) PlayerData {
data.Items = append([]Asset(nil), data.Items...)
return data
}
测试覆盖版本递增、旧版本冲突、同一 saveID 重试、校验失败回滚和记录不存在:
// file: ch06/save/loadsave_test.go
// tier: L1
// verify: go test ./ch06/save/
package save
import (
"context"
"errors"
"testing"
)
func seedPlayer(repo *MemoryRepository) PlayerData {
repo.Seed(PlayerData{PlayerID: 7, Version: 3, Name: "夜风", Level: 10, Gold: 500, Items: []Asset{{ItemID: 1001, Count: 2}}})
return PlayerData{PlayerID: 7, Version: 3, Name: "夜风", Level: 11, Gold: 650, Items: []Asset{{ItemID: 1001, Count: 2}, {ItemID: 2001, Count: 5}}}
}
func TestLoadSaveAdvancesVersion(t *testing.T) {
repo := NewMemoryRepository()
data := seedPlayer(repo)
svc := NewService(repo)
loaded, err := svc.Load(context.Background(), 7)
if err != nil || loaded.Version != 3 {
t.Fatalf("load: %v version=%d", err, loaded.Version)
}
saved, err := svc.Save(context.Background(), data, "save-1")
if err != nil || saved.Version != 4 {
t.Fatalf("save: %v version=%d", err, saved.Version)
}
if again, err := svc.Load(context.Background(), 7); err != nil || again.Gold != 650 || again.Version != 4 {
t.Fatalf("reload: %v %+v", err, again)
}
}
func TestSaveRejectsStaleVersion(t *testing.T) {
repo := NewMemoryRepository()
data := seedPlayer(repo)
svc := NewService(repo)
first := data
first.Gold = 600
if _, err := svc.Save(context.Background(), first, "save-1"); err != nil {
t.Fatal(err)
}
second := data
second.Gold = 700
if _, err := svc.Save(context.Background(), second, "save-2"); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale save: %v", err)
}
if row, _ := repo.Load(context.Background(), 7); row.Gold != 600 || row.Version != 4 {
t.Fatalf("stale save mutated row: %+v", row)
}
}
func TestSaveIsIdempotentBySaveID(t *testing.T) {
repo := NewMemoryRepository()
data := seedPlayer(repo)
svc := NewService(repo)
first, err := svc.Save(context.Background(), data, "save-1")
if err != nil {
t.Fatal(err)
}
retry, err := svc.Save(context.Background(), data, "save-1")
if err != nil || retry.Version != first.Version {
t.Fatalf("retry: %v version=%d want %d", err, retry.Version, first.Version)
}
}
func TestSaveRollsBackOnValidationError(t *testing.T) {
repo := NewMemoryRepository()
data := seedPlayer(repo)
svc := NewService(repo)
data.Gold = 999
data.Items = append(data.Items, Asset{ItemID: 3001, Count: 0})
if _, err := svc.Save(context.Background(), data, "save-bad"); !errors.Is(err, ErrInvalidData) {
t.Fatalf("invalid save: %v", err)
}
row, err := svc.Load(context.Background(), 7)
if err != nil || row.Gold != 500 || row.Version != 3 || len(row.Items) != 1 {
t.Fatalf("row changed after rollback: %v %+v", err, row)
}
}
func TestLoadMissingPlayer(t *testing.T) {
if _, err := NewService(NewMemoryRepository()).Load(context.Background(), 404); !errors.Is(err, ErrRecordNotFound) {
t.Fatalf("missing player: %v", err)
}
}
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch06/save && go vet ./ch06/save/ && go test ./ch06/save/ -count=1
ok gspractice/ch06/save 0.115s
小结
加载策略决定内存与首登延迟,保存策略决定崩溃时丢多少数据。核心数据强一致、外围数据可降级;保存用版本号防覆盖、用 saveID 防重复,失败必须显式处理而不是只写日志。下线保存是底线,不是唯一手段。
常见错误与边界
- 登录时按账号全表扫描角色和背包,登录峰值直接把数据库打满。
- 加载部分失败仍进入场景,下线时用零值覆盖了未加载的模块。
- 保存失败只打日志,玩家继续操作,最终整段数据丢失。
- 字段级更新不带版本条件,两个进程互相覆盖。
- 下线保存同步阻塞断开流程,数据库抖动时大量连接堆积。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。