网络-分包/客户端ID冲突/超时重传/重复提交与状态一致等
思路≠实际应用
壹、分包
一、理论
当数据量超过协议层 MTU(最大传输单元)时,需要将数据拆分为多个分包传输,接收端再重组为完整数据。
需要注意 3 个关键问题:
- 分包标识:每个分包需包含 “总包数” 和 “当前包序号”,确保接收端能按顺序重组。
- 数据长度:每个分包需标记当前包的有效数据长度。
- 校验(可选):通过校验和 / CRC 确保数据完整性。
根据以上点,可用如下格式确定包体:
[4字节:总包数] + [4字节:当前包序号] + [4字节:当前包数据长度] + [N字节:当前包数据]
跨平台传输时,需统一字节序。
二、代码举例
2.1 C#
2.1.1 发送
using System;
using System.Net.Sockets;
using System.Text;class Sender {static void SendData(TcpClient client, byte[] data) {const int MAX_PACKET_SIZE = 1024; // 每个分包最大数据量int totalPackets = (data.Length + MAX_PACKET_SIZE - 1) / MAX_PACKET_SIZE; // 总包数NetworkStream stream = client.GetStream();for (int i = 0; i < totalPackets; i++) {int offset = i * MAX_PACKET_SIZE;int dataSize = Math.Min(MAX_PACKET_SIZE, data.Length - offset);// 构建协议头(总包数、当前序号、数据长度,各占4字节)byte[] header = new byte[12];BitConverter.GetBytes(totalPackets).CopyTo(header, 0); // 总包数BitConverter.GetBytes(i).CopyTo(header, 4); // 当前序号BitConverter.GetBytes(dataSize).CopyTo(header, 8); // 数据长度// 发送头 + 数据stream.Write(header, 0, header.Length);stream.Write(data, offset, dataSize);}}static void Main() {TcpClient client = new TcpClient("127.0.0.1", 8888); // 连接服务器byte[] bigData = Encoding.UTF8.GetBytes(new string('A', 10240)); // 10KB数据SendData(client, bigData);client.Close();}
}2.1.2 接收
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;class Receiver {static byte[] ReceiveData(TcpClient client) {Dictionary<int, byte[]> packets = new Dictionary<int, byte[]>();int totalPackets = -1;int receivedCount = 0;NetworkStream stream = client.GetStream();while (true) {// 读取协议头(12字节)byte[] header = new byte[12];stream.Read(header, 0, header.Length);int total = BitConverter.ToInt32(header, 0);int index = BitConverter.ToInt32(header, 4);int dataSize = BitConverter.ToInt32(header, 8);if (totalPackets == -1) {totalPackets = total;}// 读取当前包数据byte[] data = new byte[dataSize];stream.Read(data, 0, dataSize);// 存储分包packets[index] = data;receivedCount++;// 所有包接收完成,重组if (receivedCount == totalPackets) {List<byte> fullData = new List<byte>();for (int i = 0; i < totalPackets; i++) {fullData.AddRange(packets[i]);}return fullData.ToArray();}}}static void Main() {TcpListener server = new TcpListener(IPAddress.Any, 8888);server.Start();TcpClient client = server.AcceptTcpClient();byte[] fullData = ReceiveData(client);Console.WriteLine($"接收完整数据,长度:{fullData.Length}字节");client.Close();server.Stop();}
}2.2 C++
2.2.1 发送
#include <iostream>
#include <vector>
#include <cstring>
#include <winsock2.h> // Windows Socket库(Linux用<sys/socket.h>)
#pragma comment(lib, "ws2_32.lib")// 分包发送函数
void SendData(SOCKET sock, const char* data, int totalLen) {const int MAX_PACKET_SIZE = 1024; // 每个分包的最大数据量(小于MTU)int totalPackets = (totalLen + MAX_PACKET_SIZE - 1) / MAX_PACKET_SIZE; // 总包数(向上取整)for (int i = 0; i < totalPackets; ++i) {// 计算当前包的偏移量和数据长度int offset = i * MAX_PACKET_SIZE;int dataSize = std::min(MAX_PACKET_SIZE, totalLen - offset);// 构建分包(协议头 + 数据)char packet[4 + 4 + 4 + MAX_PACKET_SIZE]; // 头12字节 + 最大数据1024字节*(int*)packet = totalPackets; // 总包数(4字节)*(int*)(packet + 4) = i; // 当前包序号(4字节)*(int*)(packet + 8) = dataSize; // 当前包数据长度(4字节)memcpy(packet + 12, data + offset, dataSize); // 复制数据send(sock, packet, 12 + dataSize, 0);}
}int main() {// 初始化Socket(Windows要,Linux略)WSADATA wsaData;WSAStartup(MAKEWORD(2, 2), &wsaData);SOCKET clientSock = socket(AF_INET, SOCK_STREAM, 0);// 连接服务器...std::string bigData(10240, 'A'); // 10KB数据SendData(clientSock, bigData.c_str(), bigData.size());// 关闭资源closesocket(clientSock);WSACleanup();return 0;
}2.2.2 接收
#include <iostream>
#include <map>
#include <vector>
#include <winsock2.h>
#pragma comment(lib, "ws2_32.lib")// 接收并重组数据
std::vector<char> ReceiveData(SOCKET sock) {std::map<int, std::vector<char>> packets; // 存储已接收的分包int totalPackets = -1;int receivedPackets = 0;while (true) {char header[12];recv(sock, header, 12, 0);int total = *(int*)header;int index = *(int*)(header + 4);int dataSize = *(int*)(header + 8);// 首次接收时记录总包数if (totalPackets == -1) {totalPackets = total;}// 接收当前包的数据std::vector<char> data(dataSize);recv(sock, data.data(), dataSize, 0);packets[index] = data;receivedPackets++;// 所有包接收完成,重组数据if (receivedPackets == totalPackets) {std::vector<char> fullData;for (int i = 0; i < totalPackets; ++i) {fullData.insert(fullData.end(), packets[i].begin(), packets[i].end());}return fullData;}}
}int main() {SOCKET clientSock = accept(serverSock, NULL, NULL);std::vector<char> fullData = ReceiveData(clientSock);std::cout << "接收完整数据,长度:" << fullData.size() << "字节" << std::endl;closesocket(clientSock);WSACleanup();return 0;
}贰、多客户端序号冲突
一、理论
当多个客户端向服务器发送分包时,若仅用 “序号”(如 0、1、2...)标识,会出现两个问题:
- 序号重复:客户端 A 和客户端 B 可能同时使用序号
1发送分包,服务器无法区分。 - 排序混乱:服务器需要分别重组每个客户端的分包,不能将不同客户端的序号混在一起排序。
解决方案:给每个分包增加客户端唯一ID(服务器分配给每个客户端的唯一标识),使每个分包的标识变为(ClientID, 序号),确保全球范围内唯一。
根据以上点,可用如下格式确定包体:
[4字节:客户端ID] + [4字节:总包数] + [4字节:当前包序号] + [4字节:当前包数据长度] + [N字节:当前包数据]
二、代码
2.1 发送
分配唯一 ClientID发送分包
class ClientSender {private int clientID; // 从服务器获取的唯一IDprivate int packetIndex = 0; // 本客户端的分包序号(独立递增)public void SendBigData(byte[] data) {const int MAX_PACKET_SIZE = 1024;int totalPackets = (data.Length + MAX_PACKET_SIZE - 1) / MAX_PACKET_SIZE;NetworkStream stream = client.GetStream();for (int i = 0; i < totalPackets; i++) {int offset = i * MAX_PACKET_SIZE;int dataSize = Math.Min(MAX_PACKET_SIZE, data.Length - offset);// 构建协议头(包含ClientID)byte[] header = new byte[16]; // 4字节ClientID + 12字节原有头BitConverter.GetBytes(clientID).CopyTo(header, 0); // 客户端IDBitConverter.GetBytes(totalPackets).CopyTo(header, 4); // 总包数BitConverter.GetBytes(i).CopyTo(header, 8); // 本客户端的序号BitConverter.GetBytes(dataSize).CopyTo(header, 12); // 数据长度// 发送头 + 数据stream.Write(header, 0, header.Length);stream.Write(data, offset, dataSize);}packetIndex = 0; // 一组数据发送完成后重置序号(或继续递增用于下一组)}
}按 ClientID 分组处理
// C#服务器示例:按ClientID分组重组分包
class ServerReceiver {// 缓存结构:ClientID → (分包缓存、总包数、已接收数量)private Dictionary<int, ClientPacketState> clientPackets = new Dictionary<int, ClientPacketState>();public void OnReceivePacket(int clientID, int totalPackets, int index, byte[] data) {// 初始化该客户端的缓存(首次接收时)if (!clientPackets.ContainsKey(clientID)) {clientPackets[clientID] = new ClientPacketState {total = totalPackets,received = 0,packets = new Dictionary<int, byte[]>()};}ClientPacketState state = clientPackets[clientID];// 存储当前分包(过滤重复包,可选)if (!state.packets.ContainsKey(index)) {state.packets[index] = data;state.received++;}// 所有分包接收完成,重组数据if (state.received == state.total) {List<byte> fullData = new List<byte>();for (int i = 0; i < state.total; i++) {fullData.AddRange(state.packets[i]);}Console.WriteLine($"客户端 {clientID} 的数据重组完成,长度:{fullData.Count}");// 清理缓存(避免内存泄漏)clientPackets.Remove(clientID);}}// 辅助类:存储客户端的分包状态private class ClientPacketState {public int total; // 总包数public int received; // 已接收数量public Dictionary<int, byte[]> packets; // 序号→数据}
}接收与重组
class ServerReceiver {private Dictionary<int, ClientPacketState> clientPackets = new Dictionary<int, ClientPacketState>();public void OnReceivePacket(int clientID, int totalPackets, int index, byte[] data) {if (!clientPackets.ContainsKey(clientID)) {clientPackets[clientID] = new ClientPacketState {total = totalPackets,received = 0,packets = new Dictionary<int, byte[]>()};}ClientPacketState state = clientPackets[clientID];if (!state.packets.ContainsKey(index)) {state.packets[index] = data;state.received++;}if (state.received == state.total) {List<byte> fullData = new List<byte>();for (int i = 0; i < state.total; i++) {fullData.AddRange(state.packets[i]);}Console.WriteLine($"客户端 {clientID} 的数据重组完成,长度:{fullData.Count}");// 清理缓存(避免内存泄漏)clientPackets.Remove(clientID);}}private class ClientPacketState {public int total; // 总包数public int received; // 已接收数量public Dictionary<int, byte[]> packets; // 序号→数据}
}叁、超时移除
超时清理:给每个客户端的分包缓存设置超时时间(如 5 秒),超时未接收完整则清理缓存,避免内存泄漏。
void CleanExpiredPackets() {var expiredIDs = clientPackets.Where(kv => DateTime.Now - kv.Value.createTime > TimeSpan.FromSeconds(5)).Select(kv => kv.Key).ToList();foreach (var id in expiredIDs) {clientPackets.Remove(id);}
}肆、超时重传
一、理论
流程
丢包检测→超时触发→重传确认
需注意 3 个问题:
- 确认机制:接收方收到分包后,向发送方返回“确认包”,告知哪些分包已收到。
- 超时判断:发送方为每个未确认的分包设置超时时间,超时未收到 ACK 则重传。
- 重传策略:限制最大重传次数,避免避免网络彻底中断连时无限重传。
根据以上点,可用如下格式确定发送包体:
[1字节:类型(0为数据分包)] + [4字节:客户端ID] + [4字节:数据包唯一标识] + [4字节:总包数] + [4字节:当前包序号] + [4字节:数据长度] + [N字节:数据(Data)]
可用如下格式确接收送包体:
[1字节:类型(1为确认包)] + [4字节:客户端ID(ClientID)] + [4字节:数据包唯一标识(DataID)] + [4字节:已收到的分包序号(Index)] // 告知发送方“该序号的分包已收到”
二、代码
2.1 发送
class SenderWithRetry {private TcpClient client;private int clientID; // 服务器分配的客户端IDprivate int nextDataID = 1; // 数据包唯一标识private Dictionary<int, UnconfirmedPackets> unconfirmedCache = new Dictionary<int, UnconfirmedPackets>(); // DataID → 未确认分包private Timer retryTimer; // 定时检查超时的定时器private const int TIMEOUT_MS = 500; // 超时时间private const int MAX_RETRY = 3; // 最大重传次数public SenderWithRetry(TcpClient client, int clientID) {this.client = client;this.clientID = clientID;// 启动定时器,每100ms检查一次超时retryTimer = new Timer(CheckTimeout, null, 0, 100);}// 发送大数据包public void SendBigData(byte[] data) {int dataID = nextDataID++; // 为当前数据分配唯一IDconst int MAX_PACKET_SIZE = 1024;int totalPackets = (data.Length + MAX_PACKET_SIZE - 1) / MAX_PACKET_SIZE;// 初始化该数据的未确认缓存var packets = new UnconfirmedPackets {total = totalPackets,packets = new Dictionary<int, PacketInfo>(),retryCount = new Dictionary<int, int>() // 记录每个分包的重传次数};// 拆分并发送所有分包for (int i = 0; i < totalPackets; i++) {int offset = i * MAX_PACKET_SIZE;int dataSize = Math.Min(MAX_PACKET_SIZE, data.Length - offset);// 构建数据分包byte[] packet = BuildDataPacket(clientID, dataID, totalPackets, i, data, offset, dataSize);client.GetStream().Write(packet, 0, packet.Length);// 缓存未确认的分包(记录发送时间和数据)packets.packets[i] = new PacketInfo {data = packet,sendTime = DateTime.Now};packets.retryCount[i] = 0; // 初始重传次数0}unconfirmedCache[dataID] = packets;}// 处理接收方返回的确认包(ACK)public void OnAckReceived(int dataID, int index) {if (unconfirmedCache.TryGetValue(dataID, out var packets)) {// 移除已确认的分包packets.packets.Remove(index);packets.retryCount.Remove(index);// 若所有分包都已确认,清理缓存if (packets.packets.Count == 0) {unconfirmedCache.Remove(dataID);Console.WriteLine($"数据 {dataID} 所有分包已确认,传输完成");}}}// 定时检查超时分包并重传private void CheckTimeout(object state) {DateTime now = DateTime.Now;List<int> completedDataIDs = new List<int>();foreach (var kv in unconfirmedCache) {int dataID = kv.Key;var packets = kv.Value;// 遍历该数据的所有未确认分包foreach (var pktKv in packets.packets.ToList()) { // ToList避免迭代中修改集合int index = pktKv.Key;var pktInfo = pktKv.Value;// 检查是否超时if ((now - pktInfo.sendTime).TotalMilliseconds > TIMEOUT_MS) {// 检查是否超过最大重传次数if (packets.retryCount[index] >= MAX_RETRY) {Console.WriteLine($"数据 {dataID} 分包 {index} 重传 {MAX_RETRY} 次失败,放弃传输");packets.packets.Remove(index);packets.retryCount.Remove(index);continue;}// 重传分包client.GetStream().Write(pktInfo.data, 0, pktInfo.data.Length);packets.retryCount[index]++;pktInfo.sendTime = now; // 更新发送时间Console.WriteLine($"数据 {dataID} 分包 {index} 超时,第 {packets.retryCount[index]} 次重传");}}// 若所有分包都失败或确认,标记清理if (packets.packets.Count == 0) {completedDataIDs.Add(dataID);}}// 清理已完成的缓存foreach (var id in completedDataIDs) {unconfirmedCache.Remove(id);}}// 构建数据分包private byte[] BuildDataPacket(int clientID, int dataID, int total, int index, byte[] data, int offset, int dataSize) {int headerSize = 1 + 4 + 4 + 4 + 4 + 4; // 类型(1) + ClientID(4) + DataID(4) + Total(4) + Index(4) + DataLen(4)byte[] packet = new byte[headerSize + dataSize];int pos = 0;// 填充协议头packet[pos++] = 0; // 类型:数据分包BitConverter.GetBytes(clientID).CopyTo(packet, pos); pos += 4;BitConverter.GetBytes(dataID).CopyTo(packet, pos); pos += 4;BitConverter.GetBytes(total).CopyTo(packet, pos); pos += 4;BitConverter.GetBytes(index).CopyTo(packet, pos); pos += 4;BitConverter.GetBytes(dataSize).CopyTo(packet, pos); pos += 4;// 填充数据Array.Copy(data, offset, packet, pos, dataSize);return packet;}private class UnconfirmedPackets {public int total; // 总包数public Dictionary<int, PacketInfo> packets; // 序号→分包数据和发送时间public Dictionary<int, int> retryCount; // 序号→重传次数}private class PacketInfo {public byte[] data; // 分包完整数据(用于重传)public DateTime sendTime; // 最后一次发送时间}
}2.2 接收
class ReceiverWithAck {private TcpClient client;private Dictionary<int, ClientPacketState> clientPackets = new Dictionary<int, ClientPacketState>(); // (ClientID+DataID) → 重组状态public ReceiverWithAck(TcpClient client) {this.client = client;// 启动线程接收数据new Thread(ReceiveLoop).Start();}private void ReceiveLoop() {NetworkStream stream = client.GetStream();byte[] typeBuffer = new byte[1]; while (true) {// 读取包类型stream.Read(typeBuffer, 0, 1);byte type = typeBuffer[0];if (type == 0) {// 处理数据分包HandleDataPacket(stream);} else if (type == 1) {// 处理确认包(服务器作为接收方时,通常不需要处理ACK,此处仅为示例)// (若服务器向客户端发送数据,才需要客户端返回ACK,逻辑类似)}}}private void HandleDataPacket(NetworkStream stream) {// 读取数据分包的协议头(ClientID、DataID、Total、Index、DataLen)byte[] header = new byte[4 + 4 + 4 + 4 + 4]; // 20字节stream.Read(header, 0, header.Length);int pos = 0;int clientID = BitConverter.ToInt32(header, pos); pos += 4;int dataID = BitConverter.ToInt32(header, pos); pos += 4;int total = BitConverter.ToInt32(header, pos); pos += 4;int index = BitConverter.ToInt32(header, pos); pos += 4;int dataSize = BitConverter.ToInt32(header, pos); pos += 4;// 读取分包数据byte[] data = new byte[dataSize];stream.Read(data, 0, dataSize);// 生成唯一键(ClientID + DataID),区分不同客户端的不同数据string key = $"{clientID}_{dataID}";// 初始化重组状态if (!clientPackets.ContainsKey(key)) {clientPackets[key] = new ClientPacketState {total = total,received = 0,packets = new Dictionary<int, byte[]>()};}ClientPacketState state = clientPackets[key];// 存储分包(去重)if (!state.packets.ContainsKey(index)) {state.packets[index] = data;state.received++;// 立即返回确认包(ACK)SendAck(clientID, dataID, index);}// 检查是否所有分包都已收到if (state.received == state.total) {// 重组数据List<byte> fullData = new List<byte>();for (int i = 0; i < total; i++) {fullData.AddRange(state.packets[i]);}Console.WriteLine($"客户端 {clientID} 的数据 {dataID} 重组完成,长度:{fullData.Count}");// 清理缓存clientPackets.Remove(key);}}// 发送确认包(ACK)给发送方private void SendAck(int clientID, int dataID, int index) {// 构建确认包byte[] ackPacket = new byte[1 + 4 + 4 + 4]; // 类型(1) + ClientID(4) + DataID(4) + Index(4)int pos = 0;ackPacket[pos++] = 1; // 类型:确认包BitConverter.GetBytes(clientID).CopyTo(ackPacket, pos); pos += 4;BitConverter.GetBytes(dataID).CopyTo(ackPacket, pos); pos += 4;BitConverter.GetBytes(index).CopyTo(ackPacket, pos); pos += 4;// 发送确认包client.GetStream().Write(ackPacket, 0, ackPacket.Length);}// 辅助类:存储重组状态private class ClientPacketState {public int total;public int received;public Dictionary<int, byte[]> packets;}
}三、注意
批量确认(减少 ACK 数量):接收方不必收到一个分包就发一次 ACK,可累计多个序号,打包成一个 ACK 发送,减少网络开销(适用于高并发场景)。
动态超时时间:超时时间可根据 RTT动态调整(如 RTT 为 200ms,则超时设为 RTT 的 2~3 倍),避免网络延迟大时误判丢包。
快速重传:若接收方收到乱序分包(如已收到
0,2,但未收到1),可主动发送 “重复 ACK”(如连续 3 次 ACK=1),触发发送方立即重传1,无需等待超时。断线检测:若某个数据的所有分包重传
MAX_RETRY次均失败,可判定为客户端断线,触发重连逻辑。
伍、分布式系统“重复提交”和“状态一致性”
场景:客户端买东西花费10金币,但尚未收到响应就断网了,客户端金币未更新。下一次打开再次购买之后,服务器端会在上次基础上那个再加一次金币扣除,那实际客户端以为只买了一次,缺扣了两次。(不考虑客户端开启,服务器端传递数据包内含金币,于是在游戏开启时更新的情况)
核心矛盾是:客户端操作已被服务器执行,但客户端因断网未收到结果,导致客户端本地状态与服务器不一致,再次操作时引发如重复扣减等操作。
核心方案是:幂等性设计(保证重复操作的结果与一次操作一致)+状态同步机制(确保客户端与服务器状态最终一致)
一、幂等性设计
实现方式:为每个操作分配唯一 ID
客户端生成唯一 RequestID:每次发起购买请求时,客户端生成一个全局唯一的 RequestID(如 UUID 或 “客户端 ID + 自增序号”),并将其包含在请求中。例:购买请求格式 =
{RequestID: "1001_5", 道具ID: 101, 数量: 1}(1001 是客户端 ID,5 是自增序号)。服务器记录已处理的 RequestID:服务器收到请求后,先检查本地缓存中是否存在该 RequestID:
若不存在:执行扣金币 + 发道具,记录 RequestID 为 “已处理”,并返回结果。
若存在:直接返回上次的处理结果
客户端本地缓存 RequestID:客户端发送请求后,缓存该 RequestID 和对应的操作(如 “购买道具 101,消耗 10 金币”)。断网重连后,若未收到结果,可重发该 RequestID 的请求,服务器会返回已处理的结果,客户端据此更新本地状态。
二、重连后的全量 / 增量同步
即使操作具备幂等性,客户端断网期间可能存在其他状态变化(如其他设备登录操作、系统邮件奖励等),因此需要在重连时强制同步服务器状态。
实现步骤:
客户端重连时主动请求同步:客户端重新连接服务器后,立即发送“状态同步请求”,包含本地最后一次同步的时间戳或版本号。例:
{SyncType: "full", LastSyncTime: "2024-05-01 12:00:00"}。服务器返回全量或增量状态:
- 若客户端本地状态过旧(如超过 1 小时),服务器返回全量状态(当前金币数、道具列表、等级等)。
- 若客户端本地状态较新,服务器返回增量状态(仅返回上次同步后发生变化的数据)。
// 服务器同步状态的伪代码
public SyncResponse SyncState(int clientID, DateTime lastSyncTime) {Player player = GetPlayer(clientID);// 计算增量变化(变更的数据)var changedItems = player.Items.Where(item => item.UpdateTime > lastSyncTime).ToList();var changedGold = player.GoldHistory.Where(h => h.Time > lastSyncTime).LastOrDefault()?.Gold ?? player.Gold;return new SyncResponse {Gold = player.Gold, // 全量金币ChangedItems = changedItems, // 增量道具LastSyncTime = DateTime.Now // 本次同步时间};
}客户端覆盖本地状态:客户端收到服务器的同步数据后,用服务器数据覆盖本地状态(而非合并),确保 “服务器状态为权威”。例:无论本地显示多少金币,都以服务器返回的
Gold字段为准。
三、事务与断点续传
若操作涉及多个步骤(如 “扣金币→减库存→发道具”),断网可能导致部分步骤完成但客户端未收到结果。此时需用事务确保步骤的原子性,并用断点续传处理半数据。
1. 操作的原子性(事务):
服务器执行多步骤操作时,使用事务(数据库事务或内存事务)确保 “要么全成功,要么全失败”:
// 伪代码:用事务确保操作原子性
public void ProcessMultiStep(Request req) {using (var transaction = db.BeginTransaction()) {try {// 步骤1:扣金币player.Gold -= 10;// 步骤2:减库存propStock[req.PropID] -= 1;// 步骤3:发道具player.AddItem(req.PropID, 1);// 所有步骤成功,提交事务transaction.Commit();// 记录RequestID,返回成功} catch (Exception) {// 任何步骤失败,回滚事务(恢复金币和库存)transaction.Rollback();// 返回失败}}
}2. 断点续传(针对大数据包):
若断网时正在传输分数据包,重连后客户端可发送 “续传请求”,告知服务器已收到的分包序号,服务器仅重传未收到的部分,避免重复传输。
四、用户体验优化:本地暂存与提示
为减少用户困惑,客户端可做以下优化:
本地暂存操作结果:发送购买请求后,客户端先在本地 “预扣金币” 并显示 “购买中”,待收到服务器确认后再 “正式扣减”;若断网,重连同步后修正本地状态(如服务器未扣则恢复预扣的金币)。
明确的状态提示:断网时显示 “网络连接中断,操作结果可能延迟”;重连同步后提示 “已同步最新数据,您的金币为 XXX”。
总结
- 幂等性:用 RequestID 确保重复操作不重复生效。
- 服务器权威:重连时强制同步服务器状态,覆盖本地数据。
- 原子性:多步骤操作通过事务保证要么全成,要么全败。
六、补充
一、 粘包
TCP 通信中,粘包是指发送方发送的多个数据包在接收方被 “合并” 成一个连续的字节流,导致接收方无法直接区分出原来的每个独立数据包的边界。
TCP 是面向字节流的协议,它的核心是 “可靠地传输一串连续的字节”,但不会主动为数据包添加边界标识。
- 发送方的缓冲机制:TCP 会把小数据包合并成一个大的 “数据块” 再发送(减少网络交互次数,提高效率),这叫 “Nagle 算法”。
- 接收方的缓冲机制:接收方的操作系统会把收到的字节先存到缓冲区,应用程序(如
recv/Read函数)从缓冲区读取数据时,可能一次读到多个数据包的内容。
举例
假设客户端分 3 次发送数据:
- 第 1 次:
"hello"(5 字节) - 第 2 次:
"world"(5 字节) - 第 3 次:
"!"(1 字节)
接收方调用 recv 读取时,可能出现以下情况(粘包):
- 第一次
recv读到"helloworld!"(11 字节,3 个包全粘在一起) - 第一次
recv读到"hellowor"(8 字节,前两个包粘了一部分),第二次recv读到"ld!"(3 字节,剩余部分)
此时接收方无法直接判断原始数据包的边界,若强行按固定长度拆分,就会导致数据解析错误
缺点
在需要按 “数据包” 解析的场景(如协议交互),粘包会导致:
- 协议头解析错误(比如把两个包的头和数据混在一起)
- 数据不完整(读取到一半的包)
- 业务逻辑混乱(比如把 “购买道具” 和 “发送聊天” 的数据包粘在一起,导致服务器误判)
解决方案
核心是给数据包添加 “边界标识”,让接收方能够准确拆分出每个独立的包。常见方案有 3 种:
1. 固定长度
- 约定每个数据包的长度固定(如 1024 字节),不足的用空字节填充。
- 接收方每次读取固定长度,即可拆分出每个包。
- 缺点:浪费带宽(小数据也要填充),不灵活。
2. 特殊分隔符
- 在每个数据包末尾添加一个特殊字符(如
\n或自定义符号),作为包结束的标志。 - 接收方读取字节流时,遇到分隔符就拆分出一个完整包。
- 缺点:若数据中本身包含分隔符(如聊天内容里有
\n),会误拆分,需要额外转义处理。
3. 协议头带长度
每个数据包分为 “协议头” 和 “数据体”:
- 协议头:用固定字节(如 4 字节)存储 “数据体的长度”。
- 数据体:实际要传输的内容。
接收方先读取协议头,得到数据体长度,再按长度读取完整的数据体,从而拆分出一个包。
二、 多线程处理客户端加锁
多线程处理客户端连接时,操作clientPackets字典需加锁(如lock语句),避免并发冲突。
private readonly object clientPacketsLock = new object();// 1. 新增/修改客户端分包状态时加锁
if (!clientPackets.ContainsKey(key))
{lock (clientPacketsLock){if (!clientPackets.ContainsKey(key)) // 双重检查,避免重复初始化{clientPackets[key] = new ClientPacketState(...);}}
}// 2. 读取并修改分包数据时加锁
lock (clientPacketsLock)
{state.packets[index] = data;state.received++;
}// 3. 移除已完成的客户端缓存时加锁
lock (clientPacketsLock)
{clientPackets.Remove(key);
}// 4. 遍历字典时加锁(避免遍历中被修改)
lock (clientPacketsLock)
{foreach (var kv in clientPackets.ToList()) // ToList避免迭代中修改原集合{// 处理逻辑}
}三、 减少锁竞争
高流量服务器可按ClientID哈希分片,减少锁竞争(如 10 个分片字典,按 ClientID%10 分配。
// 1. 定义分片数量和分片结构
private const int SHARD_COUNT = 10; // 10个分片
private readonly List<Shard> shards = new List<Shard>();// 分片内部包含独立的字典和锁
private class Shard
{public Dictionary<string, ClientPacketState> packets = new Dictionary<string, ClientPacketState>();public readonly object syncLock = new object(); // 每个分片独立的锁
}// 2. 初始化分片
public ServerReceiver()
{for (int i = 0; i < SHARD_COUNT; i++){shards.Add(new Shard());}
}// 3. 计算ClientID对应的分片索引
private int GetShardIndex(int clientID)
{// 按ClientID哈希取模,分配到0~9号分片return Math.Abs(clientID.GetHashCode() % SHARD_COUNT);
}// 4. 操作时仅锁定对应分片的锁
public void HandlePacket(int clientID, string key, byte[] data, int index)
{// 计算分片索引int shardIndex = GetShardIndex(clientID);Shard shard = shards[shardIndex];// 锁定当前分片的锁lock (shard.syncLock){if (!shard.packets.ContainsKey(key)){shard.packets[key] = new ClientPacketState();}var state = shard.packets[key];state.packets[index] = data;state.received++;if (state.received == state.total){shard.packets.Remove(key);}}
}