Unity实时对战网络优化:协议瘦身、心跳调整与异步接收
事情是从一次深夜告警开始的。当时我们某个实时对战项目刚开放压测一进战场就有玩家反馈画面卡成幻灯片看流量统计更是吓了一跳单局十分钟的移动同步居然跑出远超预期的流量。我和负责服务端的同学把抓包日志摊开发现协议里塞了一堆客户端根本用不到的字段坐标、方向、速度全都是全套float心跳包就像定时闹钟一样不管有没有业务数据都在猛发。这些问题叠加在一起主线程还同步阻塞在Socket的Receive调用上不卡才怪。后来我们把这堆问题拆成五个方向来做减少无用字段、降低字段精度、byte转vector3表示方向、心跳包优化、异步线程接收。五件事做完单局流量和主线程卡顿都有非常明显的改善。这篇文章就把这套优化过程按实际推进顺序拆开讲如果你正在做Unity移动端实时对战或者要跟服务端同学一起订正网络协议应该能直接拿走其中一部分方案。1. 一次线上告警的复盘先量化再动手1.1 我们是怎么发现问题的项目并不是一上来就崩。早期玩法demo人少一局只有几个内部账号在打网络层怎么糙都看不出来。压测规模一起来问题立刻暴露有的机型发热明显有的低端安卓机在主界面进战斗时掉帧到没法操作更夸张的是单局流量曲线几乎跟着战斗时长线性涨。我们没有急着改代码而是先把问题量化。主要做了三件事抓包统计单局所有上行和下行的消息类型及占比把每条报文的字段和客户端实际读取点做了一次对照再用Unity Profiler抓主线程耗时看卡顿到底花在哪里。结果整理成一张表检查项现象结论单局流量移动同步类消息占绝对大头单包字段偏大协议体过重需要瘦身心跳包空闲时每3秒一个空包占总报文数30%以上心跳频率不合理主线程卡顿Receive等待和网络处理占主线程耗时10%~15%同步接收模型需要替换这张表本身不复杂但它决定了后续所有工作的优先级。改协议不是“顺手”的事客户端服务端要同步发版字段一旦删错线上玩家可能直接收不到关键消息。所以先搞清楚哪些报文吃掉最多流量、哪些字段实际没人用、卡顿是在网络层还是逻辑层再考虑怎么改。1.2 三个瓶颈怎么排队处理我们的实际处理顺序和标题略有不同。异步线程接收最先做因为只要主线程还卡在Socket上后面所有优化都没法稳定验证心跳包优化排第二因为它不涉及协议字段兼容改动量小、收益直观字段裁剪和精度压缩最后做因为它们需要客户端和服务端同时改协议并且要规划灰度。这个顺序有个好处每一步都能单独上线、单独验证。网络问题最怕一次改造牵扯太多变量最后出了问题不知道是新协议导致的还是线程模型导致的。先解决线程再动报文链路会清晰很多。1.3 动手前先给协议做“字段盘点”别看着哪个字段没用就顺手删。我们当时用了一个笨但有效的办法把服务端协议定义导出来逐个字段问客户端同学“这个字段谁在读”没人能立刻回答的再通过搜索代码二次确认。同时让服务端在日志里记录每条消息字段的上报频率重点看那些“回传了但客户端从未引用”的字段。很多无用字段就是在这轮盘点里浮出来的旧版本兼容字段、调试用字段、服务端主动塞进来的时间戳还有明明可以用状态位表达却用了整个int的枚举。删字段的前提是能证明它无用而不是感觉它无用。2. 协议瘦身删字段、砍精度而不是只盯着字节流2.1 哪些字段属于“无用字段”无用字段比想象中多。常见几类历史兼容字段。早期协议版本留下的旧字段老客户端早就没有流量了但服务端还在照常填充。服务端时间戳。很多移动同步包里带一个long类型的时间戳客户端其实有自己的本地时钟真正需要时间对齐时也会优先用服务器的帧序号这个字段几乎没人读。可推导字段。同时下发“速度”和“位移增量”同时下发“朝向向量”和“朝向角度”这些字段之间存在固定换算关系保留一个就够。调试字段。压测阶段为了查问题加的半成品字段上线前没有清理。最典型的是一个消息里同时包含pos和lastPos客户端完全可以通过插值自己算出上一个位置服务端却把两个三维float全传过来了。这种字段删掉之后单条消息直接少24字节。2.2 字段精度怎么砍给数值定一个“够用”的标准降精度不是无脑把float改成short要先问一个问题这个数值在业务里变化范围多大人眼能感知到的最小差异是多少。以移动同步为例世界坐标通常不需要精确到毫米。地图边界比如是-2048到2048米用ushort从0到65535做定点映射精度大约能到0.0625米也就是6.25厘米。玩家位移在客户端表现上根本觉察不到厘米级误差但字段从4字节降到了2字节。Y坐标因为地图高度差一般不大可以用short范围-512到512米精度能到0.015625米。方向字段更典型。一个归一化Vector3是3个float12字节实际只需要水平朝向角。后面专门讲byte转Vector3的方案。速度字段可以把连续float映射成档位byte比如0到7档对应静止、走路、跑步、疾跑等。动画状态和移动状态经常是重复信息保留一个枚举就行。定点坐标转换可以做成公共工具函数public static ushort EncodeCoord(float v, float min, float max) { float normalized Mathf.Clamp01((v - min) / (max - min)); return (ushort)(normalized * ushort.MaxValue); } public static float DecodeCoord(ushort raw, float min, float max) { return min (raw / (float)ushort.MaxValue) * (max - min); }这套函数客户端和服务端要严格共用同一版本否则一端编码一端解码坐标会偏。2.3 一个移动同步包的压缩对照拿我们实际的移动同步包举例优化前长这样字段原类型原字节playerIdint4position3 * float12direction3 * float12speedfloat4animStateint4flags4 * bool4timelong8冗余字段若干16合计64优化后字段新类型新字节playerIdushort2positionushort ushort short6directionbyte1speedbyte1animStatebyte1flagsbyte 位段1timeuint324合计16单条移动同步包从64字节降到16字节整整砍掉75%。这个压缩比在实时对战里非常可观因为移动同步是每秒钟几十条的最高频消息。2.4 协议升级的兼容策略字段精简之后协议本身是不兼容的。我们给所有消息外套了一层协议头里面带版本号。服务端同时保留新旧两套解析逻辑灰度期间按客户端版本分发新客户端走新协议老客户端继续走旧协议等新版本覆盖率足够高之后再下线旧逻辑。这里最怕的是“吞错”。新协议解析如果发现长度对不上或者字段值明显异常一定不要静默忽略直接断线并上报错误日志否则后面所有同步数据都是错的玩家会看到更诡异的现象。3. 一字节方向Byte转Vector3的量化原理与边界3.1 为什么一个字节反而够用很多同学看到“byte转Vector3表示方向”会觉得离谱一个方向在三维空间里明明需要三个float才能表示一个字节怎么可能够。关键在于我们只关心水平朝向。角色在绝大多数游戏里不会像飞机一样任意翻滚移动方向本质上是在二维地面上转的一个角度。方向向量从“三维单位向量”退化成了“圆周上的一个点”0到255刚好可以把圆周切成256段。如果去掉0作为“无方向”的哨兵值剩下的1到255对应255个方向档位每个档位约1.41度。1.41度的误差在玩家视角里几乎看不出来因为客户端并不是每一帧都拿这个方向硬切而是用旋转插值把角色从当前角度转到目标角度。加上网络同步本身就有延迟和丢包这点量化误差完全被掩盖了。3.2 一字节方向的编解码实现直接放代码。编码端把归一化方向转成0到360度再映射到1到255public static byte EncodeDirection(Vector3 dir) { if (dir.sqrMagnitude 0.0001f) return 0; // 0 表示无方向 float angle Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg; if (angle 0f) angle 360f; return (byte)(Mathf.FloorToInt(angle / 360f * 255f) 1); } public static Vector3 DecodeDirection(byte raw) { if (raw 0) return Vector3.zero; float angleRad (raw - 1) / 255f * 360f * Mathf.Deg2Rad; return new Vector3(Mathf.Sin(angleRad), 0f, Mathf.Cos(angleRad)); }代码里的关键点有几个。Atan2的输入是x和z对应Unity水平面上的横轴和纵轴返回角度以弧度表示要转成度数。编码时加1是为了把1到255留给有效方向0专门表示无效方向。解码时减1还原档位得到的Vector3一定落在XZ平面且接近单位长度。这里没有用Mathf.RoundToInt而用FloorToInt主要为了避免角度刚好在360度时溢出成0。如果你倾向取间隔中点来进一步降低误差可以在解码公式里给档位加0.5编码端保持不变。3.3 哪些场景不能硬上一字节一字节方向只适合水平朝向。如果是飞行射击、太空战斗这类需要俯仰角的方向同步就得考虑2字节甚至更多。常见的替代方案是yaw和pitch各占一个byte或者把两者的量化值拼成一个ushort。额外补一个长度或速度字段就能覆盖“带大小的方向向量”。另外需要连续平滑旋转插值的场景也不要试图靠更强精度解决。插值本来就应该在客户端做帧与帧之间的角度变化可以通过时间和当前角度推算协议端只负责传目标朝向。3.4 实际踩过的坑第一个坑是方向向量没归一化。客户端发送前如果拿的是currentTransform.forward一般已经是归一化的但如果是从其他向量算出来的长度可能不为1。好在Atan2只看方向不看长度这个坑不算致命可服务端如果用DecodeDirection解出的单位向量去做移动验证就要保证编码端输入也是方向量而不是位移量。第二个坑是“无方向”和“方向为0度”的冲突。方向朝正Z轴时角度为0如果不单独留一个哨兵值解码后玩家会被强制归零。我们采用raw 0表示无方向实际角度全部往后顺延一位处理起来最干净。第三个坑是服务端验证客户端位移时要使用同一套解码函数。服务端不能为了省事继续用客户端上报前的原始float方向做校验否则和客户端看到的方向不一致会把正常玩家判定为瞬移。4. 心跳包优化从定时轰炸到按需心跳4.1 心跳包为什么存在但默认方案太粗暴心跳包是为了让中间设备和服务器知道“这条连接还活着”。移动网络下客户端IP和端口可能因为NAT超时被回收如果不定期发数据服务器那边也会因为长时间没收到包而主动断开。很多项目图省事直接设成3秒一个心跳。结果就是在线玩家什么操作都不做客户端也在不停发空包。压测时一统计心跳包能占到总报文数的30%以上服务端为这些空包白白消耗CPU和带宽。4.2 按需心跳用业务流量自动续期核心思路是只要有任意业务包发送连接就是活的不需要额外心跳。只有在一段时间内完全没有业务流量时才补发一个心跳包维持连接。实现起来很简单记录最后一次发送任何包的时间每次发包时更新private float _lastAnyPacketTime; private const float HeartbeatInterval 30f; public void SendPacket(byte[] data) { _socket.Send(data); _lastAnyPacketTime Time.realtimeSinceStartup; } void Update() { if (Time.realtimeSinceStartup - _lastAnyPacketTime HeartbeatInterval) { SendHeartbeat(); _lastAnyPacketTime Time.realtimeSinceStartup; } }在战斗场景里移动同步、技能、伤害这些消息本身就一直在发心跳逻辑被完全压制不会额外占用流量。只有玩家站街时才会每30秒发一个心跳包。和原来3秒一个相比空闲状态下的心跳包数量减少了90%。有个细节要注意如果客户端在发送线程里发包而主线程读_lastAnyPacketTime会有线程安全问题。可以用int lastTick Environment.TickCount配合原子操作来记录避免用Time.realtimeSinceStartup跨线程访问。4.3 调整心跳间隔和服务端超时时间的比例心跳间隔不是越小越稳要和服务器判定超时的阈值拉开一个安全倍数。我们当时用30秒心跳、90秒无数据判定离线留了3倍余量。像NAT设备常见的60秒左右空闲回收30秒刚好赶在它前面。场景心跳间隔服务端判定超时空闲连接每小时空包优化前3秒10秒1200优化后30秒90秒120服务端判定离线不要只看心跳应该看“任意包”。只要业务包还在收发就不应该因为心跳没来就断线。这一点改动往往比客户端调间隔更关键。4.4 前后台切换和断线重连移动端进后台后定时器会暂停心跳发不出去。这时要主动停掉心跳计时而不是让代码继续跑。回到前台后第一时间补发一个心跳并且给连接一个很短的探测期如果发现socket已经不可用就直接走重连流程。重连之后还有一个容易被忽略的地方玩家回到战斗场景时客户端不能再按“之前的最后状态”继续同步而应该请求服务端补发最近状态快照。否则位置、朝向、血量都会和服务端对不齐。4.5 心跳优化里的隐性问题发送失败时不要更新_lastAnyPacketTime。曾经遇到过网络层发送业务包抛了异常上层以为发出去了随手更新了时间结果空闲计时被重置心跳迟迟没发服务端认为掉线。正确做法是发包成功后才更新时间发送失败直接触发断线重连逻辑。另外有些网络库自带重连功能重连成功后要重置空闲计时否则刚连接上就发现“空闲时间超了”立刻补发一个心跳虽然问题不大但会让日志看起来很奇怪。5. 异步线程接收主线程不碰Socket只碰队列5.1 主线程同步Receive为什么必然卡顿Unity的主线程承担了渲染、逻辑、UI和物理任何阻塞调用都会直接体现在帧率上。Socket.Receive在没有数据时会让当前线程进入等待状态。就算网络状态正常单独几次调用的耗时不大可多人在线的战斗场景里消息频率极高反序列化一点点耗时都会叠加上去。我们最初看Profiler主线程里网络相关耗时很长底层就是阻塞Receive加同步解析。解决方向不是把Receive改成非阻塞轮询而是彻底把“收字节”这个动作移出主线程。5.2 独立接收线程 并发队列最直观的方案是开一个后台线程专注调用Receive把收到的字节流按包拆分后丢进并发队列主线程在每帧Update里批量取出完整包解析。业务逻辑仍然在主线程跑线程安全的边界只有一条队列。private readonly ConcurrentQueuebyte[] _packetQueue new ConcurrentQueuebyte[](); private volatile bool _recvRunning; private Thread _recvThread; void StartRecvThread() { _recvRunning true; _recvThread new Thread(() { byte[] buffer new byte[4096]; while (_recvRunning) { try { int length _socket.Receive(buffer); if (length 0) break; byte[] copy new byte[length]; Buffer.BlockCopy(buffer, 0, copy, 0, length); _packetQueue.Enqueue(copy); } catch (SocketException ex) { if (_recvRunning) { Disconnect(ex.SocketErrorCode); break; } } } }) { IsBackground true }; _recvThread.Start(); } void Update() { while (_packetQueue.TryDequeue(out byte[] packet)) { ProcessPacket(packet); } }注意这里每次Receive后都把数据拷贝一份再入队因为接收缓冲区会被下一次Receive覆盖。如果希望减少GC可以设计一个对象池收到包后从池里拿对象不再使用后归还。但对大多数项目来说先跑通功能再优化分配也不迟。5.3 拆包和粘包必须自己处理TCP是字节流不是消息流。Receive返回的长度不代表一个完整网络包很可能收到半个包也可能一次收到好几个包。所以接收线程不能傻傻地把length当成一包就入队而是要维护一段累计缓冲区先解析包头长度再切出完整报文。最简单的长度头格式4字节int表示payload长度后面跟payload。接收线程不断把新数据追加进缓冲区循环切割void OnRawData(byte[] chunk, int count) { _recvBuffer.AddRange(chunk, 0, count); while (_recvBuffer.Count 4) { int payloadLen BitConverter.ToInt32(_recvBuffer.Array, _recvBuffer.Offset); if (_recvBuffer.Count 4 payloadLen) break; byte[] packet new byte[payloadLen]; Buffer.BlockCopy(_recvBuffer.Array, _recvBuffer.Offset 4, packet, 0, payloadLen); _recvBuffer.RemoveRange(0, 4 payloadLen); _packetQueue.Enqueue(packet); } }如果消息频率高用Listbyte加RemoveRange会频繁搬移内存。实时对战项目建议用环形缓冲区或者带读写游标的字节缓冲这里不再展开但方向是对的。5.4 队列积压和丢包策略接收线程往队列里塞包的速度远大于主线程消耗速度时队列会无限膨胀最终OOM。要设置一个上限。超过上限时不能简单Clear所有包得区分同步消息和事件消息。位置、朝向这类同步消息丢旧保新反而更合理因为玩家看到的是最新位置旧包没意义。技能开始、伤害结算、掉线通知这类事件消息则一条都不能丢。我们当时的做法是队列满时丢弃队首的同步类旧包保证关键事件不丢同时上报警告方便开发期及时发现主线程处理不过来。5.5 为什么不直接上异步Socket API后来我们也比较过SocketAsyncEventArgs方案。它在数据到达时通过线程池回调通知本质上仍然要把数据交回主线程处理否则Unity对象访问会出问题。既然绕不开线程切换那独立接收线程配合并发队列反而更直观谁能看到字节流、谁在解析包、谁在调Unity API三者的边界非常清楚排查问题也容易。对Unity项目来说稳定和可控比极致性能更重要。独立线程接收虽然多了一个线程但换来了主线程不被阻塞的确定性。6. 压测数据复盘与两个容易后悔的细节6.1 模拟压测得到的数据整套优化做完我们在模拟环境下跑了一轮同类战局结果看起来比较直观指标优化前优化后移动同步包平均字节6416空闲连接心跳频率3秒/个30秒/个主线程网络相关耗时明显占用基本可忽略单局上行流量相对值100%约35%这个结果不是靠某一个手段得到的是线程模型、心跳、协议压缩三件事合力的结果。流量下降主要来自字段精简和方向压缩主线程改善主要来自异步接收心跳优化则让服务端网关的压力明显下降。6.2 值得后悔的第一个细节调试信息被删太干净字段精简的时候我们把日志、调试开关、冗余状态全删了。线上真的出问题时才发现想排查某个玩家当时的客户端状态协议里根本没有可用的上下文。后来补了一个可裁剪的debugContext字段平时默认不填排障时服务端按需下发开启指令客户端再把关键状态打包上来。这个字段平时占用几乎可以忽略关键时刻能救命。6.3 值得后悔的第二个细节编解码逻辑在客户端服务端各写了一份方向压缩和坐标定点的编解码函数客户端和服务端各写了一份看起来逻辑一样但浮点取整和边界处理差了一点点。压测时大部分玩家没问题个别玩家在特定朝向角会出现轻微漂移最后定位到是两端解析方向时档位映射差了一位。从此以后统一改成从一个代码仓库生成公共库客户端和服务端引用同一份编解码源码并且把0度、无方向、边界坐标这类用例全部写成单元测试。这轮优化做完我的体会是网络模块优化不是把每个包压得越小越好而是先看清“卡”和“费”来自哪里再决定是切线程、调心跳还是砍字段。每一步改动都要能单独验证、单独上线。如果只让我留一条经验我会说协议压缩和线程优化本身都不难难的是让客户端和服务端长期保持同一套“换算标准”这件事越早统一后面踩的坑越少。