内部测试第 4 天QA 提了一个 P0。标题只有七个字“一个人卡全场卡。”复现步骤更简单1. 开一局 5v5 2. 把其中【任意一台】测试机的网络设为 400ms 延迟 3. 观察其余 9 台结果被限速的那台 : 逻辑帧率 3.2 fps ★ 其余 9 台 : 逻辑帧率 3.2 fps一个数都不差。开发的第一反应是是不是测试环境串了。重测三遍数字一模一样。这不是 bug。这是严格锁步Strict Lockstep的设计本身。十个人被一根绳子捆在一起绳子的长度由跑得最慢的那个人决定。第一幕先理解它为什么「必须等」帧同步的铁律所有客户端跑【完全相同】的逻辑代码 ↓ 喂给它们【完全相同】的输入序列 ↓ ★ 必然得到【完全相同】的结果所以它同步的不是世界状态而是每个人按了什么键。// 每台设备上都跑着这个循环一模一样voidLogicTick(intframeId,PlayerInput[]allInputs){for(inti0;iallInputs.Length;i)_units[i].ApplyInput(allInputs[i]);UpdateProjectiles();ResolveDamage();UpdateAI();}注意参数allInputs—— 所有人的输入。少一个人的这一帧就不能跑。少一个会怎样玩家 7 的输入没到 ↓ 如果我先跑这一帧把他当没操作 ↓ ★ 但他实际上按了大招 ↓ 其他人的机器上他放了大招我的机器上他站着不动 ↓ ★ 世界分叉了 —— 从此两个平行宇宙再也回不来所以严格锁步的逻辑是无懈可击的要么等到所有人的输入要么不跑。不存在第三种选择。它的正确性 100%。它的代价是——把十个人的命运焊死在一起。代码长这样// ❌ 严格锁步的服务器主循环publicclassStrictLockstepServer{Dictionaryint,Dictionarylong,PlayerInput_pendingnew();int_currentFrame0;int_playerCount10;publicvoidRun(){while(_running){varframeInputs_pending.GetValueOrDefault(_currentFrame);// ★★★ 血案的源头就是这一行 ★★★if(frameInputsnull||frameInputs.Count_playerCount){Thread.Sleep(2);continue;// ← 死等一个都不能少}Broadcast(_currentFrame,frameInputs);_pending.Remove(_currentFrame);_currentFrame;}}}frameInputs.Count _playerCount—— 这一个条件判断是后面所有血案的共同祖先。第二幕血案现场血案一延迟传染——所有人的延迟 最差那个人的延迟时间线还原t0ms 服务器开始收集第 1024 帧的输入 t35ms 玩家 1~6 的输入到了网络好 t52ms 玩家 8~10 的输入到了 ★ 还差玩家 7他在地铁里 t52~430ms ★★★ 九个人在等一个人 ★★★ 这 378 毫秒里服务器什么都没做 十台手机的逻辑世界全部冻结 t430ms 玩家 7 的输入终于到了 t431ms 广播第 1024 帧 ★ 这一帧耗时 431ms而设计值是 66ms ★ 实际逻辑帧率2.3 fps用数字说话玩家自身网络 RTT实际感受到的延迟玩家 1光纤18 ms431 ms玩家 2光纤22 ms431 ms………玩家 7地铁420 ms431 ms这就是严格锁步最残酷的地方你花钱拉的千兆光纤一点用都没有。你的游戏体验由那个在地铁里的陌生人决定。更让人无语的是它的传播性10 个人的对局只要有【任意一人】网络差 ↓ ★ 100% 的玩家受影响而10 个人里至少有一个网络差的概率有多大假设单人网络良好的概率 95%已经很乐观了 10 人全部良好的概率 0.95^10 59.9% ★ 也就是说40% 的对局会有人拖后腿血案二死亡螺旋——卡顿会自我放大这是最恐怖的一个因为它会正反馈。① 画面卡住了 ↓ ② ★ 玩家的本能反应疯狂点屏幕 / 狂搓技能 ↓ ③ 客户端老老实实把每一次点击都发出去 ↓ ④ 上行包量暴增 5~10 倍 ↓ ⑤ 本来就拥堵的上行链路彻底堵死 ↓ ⑥ 更卡 ↓ 回到 ① ★★★ 螺旋下降 ★★★实测数据弱网玩家在卡顿 2 秒后正常状态上行 15 包/秒 卡顿期间★ 上行 112 包/秒代码上的问题出在哪// ❌ 每次点击都立刻发一个包voidOnSkillButtonClicked(intskillId){varpktnewInputPacket{Frame_localFrame,SkillskillId};_socket.Send(Serialize(pkt));// ★ 玩家点 10 次 发 10 个包}必须改成按帧聚合// ✅ 输入只累积到当前帧的缓冲里按固定节拍统一发送PlayerInput_pendingInput;voidOnSkillButtonClicked(intskillId){_pendingInput.SkillFlags|(byte)(1skillId);// ★ 只置位不发包}voidFixedNetworkTick()// 每 66ms 调用一次雷打不动{_pendingInput.Frame_localFrame;SendWithRedundancy(_pendingInput);// 一帧一个包不多不少_pendingInputdefault;}这条规则值得单独记住客户端的发包节奏必须由时钟驱动绝不能由玩家的手指驱动。因为人在焦虑时会疯狂输入而那恰恰是网络最差的时候。血案三掉线 全场人质玩家 7 不是慢是直接断网了。// ❌ 原始实现没有超时机制if(frameInputs.Count_playerCount){Thread.Sleep(2);continue;}// ★ 玩家 7 永远不会发来输入了 → 死循环 → 游戏永久冻结加个超时坑更多。// ⚠️ 看似修好了其实引入了新问题if(frameInputs.Count_playerCount){if(Now()-_frameStartTimeTIMEOUT_MS){Thread.Sleep(2);continue;}// 超时了把缺失的当无操作补上FillMissingWithIdle(frameInputs);}新问题一超时设多少设 200ms → 网络稍差的人被频繁判无操作技能经常丢 设 800ms → ★ 每次有人卡全场冻结 800ms两头都是错的。新问题二掉线者回来了怎么办玩家 7 断线 5 秒约 75 帧 ↓ 服务器把这 75 帧都补成了无操作 ↓ 玩家 7 重连要追 75 帧 ↓ ★ 追帧期间他又跟不上实时进度 ↓ ★ 如果服务器还在等他全场再次冻结新问题三超时判定本身也会传染玩家 7 在 200ms 超时线附近反复横跳 ↓ 有时压线到达全场等 195ms 有时超时全场等 200ms 然后判无操作 ↓ ★ 帧间隔在 66ms / 195ms / 200ms 之间剧烈抖动 ↓ 玩家看到的画面一顿一顿比稳定的卡更难受血案四追帧雪崩——修好网络反而更卡这是最反直觉的一个。场景某玩家断网 3 秒后恢复服务器把积压的 45 帧一次性推给他。// ❌ 一口气全跑完voidUpdate(){while(_frameQueue.Count0){LogicTick(_frameQueue.Dequeue());// ★ 45 帧 × 14ms 630ms}Render();}手机上的实际表现主线程卡死 630ms ↓ ★ 系统认为应用无响应 ↓ Android: 触发 ANR 警告 iOS: Watchdog 计数 1 ↓ ★ 连续几次 → 进程被系统直接杀掉 ↓ 玩家看到的游戏闪退了而这时候严格锁步的第二刀来了这个玩家在追帧本地卡死 630ms ↓ ★ 追帧期间他发不出新的输入 ↓ ★ 服务器还在等他的输入 ↓ ★ 全场又冻结了 ↓ 雪崩追帧 → 发不出输入 → 全场等 → 积压更多 → 追更久正确的追帧限速 限时// ✅ 双重预算控制constintLOGIC_DT_MS66;constintMAX_CATCHUP_MS8;// ★ 一个渲染帧最多花 8ms 追帧voidUpdate(){varswStopwatch.StartNew();intbacklog_frameQueue.Count;// 根据积压程度决定这次最多跑几帧intmaxStepsbacklog20?6:backlog8?3:1;intsteps0;while(stepsmaxSteps_frameQueue.Count0){if(sw.ElapsedMillisecondsMAX_CATCHUP_MS)break;// ★ 时间预算用完LogicTick(_frameQueue.Dequeue());steps;}// ★ 关键追帧期间也要照常发送本地输入不能断if(Now()-_lastSendTimeLOGIC_DT_MS)SendLocalInput();Render();}⚠️注意那行追帧期间也要照常发送本地输入。很多实现把发包放在LogicTick里追帧时逻辑跑飞了发包节奏也跟着乱。必须把发包和跑逻辑解耦各走各的时钟。血案五一个比特的分叉整局报废严格锁步的另一面它假设所有端算得一模一样。一旦这个假设破了后果比卡顿严重得多。// ❌ 看起来人畜无害的一行floatdistMathf.Sqrt(dx*dxdy*dy);if(distskillRange)ApplyDamage(target);iPhone 14 : dist 5.0000000000 某 Android : dist 5.0000001192 ← 差 1 个 ULP skillRange 5.0 ↓ iPhone : 5.0 5.0 → ★ 命中 Android: 5.0000001 5.0 → ★ 没中 ↓ 一个端敌人死了一个端敌人还活着 ↓ ★ 10 秒后两个端的世界完全不同更阴险的是它的隐蔽性分叉发生在第 1024 帧 ↓ 但表现上毫无异常就少了一次伤害 ↓ 第 1500 帧一个端的小兵推到了塔下另一个端还在河道 ↓ 第 2000 帧玩家发现我明明打死他了队友说他还活着 ↓ ★ 等到被发现早就不知道是哪一帧出的问题必须有帧哈希校验// 每 30 帧算一次世界快照的指纹uintComputeChecksum(){uinth2166136261u;// FNV-1aforeach(varidin_sortedUnitIds)// ★ 必须有序遍历{varu_units[id];hMix(h,(uint)u.Pos.RawX);// ★ 定点数的原始整数值hMix(h,(uint)u.Pos.RawY);hMix(h,(uint)u.Hp);hMix(h,(uint)u.StateId);}returnh;}staticuintMix(uinth,uintv){h^v;h*16777619u;returnh;}服务器比对十个端的哈希一旦不一致① 立刻打点帧号 各端哈希 各端设备型号 ② ★ 保存完整输入序列 → 后台自动复现 ③ 客户端下发权威快照强制重同步保对局不崩没有帧哈希的帧同步项目等于在裸奔。你永远不知道线上有多少局正在平行宇宙里跑。血案六重连风暴服务器抖动一下10 人同时断线 ↓ 10 个客户端同时重连 ↓ 每个都要请求从第 X 帧到最新帧的全部数据 ↓ ★ 服务器瞬间要发 10 份历史帧可能几千帧 ↓ 带宽打满 → 重连超时 → 再次重连 ↓ ★ 惊群 雪崩两个必须做的防护// ① 客户端指数退避 随机抖动intdelayMath.Min(300*(1_retryCount),10000);delayRandom.Shared.Next(0,delay);// ★ 抖动是关键打散并发awaitTask.Delay(delay);// ② 服务器关键帧快照而不是重放全部历史// 每 300 帧存一个世界快照// 重连时发【最近的快照】【快照之后的输入】// → 从重放 3000 帧变成重放 300 帧第三幕解药——把绳子剪断核心思路服务器不再等人// ✅ 乐观帧Optimistic LocksteppublicclassOptimisticServer{constintLOGIC_DT_MS66;publicvoidRun(){longnextTickNowMs();while(_running){nextTickLOGIC_DT_MS;SleepUntil(nextTick);// ★ 按节拍走不看任何人脸色varinputsnewPlayerInput[_playerCount];for(inti0;i_playerCount;i){if(_pending[i].TryTake(_currentFrame,outvarinp)){inputs[i]inp;_lastInput[i]inp;}else{// ★ 没到 → 预测inputs[i]Predict(_lastInput[i]);_stats.PredictCount[i];}}BroadcastWithRedundancy(_currentFrame,inputs);_currentFrame;}}// ★★★ 预测规则是整个方案的灵魂 ★★★staticPlayerInputPredict(PlayerInputlast){returnnewPlayerInput{MoveDirlast.MoveDir,// ✅ 移动沿用人在跑大概率还在跑SkillFlags0,// ★ 技能必须清零TargetSlot0,// ★ 目标必须清零};}}SkillFlags 0这一行是血的教训。如果沿用上一帧的技能标志玩家按了大招 → 丢包 → 预测沿用 → 又放一次大招 → 再丢包 → 再放一次 → ★ 自动连放大招CD 全乱规则连续量方向、摇杆可以预测离散事件技能、攻击绝不能预测。代价转移谁网差谁承担严格锁步乐观帧一人 460ms10 人全卡到 2 fps9 人稳定 15 fps✅卡顿者本人和别人一样卡丢失几帧输入谁买单所有人只有他自己配套三件套① 冗余输入帧同步最划算的买卖// 一帧输入只有 3 字节那就每个包多带几份voidSendWithRedundancy(PlayerInputcur){_history.Enqueue(cur);while(_history.Count4)_history.Dequeue();varpktnewUpPacket{BaseFrame_localFrame,Inputs_history.ToArray()// ★ 最近 4 帧12 字节};_socket.Send(Serialize(pkt));// 加包头总共 40 字节}丢包率的变化链路丢包 5%不冗余 : 5% 冗余 4 份: 0.05^4 ★ 0.000625%而代价只是从 3 字节变成 12 字节。这是整个游戏网络里最便宜的一次防御。② 自适应输入缓冲classAdaptiveFrameBuffer{QueueFrameData_qnew();int_target2;publicFrameDataTryPop()_q.Count_target?_q.Dequeue():null;// ★ 没攒够就不跑publicvoidAdjust(floatjitterMs,floatlossRate){if(jitterMs80||lossRate0.05f)_targetMath.Min(_target1,6);// 网差 → 多垫两帧elseif(jitterMs20_q.Count_target)_targetMath.Max(_target-1,1);// 网好 → 降延迟}}③ 表现层撒谎voidOnSkillPressed(intskillId){// ★ 立刻播前摇动画、音效、UI 转圈 —— 玩家马上看到反馈Presentation.PlayCastAnim(skillId);// 逻辑层老老实实等服务器_pendingInput.SkillFlags|(byte)(1skillId);}MOBA 的技能前摇有 0.3~0.6 秒——这正好是一个天然的延迟掩体400ms 的往返可以完整藏进去。玩家看到的是我的英雄在抬手而不是我的操作没反应。第四幕那严格锁步还有用武之地吗有而且是不可替代的。场景为什么严格锁步反而合适单机 / 本地对战没有网络不存在等待问题回合制 / 卡牌天然就要等对手行动不在乎 300msRTS星际类操作本身有指令延迟惯性玩家已习惯竞技模式 高门槛可以强制要求网络达标不达标不许进对局回放/验证★ 100% 确定性用于反作弊复核严格锁步不是落后的技术它是正确性优先的选择。乐观帧是体验优先的选择——它用极小的正确性妥协偶尔丢一帧输入换来了巨大的体验提升。所有工程都是取舍。关键是你得知道自己在舍什么。Checklist帧驱动⭐服务器固定节拍推进绝不等人乐观帧输入预测方向沿用技能/目标必须清零逻辑帧率与渲染帧率解耦客户端发包⭐发包由时钟驱动不由玩家点击驱动防死亡螺旋一帧一个包输入在帧内聚合⭐上下行都带 3~4 帧冗余不做重传追帧期间照常发包发包与逻辑解耦缓冲与追帧自适应 Jitter Buffer按抖动/丢包调整⭐追帧双预算限帧数 限时间≤ 8ms/渲染帧表现层平滑过渡不跟随逻辑层瞬移确定性⭐逻辑层全定点数禁用 float禁用无序容器遍历Dictionary/HashSet确定性随机数种子服务器下发表现层绝不调用逻辑层随机数会打乱调用次数⭐帧哈希校验 不同步自动上报回放容灾每 300 帧存关键帧快照重连不重放全历史重连指数退避 随机抖动不同步时下发权威快照强制重同步监控各端预测率、不同步率、追帧峰值耗时测试⭐ 单机限速测试只限速一台看其余机器帧率tc qdiscadddev eth0 root netem delay 400ms 100ms loss8%跨平台一致性iOS/Android 跑同一份输入比对哈希30 分钟长局不同步率监控收尾回到 QA 提的那个 P0——“一个人卡全场卡”。它之所以让人震惊是因为它违反了一个朴素的直觉我的网络好我就应该玩得爽。但严格锁步把这条直觉撕碎了。它的逻辑是既然所有人必须算出同一个世界那么这个世界推进的速度就只能由最慢的那个人决定。它没有错。它只是把正确看得比体验重。而现代帧同步做的所有事——乐观帧、输入预测、冗余、限速追帧、表现层撒谎——本质上只干了一件事把那根捆住十个人的绳子剪断然后在每个人脚上单独系一根。谁跑得慢谁自己被绊一下。剩下九个人继续跑。而玩家最终看到的是那个 460 亮起来的瞬间——队友的走位顿了半拍而自己的技能依然应声而出。
