TCP 可靠、UDP 快,所以“重要数据走 TCP,位置同步走 UDP”——这句话没错,但只说到了表面。
游戏网络真正要判断的并不是“哪个协议更快”,而是:
这条消息如果晚到、丢失、重复,甚至被下一条消息覆盖,到底有没有关系?
MyFramework 同时维护 TCP 和 UDP 两条通信链路,业务层仍然只调用统一的sendPacket()。这一篇就结合实际代码看看,两种协议到底应该怎么分工。
项目地址:
GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub
一、业务层根本不直接选择 TCP 或 UDP
发送一条协议时,业务代码通常只有:
CSChangePosition packet = CSChangePosition.get(); ... sendPacket(packet);真正选择传输通道的是NetManager:
public 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,先问一个问题
假设角色连续产生三次位置:
A:100,100 B:105,100 C:110,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 必须先把缺失数据恢复出来。
对于:
交易 背包 任务 登录这是我们想要的。
但对于高频位置:
A:1秒前的位置 B:500ms前的位置 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 Header:8 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()。