5.3 崩溃恢复与状态重建
前面的调度器能把实例起来、放到合适的机器上,但实例是普通进程——它会崩。崩溃时内存里的场景状态(谁在哪、血量多少、地上有什么)全部消失。每次崩溃都从数据库全量重读,玩家要等几十秒;什么都不做,玩家就丢进度。
正确做法是快照 + 事件重放:定期把整个场景状态固化成一份快照,同时把所有改变状态的事件追加进日志;崩溃后从最新可用快照加载,再把快照之后的事件重放一遍,状态就回来了。难点在三处:事件粒度(漏一个就永远对不上)、恢复点选择(快照写坏或太旧怎么办)、以及重建期间玩家怎么办。
状态、事件与快照
先定义「状态」和「事件」。状态是场景在某一刻的完整画像;事件是让状态从一个时刻走到下一个时刻的动作。重建的正确性只取决于一件事:事件是否覆盖了所有会改变状态的动作。下面这段是 L1,go test ./internal/scheduler/ -run TestCrashRebuild 会用到它:
// file: internal/scheduler/state.go
// tier: L1
// verify: go test ./internal/scheduler/
package scheduler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
)
// EntityID 沿用全卷共享类型:高 16 位是场景 / 实例号,低 48 位是实例内序号。
type EntityID uint64
// Vec2 是服务器侧逻辑坐标,与全卷共享类型一致。
type Vec2 struct{ X, Y float64 }
// EntityState 是场景内一个实体的最小可恢复状态。真实项目里还有 buff、
// 仇恨、技能 CD,但「能不能重建」只取决于有没有把所有会变的状态记进事件。
type EntityState struct {
ID EntityID `json:"id"`
Pos Vec2 `json:"pos"`
HP int32 `json:"hp"`
}
// EventKind 是事件类型。
type EventKind uint8
const (
EvSpawn EventKind = iota // 实体进入场景
EvMove // 移动
EvDamage // 扣血
EvDespawn // 离开场景
)
// Event 是一条可重放的事件:带全局递增的 Seq。事件的粒度是恢复的关键——
// 它必须记下所有会改变状态的动作,漏一个,重建出来的状态就永远对不上。
type Event struct {
Seq uint64 `json:"seq"`
Kind EventKind `json:"kind"`
Entity EntityID `json:"entity"`
Pos Vec2 `json:"pos"`
Delta int32 `json:"delta,omitempty"`
}
// SceneState 是一张地图实例的内存状态:一批实体 + 已应用的事件序号。
// Seq 是恢复的锚点——快照记到哪个 Seq,重放就从它的下一个开始。
type SceneState struct {
Instance InstanceID
Seq uint64
Entities map[EntityID]EntityState
}
func NewSceneState(inst InstanceID) *SceneState {
return &SceneState{Instance: inst, Entities: make(map[EntityID]EntityState)}
}
// sorted 按 ID 升序返回实体,保证任何派生结果(哈希、快照)都是确定的。
func (s *SceneState) sorted() []EntityState {
ents := make([]EntityState, 0, len(s.Entities))
for _, e := range s.Entities {
ents = append(ents, e)
}
sort.Slice(ents, func(i, j int) bool { return ents[i].ID < ents[j].ID })
return ents
}
// Hash 是状态的指纹,用来断言「重建出来的状态和崩溃前逐字节一致」。
func (s *SceneState) Hash() string {
b, _ := json.Marshal(struct {
Instance InstanceID
Seq uint64
Entities []EntityState
}{s.Instance, s.Seq, s.sorted()})
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:8])
}
// Apply 把一条事件应用到状态上。Seq 必须严格递增:不连续或重复一律拒绝,
// 这样重放时任何缺口都会立刻报错,而不是静默算出一个错的状态。
func (s *SceneState) Apply(e Event) error {
if e.Seq != s.Seq+1 {
return fmt.Errorf("event gap: state at seq %d, got %d", s.Seq, e.Seq)
}
switch e.Kind {
case EvSpawn:
s.Entities[e.Entity] = EntityState{ID: e.Entity, Pos: e.Pos, HP: 100}
case EvMove:
ent, ok := s.Entities[e.Entity]
if !ok {
return fmt.Errorf("move unknown entity %d", e.Entity)
}
ent.Pos = e.Pos
s.Entities[e.Entity] = ent
case EvDamage:
ent, ok := s.Entities[e.Entity]
if !ok {
return fmt.Errorf("damage unknown entity %d", e.Entity)
}
if ent.HP -= e.Delta; ent.HP < 0 {
ent.HP = 0
}
s.Entities[e.Entity] = ent
case EvDespawn:
delete(s.Entities, e.Entity)
}
s.Seq = e.Seq
return nil
}
Apply 里最关键的是一开始那行 Seq 检查:事件必须严格递增,不连续或重复直接报错。这条约束让「漏事件」在重放时立刻暴露,而不是静默算出一个看起来正常、其实错的状态。Hash 把状态压成一个短指纹,是后面断言「重建 == 崩溃前」的工具。
恢复点选择:从哪份快照开始重放
快照不是越多越好:太密浪费 IO,太疏则重放代价高。真正要处理的是「最新那份快照可能写坏了」——进程在写快照的中途被杀,文件是半截的。所以快照带校验和,恢复时从最新往前找第一份校验通过的,再把它之后的事件补上。下面这段是 L1,go test ./internal/scheduler/ -run TestRecoverPicksLatestValidSnapshot:
// file: internal/scheduler/snapshot.go
// tier: L1
// verify: go test ./internal/scheduler/ -run TestRecoverPicksLatestValidSnapshot
package scheduler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"sort"
)
// Snapshot 是某个 Seq 处的状态快照。Checksum 覆盖 Instance/Seq/Entities:
// 恢复时先校验,损坏的快照直接丢弃、退回到更早的一个,而不是拿着半截
// 数据继续重放。
type Snapshot struct {
Instance InstanceID `json:"instance"`
Seq uint64 `json:"seq"`
Entities []EntityState `json:"entities"`
Checksum string `json:"checksum"`
}
func checksum(s Snapshot) string {
b, _ := json.Marshal(struct {
Instance InstanceID
Seq uint64
Entities []EntityState
}{s.Instance, s.Seq, s.Entities})
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// Recover 从 dir 里恢复一份状态:先选最新的、校验和正确的快照,再把事件
// 日志里 Seq 大于快照的所有事件重放上去。选最新可用快照而不是固定一份,
// 是为了在「快照写坏」和「重放代价」之间自动取平衡。
func Recover(dir string) (*SceneState, error) {
snaps, err := loadSnapshots(dir)
if err != nil {
return nil, err
}
if len(snaps) == 0 {
return nil, fmt.Errorf("no snapshot in %s", dir)
}
sort.Slice(snaps, func(i, j int) bool { return snaps[i].Seq < snaps[j].Seq })
var base Snapshot
for i := len(snaps) - 1; i >= 0; i-- { // 从最新往前找第一个校验通过的
if checksum(snaps[i]) == snaps[i].Checksum {
base = snaps[i]
break
}
}
if base.Checksum == "" {
return nil, fmt.Errorf("all snapshots in %s are corrupt", dir)
}
st := NewSceneState(base.Instance)
st.Seq = base.Seq
for _, e := range base.Entities {
st.Entities[e.ID] = e
}
events, err := loadEvents(filepath.Join(dir, "events.jsonl"))
if err != nil {
return nil, err
}
for _, e := range events {
if e.Seq <= st.Seq { // 快照已包含,跳过
continue
}
if err := st.Apply(e); err != nil {
return nil, err
}
}
return st, nil
}
Recover 的选点逻辑是「最新的、校验和正确的快照」:从最新往前扫,遇到第一份 checksum 对得上的就用它。这样一份写坏的快照不会让恢复失败,只会让重放多走一段;而事件日志里 Seq <= 快照 Seq 的会被跳过,避免重复应用。
真跑:杀掉进程 → 重建 → 断言一致
现在把三件事连起来真跑一次:起一个持有状态的真进程,读它报告的状态指纹,SIGKILL 杀掉(不给它任何落盘机会),再从快照 + 事件重放,断言重建出来的指纹与崩溃前逐字节相同。下面这段是 L2,go test ./internal/scheduler/ -run TestCrashRebuild:
// file: internal/scheduler/recovery_test.go
// tier: L2
// verify: go test ./internal/scheduler/ -run TestCrashRebuild
package scheduler
import (
"bufio"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// buildCmd 把 cmd/ 下的一个包编译到临时目录,返回二进制路径。
func buildCmd(t *testing.T, pkg string) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "bin")
cmd := exec.Command("go", "build", "-o", bin, pkg)
cmd.Dir = "../.." // 从 internal/scheduler 回到 module 根
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build %s: %v\n%s", pkg, err, out)
}
return bin
}
// TestCrashRebuild 真跑「杀掉进程 → 重建 → 断言一致」:起一个持有状态的
// 真进程,读它报告的状态指纹,SIGKILL 杀掉,再从快照 + 事件重放,断言
// 重建出来的状态与崩溃前逐字节相同。
func TestCrashRebuild(t *testing.T) {
dir := t.TempDir()
want := writeFixture(t, dir) // 造现场,返回真值指纹
cmd := exec.Command(buildCmd(t, "./cmd/scenestate"), "-dir", dir)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
sc := bufio.NewScanner(stdout)
var live string
for sc.Scan() {
if line := sc.Text(); strings.HasPrefix(line, "STATE hash=") {
live = strings.TrimPrefix(strings.Fields(strings.TrimPrefix(line, "STATE "))[0], "hash=")
break
}
}
if live == "" {
t.Fatal("child never reported state")
}
if live != want {
t.Fatalf("live state %s != ground truth %s", live, want)
}
// 模拟崩溃:SIGKILL,不给它任何落盘或优雅退出的机会。
if err := cmd.Process.Kill(); err != nil {
t.Fatal(err)
}
_ = cmd.Wait()
st, err := Recover(dir)
if err != nil {
t.Fatalf("recover: %v", err)
}
if st.Hash() != live {
t.Fatalf("rebuilt state %s != pre-crash %s", st.Hash(), live)
}
}
writeFixture(在配套文件里)先落一份覆盖 seq=4 的快照,再继续追加 5 条只进日志的事件,所以恢复必须「快照 + 重放」两条腿走路才对得上真值。子进程是 cmd/scenestate:它 Recover 之后打印 STATE hash=... 就阻塞等信号——Kill() 用的是 SIGKILL,它没有任何机会写盘,重建完全靠磁盘上的快照与日志。
重建期间的可用性
重建不是瞬间完成的:快照可能很大,事件可能要重放几万条。这期间实例处于回收态、还不能接玩家。可用性有两种做法:一是原地重建,玩家看到一个加载画面,等实例回到就绪再重连;二是异地重建,在另一台机器上从同一份快照 + 日志拉起来,就绪后再把玩家迁过去——这样崩溃节点的重建不影响其他实例。无论哪种,重建完成的判据都是同一个:状态指纹对上、实例重新进入就绪态。
本节小结 / 实测结论
本机实测:TestCrashRebuild 真起进程、读指纹、SIGKILL 杀掉、重建后指纹与崩溃前一致(约 1.8 秒,含一次 go build);TestRecoverPicksLatestValidSnapshot 验证写坏的快照会被跳过。到这里,第 5 章的三块拼图齐了:调度器起实例、Broker 决定放哪、崩溃恢复保证状态不丢。下一章离开单个实例,进入跨服玩法。
阅读导航:上一节:5.2 分配 Broker 与容量评估 · 下一节:6.1 跨服报名与匹配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。