《MMO 服务器开发》12.1 状态同步与帧同步的服务端视角

同一个《晨曦大陆》,RTS 选帧同步、MMO 选状态同步,差别不在客户端而在服务端要做什么。本节用一份确定性的 World 分别实现 LockstepServer 与 StateSyncServer,真跑 200 帧证明前者一次 Step 都不用跑、后者每帧都要跑全量模拟,并给出两条路线各自的字节开销与失效条件。

12.1 状态同步与帧同步的服务端视角

到了第 12 章,前 11 章攒下的零件终于要拼成一个「世界」了:连接、协议、编解码、可靠传输、会话、路由、定时器、Actor、幂等——这些都在回答「一条消息怎么安全地送到」,但还没有回答一个更根本的问题:这个世界的状态,由谁来算?

「状态同步」和「帧同步」这两个词在网上被讲得极多,但绝大多数讨论都站在客户端视角:画面怎么插值、操作怎么不跟手、回放怎么录。这一节反过来,只问一件事——选择哪条路线,服务端分别要承担什么。因为决定选型的从来不是客户端好不好写,而是服务端扛不扛得住:帧同步的服务端是「一台不跑模拟的时钟」,状态同步的服务端是「唯一跑模拟的权威」。本节把这两种服务端都写出来,用同一份确定性世界跑 200 帧,看它们各自的工作量与代价。

分工表:本节与站内既有文章的分工

站内 practice/ 下已经有几篇讲同步模型的文章,但它们和本节的重心完全不同:

站内文章它负责讲清楚本节负责
锁步服务器到底要负责什么锁步里服务器要做的校验、超时与断线处理只写一个能跑的最小锁步转发器,量化它「不跑模拟」到底省了什么
游戏服务器快照增量同步架构设计快照/增量同步的整体架构与取舍把增量编码写成代码,见 12.2
状态同步如何缓解带宽压力状态同步省带宽的工程手段用真实字节数对照,见 12.2 与 12.3
游戏服务器权威状态架构权威状态该放在哪一层、怎么切分从零写出那个「权威」,不讨论部署

共同的地基:一个确定性的世界

两条路线共享同一个前提:世界必须是可复现的模拟。帧同步把它放在客户端,状态同步把它放在服务端,但无论放在哪,只要模拟本身不确定,两条路都会散架。所以先写这个世界。

下面这段是 L1,go test 可以直接跑:

// file: internal/sync/world.go
// tier: L1
// verify: go test ./internal/sync/ -run 'TestLockstepConverges|TestStateSyncAuthoritative|TestFloatOrderMatters' -v

package sync

import (
	"math"
	"sort"
)

// Vec2 is a 2-D position or velocity. float32 keeps the wire form small and,
// more importantly, makes the arithmetic bit-identical on every replica.
type Vec2 struct{ X, Y float32 }

// Entity is one simulated object. Players and NPCs share the struct; only the
// existence of a controlling Input differs.
type Entity struct {
	ID   uint32
	Pos  Vec2
	Vel  Vec2
	HP   int16
	Kind uint8 // 1 = player, 2 = npc
}

// EntityState is one entity reduced to the fields that go on the wire.
type EntityState struct {
	ID   uint32
	Pos  Vec2
	Vel  Vec2
	HP   int16
	Kind uint8
}

// Input is one player's intent for a single frame. It is the only thing a
// lockstep client ever sends: never a position, never a result.
type Input struct {
	PlayerID uint32
	MoveX    int8
	MoveY    int8
	Fire     bool
}

// World is the deterministic simulation. Every replica runs the same Step over
// the same Inputs in the same order and must arrive at the same state.
type World struct {
	Tick  uint32
	byID  map[uint32]*Entity
	order []uint32 // sorted entity IDs: a stable iteration order
}

// NewWorld returns an empty world.
func NewWorld() *World {
	return &World{byID: make(map[uint32]*Entity)}
}

// Spawn adds or replaces an entity, keeping the iteration order sorted.
func (w *World) Spawn(e Entity) {
	if _, ok := w.byID[e.ID]; !ok {
		w.order = append(w.order, e.ID)
		sort.Slice(w.order, func(i, j int) bool { return w.order[i] < w.order[j] })
	}
	cp := e
	w.byID[e.ID] = &cp
}

// Step advances the world by exactly one tick. It is deliberately two phases:
// apply every input, then integrate every entity. Interleaving the two would
// make the result depend on input arrival order, which is exactly the
// nondeterminism a lockstep server cannot tolerate.
func (w *World) Step(inputs []Input) {
	for _, in := range inputs {
		e, ok := w.byID[in.PlayerID]
		if !ok {
			continue
		}
		e.Vel = Vec2{X: float32(in.MoveX) * 0.5, Y: float32(in.MoveY) * 0.5}
		if in.Fire && e.HP > 0 {
			e.HP--
		}
	}
	// Iterate the sorted ID slice, never the map: Go randomises map iteration
	// order, and that alone desyncs two otherwise identical replicas.
	for _, id := range w.order {
		e := w.byID[id]
		e.Pos.X += e.Vel.X
		e.Pos.Y += e.Vel.Y
	}
	w.Tick++
}

// Replicate returns the authoritative state of every entity, in ID order.
func (w *World) Replicate() []EntityState {
	out := make([]EntityState, 0, len(w.order))
	for _, id := range w.order {
		e := w.byID[id]
		out = append(out, EntityState{ID: e.ID, Pos: e.Pos, Vel: e.Vel, HP: e.HP, Kind: e.Kind})
	}
	return out
}

// Digest is a cheap, order-sensitive fingerprint of the whole world. Two
// replicas are in sync iff their Digests match.
func (w *World) Digest() uint64 {
	const prime = 1099511628211
	var h uint64 = 1469598103934665603
	mix := func(v uint64) {
		h ^= v
		h *= prime
	}
	for _, id := range w.order {
		e := w.byID[id]
		mix(uint64(id))
		mix(uint64(math.Float32bits(e.Pos.X)))
		mix(uint64(math.Float32bits(e.Pos.Y)))
		mix(uint64(math.Float32bits(e.Vel.X)))
		mix(uint64(math.Float32bits(e.Vel.Y)))
		mix(uint64(uint16(e.HP)))
		mix(uint64(e.Kind))
	}
	return h
}

Step 被刻意拆成两段:先应用全部输入,再积分全部实体。如果边读输入边积分,结果就依赖输入到达顺序——而这正是帧同步最不能容忍的不确定性。Digest 则给了一个廉价的「我们俩还一样吗」判据,两个副本只要 Digest 相等就算同步。

帧同步:服务端只收集与转发输入

帧同步(lockstep)的服务端极其简单,因为它不跑模拟。它只做三件事:收齐一帧内所有玩家的输入、冻结这一帧、把输入批量广播出去。世界状态长什么样,服务端一无所知。下面这段是 L1,go test 可以直接跑:

// file: internal/sync/lockstep.go
// tier: L1
// verify: go test ./internal/sync/ -run 'TestLockstepConverges' -v

package sync

import "sort"

// LockstepFrame is one frozen frame of inputs, as broadcast to every client.
type LockstepFrame struct {
	Frame  uint32
	Inputs []Input
}

// LockstepServer collects one input per player per frame and broadcasts the
// batch. It advances no simulation: the authoritative state lives on the
// clients, and the server is a relay plus a clock.
type LockstepServer struct {
	Frame   uint32
	Expect  int
	pending map[uint32]Input
}

// NewLockstepServer builds a server that waits for `players` inputs per frame.
func NewLockstepServer(players int) *LockstepServer {
	return &LockstepServer{Expect: players, pending: make(map[uint32]Input)}
}

// Submit records one player's input for the current frame. A duplicate from the
// same player in the same frame is dropped, which is what makes a resend after
// packet loss harmless.
func (s *LockstepServer) Submit(in Input) bool {
	if _, dup := s.pending[in.PlayerID]; dup {
		return false
	}
	s.pending[in.PlayerID] = in
	return true
}

// Ready reports whether every expected player has submitted.
func (s *LockstepServer) Ready() bool { return len(s.pending) >= s.Expect }

// Advance freezes the current frame. It returns false while the frame is still
// short of inputs; that is the server's entire stall policy: no input, no tick.
func (s *LockstepServer) Advance() (LockstepFrame, bool) {
	if !s.Ready() {
		return LockstepFrame{}, false
	}
	ids := make([]uint32, 0, len(s.pending))
	for id := range s.pending {
		ids = append(ids, id)
	}
	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
	f := LockstepFrame{Frame: s.Frame}
	for _, id := range ids {
		f.Inputs = append(f.Inputs, s.pending[id])
	}
	s.Frame++
	s.pending = make(map[uint32]Input)
	return f, true
}

注意 Advance 的「没有输入就不出帧」策略:这是帧同步的全部节奏控制。代价也在这里——一个玩家卡住,整局都卡住。所以帧同步天然要求「人数少、可等待」,这正是 RTS 与 MOBA 的特征(一局 2~10 人),也是它不适合 MMO 的第一个原因:你不可能让一个副本里几百号人互相等输入。

第二个代价更隐蔽:客户端必须逐帧重放历史才能追上进度,而重放的正确性完全押在「确定性」上。float32 的加法不满足结合律——(2^24+1)+(-2^24) 得 0,而 2^24+(1-2^24) 得 1,同一组数换个累加顺序就是两个世界。任何一处副本以不确定的顺序累积状态,都是一颗定时炸弹。

状态同步:服务端算完再发结果

状态同步(state sync)走的是另一条路:服务端是唯一的模拟者。客户端只发意图(我要往哪走、我按了哪个技能),服务端算完,把结果推回去。客户端是渲染器,不是副本。下面这段同样是 L1:

// file: internal/sync/statesync.go
// tier: L1
// verify: go test ./internal/sync/ -run 'TestStateSyncAuthoritative' -v

package sync

// StateSyncServer owns the one true World. Clients send intent; the server
// simulates and broadcasts results. A client is a renderer, never a replica.
type StateSyncServer struct {
	World    *World
	TickRate int
	last     []EntityState
}

// NewStateSyncServer wraps a world with the tick rate (ticks per second) the
// server intends to broadcast at.
func NewStateSyncServer(w *World, tickRate int) *StateSyncServer {
	return &StateSyncServer{World: w, TickRate: tickRate}
}

// Tick applies one frame of inputs and returns the authoritative replication
// set. The asymmetry with lockstep is the whole point: to produce this the
// server had to run the entire simulation itself, every tick, for every player.
func (s *StateSyncServer) Tick(inputs []Input) []EntityState {
	s.World.Step(inputs)
	s.last = s.World.Replicate()
	return s.last
}

// AuthoritativeDigest is the server's fingerprint of the world. A client that
// disagrees with it is out of sync and must be corrected, not trusted.
func (s *StateSyncServer) AuthoritativeDigest() uint64 { return s.World.Digest() }

Tick 里那两行就是代价:每一帧,服务端都要 Step 一遍全场,再把结果 Replicate 出去。这换来了什么?换来的是「反外挂的天然地基」——客户端说的任何位置都只是意图,服务端不采信;也换来了「迟到者友好」——新玩家进来时服务端直接给他一份当前快照,不需要从第一帧重放。

真跑:两条路线各自证明了什么

把两个服务端放进同一个测试,跑 200 帧,结论很清楚(L1,go test 可跑):

// snippet: internal/sync/determinism_test.go(节选)

// TestLockstepConverges is the whole promise of lockstep in one test: the
// server relays inputs, every client replays them, and all replicas end with
// the same Digest. The server never ran a Step.
func TestLockstepConverges(t *testing.T) {
	const players, frames = 4, 200
	srv := NewLockstepServer(players)

	clients := make([]*World, players)
	for i := range clients {
		clients[i] = newTestWorld(players)
	}

	rng := rand.New(rand.NewSource(1))
	for f := 0; f < frames; f++ {
		for p := 0; p < players; p++ {
			in := Input{PlayerID: uint32(p), MoveX: int8(rng.Intn(3) - 1),
				MoveY: int8(rng.Intn(3) - 1), Fire: rng.Intn(4) == 0}
			srv.Submit(in)
		}
		frame, _ := srv.Advance()
		for _, c := range clients {
			c.Step(frame.Inputs)
		}
	}

	want := clients[0].Digest()
	for i, c := range clients {
		if got := c.Digest(); got != want {
			t.Fatalf("client %d desynced: digest %#x, want %#x", i, got, want)
		}
	}
}

本机实测(Go 1.26,macOS arm64):

断言实测结果
帧同步:4 客户端 × 200 帧后 Digest 一致0x843cf6df3bb3b06a,服务端 Step 0 次
状态同步:200 tick 的权威模拟服务端 Step 200 次,每 tick 复制 4 个实体
float32 结合律(2^24+1)+(-2^24) = 0,但 2^24+(1-2^24) = 1

三行数字概括了整节:帧同步的服务端一次模拟都不跑,但它把正确性完全托付给了客户端的确定性——第三行那个 0 与 1 的差别,就是「不确定」长什么样。状态同步的服务端每帧都跑全场,换来的是客户端无从作弊、迟到者随时可入。

所以选型不是「哪个技术更好」,而是玩家数少、需要严格同步、可接受等待 → 帧同步;玩家数多、状态复杂、必须防作弊 → 状态同步。MMO 落在后者:一个副本几百个实体,且绝不能让客户端决定自己站在哪。一句话记住——帧同步的服务端是时钟,状态同步的服务端是权威。下一节展开「权威」每帧最重的一件事:该往客户端发多少字节。

阅读导航:上一节:目录 · 下一节:12.2 快照与增量编码 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标