事情是从一次深夜告警开始的。当时我们某个实时对战项目刚开放压测,一进战场就有玩家反馈画面卡成幻灯片,看流量统计更是吓了一跳:单局十分钟的移动同步居然跑出远超预期的流量。我和负责服务端的同学把抓包日志摊开,发现协议里塞了一堆客户端根本用不到的字段,坐标、方向、速度全都是全套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个float,12字节,实际只需要水平朝向角。后面专门讲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 一个移动同步包的压缩对照
拿我们实际的移动同步包举例,优化前长这样:
| 字段 | 原类型 | 原字节 |
|---|---|---|
| playerId | int | 4 |
| position | 3 * float | 12 |
| direction | 3 * float | 12 |
| speed | float | 4 |
| animState | int | 4 |
| flags | 4 * bool | 4 |
| time | long | 8 |
| 冗余字段 | 若干 | 16 |
| 合计 | 64 |
优化后:
| 字段 | 新类型 | 新字节 |
|---|---|---|
| playerId | ushort | 2 |
| position | ushort + ushort + short | 6 |
| direction | byte | 1 |
| speed | byte | 1 |
| animState | byte | 1 |
| flags | byte 位段 | 1 |
| time | uint32 | 4 |
| 合计 | 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到255:
public 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 ConcurrentQueue<byte[]> _packetQueue = new ConcurrentQueue<byte[]>(); 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); } }如果消息频率高,用List<byte>加RemoveRange会频繁搬移内存。实时对战项目建议用环形缓冲区或者带读写游标的字节缓冲,这里不再展开,但方向是对的。
5.4 队列积压和丢包策略
接收线程往队列里塞包的速度远大于主线程消耗速度时,队列会无限膨胀,最终OOM。要设置一个上限。超过上限时不能简单Clear所有包,得区分同步消息和事件消息。
位置、朝向这类同步消息丢旧保新反而更合理,因为玩家看到的是最新位置,旧包没意义。技能开始、伤害结算、掉线通知这类事件消息则一条都不能丢。我们当时的做法是队列满时丢弃队首的同步类旧包,保证关键事件不丢,同时上报警告,方便开发期及时发现主线程处理不过来。
5.5 为什么不直接上异步Socket API
后来我们也比较过SocketAsyncEventArgs方案。它在数据到达时通过线程池回调通知,本质上仍然要把数据交回主线程处理,否则Unity对象访问会出问题。既然绕不开线程切换,那独立接收线程配合并发队列反而更直观:谁能看到字节流、谁在解析包、谁在调Unity API,三者的边界非常清楚,排查问题也容易。
对Unity项目来说,稳定和可控比极致性能更重要。独立线程接收虽然多了一个线程,但换来了主线程不被阻塞的确定性。
6. 压测数据复盘与两个容易后悔的细节
6.1 模拟压测得到的数据
整套优化做完,我们在模拟环境下跑了一轮同类战局,结果看起来比较直观:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 移动同步包平均字节 | 64 | 16 |
| 空闲连接心跳频率 | 3秒/个 | 30秒/个 |
| 主线程网络相关耗时 | 明显占用 | 基本可忽略 |
| 单局上行流量相对值 | 100% | 约35% |
这个结果不是靠某一个手段得到的,是线程模型、心跳、协议压缩三件事合力的结果。流量下降主要来自字段精简和方向压缩,主线程改善主要来自异步接收,心跳优化则让服务端网关的压力明显下降。
6.2 值得后悔的第一个细节:调试信息被删太干净
字段精简的时候,我们把日志、调试开关、冗余状态全删了。线上真的出问题时才发现,想排查某个玩家当时的客户端状态,协议里根本没有可用的上下文。后来补了一个可裁剪的debugContext字段:平时默认不填,排障时服务端按需下发开启指令,客户端再把关键状态打包上来。这个字段平时占用几乎可以忽略,关键时刻能救命。
6.3 值得后悔的第二个细节:编解码逻辑在客户端服务端各写了一份
方向压缩和坐标定点的编解码函数,客户端和服务端各写了一份,看起来逻辑一样,但浮点取整和边界处理差了一点点。压测时大部分玩家没问题,个别玩家在特定朝向角会出现轻微漂移,最后定位到是两端解析方向时档位映射差了一位。从此以后统一改成从一个代码仓库生成公共库,客户端和服务端引用同一份编解码源码,并且把0度、无方向、边界坐标这类用例全部写成单元测试。
这轮优化做完,我的体会是:网络模块优化不是把每个包压得越小越好,而是先看清“卡”和“费”来自哪里,再决定是切线程、调心跳还是砍字段。每一步改动都要能单独验证、单独上线。如果只让我留一条经验,我会说:协议压缩和线程优化本身都不难,难的是让客户端和服务端长期保持同一套“换算标准”,这件事越早统一,后面踩的坑越少。