
做实时多人联机功能那阵子我被自己的代码坑得不轻。两个人明明在同一局游戏里一个客户端看到的敌方位置在3秒前另一个看到的在5秒前而负责同步的网络消息却都显示正常。排查到最后问题不在单纯丢包而是我的业务代码里塞满了同步逻辑房间管理、输入广播、状态校验、断线重连全揉在一起任何一环出错都会让整个同步体系崩掉。痛定思痛之后我把这些跟具体玩法无关的同步能力拆出来沉淀成了一套专门实现帧同步和数据同步的SDK也就是后文说的 FDSync。这篇文章算是一次完整的设计复盘。FDSync 解决的核心问题说白了就两个多人实时交互中保证所有端用同样的输入、同样的顺序跑出同样的结果这是帧同步保证不同端维护的字段级数据在弱网下最终一致这是数据同步。它适合实时对战、协同白板、互动演出、联机编辑器这一类要求严格一致性的场景。如果你只是做背包同步、消息推送这类弱同步需求其实不需要上这套东西反过来如果你在做格斗、RTS、回合制策略又不想自己啃确定性、回滚、重连这些硬骨头那这篇文章里的设计取舍很值得参考。1. 为什么通用消息SDK搞不定实时多人同步1.1 “能发消息”和“能同步逻辑”是两码事很多团队一开始会直接用通用消息SDK做同步把输入包、状态包当普通消息发出去以为只要两端能互通就万事大吉。真正跑起来之后你会发现业务层要补的东西比想象中多得多。你得自己给消息编号、自己实现可靠重传和去重、自己约定延迟多久算超时、自己决定哪些帧该等待、哪些帧该丢弃。这些代码如果每个项目都重写一遍那每个项目都要在同一个坑里反复折腾。通用消息SDK没有“逻辑帧”这个概念。它不知道什么叫“一局游戏的第102帧”也不知道“第102帧必须包含玩家A的输入、玩家B的输入、玩家C本帧无输入”这些全部信息。于是业务层就需要反复做同一件事把网络包映射到帧模型、对缺失的输入做填充、在出现不一致时去翻日志找断点。这些问题跟具体玩法毫无关系但消耗的开发时间非常可观。与其让每个业务团队各自踩坑不如把“帧模型 输入确定性 数据一致性”做成一套公共基础设施。1.2 帧同步和状态同步是两套完全不同的心智模型在设计 FDSync 之前我还踩过一个认知上的坑以为只要把状态数据频繁同步出去就能达到“像帧同步一样”的一致性。后来发现两者解决问题的思路完全不同。维度帧同步状态同步同步对象玩家输入、事件序列游戏状态字段计算位置每个客户端本地运算服务端权威计算后下发带宽占用小每帧只传输入大高频字段整包同步时尤其明显一致性保证严格确定性逻辑可回放最终一致允许短暂偏差典型场景格斗、RTS、协同白板MMO、休闲游戏、大厅、背包FDSync 没有二选一而是把两条通道都做成了一等公民。原因很实际一个完整项目里战斗或核心协作环节需要严格一致的帧同步大厅、背包、昵称这些外围数据却只需要字段级同步。如果强行全用帧同步低频业务数据也要跟着帧节奏走白白增加复杂度如果全用状态同步核心对战部分的延迟和带宽又撑不住。两个模块彼此独立、又能对接是我认为最舒服的形态。1.3 FDSync 的总体架构与边界FDSync 大体分为三层。同步核心层包含帧同步引擎、数据同步服务、快照与回放模块传输适配层包含可靠UDP通道、不可靠实时通道、WebSocket 等工具层包含弱网模拟器、Hash 对账、回放日志分析。业务层只需要实现一个可以“接收输入并修改状态”的模拟器接口剩下的输入缓存、帧序控制、断线重连都交给 SDK。这里必须划清边界FDSync 不负责账号、支付、匹配这类业务也不试图把房间管理做成通用SaaS。它只负责一个房间内逻辑与数据的一致性。边界划清楚之后有个明显好处当业务代码里开始出现“自己实现的同步逻辑”时你立刻能意识到架构被打破了而不是继续往里堆补丁。2. 帧同步引擎从输入采集到确定性回放2.1 逻辑帧与输入帧帧同步的第一步是把时间切成离散的逻辑帧。FDSync 用一个 32 位递增的frameId作为全局锚点每一帧包含一批输入。对动作类游戏逻辑帧率通常设在 30Hz对RTS或协同编辑类20Hz 甚至 10Hz 也够用回合制则不需要跟帧率绑定按回合号走即可。服务端在每个固定 tick 内收集所有玩家的输入打包成Frame再广播给所有客户端。客户端不能收到什么执行什么必须先按frameId排入本地缓冲确保每个客户端都以完全相同的顺序执行同一批输入。这里有一个很反直觉的点自己本地玩家的输入往往也要延迟一拍再提交不能“我按下按键立刻进逻辑”否则本端执行第15帧时远端可能还在等第15帧的输入包。这就是常见的“输入延迟”机制FDSync 把这个延迟抽象成可配置参数默认值为2帧。2.2 确定性是命门帧同步最核心的约束是确定性相同初始状态、相同输入序列、相同执行顺序必然得到相同结果。FDSync 在这方面做了几条硬性规定。第一逻辑层必须使用确定性数学。如果直接在模拟层里使用普通浮点除法、三角函数、平方根很容易在 x86 和 ARM 平台、或者不同编译优化等级下产生微小差异。我们早期就吃过这个亏PC 上测试几百帧 hash 全对发到骁龙芯片的安卓机上第 40 帧就开始不一致。后来逻辑层统一改为整数化或定点数计算三角函数改用查找表物理相关计算也尽量收敛到确定性范围内。第二随机数必须由 SDK 统一管理。业务代码里写的Random.Next()如果依赖系统时间或全局种子那每个端都会产生不同序列。FDSync 给每个逻辑帧提供一个确定性随机源种子由帧号和玩家输入共同派生。一个简化版实现长这样public sealed class DeterministicRandom { private ulong _state; public DeterministicRandom(ulong seed) { _state seed 0xFFFFFFFFFFFFFFFFUL; } public uint Next() { _state _state * 6364136223846793005UL 1442695040888963407UL; return (uint)(_state 32); } }第三不要用墙上时钟参与逻辑运算。哪怕是毫秒级的DateTime.Now也会让不同端的判定点错开。逻辑里能用帧号表达的时间一律用帧号。同步引擎内部每一帧都会对整局状态计算一个 64 位哈希随 Frame 一起广播。客户端跑完一帧后对比 hash如果全部一致基本可以认定同步健康一旦不一致系统会立即记录差异帧号并触发快照回滚。哈希字段不能放在业务层自己算否则业务层一偷懒就形同虚设。2.3 帧缓冲、等待策略和延迟补偿网络世界没有不丢包的所以帧同步必须处理“到了该执行第N帧却缺输入”的情况。FDSync 的做法是在客户端维护一个按帧号排序的输入缓冲同时引入目标延迟给网络波动留出余量。目标延迟并不是越大越好我的经验是通常取 2 到 3 帧延迟逐帧动态调整避免频繁切换导致操作手感突变。如果到时间仍然缺某位玩家的输入FDSync 提供三种策略供业务选择等待重传、预测上一帧输入、跳过该输入。等待策略适合可靠控制类输入预测适合移动方向类输入跳过适合命中判定这类无法预测的输入。三种策略可以在同一局中按输入类型混用。网络抖动加剧时系统会在缓冲帧数低于阈值后自动暂停执行优先保证一致性如果长时间无法恢复就进入快照重同步流程。策略适用输入优点风险等待重传操作、技能、攻击准确性高延迟增加预测上一帧方向、移动手感顺滑预测错误需要回滚跳过离散事件、随机指令保持节奏表现缺失2.4 快照、回放与断线重连帧同步不能只靠“每帧都对”因为断线、切后台、进程被杀都会导致某端彻底掉队。FDSync 默认每隔固定帧数生成一份可序列化的逻辑快照快照内容由业务模拟器的状态序列化接口提供。快照有两个用途一是放在内存环形缓冲里为回滚保存最近 256 帧的状态二是用于断线重连。重连流程大致是这样客户端重连后先请求最新快照拿到快照后清空本地逻辑状态恢复快照再向服务端请求快照之后的输入历史。因此服务端必须额外维护一个最近 N 帧的输入环形缓冲不能广播完就丢掉。录制回放是调试帧同步的法宝。只要把每一帧的输入原样落盘故障出现后就能完整复现。FDSync 的回放文件只记输入和帧号不记状态回放时重新执行一遍模拟器即可。我们实践中最有价值的调试方式是让不同客户端把各自执行同一局输入后的每帧 hash 都上报给日志平台哪个帧分叉一目了然。3. 数据同步模块字段级增量是刚需3.1 为什么不能整包同步很多项目做数据同步的第一版都是把整个对象序列化后全量下发。这在对象少、字段少、频率低的时候勉强能用一旦头像、昵称、Buff、装备强化这些字段混在一起就会遇到三个问题带宽浪费、冲突面过大、回调难以定位。你只想改一个昵称结果整包下发接收端只能整体替换UI刷新逻辑也会被多余字段反复触发。所以 FDSync 的数据同步模块从一开始就按“数据对象 字段”两级模型设计。一个玩家、一间房、一个任务都是一个 DataObjectDataObject 里每个字段独立管理版本。同步包中只携带发生过变更的字段接收端按版本号决定是否应用这样既省带宽又让数据冲突的位置精确到字段。3.2 脏标记与合并字段级增量听起来简单实现里有几个细节值得说。FDSync 会为每个 DataObject 维护一个脏字段表业务在同一个逻辑帧内多次修改同一个字段时只保留最后一次写入值。这个“合并”策略减少了不少无谓传输。同时 SDK 提供批量提交接口让业务可以把多个字段改动打包成一个变更集一个变更集对应一个同步事件。典型用法是这样var obj dataSync.GetOrCreateObject(player_1, Player); using (var batch obj.BeginBatch()) { obj.SetField(hp, 90); obj.SetField(mp, 80); obj.SetField(weapon, sword_03); // 中间所有修改在Commit时统一合并 } obj.Commit();通道选择上FDSync 把字段分成三类实时字段、可靠字段、控制字段。实时字段用于位置、朝向这类高频数据允许丢弃旧值只保留最新可靠字段用于金币、血量、道具不能丢控制字段用于创建对象、删除对象必须保证严格的先后顺序。业务声明字段类型时就指定通道SDK 根据通道决定底层传输策略。3.3 冲突处理与版本合并多人同时修改数据对象必然遇到冲突。FDSync 使用的版本规则比较接近 Lamport 时间戳的思想每个字段自带一个单调递增的版本号写入时取本端已知最大版本加一应用时只接受比自己当前版本更大的更新。冲突规则如下场景处理方式不同玩家修改不同字段双方修改都生效不同玩家修改同一字段版本号高者胜版本相同时帧号大者胜业务有特殊合并逻辑通过冲突回调让业务自行合并这套规则能覆盖绝大多数常规项目。如果你要做的是一把武器同时被两个人抢夺这类强一致场景就不能只靠后写覆盖必须在业务层加锁或仲裁。顺便提一句字段版本不能汇报各客户端的系统时间因为时钟偏移会造成“明明是后写版本号却更小”的假象。3.4 数据同步与帧同步的协同机制FDSync 允许数据对象在帧同步流程内被写入字段并自动携带当前的frameId。这样一来数据变更的顺序就能跟逻辑帧对齐对需要回放的分析场景特别重要。不过这里有个容易踩的坑不要把帧同步的核心战斗状态也丢进数据同步模块。战斗中的位置、血量如果既走帧同步又走数据同步会出现两个数据源相互覆盖这正是我们项目早期线上事故的根源。我的原则很简单战斗状态帧同步管展示状态数据同步管。数据同步负责那些“业务上独立、不需要每帧精确一致”的数据例如玩家昵称、房间配置、排行榜战斗内部的“真实状态”只有帧同步引擎能改。4. 接入流程与核心API两天跑通一个Demo4.1 API 使用前的三个前提FDSync 的 API 本身不复杂但接入之前必须想清楚三件事。第一模拟器必须能写成纯函数式逻辑接收FrameContext里面包含帧号和输入列表然后修改自己内部状态。如果业务逻辑依赖外部事件、UI、音频那先要把这些外部依赖剥离出去。第二数据结构要确定。不要在模拟器里塞Dictionarystring, object当万能状态序列化、快照、hash 计算都会变得不可控。建议定义明确的字段结构序列化方案用扁平的二进制格式或 Protocol Buffers。第三想清楚帧率与输入延迟。低帧率有更好的性能但对操作响应不敏感高帧率手感好但带宽和 CPU 消耗上升。策略类 20Hz 常见动作类 30Hz 常见。输入延迟默认 2 帧对局类游戏可以先从这里起步。4.2 帧同步接入流程接入代码只需要完成初始化、提交输入、消费帧三步。一个最小化的接入长这样var engine FdsEngine.Create(new FdsOptions { FrameRate 20, InputDelay 2, SnapshotInterval 20, RoomId room_01, UserId user_01, Simulator new BattleSimulator() }); engine.OnFrameReady (frame, inputs) { // 本帧输入已经按玩家序号排好顺序 foreach (var p in inputs) { simulator.ApplyInput(p); } // 所有输入应用完再执行本帧逻辑 simulator.Step(frame.FrameId); }; // 本地玩家产生输入编码后提交 engine.SubmitInput(InputCodec.Encode(operation));提交输入前不必担心顺序问题SDK 内部会对帧做缓冲和排序。消费帧时也不要在处理函数里直接调用引擎再次提交输入那样会打断帧执行流程。业务逻辑与帧回调之间尽量通过消息队列解耦防止一帧处理时间过长拖垮调度。4.3 数据同步接入流程数据同步的接入更直接核心是 GetOrCreateObject、SetField、Commit 三件套外加字段变更回调。var dataSync engine.CreateDataSyncService(); var playerObj dataSync.GetOrCreateObject(player_1); playerObj.SetField(nickname, 夜行者); playerObj.Commit(); playerObj.OnFieldChanged (field, value, version) { // 收到远端字段更新注意切回UI线程再做表现层刷新 dispatcher.Post(() UpdateUI(field, value)); };这里要注意回调线程与 UI 线程的关系。网络收到更新时可能处于 SDK 的 IO 线程如果直接把值交给 UI 控件会出现偶发崩溃。最好在接入层统一做线程切换。另一个容易被忽视的点是字段更新回调可能因为本地修改而触发需要根据来源标记判断是不是要刷新 UI避免自己改完又被自己触发一遍。4.4 示例双人实时协同画板跑通一个双人画板 Demo是验证这套 SDK 是否好用的最快方式。如果只要求显示最终画布数据同步就够了鼠标移动的轨迹点作为实时字段写入数据对象另一端订阅字段刷新渲染。但如果要求两台设备看到完全一致的笔顺、而且能回放每一次落笔就需要帧同步。实现思路是每帧采集本帧内的鼠标采样点编码成输入包提交引擎按帧号顺序把输入交给画板模拟器模拟器按固定顺序往画布模型中追加顶点序列画布模型维护一个轻量级 hash任何一端 hash 不一致就立刻报警。我们曾用这个 Demo 验证了整个 SDK 的确定性、回放、断线重连三项能力效果比直接写在白板业务里清晰得多。5. 网络层是另一半工程弱网下的取舍5.1 传输层选择帧同步和实时数据同步的压力主要在传输层。TCP 有天然的顺序与可靠保证但在弱网环境下队头阻塞非常影响帧到达的实时性一个包丢了后续一堆包都要排队等待重传。FDSync 默认采用 UDP 自研可靠层的组合把控制类消息走可靠有序通道高频位置类消息走即时丢弃通道帧输入走一个折中的可靠有序通道但对等待时间做了上限。二次封装时也可以直接换用 KCP、ENET 或 QUIC 这类现成协议。我的建议是不要迷信某一种协议关键是它必须支持“消息优先级”和“部分消息可丢弃”的能力。FDSync 把传输层抽象成了接口业务可以自行替换但网络层之上暴露给下层的语义必须统一。5.2 消息优先级与带宽限制弱网环境下最忌讳把所有消息一视同仁地塞进一个可靠通道。FDSync 的消息被分成四级优先级消息类型可靠性顺序要求丢包策略P0创建/销毁、快照、重连信令可靠严格必须重传P1帧输入包可靠严格重传超时后走快照P2数据字段可靠更新可靠宽松重传P3高频实时字段允许丢弱只发最新值同时 FDSync 内置一个令牌桶进行带宽限制。比如帧输入通道设成每玩家每帧最多 256 字节实时字段通道按总带宽的 30% 封顶。设置上限不是为了让传输变慢而是防止某个玩家的异常高频写入把整局通信拖垮。这个设计在高并发房间特别重要否则只要有一个端的字段更新逻辑写出死循环整个房间都会遭殃。5.3 弱网模拟与压力测试提个建议上线前不要只在“网速很好”的办公室测一定要在弱网模拟器里跑。FDSync 的工具层内置了网络模拟参数丢包率从 1% 到 10%、延迟抖动按正态分布、带宽限制到足以触发拥塞的值。每次调完逻辑都要跑一遍固定的弱网用例而不是等客户反馈。我们的常用压测方式是单房间 8 个模拟客户端逻辑帧率 20Hz每人每帧输入 64 字节服务端数据同步维护一万个活跃 DataObject。在 5% 丢包、延迟抖动 80ms 的条件下连续跑 30 分钟帧哈希一致性保持 100%重连恢复时间控制在几百毫秒以内。这个结果不是一上来就有的中间调过输入延迟、调过快照频率、也砍过服务端日志写入量。压力测试的价值不只是确认真能用更多是帮你找出隐藏的并发问题和资源瓶颈。5.4 上线后的排查清单问题往往会以“延迟升高”或“不同步”的形式出现。我整理了一份基于实际经验的排查清单现象可能原因排查手段解决方向所有客户端延迟同步上升服务端帧打包积压观察帧广播耗时时长优化打包逻辑、降低帧率某个玩家操作明显延迟目标延迟固定过高检查RTT和输入缓冲深度动态调整输入延迟帧哈希不一致逻辑缺帧/浮点误差对比多端回放日志对齐输入顺序、修浮点重连后状态异常快照版本落后查看快照帧号服务端发送最新快照后补输入历史数据字段不刷新UI回调线程错误看线程模型统一调度到UI线程6. 上线前必须解决的几个魔鬼细节6.1 时间同步与帧对账多人同步里最容易踩的暗坑是客户端与服务端的时间基准不一致。客户端上报自己本地的时间服务端直接拿这个时间打帧号会造成帧对账混乱。FDSync 内部实现了类似 NTP 的简单时钟采样在握手阶段测量 RTT用(T1 T2) / 2估算时钟偏移后续上报统一使用修正后的单调时钟。逻辑计时还要避免使用系统墙钟。客户端切后台再回前台系统时间可能跳变直接用墙钟会让人物剧情闪现。正确做法是用单调递增时钟只负责计算本帧距离上一帧的间隔真正的同步节拍靠服务端帧号驱动。6.2 跨平台浮点与处理器差异浮点确定性问题我在 2.2 节提过这里展开到具体的处理方案操作风险建议处理浮点除法平台指令集不同可能产生尾数差异使用定点数或整数化三角函数库实现差异明显预计算查找表随机数全局种子漂移SDK统一确定性随机源物理碰撞迭代碰撞顺序受对象遍历顺序影响自己实现确定性物理或固定排序最关键的一条不要相信“同一份代码在不同设备结果一定相同”。跨平台下任何浮点数运算都可能因为编译器优化而不同。一开始就统一为整数和定点数能省掉后期大量排查成本。6.3 回滚与预测的边界帧同步经常会用到预测加回滚。FDSync 提供RollbackScope来标记可回滚的状态但这只是给模拟层用的。UI动画、音频播放、粒子特效、外部服务请求都不应该放在可回滚状态里它们一旦被执行了回滚无法撤销副作用。举个例子预测自己发出一个技能命中特效已经播放下一秒服务端下发真实帧发现该技能因为距离判定并未命中。回滚只能重置逻辑层状态播放过的特效需要由表现层自己去匹配和隐藏。这个边界必须在架构层面划清否则回滚机制最终会被业务代码“绕过”大家为了表现正确开始改逻辑层确定性就保不住了。6.4 引擎集成逻辑层与渲染层分离对于 Unity、UE 这类游戏引擎FDSync 特别强调逻辑层与渲染层分离。帧同步模拟器应该是一份不依赖场景对象的纯 C# 或 C 模块渲染层只读取逻辑层暴露的位置、旋转等只读数据不能直接修改模拟器的状态。避免在Update里跑逻辑逻辑要由 SDK 的固定节奏驱动而不是渲染帧驱动。很多人问我为什么不能直接用Transform.position同步。原因有两个第一渲染帧率不固定可能 30 帧也可能 120 帧直接驱动逻辑会造成帧序错乱第二场景对象属于表现层把逻辑直接挂在场景对象上序列化、快照、回放都会变得极其痛苦。把渲染层当作逻辑层的“显示器”很多问题就不存在了。6.5 对账监控与回放留证上线之后还要有对账机制。每帧哈希一致只能说明模拟过程本身没问题不能说明操作是否合理。建议在服务端把每个房间的帧输入日志保存最近一段时间客户端上报异常帧号后直接拉回放文件做离线导出。这比让玩家录屏、开发猜原因要高效得多。SDK 版本升级时序列化格式必须保持向后兼容。我们遇到过升级后旧回放文件无法解析的情况后来统一约定字段结构变化时老的回放文件用旧解析器处理新版本不做隐式兼容。这在同步类 SDK 里值得奉为铁律。最后分享一个我自己的经验同步 SDK 好不好的评判标准不是 API 多炫、协议多复杂而是业务同学在写代码的时候能不能完全不关心同步。一旦业务层开始自己偷偷处理断线重连、自己缓存消息、自己改消息顺序说明 SDK 的边界已经裂了。FDSync 在设计上始终强调业务只把逻辑写成一个接收输入并修改状态的函数同步交给 SDK如果哪一天我们又想在业务代码里找同步 bug第一步不是去修 bug而是回去看看 SDK 的接口或者边界是不是设计错了。这个原则比任何优化技巧都值得先定下来。