《游戏服务端编程实践》12.1 实时状态与快照策略

围绕游戏热状态的分层、全量快照与增量日志的组合、快照频率与恢复时间的权衡、写时复制与分代快照展开,给出崩溃恢复与日志截断的可执行流程,并用 Go 实现可按版本恢复的快照存储;文章还分析只做定时全量、日志先删后持久化、快照阻塞主循环三类常见故障。

一、热状态与冷数据的分界

游戏服运行时的状态不能只有一种存法。在线玩家的坐标、血量、buff、技能冷却、房间成员关系,每秒会被读写多次,放在内存里才够快;账号资料、背包持久记录、充值订单、审计日志,写入频率低但要求长期可靠,应该落到数据库。

“热状态放内存、冷数据落库”只说了前半句。内存是易失的,进程崩溃、机器断电、发布重启都会让热状态消失。要让玩家重新登录时看到同一份进度,就必须在内存之外保留一份可恢复的副本。全量快照负责给出某个时刻的完整底图,增量日志负责记录底图之后发生的每一次变化,两者合起来才是可用的持久化方案。

分层之后还要明确一致性边界。内存中的值可以比数据库新,因为还没到落库时机;但一旦向客户端确认了某次修改,就必须保证这次修改已经进入日志,否则崩溃后无法重放。这个确认点通常称为持久化点,它决定了故障时最多丢多少操作。

二、全量快照与增量日志

维度全量快照增量日志
记录内容某一版本的全部状态版本之间的单次变更
写入成本高,随状态大小增长低,每次只追加一条
恢复成本只需加载一次需要从快照版本逐条重放
存储占用周期性产生大文件持续增长,依赖截断
主要风险频率过高拖慢主循环日志损坏或提前删除

只做全量快照,恢复时间等于“最近一次快照之后积累的全部变更”,状态越大、快照间隔越长,恢复越慢;只做增量日志,日志会无限增长,启动时要从头重放,同样不可接受。工程上的组合是:低频全量快照打底,高频增量日志补齐。

日志截断有一个硬条件:快照必须已经持久化,并且只允许删除版本号小于等于快照版本的条目。假如快照版本是 120,日志里有 121 到 150 的条目,那么 1 到 120 可以删,121 之后必须保留。删除顺序一旦反过来,崩溃后就既没有新快照、也没有对应日志,状态会直接回退。

三、频率、大小与恢复时间的三角权衡

参数示例值影响
热状态大小200 MB决定单次快照耗时与文件大小
快照间隔5 分钟越长,快照开销越低,重放越长
写入速率5,000 次/秒决定日志增长速度
单次快照耗时2 秒必须与主循环错峰
恢复重放耗时30 秒直接影响故障恢复时长

表中的数字只是示例值,用来演示估算方法,不代表任何真实项目。恢复时间可以粗略拆成“加载快照 + 重放日志”两部分:加载快照约等于状态大小除以磁盘吞吐,重放日志约等于日志条数除以单条应用成本。把快照间隔从 5 分钟拉长到 30 分钟,快照次数降到六分之一,但最坏情况要多重放 25 分钟的写入。

选择间隔时不能只看平均值,要看故障恢复的目标时间。如果要求 60 秒内完成恢复,而示例中重放 5 分钟日志已经需要 30 秒,那么间隔再长就会突破目标。反过来,如果快照本身会阻塞主循环 2 秒,间隔太短又会影响在线体验。

四、写时复制与分代快照

最直接的快照做法是暂停所有写操作,复制整份状态,再恢复写入。状态小的时候可以接受,状态一大就会造成明显的卡顿。写时复制(copy-on-write)把“复制”拆开:快照开始时只记录当前版本,后续写操作写入新的页或新的对象,旧版本保持不变,后台线程慢慢把旧版本落盘。

分代快照是另一个常用手段。目录中保留 snapshot.v1、snapshot.v2 两代文件,用一个指针文件或清单记录当前有效代。新快照写入新代,完成后再切换指针;旧代在确认新代可用之前不删除。这样即使新快照写坏,也能退回上一代继续启动。

本文的实现没有引入页级写时复制,而是在短读锁内复制一份 map,再把文件写入放到锁外。这对中小规模状态足够,因为复制只占住读锁,不阻塞其他读;但状态达到数百 MB 时,复制本身仍会带来停顿,需要改成分片快照或真正的 COW 结构。

五、崩溃恢复流程

恢复顺序必须固定,不能依赖“大概是对的”:

  1. 找到最近一次完整快照。完整性由“写临时文件、同步、原子重命名”保证。
  2. 加载快照,得到基础版本号和状态副本。
  3. 从日志文件按顺序读取版本号大于基础版本的条目。
  4. 检查版本号连续;出现缺口立即失败,而不是跳过缺口继续重放。
  5. 应用条目直到最新版本,或直到调用方指定的目标版本。
  6. 以最后应用的版本号继续对外提供写入,新写入从该版本加一开始。

恢复过程中最容易被忽略的是“目标版本”能力。排障时经常需要把状态恢复到某个已知良好版本,再对比之后的变更;如果恢复接口只能回到最新,就失去了定位问题的抓手。本文的 Load 接受 upto 参数,upto 为 0 表示最新,否则在重放到指定版本后停止。

日志截断发生在恢复之外,由快照流程触发。只有快照文件完成 fsync 并成功重命名之后,才能截断版本号小于等于快照版本的日志。即使截断失败,恢复时也会跳过小于等于基础版本的条目,因此重复重放是幂等的。

六、失败场景:先删日志后持久化

某项目为了控制磁盘占用,在生成快照的内存副本后,先把日志中“已经包含”的条目删掉,再异步把快照写盘。平时看起来没有问题,直到一次发布重启恰好发生在删除日志之后、快照落盘之前。

重启后,目录里只有上一代旧快照,新日志已经被删。旧快照的版本是 100,被删掉的日志覆盖 101 到 180,恢复只能回到版本 100。玩家这段时间获得的道具、消耗的资源、推进的任务全部回退,客诉和补偿成本远高于节省的磁盘。

修复方式不是“把删除做得更快”,而是调整顺序:先把新快照写到临时文件,fsync 后原子重命名,确认快照可读,再截断日志。崩溃发生在重命名之前,旧快照和完整日志都还在;发生在重命名之后、截断之前,重放会重复应用已经包含在快照中的条目,而恢复逻辑会按版本号跳过它们。

七、Go 实现:snapshot.go

下面的 Store 把“追加日志、修改内存、复制快照、截断日志”串成一条路径。Put 和 Delete 在写锁内分配版本并追加日志,成功后才修改内存;Snapshot 在短读锁内复制状态,文件写入不持有状态锁;Load 负责快照加日志重放,并拒绝版本缺口和目标版本早于快照的请求。日志的追加、读取与截断在 wal.go 中实现。

// file: ch12/snapshot/snapshot.go
// tier: L1
// verify: go test ./ch12/snapshot/

// Package snapshot 演示游戏热状态的“全量快照 + 增量日志”恢复模型。
package snapshot

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sync"
)

var (
	// ErrBeforeSnapshot 表示请求的版本早于最近一次全量快照。
	ErrBeforeSnapshot = errors.New("snapshot: version predates the base snapshot")
	// ErrVersionGap 表示日志版本不连续,说明日志被误删或损坏。
	ErrVersionGap = errors.New("snapshot: wal version gap")
	// ErrEmptyKey 表示键为空。
	ErrEmptyKey = errors.New("snapshot: empty key")
)

// Image 是一份状态副本,既可作为快照落盘,也可作为恢复结果。
type Image struct {
	Version uint64            `json:"version"`
	Data    map[string]string `json:"data"`
}

// Store 是内存热状态。写操作先追加日志,再修改内存。
type Store struct {
	mu      sync.RWMutex
	data    map[string]string
	version uint64
	dir     string
	wal     *WAL

	snapMu      sync.Mutex
	snapVersion uint64
}

// Open 加载最近快照并重放其后的日志。
func Open(dir string) (*Store, error) {
	img, err := Load(dir, 0)
	if err != nil {
		return nil, err
	}
	wal, err := OpenWAL(dir)
	if err != nil {
		return nil, err
	}
	return &Store{
		data:        img.Data,
		version:     img.Version,
		dir:         dir,
		wal:         wal,
		snapVersion: img.Version,
	}, nil
}

// Load 从目录恢复状态。upto 为 0 表示恢复到最新版本,否则恢复到指定版本。
func Load(dir string, upto uint64) (*Image, error) {
	base := &Image{Version: 0, Data: map[string]string{}}
	raw, err := os.ReadFile(filepath.Join(dir, "snapshot.json"))
	switch {
	case err == nil:
		if err := json.Unmarshal(raw, base); err != nil {
			return nil, err
		}
		if base.Data == nil {
			base.Data = map[string]string{}
		}
	case !errors.Is(err, os.ErrNotExist):
		return nil, err
	}
	if upto != 0 && upto < base.Version {
		return nil, fmt.Errorf("%w: want %d, base %d", ErrBeforeSnapshot, upto, base.Version)
	}
	entries, err := readEntries(filepath.Join(dir, "wal.log"))
	if err != nil {
		return nil, err
	}
	last := base.Version
	for _, e := range entries {
		if e.Version <= base.Version {
			continue
		}
		if e.Version != last+1 {
			return nil, fmt.Errorf("%w: got %d after %d", ErrVersionGap, e.Version, last)
		}
		if upto != 0 && e.Version > upto {
			break
		}
		apply(base.Data, e)
		last = e.Version
	}
	if upto != 0 && last < upto {
		return nil, fmt.Errorf("%w: want %d, last %d", ErrVersionGap, upto, last)
	}
	base.Version = last
	return base, nil
}

// Put 写入一个键,返回分配到的版本号。
func (s *Store) Put(key, value string) (uint64, error) {
	if key == "" {
		return 0, ErrEmptyKey
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	next := s.version + 1
	if err := s.wal.Append(Entry{Version: next, Op: OpPut, Key: key, Value: value}); err != nil {
		return 0, err
	}
	s.data[key] = value
	s.version = next
	return next, nil
}

// Delete 删除一个键,返回分配到的版本号。
func (s *Store) Delete(key string) (uint64, error) {
	if key == "" {
		return 0, ErrEmptyKey
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	next := s.version + 1
	if err := s.wal.Append(Entry{Version: next, Op: OpDelete, Key: key}); err != nil {
		return 0, err
	}
	delete(s.data, key)
	s.version = next
	return next, nil
}

// Get 返回内存中的当前值。
func (s *Store) Get(key string) (string, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	v, ok := s.data[key]
	return v, ok
}

// Version 返回当前版本号。
func (s *Store) Version() uint64 {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.version
}

// Snapshot 复制当前状态并原子写入 snapshot.json,然后截断已被覆盖的日志。
// 复制在短读锁内完成,文件写入不阻塞 Put/Delete。
func (s *Store) Snapshot() (*Image, error) {
	s.mu.RLock()
	img := &Image{Version: s.version, Data: make(map[string]string, len(s.data))}
	for k, v := range s.data {
		img.Data[k] = v
	}
	s.mu.RUnlock()

	s.snapMu.Lock()
	defer s.snapMu.Unlock()
	if img.Version < s.snapVersion {
		return img, nil // 更新的快照已经落盘,放弃这次重复写。
	}
	if err := writeImage(filepath.Join(s.dir, "snapshot.json"), img); err != nil {
		return nil, err
	}
	s.snapVersion = img.Version
	if err := s.wal.Truncate(img.Version); err != nil {
		return nil, err
	}
	return img, nil
}

// Close 关闭日志句柄;内存状态仍然可读,但不再接受持久写入。
func (s *Store) Close() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.wal.Close()
}

func apply(data map[string]string, e Entry) {
	switch e.Op {
	case OpPut:
		data[e.Key] = e.Value
	case OpDelete:
		delete(data, e.Key)
	}
}

func writeImage(path string, img *Image) error {
	raw, err := json.Marshal(img)
	if err != nil {
		return err
	}
	tmp := path + ".tmp"
	f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
	if err != nil {
		return err
	}
	if _, err := f.Write(raw); err != nil {
		_ = f.Close()
		return err
	}
	if err := f.Sync(); err != nil {
		_ = f.Close()
		return err
	}
	if err := f.Close(); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}

Snapshot 里有一个容易写错的细节:多个快照可能并发进入,较旧的快照如果后拿到写锁,不能覆盖较新的文件。代码用 snapVersion 记录已经落盘的版本,发现当前副本更旧就直接返回,避免“旧快照覆盖新快照、随后又按旧版本截断日志”的组合故障。

八、测试覆盖

测试断言
TestRecoverToVersion恢复到最新版本与指定版本,早于快照的版本报错
TestTruncateKeepsUnflushedEntries快照后日志被清空,后续写入仍可恢复
TestSnapshotDuringConcurrentWrites并发写期间每个快照的键数与版本一致
TestLoadRejectsVersionGap日志版本缺口不会被静默跳过

并发测试利用了一个简单不变量:所有写入都是唯一键的 Put,没有删除,因此版本号为 N 时状态里应当恰好有 N 个键。快照在任意时刻复制,都必须满足这个关系;如果复制过程中混入了未完成写入,键数与版本就会对不上。

九、运行验证

cd .local/checks/gs-practice-lab
gofmt -l ch12/snapshot
go vet ./ch12/snapshot/
go test ./ch12/snapshot/ -count=1
ok  	gspractice/ch12/snapshot	3.284s

gofmt -l 没有输出表示格式一致;go vet 没有输出表示静态检查通过。测试使用 t.TempDir(),每个用例都有独立的快照与日志目录,不会互相污染。

小结

热状态需要内存性能,也需要崩溃后恢复的能力。全量快照给出底图,增量日志补齐变更,恢复时按版本连续重放;快照频率、状态大小与恢复时间互相牵制,必须按目标恢复时间反推。写时复制和分代快照降低快照对主循环的影响,而日志截断只能在快照持久化之后进行。把版本号、连续性检查和原子重命名做扎实,恢复逻辑才有可验证的边界。

常见错误与边界

  • 只做定时全量快照,状态一大恢复时间不可控。
  • 先删日志再持久化快照,崩溃后回退到旧版本。
  • 快照在持有写锁时完成文件写入,主循环被长时间阻塞。
  • 恢复时跳过版本缺口继续重放,得到一份看似可用但实际错误的状态。
  • 多个快照并发落盘,旧副本覆盖新副本后又按旧版本截断日志。
  • 目标版本早于最近快照仍尝试恢复,却没有明确报错。
  • 日志写入成功但内存修改失败时没有回滚版本号,后续版本出现空洞。
  • 把快照文件直接覆盖写,进程在写一半时崩溃,留下无法解析的文件。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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