一、热状态与冷数据的分界
游戏服运行时的状态不能只有一种存法。在线玩家的坐标、血量、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 结构。
五、崩溃恢复流程
恢复顺序必须固定,不能依赖“大概是对的”:
- 找到最近一次完整快照。完整性由“写临时文件、同步、原子重命名”保证。
- 加载快照,得到基础版本号和状态副本。
- 从日志文件按顺序读取版本号大于基础版本的条目。
- 检查版本号连续;出现缺口立即失败,而不是跳过缺口继续重放。
- 应用条目直到最新版本,或直到调用方指定的目标版本。
- 以最后应用的版本号继续对外提供写入,新写入从该版本加一开始。
恢复过程中最容易被忽略的是“目标版本”能力。排障时经常需要把状态恢复到某个已知良好版本,再对比之后的变更;如果恢复接口只能回到最新,就失去了定位问题的抓手。本文的 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(),每个用例都有独立的快照与日志目录,不会互相污染。
小结
热状态需要内存性能,也需要崩溃后恢复的能力。全量快照给出底图,增量日志补齐变更,恢复时按版本连续重放;快照频率、状态大小与恢复时间互相牵制,必须按目标恢复时间反推。写时复制和分代快照降低快照对主循环的影响,而日志截断只能在快照持久化之后进行。把版本号、连续性检查和原子重命名做扎实,恢复逻辑才有可验证的边界。
常见错误与边界
- 只做定时全量快照,状态一大恢复时间不可控。
- 先删日志再持久化快照,崩溃后回退到旧版本。
- 快照在持有写锁时完成文件写入,主循环被长时间阻塞。
- 恢复时跳过版本缺口继续重放,得到一份看似可用但实际错误的状态。
- 多个快照并发落盘,旧副本覆盖新副本后又按旧版本截断日志。
- 目标版本早于最近快照仍尝试恢复,却没有明确报错。
- 日志写入成功但内存修改失败时没有回滚版本号,后续版本出现空洞。
- 把快照文件直接覆盖写,进程在写一半时崩溃,留下无法解析的文件。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。