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 快照与增量编码 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。