《游戏服务端编程实践》8.1.2 帧同步与状态同步机制

对比帧同步只传输入与状态同步服务器算完传状态的机制差异,拆解定点数、随机种子、遍历顺序和浮点禁用四项确定性要求,讨论带宽、作弊、断线重连与观战,并用 Go 实现按固定 tick 收集输入、缺失补零且帧号连续的 Lockstep 收集器及测试。

一、两种同步的分界线

帧同步和状态同步的分界线只有一句:网络上传的是“输入”还是“结果”。

帧同步(lockstep)中,客户端把操作输入发给服务器或对等端,服务器按固定 tick 收集所有输入并广播输入帧;每个客户端用相同的初始状态和相同的输入序列本地演算,得到相同结果。服务器通常不计算战斗,只做输入收集、帧号编排和一致性校验。

状态同步中,服务器是唯一计算者。客户端上传输入,服务器更新世界状态,再把状态快照或增量下发给客户端。客户端只负责表现和少量预测,不负责决定结果。

这条分界线决定了很多下游问题。帧同步的带宽主要花在输入上,状态同步的带宽主要花在实体状态上;帧同步的 correctness 依赖确定性,状态同步的 correctness 依赖服务器逻辑本身;帧同步的回放是输入序列,状态同步的回放是状态快照或带时间戳的增量。

二、机制对照

维度帧同步状态同步
网络内容玩家输入(按键、指令、目标)实体状态、属性、事件
计算位置各客户端本地演算,服务器可只做收集服务器集中计算
带宽特征上行小、下行小,随玩家数增长下行随实体数和变化频率增长
确定性要求极高,所有端必须逐位一致较低,客户端以服务器状态为准
作弊面客户端可篡改本地演算,需服务器校验输入客户端难以直接改结果,但可伪造输入
断线重连需要从某帧开始重放输入直接下发当前快照即可
观战拉取输入流即可观看拉取状态流或快照
典型场景RTS、MOBA、格斗、自走棋战斗段MMO、FPS、大世界、卡牌

不要被“帧同步更省带宽”这句话误导。它的下行确实小,但前提是输入频率低、玩家数可控;如果每个玩家每帧都要上传高频摇杆数据,输入流本身也会变成负担。状态同步的下行也不一定大,经过兴趣管理和增量压缩后,很多 MMO 单玩家每秒只需要几十 KB。

还有一点常被忽略:帧同步的“服务器”可以很轻,但不等于没有服务器。服务器仍要负责房间管理、玩家加入退出、帧号编排、输入校验和掉线处理。把帧同步理解成“客户端直连、服务器只做转发”,在公网环境里会遇到 NAT、作弊和重连问题。

状态同步的服务器则承担完整模拟。它需要决定 tick 频率、状态编码、增量策略和可见性裁剪。频率越高,手感越好,CPU 和带宽也越高;频率越低,服务器越省,但客户端预测的纠偏越明显。这个取舍没有统一答案,要按玩法对延迟的敏感度来定。

三、确定性不是口号,是四条硬约束

帧同步要成立,所有客户端对同一帧、同一输入必须算出同一结果。以下四件事任何一件做不到,就会在某台机器上分叉。

第一,定点数。浮点运算在不同 CPU、不同编译器优化、不同数学库下可能产生末位差异。战斗逻辑中的位置、速度、伤害系数应使用定点整数(例如以 1/1000 为单位),或者使用团队自己实现的确定性数学库。

第二,随机种子。随机数必须来自可复现的种子,并且每个帧、每个行为使用明确的随机流。不能调用系统时间、全局随机源,也不能让客户端各自决定种子。常见做法是服务器在战斗开始时下发种子,之后按 seed + frame 派生。

第三,遍历顺序。哈希表、集合、并发 map 的遍历顺序不稳定。任何会影响结算顺序的集合,都必须使用有序切片,或在遍历前按稳定键排序。单位 ID、技能 ID、目标 ID 都是天然的排序键。

第四,浮点禁用要落到工程约束。只在代码规范里写“不要用 float”没有用,要在战斗逻辑包里禁用相关 API、在 CI 中扫描,或者在语言层面使用定点类型。否则某次“只是改了个插值”就会引入分叉。

约束失败表现工程手段
定点数两端伤害差 1、位置漂移定点整数类型、确定性数学库
随机种子暴击结果不同、掉落不同服务器下发种子,按帧派生随机流
遍历顺序多目标技能命中顺序不同有序切片、稳定排序键
浮点禁用偶发不同步,难复现包级 lint、CI 扫描、代码评审清单

这四条约束要落到“战斗逻辑包”这一层,而不是全项目一刀切。表现层可以使用浮点、使用系统随机数、使用无序集合;只要它们不参与结果判定,就不会破坏同步。边界越清晰,工程约束越容易执行。

定点数的精度要按玩法选。位置用 1/1000 还是 1/10000,取决于地图尺度和最小移动单位;伤害用整数还是定点,取决于是否允许小数倍率。精度不够会出现“走不动”或“伤害恒为 0”,精度过高则浪费带宽和计算。

随机流的派生也要有规范。推荐按 battleSeed、frame、sourceID、effectID 组合派生,避免“谁先调用谁拿到随机数”。同一帧内多个效果需要随机时,按稳定的 effectID 排序后依次取流,而不是依赖遍历顺序。

确定性测试不能只跑一遍。至少要在不同架构、不同优化级别、不同随机种子下跑同一段输入,比较最终状态哈希。哈希不一致时,再按帧二分定位第一处分叉,而不是靠日志猜。

四、带宽、作弊与断线重连

帧同步的带宽优势来自“输入远小于状态”。一个 RTS 玩家每帧可能只上传一个命令号加目标 ID,几百个单位的移动由所有端本地演算。代价是每个客户端都要承担完整模拟的 CPU,并且任何一处不确定性都会被放大。

作弊面也随之变化。客户端可以修改本地内存、伪造输入、加速演算,但它不能直接改服务器上的结果,因为服务器通常只转发输入。防作弊的重点是输入合法性:命令是否在冷却内、目标是否在视野内、资源是否足够、帧号是否连续。服务器不必重算整场战斗,但必须能校验每条输入。

断线重连是帧同步的薄弱点。客户端重连后,不能只拿到“当前状态”,因为它的本地模拟已经断了;它需要从某个确认帧开始,拉取完整输入流并快速重放,直到追上当前帧。重放期间可以暂停表现,但不能跳过逻辑帧,否则输入序列对不上。

观战反而简单:观战端只需要输入流和初始状态,按同样规则演算即可。它可以选择延迟若干帧观看,以避免看到“未来”的信息。状态同步的观战则需要持续接收状态流,带宽更高,但实现上不需要确定性模拟。

回放和观战共享同一条输入流,但要求不同。回放要求完整、可定位、可长期保存;观战要求实时、低延迟、可中途加入。设计时最好把“输入记录”和“观战广播”分开:记录追求完整,广播可以裁剪或压缩。

断线重连还有一层“输入确认”问题。客户端需要知道服务器已经收到哪些帧,否则重连后可能重复提交。常见做法是服务器定期下发已确认帧号,客户端只补发未确认的输入;服务器对重复帧号幂等处理,避免重复执行。

五、失败场景:三个真实会发生的分叉

第一个是 map 遍历。某次多目标技能结算,代码遍历 map[unitID]bool 收集命中目标,再按遍历顺序依次扣血。由于不同端的 map 哈希种子不同,命中顺序不同,带有“击杀后触发连锁”的技能在不同端产生了不同结果。修复方式不是“加锁”,而是把目标 ID 收集到切片并排序。

第二个是随机数不固定种子。战斗开始时客户端各自用当前时间初始化随机源,平时看不出问题,直到某次暴击判定在两端产生不同结果。更隐蔽的是“随机流共享”:治疗和暴击共用一条随机流,一端多调用了一次随机数,后续所有判定全部错位。正确做法是每个行为、每个帧使用独立且可派生的随机流。

第三个是掉线后补帧不重放。客户端重连后直接接受服务器下发的“当前帧状态”,但本地逻辑仍停留在旧帧。下一帧输入到达时,客户端从旧状态继续演算,和服务器产生永久分叉。补帧必须按帧号重放输入,不能只同步一个状态快照,除非同时重建整个模拟上下文。

这三个问题的共同点是:它们在单机测试里都不会出现,只有在多端、不同机器、断线重连时才暴露。因此帧同步项目必须有多端一致性测试,而不能只测“逻辑对不对”。

一致性测试的输入要可复现。把随机种子、玩家输入序列、帧号一起写进测试用例,跑完之后比较状态哈希和事件序列。只要有一端不一致,就用二分法缩小到具体帧和具体效果,而不是靠人眼看回放。

六、输入帧协议与延迟预算

输入帧的协议不需要复杂,但字段要能支撑重连、校验和回放。

字段作用注意事项
帧号标识逻辑帧,必须连续重连、回放、去重都依赖它
玩家 ID标识输入来源不能由客户端自报,需与连接绑定
命令号区分操作类型与战斗规则版本一起校验
目标与参数描述操作对象服务器需校验合法性
客户端时间估算延迟只作参考,不能作为结算依据
输入序号防止重放与乱序同一帧内按序号稳定排序

帧号连续是硬要求。允许跳帧会让客户端无法判断“是丢包还是服务器没推进”,重连时也无法确定从哪一帧开始补。即使某一帧没有任何输入,也要生成空帧,保持序列完整。

延迟预算要提前分配。假设目标 RTT 是 100 毫秒,输入缓冲占 2 帧、服务器处理占 1 帧、下行占 1 帧,那么每帧的逻辑时间就不能太长。帧率越低,单帧能容纳的输入窗口越大,但操作反馈越粗;帧率越高,反馈越细,CPU 和带宽越高。

输入缓冲不是越大越好。缓冲能吸收抖动,但也会增加输入延迟;缓冲太小,网络一抖就丢输入。常见做法是根据近期 RTT 动态调整缓冲帧数,并设置上限,避免高延迟玩家把整场战斗拖慢。

服务器校验要覆盖“能不能做”和“什么时候能做”。前者是冷却、资源、距离、视野;后者是帧号、输入序号、是否已经行动。只校验前者,客户端可以重复提交同一帧输入;只校验后者,客户端可以在冷却外释放技能。

七、Go 实现:固定 tick 的输入收集器

下面的 Lockstep 不执行战斗演算,只负责收集输入并生成连续的输入帧。这样可以把“同步骨架”和“战斗规则”分开测试。

// file: ch08/model/lockstep.go
// tier: L1
// verify: go test ./ch08/model/

package model

import (
	"errors"
	"sync"
)

var (
	ErrLockstepClosed  = errors.New("lockstep: closed")
	ErrLockstepPlayers = errors.New("lockstep: players required")
	ErrDuplicatePlayer = errors.New("lockstep: duplicate player")
	ErrUnknownPlayer   = errors.New("lockstep: player not joined")
	ErrDuplicateInput  = errors.New("lockstep: player already submitted for frame")
)

// PlayerInput 是单个玩家在一个逻辑帧内的输入指令。
type PlayerInput struct {
	Player  uint64
	Command int8
}

// InputFrame 是一帧的完整输入。Inputs 按玩家加入顺序排列,
// 不使用 map,避免遍历顺序影响确定性演算。
type InputFrame struct {
	Frame  uint64
	Inputs []PlayerInput
}

// Lockstep 只收集输入,不计算战斗结果。每个 tick 结束时 Step
// 生成一帧;未提交的玩家补空输入(Command 为 0)。
type Lockstep struct {
	mu      sync.Mutex
	players []uint64
	pending map[uint64]int8
	next    uint64
	history []InputFrame
	closed  bool
}

func NewLockstep(players []uint64) (*Lockstep, error) {
	if len(players) == 0 {
		return nil, ErrLockstepPlayers
	}
	seen := make(map[uint64]bool, len(players))
	for _, p := range players {
		if p == 0 || seen[p] {
			return nil, ErrDuplicatePlayer
		}
		seen[p] = true
	}
	return &Lockstep{
		players: append([]uint64(nil), players...),
		pending: make(map[uint64]int8, len(players)),
		next:    1,
	}, nil
}

// Submit 缓存玩家对下一个 tick 的输入,同一帧重复提交会被拒绝。
func (l *Lockstep) Submit(player uint64, command int8) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	if l.closed {
		return ErrLockstepClosed
	}
	if !l.joined(player) {
		return ErrUnknownPlayer
	}
	if _, ok := l.pending[player]; ok {
		return ErrDuplicateInput
	}
	l.pending[player] = command
	return nil
}

// Step 结束当前 tick:按加入顺序生成完整输入帧,并把帧号加一。
// 即使没有任何输入,也会生成一帧,保证帧号连续。
func (l *Lockstep) Step() (InputFrame, error) {
	l.mu.Lock()
	defer l.mu.Unlock()
	if l.closed {
		return InputFrame{}, ErrLockstepClosed
	}

	frame := InputFrame{Frame: l.next, Inputs: make([]PlayerInput, 0, len(l.players))}
	for _, p := range l.players {
		cmd, ok := l.pending[p]
		if !ok {
			cmd = 0 // 缺失输入补零,所有端才能算出同一个结果
		}
		frame.Inputs = append(frame.Inputs, PlayerInput{Player: p, Command: cmd})
	}

	l.pending = make(map[uint64]int8, len(l.players))
	l.next++
	l.history = append(l.history, frame)
	return frame, nil
}

func (l *Lockstep) joined(player uint64) bool {
	for _, p := range l.players {
		if p == player {
			return true
		}
	}
	return false
}

// Frame 返回下一个将要生成的帧号。
func (l *Lockstep) Frame() uint64 {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.next
}

// History 返回已生成帧的副本,可用于断线重连时从指定帧重放。
func (l *Lockstep) History() []InputFrame {
	l.mu.Lock()
	defer l.mu.Unlock()
	out := make([]InputFrame, len(l.history))
	for i, f := range l.history {
		out[i] = InputFrame{Frame: f.Frame, Inputs: append([]PlayerInput(nil), f.Inputs...)}
	}
	return out
}

func (l *Lockstep) Close() {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.closed = true
}

实现上有三个刻意选择。第一,Inputs 使用按玩家加入顺序排列的切片,而不是 map,避免序列化或遍历时出现不确定顺序。第二,Step 对未提交的玩家补 Command: 0,保证每一帧都包含全部玩家,客户端不会因为“少了一个人”而改变随机流或遍历长度。第三,即使没有任何输入,Step 也会生成一帧并递增帧号,帧号连续是重连和回放的前提。

测试覆盖:缺失输入补零、帧号从 1 开始连续递增、同一帧重复提交被拒绝、未加入玩家被拒绝、关闭后拒绝提交与推进。History 返回深拷贝,重连时可以从历史帧开始重放,而不必担心调用方修改内部切片。

八、什么时候选哪一种

如果战斗逻辑复杂、单位数量多、输入频率低,并且团队愿意为确定性投入工程约束,帧同步是合理选择。RTS、MOBA、格斗和部分自走棋战斗段都属于这一类。

如果世界持续变化、实体数量大、玩家视野不同、断线重连必须快速恢复,状态同步更稳。MMO、FPS、大世界和大多数卡牌项目都属于这一类。它的服务器 CPU 和带宽成本更高,但客户端不需要承担完整模拟,也不要求逐位确定性。

还有中间路线:服务器做权威模拟,客户端做表现层预测;或者战斗段用帧同步,外围系统用状态同步。选择的关键不是“哪个更先进”,而是团队能否承担对应的确定性成本或带宽成本。

帧同步对团队的要求更偏“工程纪律”。战斗逻辑不能随手用 map、不能随手调随机数、不能随手插浮点;这些约束需要代码评审、CI 和测试共同维护。团队没有相应习惯时,帧同步的分叉问题会反复出现,而且很难定位。

状态同步对团队的要求更偏“服务端能力”。服务器要扛住完整模拟,要有状态编码、兴趣管理、增量压缩、断线恢复和容量规划。客户端也需要处理预测与纠偏,但不需要逐位复现战斗结果。

测试基础设施也是选型依据。帧同步需要多端一致性测试、输入回放和状态哈希;状态同步需要协议兼容测试、快照恢复测试和带宽压测。哪套基础设施更成熟,往往比架构图更能决定项目能否按期上线。

最后,不要为了“统一”而统一。一个项目里可以同时存在帧同步战斗和状态同步大世界,只要边界清楚、数据契约明确。强行把所有玩法塞进同一种同步模型,通常会在某个阶段付出更高的改造成本。

无论选哪种,先把“输入帧号连续、缺失输入补齐、重复输入拒绝”这三件事做对。它们是同步层的地基,地基不稳时,上层的战斗规则越复杂,问题越难定位。

小结

帧同步传输入,状态同步传状态。帧同步的难点是确定性:定点数、随机种子、遍历顺序、浮点禁用,缺一不可;状态同步的难点是带宽与服务器计算。断线重连在帧同步里需要重放输入,在状态同步里可以下发快照。先把输入帧的连续性和缺失补齐做对,再谈战斗演算。

常见错误与边界

  • 在帧同步逻辑里遍历 map。命中顺序、结算顺序都会随哈希种子变化。
  • 随机数不固定种子,或让多个行为共享一条随机流。
  • 掉线重连只同步状态快照,不重放输入,导致本地模拟与服务器分叉。
  • 认为帧同步一定省带宽。高频输入或大量玩家时,输入流同样会成为瓶颈。
  • 认为状态同步不需要防作弊。客户端仍可伪造输入,服务器必须校验合法性。
  • 把表现层的插值、粒子随机数混进战斗逻辑,破坏确定性。
  • 帧号不连续或允许跳帧。重连、回放和观战都会因此失去基准。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist