You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Unity游戏C# TCP服务端大批量消息丢失问题排查与解决咨询

Unity TCP客户端消息丢失问题修复方案

问题定位

本地测试正常,但部署到远程服务器(AWS EC2/家用服务器)后,发送100+条消息时客户端无法接收全部内容,且出现packetLength=16777216(2^24)的异常值。已排除防火墙问题,问题根源在客户端数据包解析逻辑,同时服务端发送逻辑也存在优化空间。


核心问题分析

  1. 粘包导致解析错位:TCP是流协议,多次发送的数据包会被合并传输。当前客户端缓存处理逻辑错误,未正确保留未处理的剩余数据,导致长度字段读取错位,解析出异常长度值。
  2. 缓存重置逻辑错误:ReceiveCallback中调用receivedData.Reset(HandleData(_data)),若HandleData返回false(存在未处理数据),Reset会清空缓存,直接丢失剩余数据。
  3. 字节序不匹配:服务端与客户端的ReadInt()/WriteInt()字节序不一致,小端数据被当成大端读取时,会出现0x00000010变成0x10000000(即16777216)的异常。
  4. 服务端无发送确认:使用BeginWrite无回调,无法确认数据是否发送成功,快速发送大量消息时可能导致缓冲区溢出。

修复方案

一、修正客户端数据包解析逻辑

1. 重构缓存与接收回调逻辑

取消错误的Reset操作,改为将新数据追加到缓存,保留未处理的剩余数据:

private void ReceiveCallback(IAsyncResult _result){
    try {
        int _byteLength = stream.EndRead(_result);
        if (_byteLength <= 0){
            client.Disconnect();
            return;
        }

        // 将新接收的数据追加到缓存,而非覆盖重置
        receivedData.AppendBytes(receiveBuffer, 0, _byteLength);
        // 处理所有能解析的完整数据包
        HandleCompletePackets(); 
        stream.BeginRead(receiveBuffer, 0, dataBufferSize, ReceiveCallback, null);
    } catch(Exception _ex) {
        Disconnect();
        Debug.Log(_ex);
    }
}

2. 优化数据包解析循环

使用预览长度替代直接读取,避免因数据不足导致的解析错位,同时增加非法长度校验:

private void HandleCompletePackets(){
    const int MAX_PACKET_SIZE = 1024 * 1024; // 限制最大包大小,防止恶意数据
    while (receivedData.UnreadLength() >= 4){
        // 先预览长度,不移动读取指针
        int _packetLength = receivedData.PeekInt();
        if (_packetLength <= 0 || _packetLength > MAX_PACKET_SIZE){
            Debug.LogError($"非法数据包长度: {_packetLength},断开连接");
            client.Disconnect();
            return;
        }

        // 确认缓存中有足够数据后,再正式读取
        if (receivedData.UnreadLength() >= 4 + _packetLength){
            receivedData.ReadInt(); // 跳过预览的长度字段
            byte[] _packetBytes = receivedData.ReadBytes(_packetLength);
            ThreadManager.ExecuteOnMainThread(() => {
                using (Packet _packet = new Packet(_packetBytes)){
                    int _packetId = _packet.ReadInt();
                    if (client.packetHandlers.TryGetValue(_packetId, out var handler)){
                        handler(_packet);
                    } else {
                        Debug.LogError($"未找到包ID {_packetId}的处理器");
                    }
                }
            });
        } else {
            // 数据不足,等待下一次接收
            break;
        }
    }
}

注:需要给receivedData类添加PeekInt()方法(只读不移动指针)和AppendBytes()方法(追加数据到缓存)。

3. 统一字节序

确保服务端和客户端的整数读写使用相同字节序,比如统一用小端:

  • 服务端写入整数时:BitConverter.GetBytes(_value)(若系统是小端),或强制用BitConverter.GetBytes(_value).Reverse()转为大端(网络字节序)
  • 客户端读取时对应处理,避免解析错误。

二、优化服务端发送逻辑

添加发送回调确认发送完成,避免缓冲区溢出,同时实现发送队列控制速率:

private Queue<Packet> sendQueue = new Queue<Packet>();
private bool isSending = false;

public void SendData(Packet _packet)
{
    if (socket == null) return;
    lock (sendQueue)
    {
        sendQueue.Enqueue(_packet);
    }
    if (!isSending)
    {
        ProcessSendQueue();
    }
}

private void ProcessSendQueue()
{
    lock (sendQueue)
    {
        if (sendQueue.Count == 0)
        {
            isSending = false;
            return;
        }
        isSending = true;
        Packet packet = sendQueue.Dequeue();
        stream.BeginWrite(packet.ToArray(), 0, packet.Length(), OnSendCompleted, null);
    }
}

private void OnSendCompleted(IAsyncResult ar)
{
    try
    {
        stream.EndWrite(ar);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"发送数据失败: {ex}");
    }
    // 继续发送队列中的下一个包
    ProcessSendQueue();
}

三、实现消息确认与未接收回调

通过**请求-响应(ACK)**机制处理消息丢失,触发未接收回调:

客户端ACK逻辑

处理完消息后回复确认包:

// 示例:注册消息处理器时添加ACK回复
client.packetHandlers[(int)PacketIDs.GameMessage] = (packet) => {
    int msgId = packet.ReadInt();
    // 处理业务逻辑...
    
    // 发送ACK确认
    using (Packet ackPacket = new Packet((int)PacketIDs.ACK)){
        ackPacket.Write(msgId);
        client.SendTCPData(ackPacket);
    }
};

服务端超时与重传逻辑

维护未确认消息列表,超时触发重传或未接收回调:

private Dictionary<int, (Packet packet, DateTime sendTime, int retryCount)> unAckedMessages = new Dictionary<int, (Packet, DateTime, int)>();
private int nextMsgId = 0;
private const int MAX_RETRY = 3;
private const int TIMEOUT_SECONDS = 5;

// 发送可靠消息(需要确认)
public void SendReliableData(Packet _packet){
    int msgId = nextMsgId++;
    // 在数据包头部加入唯一消息ID
    Packet reliablePacket = new Packet(_packet);
    reliablePacket.InsertInt(0, msgId); // 插入到包开头
    SendData(reliablePacket);
    unAckedMessages[msgId] = (reliablePacket, DateTime.Now, 0);
}

// 定时检查超时(建议用定时器每秒执行一次)
private void CheckMessageTimeout(){
    List<int> failedMsgs = new List<int>();
    foreach(var kvp in unAckedMessages){
        if ((DateTime.Now - kvp.Value.sendTime).TotalSeconds > TIMEOUT_SECONDS){
            if (kvp.Value.retryCount < MAX_RETRY){
                // 重传消息
                SendData(kvp.Value.packet);
                unAckedMessages[kvp.Key] = (kvp.Value.packet, DateTime.Now, kvp.Value.retryCount + 1);
            } else {
                // 重传失败,触发未接收回调
                OnMessageUnreceived(kvp.Key, kvp.Value.packet);
                failedMsgs.Add(kvp.Key);
            }
        }
    }
    // 清理失败的消息
    foreach(int msgId in failedMsgs){
        unAckedMessages.Remove(msgId);
    }
}

// 处理客户端ACK
private void HandleACK(Packet packet){
    int msgId = packet.ReadInt();
    if (unAckedMessages.ContainsKey(msgId)){
        unAckedMessages.Remove(msgId);
    }
}

// 自定义未接收回调
private void OnMessageUnreceived(int msgId, Packet packet){
    Console.WriteLine($"消息ID {msgId} 重传{MAX_RETRY}次后仍未被客户端接收");
    // 这里可添加业务逻辑,比如记录日志、通知玩家等
}

内容的提问来源于stack exchange,提问作者Rick Vink

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.19 16:20:42