《游戏服务端编程实践》6.3.2 数据加载与保存

围绕角色数据的加载与保存展开:比较一次加载和懒加载、四种保存时机各自的丢数据窗口、全量与增量及字段级更新的取舍,并用带版本校验和 saveID 幂等的 Repository 实现演示版本冲突、校验失败回滚、重复保存与记录不存在等场景,同时说明保存失败向上传递的处理方式。

一、加载策略先定,保存策略才有意义

角色数据不是一个对象,而是角色表、背包、任务、邮件、冷却等多个存储的聚合。登录时是把它们一次读全,还是用到再读,决定了首登延迟、内存占用和后续保存的复杂度。

策略首登延迟常驻内存一致性适用
一次加载全部高,随数据量增长高简单,进场景时已完整角色数据小、玩法频繁交叉访问
按模块懒加载低低需要处理加载失败与并发大背包、邮件、长尾系统
一次加载核心 + 懒加载外围中中核心强一致,外围最终一致大多数 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 防重复,失败必须显式处理而不是只写日志。下线保存是底线,不是唯一手段。

常见错误与边界

  • 登录时按账号全表扫描角色和背包,登录峰值直接把数据库打满。
  • 加载部分失败仍进入场景,下线时用零值覆盖了未加载的模块。
  • 保存失败只打日志,玩家继续操作,最终整段数据丢失。
  • 字段级更新不带版本条件,两个进程互相覆盖。
  • 下线保存同步阻塞断开流程,数据库抖动时大量连接堆积。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist