一次重启,世界倒退了两小时
《群岛纪元》的世界服分片每周要做一次滚动重启。某个周二凌晨,运维按流程重启了 world-07,进程在 40 秒内恢复,监控全绿。到了早上,客服收到一批工单:玩家的建筑完成时间往后跳了两小时,昨晚刚点的科技又变回“研究中”。
复盘发现三个事实同时成立:
- 世界状态在内存里每 200ms 推进一次,重启后内存全丢;
- 数据库每 5 分钟异步刷一次玩家建筑和科技,最后一次刷盘在重启前 2 分钟;
- 快照每 30 分钟做一次,最近一份是重启前 22 分钟。
团队的第一反应是“那就把快照频率提高到 1 分钟”。但这会把快照写放大 30 倍,而且没有回答一个更基本的问题:恢复时以谁为准?
如果快照的 tick 比数据库最后提交的事务还旧,用快照覆盖数据库就是主动丢数据;如果数据库比快照新,但快照里还有数据库尚未落盘的联盟状态,只读数据库又会丢掉另一部分。问题的根因不是频率,而是三种状态没有被区分。
三种状态,三种权威
游戏服务端里常说的“状态”,至少包含三层,它们的生命周期和权威性完全不同。
内存态:当前 tick 的唯一权威
内存态是进程里正在被读写的世界副本。它在当前 tick 内是唯一权威:玩家的移动、建筑的倒计时、联盟的集结,都以内存里的值为准。
内存态的特点是变化快、寿命短、不跨进程。进程崩溃,它就没了。这不是缺陷,而是实时性的代价:一局 5v5 每 33ms 变一次的状态不可能每次都写数据库。
持久态:提交之后的最终权威
持久态是已经提交到数据库、事件日志或对象存储的状态。它不一定是最新的,但它是已经承诺给玩家的那部分事实。
关键在“提交”二字。异步刷盘、批量写、消息队列里的未确认消息,都不算持久态。玩家看到“建筑升级成功”的那一刻,服务端必须已经有一份可恢复的记录,否则这个成功就是假的。
快照:某个 tick 的派生副本
快照是内存态在某个 tick 的完整拷贝,用来加速崩溃恢复。它有明确的 tick、明确的 schema 版本,但没有独立权威:它只代表“那一刻的内存长什么样”,不能覆盖之后已经提交的持久态。
三者的权威关系可以写成一条规则:
tick 内:内存态权威
提交后:持久态权威
恢复时:快照提供起点,日志/事务提供增量,持久态负责对账
换成表格:
| 状态 | 权威范围 | 存储位置 | 寿命 | 丢失后果 | 恢复来源 |
|---|---|---|---|---|---|
| 内存态 | 当前 tick | 进程堆 | 进程生命周期 | 当前 tick 丢失 | 快照 + 日志重放 |
| 持久态 | 已提交事务 | 数据库/事件日志 | 长期 | 已承诺事实丢失 | 备份 + PITR |
| 快照 | 某个 tick 的副本 | 对象存储/本地盘 | 数小时到数天 | 恢复变慢 | 上一份快照 + 更长的日志 |
这张表最重要的一列是“权威范围”。很多故障不是技术问题,而是两个组件都以为自己权威:快照恢复覆盖了数据库的新记录,或者数据库回滚覆盖了快照里的联盟状态。
快照不是备份
“我们每小时做快照,所以有备份”是游戏服务端最常见的一句话,也是错的。
快照和备份解决的是两个不同的问题:
| 维度 | 快照 | 备份 |
|---|---|---|
| 目的 | 缩短崩溃恢复时间 | 应对数据丢失、误删、逻辑损坏 |
| 恢复目标 | 回到某个 tick,再重放日志 | 回到某个历史时间点 |
| 保留策略 | 最近几份即可 | 按天/周/月保留,可长期 |
| 故障域 | 常与主库同区域、同账号 | 必须独立区域、独立账号 |
| 一致性 | 反映内存当时的样子,可能包含错误 | 需要经过校验与演练 |
| 演练 | 重启时自然验证 | 必须定期做恢复演练 |
如果快照和主库在同一个对象存储桶、同一个云账号、同一个区域,一次误删或一次账号泄漏会把两者一起带走。更隐蔽的情况是逻辑损坏:内存里的金币因为 bug 已经算错,快照只是把这个错误状态忠实地保存下来。备份需要的是“能回到错误发生之前”,快照给不了。
所以快照可以很频繁,备份必须独立。两者不能互相替代。
快照频率与成本的取舍
快照频率由三个量共同决定:可接受的最坏丢失窗口、单次快照的成本、恢复时重放日志的长度。
下面的数字是示例值,假设单分片状态 200MB、顺序写吞吐 120MB/s、每份快照保留 3 个版本:
| 间隔 | 每日次数 | 单次写入耗时(示例) | 最坏丢失窗口 | 日增存储(示例) | 适用场景 |
|---|---|---|---|---|---|
| 1 分钟 | 1440 | 约 1.7s | 1 分钟 | 约 281GB | 竞技房间,状态小且可丢弃 |
| 5 分钟 | 288 | 约 1.7s | 5 分钟 | 约 56GB | SLG 世界分片,配合事件日志 |
| 30 分钟 | 48 | 约 1.7s | 30 分钟 | 约 9.4GB | 恢复时间不敏感的后台服务 |
| 2 小时 | 12 | 约 1.7s | 2 小时 | 约 2.3GB | 可接受长重放的低频世界 |
这张表只算存储,没算真正的成本大头:快照期间的停顿。如果快照需要暂停世界 1.7 秒,SLG 的全球王战会直接炸;如果使用写时复制或 fork,停顿可以降到毫秒级,但内存占用会上升。
所以选频率时先问四件事:崩溃后最多允许丢多少秒操作、这段时间的日志能否可靠重放、快照会不会暂停世界、恢复演练有没有在真实数据量上跑过。
《群岛纪元》最后选的是“5 分钟快照 + 全量事件日志 + 每日独立备份”。5 分钟不是拍脑袋,而是从“最坏丢 5 分钟建筑进度、重放 5 分钟日志约 12 秒”这两个可接受值反推出来的。
快照格式:版本号、tick 与校验和
一个能用于生产的快照,至少要在字节层面自描述四件事:
- 这是不是一份快照(magic);
- 它属于哪个 schema 版本(version);
- 它对应哪个模拟 tick(tick);
- 内容有没有损坏(checksum)。
很多事故来自“能读出来”被当成“读对了”。没有版本号,新代码读旧快照会把字段错位解释;没有校验和,截断的文件会被当成合法状态加载;没有 tick,恢复后无法确定从哪条日志开始重放。
下面的实现把头部、长度和 CRC32 一起写进文件:
// file: internal/state/snapshot.go
// tier: L1
// verify: go test ./internal/state/
package state
import (
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
)
var (
ErrEmptySnapshot = errors.New("state: empty snapshot")
ErrFormat = errors.New("state: malformed snapshot")
ErrVersionMismatch = errors.New("state: snapshot version mismatch")
ErrChecksum = errors.New("state: snapshot checksum mismatch")
)
// magic is "GSS1": game state snapshot, format 1.
const magic uint32 = 0x47535331
// Snapshot is a point-in-time copy of a world shard. Version is the data
// schema version, Tick is the simulation tick the copy was taken at, and
// Checksum covers version, tick and payload.
type Snapshot struct {
Version int
Tick uint64
Payload []byte
Checksum uint32
}
// Capture copies the payload so later mutation of the caller's buffer cannot
// silently change a snapshot that was already sealed.
func Capture(version int, tick uint64, payload []byte) *Snapshot {
s := &Snapshot{
Version: version,
Tick: tick,
Payload: append([]byte(nil), payload...),
}
s.Checksum = checksum(s)
return s
}
func checksum(s *Snapshot) uint32 {
h := crc32.NewIEEE()
var b4 [4]byte
binary.BigEndian.PutUint32(b4[:], uint32(s.Version))
h.Write(b4[:])
var b8 [8]byte
binary.BigEndian.PutUint64(b8[:], s.Tick)
h.Write(b8[:])
h.Write(s.Payload)
return h.Sum32()
}
// Encode serializes the snapshot into a self-describing byte slice:
//
// magic(4) | version(4) | tick(8) | payload len(4) | payload | crc32(4)
//
// The length prefix is what lets Decode reject a truncated file instead of
// slicing past the buffer.
func (s *Snapshot) Encode() []byte {
out := make([]byte, 0, 20+len(s.Payload)+4)
var b [8]byte
binary.BigEndian.PutUint32(b[:4], magic)
out = append(out, b[:4]...)
binary.BigEndian.PutUint32(b[:4], uint32(s.Version))
out = append(out, b[:4]...)
binary.BigEndian.PutUint64(b[:], s.Tick)
out = append(out, b[:]...)
binary.BigEndian.PutUint32(b[:4], uint32(len(s.Payload)))
out = append(out, b[:4]...)
out = append(out, s.Payload...)
binary.BigEndian.PutUint32(b[:4], checksum(s))
return append(out, b[:4]...)
}
// Decode validates framing and checksum before returning a snapshot. A
// corrupted file must fail here, not after the world has already been
// overwritten in memory.
func Decode(data []byte) (*Snapshot, error) {
if len(data) == 0 {
return nil, ErrEmptySnapshot
}
if len(data) < 20+4 {
return nil, ErrFormat
}
if binary.BigEndian.Uint32(data[0:4]) != magic {
return nil, ErrFormat
}
version := int(int32(binary.BigEndian.Uint32(data[4:8])))
tick := binary.BigEndian.Uint64(data[8:16])
n := int(binary.BigEndian.Uint32(data[16:20]))
if n < 0 || len(data) != 20+n+4 {
return nil, ErrFormat
}
s := &Snapshot{
Version: version,
Tick: tick,
Payload: append([]byte(nil), data[20:20+n]...),
}
want := binary.BigEndian.Uint32(data[20+n:])
if got := checksum(s); got != want {
return nil, fmt.Errorf("%w: stored=%08x computed=%08x", ErrChecksum, want, got)
}
s.Checksum = want
return s, nil
}
// Restore is the gate between a decoded snapshot and live memory. It refuses
// nil, checksum-damaged and version-incompatible snapshots. Only an exact
// schema match is accepted: reading a v3 payload with v4 code is a guess, and
// guessing about world state is how silent corruption ships.
func Restore(s *Snapshot, currentVersion int) error {
if s == nil {
return ErrEmptySnapshot
}
if s.Checksum != checksum(s) {
return fmt.Errorf("%w: stored=%08x computed=%08x", ErrChecksum, s.Checksum, checksum(s))
}
if s.Version != currentVersion {
return fmt.Errorf("%w: snapshot=%d current=%d", ErrVersionMismatch, s.Version, currentVersion)
}
return nil
}
三个设计决定值得说明:Capture 复制 payload,避免调用方复用缓冲区改写已封存的快照;Decode 先验证长度再切片,截断文件返回格式错误而不是越界 panic;Restore 只接受精确版本匹配,跨版本“尽量兼容”只是把字段错位解释包装成成功。CRC32 只发现偶然损坏,不防篡改;跨信任边界应换成 SHA-256 或 MAC。
四类测试
快照代码最容易“正常路径通过、故障路径全错”。测试至少覆盖四类:正常恢复、版本不兼容、校验损坏、空快照。
// file: internal/state/snapshot_test.go
// tier: L1
// verify: go test ./internal/state/
package state
import (
"bytes"
"errors"
"testing"
)
func TestCaptureEncodeDecodeRestore(t *testing.T) {
payload := []byte(`{"island":"harbor","gold":1200}`)
snap := Capture(7, 88231, payload)
encoded := snap.Encode()
got, err := Decode(encoded)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if err := Restore(got, 7); err != nil {
t.Fatalf("Restore: %v", err)
}
if got.Version != 7 || got.Tick != 88231 {
t.Fatalf("header lost: version=%d tick=%d", got.Version, got.Tick)
}
if !bytes.Equal(got.Payload, payload) {
t.Fatalf("payload changed: %q", got.Payload)
}
}
func TestRestoreRejectsVersionMismatch(t *testing.T) {
snap := Capture(3, 100, []byte("v3-world"))
err := Restore(snap, 4)
if !errors.Is(err, ErrVersionMismatch) {
t.Fatalf("got %v, want ErrVersionMismatch", err)
}
}
func TestDecodeRejectsCorruptedChecksum(t *testing.T) {
snap := Capture(7, 1, []byte("world"))
data := snap.Encode()
data[len(data)-1] ^= 0xff // flip a payload/checksum-adjacent byte
if _, err := Decode(data); !errors.Is(err, ErrChecksum) {
t.Fatalf("got %v, want ErrChecksum", err)
}
}
func TestRestoreRejectsTamperedStruct(t *testing.T) {
snap := Capture(7, 1, []byte("world"))
snap.Payload[0] = 'W' // checksum was computed before this mutation
if err := Restore(snap, 7); !errors.Is(err, ErrChecksum) {
t.Fatalf("got %v, want ErrChecksum", err)
}
}
func TestEmptySnapshot(t *testing.T) {
if _, err := Decode(nil); !errors.Is(err, ErrEmptySnapshot) {
t.Fatalf("Decode(nil): got %v", err)
}
if err := Restore(nil, 1); !errors.Is(err, ErrEmptySnapshot) {
t.Fatalf("Restore(nil): got %v", err)
}
if _, err := Decode([]byte{0x00}); !errors.Is(err, ErrFormat) {
t.Fatalf("Decode(short): got %v", err)
}
}
第四和第五个测试是额外的:TestRestoreRejectsTamperedStruct 验证“结构体在 Capture 之后被改过”,TestEmptySnapshot 同时覆盖 nil、零字节和过短输入。故障测试必须走公开入口,而不是直接构造一个非法结构体,否则测的是测试代码,不是生产路径。
本机验证
在 gsarch-lab 中实际执行:
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/state
$ go vet ./internal/state
$ go test ./internal/state/ -v
=== RUN TestCaptureEncodeDecodeRestore
--- PASS: TestCaptureEncodeDecodeRestore (0.00s)
=== RUN TestRestoreRejectsVersionMismatch
--- PASS: TestRestoreRejectsVersionMismatch (0.00s)
=== RUN TestDecodeRejectsCorruptedChecksum
--- PASS: TestDecodeRejectsCorruptedChecksum (0.00s)
=== RUN TestRestoreRejectsTamperedStruct
--- PASS: TestRestoreRejectsTamperedStruct (0.00s)
=== RUN TestEmptySnapshot
--- PASS: TestEmptySnapshot (0.00s)
PASS
ok gsarchlab/internal/state 0.236s
gofmt -l 与 go vet 均无输出,5 个测试全部通过。这是 L1 口径:不依赖数据库、对象存储或网络,单包可复现。
恢复流程:快照只是起点
恢复一个世界分片,正确的顺序不是“加载快照然后开服”,而是:
1. 选择最新一份通过校验的快照
2. 校验 magic / version / checksum
3. 加载到内存,得到 tick = N
4. 从事件日志重放 tick N+1 到当前已提交位置
5. 与持久态做对账,处理快照之后已提交但日志缺失的记录
6. 确认世界时间、玩家资产、联盟状态一致
7. 才允许接入流量
第 4 步和第 5 步不能省。快照的 tick 是 N,但数据库可能已经提交到 N+300;只加载快照会把玩家最近 5 分钟的建筑进度抹掉。第 5 步的对账规则必须显式:以持久态的已提交事务为准,快照和日志只负责重建那些尚未落库的运行时状态。
恢复失败时的兜底有三档:回退上一份快照,接受更长重放;把分片标为只读,让玩家看到维护而不是错误世界;日志缺口补不齐时从独立备份恢复,并补偿这段时间的玩家损失。
最糟的处理是“先开服再说”。玩家一旦在错误状态上继续操作,后续对账会变成不可能。
常见错误与边界
第一,把快照当备份。快照和主库同区域、同账号、同生命周期,一次误删就一起消失。
第二,快照不带 schema 版本,或用提高频率代替恢复目标。前者让字段错位被解释成合法值,后者只是缩短窗口,解决不了日志缺口。
第三,只在写入时校验,不在加载时校验,还把 CRC32 当安全校验。位翻转和恶意篡改都需要在加载时拦截,后者要用 SHA-256 或 MAC。
第四,恢复后直接开服。快照之后的事件没有重放,玩家看到的是“时间倒流”。
第五,忽略快照停顿。一次 1.7 秒的全局暂停,对竞技房间就是一次事故;快照方案必须和世界 tick 一起设计。
小结
内存态、持久态和快照不是同一层东西。内存态在当前 tick 内权威,持久态在提交后权威,快照只是某个 tick 的派生副本,负责缩短恢复时间,不负责兜底数据丢失。
这一节用 internal/state 把快照的字节格式、版本检查和校验和落成了可执行代码,并用五类测试覆盖正常与故障路径。下一节讨论这些状态按什么节奏推进:固定步长 tick、补帧上限、漂移校正,以及世界时钟为什么不能直接等于墙上时间。
阅读导航:上一节:3.3 分区、分服与全球同服 · 下一节:4.2 tick、帧预算与世界时钟 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。