
TCP 可靠、UDP 快所以“重要数据走 TCP位置同步走 UDP”——这句话没错但只说到了表面。游戏网络真正要判断的并不是“哪个协议更快”而是这条消息如果晚到、丢失、重复甚至被下一条消息覆盖到底有没有关系MyFramework 同时维护 TCP 和 UDP 两条通信链路业务层仍然只调用统一的sendPacket()。这一篇就结合实际代码看看两种协议到底应该怎么分工。项目地址GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub一、业务层根本不直接选择 TCP 或 UDP发送一条协议时业务代码通常只有CSChangePosition packet CSChangePosition.get(); ... sendPacket(packet);真正选择传输通道的是NetManagerpublic void sendPacket(NetPacket packet) { if (mNetPacketTypeManager.isUDPPacket(packet.getPacketType())) { Character myself mCharacterManager.getMyself(); if (myself ! null) { mUDPServer.setToken(myself.getGUID()); } mUDPServer.sendNetPacket(packet); } else { mTCPServer.sendNetPacket(packet); } }所以整个业务层仍然面对同一种NetPacket只是协议注册阶段决定这个Packet ↓ TCP 还是 UDP这样业务代码不需要到处写if (useUDP) { ... } else { ... }传输方式成为了协议本身的属性。二、选 TCP 还是 UDP先问一个问题假设角色连续产生三次位置A100,100 B105,100 C110,100如果 A 丢了但 C 已经到了A × B √ C √还需要想办法把 A 找回来吗显然没必要。因为最新位置 C 已经让旧位置 A 失去价值这就是 UDP 最适合的一类数据旧数据会被新数据自然覆盖。但换成购买物品 扣除金币 领取奖励 交易确认情况完全不同。“购买成功”这个消息不能因为下一条消息到了就认为上一条可以不要。所以第一条判断原则其实是新消息能不能代替旧消息能才有资格考虑 UDP。不能优先 TCP。三、为什么位置同步特别适合 UDP项目中的CSChangePosition就是一条典型的位置同步协议public BIT_VECTOR2_INT mPixelPos new(); public BIT_LONG mTimeStamp new(); public BIT_USHORT mNormalMoveSpeed new(); public BIT_USHORT mMoveTimeMS new(); public BIT_USHORT mMoveSpeed new(); public BIT_USHORT mMoveDelta new(); public BIT_USHORT mMoveDeltaAdjusted new(); public BIT_USHORT mMapID new(); public BIT_BOOL mRun new();这里甚至包含位置 时间戳 移动速度 移动时间 地图ID 移动状态也就是说它表达的是角色此时此刻是什么状态。如果 100ms 前的位置包丢了而新的位置已经到达通常继续使用最新状态即可。而且CSChangePosition自己还携带mTimeStamp这类时间信息也给业务层判断数据新旧留下了空间。UDP 不可靠并不意味着协议设计就必须完全不知道“新旧”。四、真正麻烦的是TCP 和 UDP 之间根本没有顺序这一点比“UDP 会丢包”更容易被忽略。假设服务器依次产生TCP玩家离开场景 UDP玩家位置同步或者位置包其实更早发出只是在网络上晚到了。客户端完全可能先收到玩家离开场景然后又收到一个旧的玩家位置同步因为TCP 只能保证 TCP 自己内部有序。UDP 和 TCP 两条链路之间没有一个共同的消息顺序。项目里的SCOtherPlayerPosition就专门处理了这个问题CharacterPlayer player mOtherPlayerManager.getPlayerOther(mPlayerGUID[i]); if (player null) { return; } // 位置同步使用UDP,其他消息使用TCP, // 所以玩家离开场景后仍可能收到位置消息 if (!mMapSceneManager.getMap().isPlayerInScene(player)) { return; }这段代码非常有代表性。TCP 已经告诉客户端这个玩家离开场景了但 UDP 的旧位置包可能随后才飞过来。所以不能看到位置包就无脑应用而是必须再次确认这个玩家现在还在不在场景这其实就是混合 TCP UDP 后必须面对的跨通道时序问题。五、UDP 不只是“更快”而是不会等待旧数据TCP 还有一个非常关键的特性。假设Packet A Packet B Packet C底层属于同一条 TCP 字节流。如果 A 对应的一部分数据丢失即使 B、C 的网络数据已经到达应用层也不能越过 A 直接拿到后面的字节。TCP 必须先把缺失数据恢复出来。对于交易 背包 任务 登录这是我们想要的。但对于高频位置A1秒前的位置 B500ms前的位置 C现在的位置如果 A 已经过时却还阻塞着后面的 C就没有那么划算了。UDP 的思路完全不同A 丢了 ↓ 算了 B 到了 ↓ 处理 C 到了 ↓ 继续处理它不保证可靠却避免了旧数据拖住新数据。所以实时同步真正看重的不只是延迟低而是最新状态能够尽快到达业务层。六、哪些消息绝对不能直接扔给 UDP例如购买道具客户端 购买商品1001 服务器 扣100金币 增加1个商品如果用当前这种没有 ACK、重传、去重机制的 UDP请求丢了 → 到底要不要重发 重发了 → 第一条是不是其实已经到了 到了两次 → 会不会购买两次马上就会引入请求ID ACK 超时 重传 去重 幂等 顺序控制写到最后本质上是在 UDP 上重新实现一套可靠协议。而 TCP 已经帮你做了这些底层工作。所以类似登录 购买 交易 邮件 任务提交 背包操作 装备操作 奖励领取这种每一次状态变化都有业务意义的消息更适合直接交给 TCP。七、UDP 最大的问题不是丢包而是你必须接受它丢包MyFramework 当前NetConnectUDPBit没有实现ACK 丢包重传 乱序重排 重复包过滤这其实反而让 UDP 的定位非常清晰。它不是用 UDP 实现另外一套 TCP。而是只把真正允许丢失的数据放进去。所以判断一条消息是否适合 UDP可以连续问四个问题丢一次能接受吗 晚到能接受吗 新消息能覆盖旧消息吗 乱序到达时业务能识别或容忍吗只要其中某一个答案是不能就应该非常谨慎。八、不要为了省十几个字节强行用 UDP从纯协议开销看在最基础的 IPv4 情况下不考虑额外选项TCP Header至少20 Byte UDP Header8 ByteUDP 确实更轻。但游戏里选 UDP 的核心原因绝对不是每个包少12 Byte而是它的传输语义更适合高频 实时 允许丢失 最新状态优先如果为了省一点包头把交易系统改成 UDP然后自己增加ACK Sequence Retry Timeout Duplicate Check最后可能不仅没省多少复杂度还直接爆炸。九、MyFramework 的 TCP / UDP 分工最终可以总结成TCP ──────────────── 可靠、有序 旧数据不能随便丢 适合业务状态变化 登录 背包 装备 交易 任务 邮件 购买 奖励 UDP ──────────────── 允许丢包 最新数据可以覆盖旧数据 不等待历史状态 角色位置 高频实时状态 允许丢失的同步数据 UDP心跳而 MyFramework 在上层把两套传输统一成了NetPacket ↓ sendPacket() ↓ 根据PacketType判断 ↙ ↘ TCP UDP ↓ ↓ NetConnectTCP NetConnectUDP业务层不需要关心 Socket。真正需要想清楚的是这条消息到底属于“事件”还是“状态”。一次购买、一次交易、一次领取奖励是不能消失的事件。角色现在在哪里、朝向哪里、正在以什么速度移动更接近可以不断覆盖的状态。这往往比“TCP 和 UDP 谁性能更高”更能决定一条游戏协议到底应该走哪条链路。而一旦同时使用两条链路还必须记住最后一个坑TCP 内部有顺序UDP 内部不保证顺序而 TCP 和 UDP 之间更不存在任何全局顺序。这也是为什么真正成熟的游戏网络代码绝不会只是简单地把Send()换成SendTo()。