TCP 粘包(TCP Packet Merging) 是指多个小的数据包在 TCP 传输过程中被合并在一起,接收方读取时无法正确分辨数据边界,导致数据解析错误。
TCP 是流式协议,没有数据包的概念,它只是保证数据按照字节流的顺序传输,不保证接收方能按照原始发送时的数据边界来接收数据。因此,TCP 可能会把多个数据包合并(粘包)或者拆分(拆包)。
当发送方的数据量较小,TCP 不会立即发送,而是等缓冲区满了再一起发送,这样可以减少网络开销。导致多个小数据包合并成一个大的数据包,产生粘包。
假设我们在 TCP 连接中连续发送三条消息:
send(socket, "Hello", 5, 0);
send(socket, "World", 5, 0);
send(socket, "!!!", 3, 0);
如果 TCP 将这三次 send
的数据合并在一起,接收方可能会收到:
HelloWorld!!!
这样就无法判断消息边界,导致解析困难。
原因
setsockopt
关闭: int flag = 1;
setsockopt(socket, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(int));
接收方 读取数据不及时或者一次性读取了多个数据包,导致多个包的数据合并读取,形成粘包。
如果发送方连续发送:
send(socket, "Hello", 5, 0);
send(socket, "World", 5, 0);
send(socket, "!!!", 3, 0);
接收方可能这样读取:
char buffer[20];
recv(socket, buffer, 20, 0);
如果 recv()
读取到了所有数据,buffer 里存的是:
HelloWorld!!!
但接收方可能预期每条消息是独立的,所以会出现粘包问题。
原因
recv()
读取数据时,可能一次读取多个包的内容。除了粘包,拆包(packet fragmentation) 也是常见问题。
如果单次发送的数据超过了 TCP 最大传输单元(MTU),TCP 会自动拆分数据包。
假设 send()
发送 5000 字节,而 TCP 的 MTU 设为 1500 字节,则会拆分成:
Packet 1: 1500 bytes
Packet 2: 1500 bytes
Packet 3: 1500 bytes
Packet 4: 500 bytes
这样接收方 recv()
时可能会一次只收到部分数据,需要多次 recv()
才能完整还原。
由于 TCP 没有消息边界,需要在应用层手动处理数据边界:
如果每条消息长度固定,可以按照固定字节数读取:
recv(socket, buffer, 10, 0); // 一次读取 10 字节
但这种方法仅适用于所有消息长度一致的情况。
在消息结尾添加特殊字符,接收方按照这个字符分割数据:
send(socket, "Hello|", 6, 0);
send(socket, "World|", 6, 0);
接收方:
char buffer[1024];
recv(socket, buffer, 1024, 0);
然后通过 |
来拆分数据:
char *token = strtok(buffer, "|");
while (token) {
printf("Received message: %s\n", token);
token = strtok(NULL, "|");
}
缺点:
|
不会出现在正常数据中。在数据前面加上消息长度,接收方先读取长度,再读取完整数据:
struct Message {
uint32_t length; // 4字节,表示消息长度
char data[1024]; // 消息体
};
发送数据:
uint32_t len = htonl(strlen(data)); // 转换为网络字节序
send(socket, &len, 4, 0); // 先发送长度
send(socket, data, strlen(data), 0); // 再发送数据
接收方:
uint32_t len;
recv(socket, &len, 4, 0); // 先读取 4 字节长度
len = ntohl(len); // 转换回主机字节序
recv(socket, buffer, len, 0); // 再读取数据
优势:
方案 | 适用场景 | 复杂度 |
---|---|---|
固定长度消息 | 适用于消息长度固定的协议 | 低 |
特殊分隔符(如 \n 、` |
`) | 适用于文本协议(如 HTTP) |
消息头 + 消息体(推荐) | 适用于二进制协议(如 TCP 长连接) | 高 |
这样就能高效避免 TCP 粘包问题啦!