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