33、TCP粘包问题:粘包产生原因、解决粘包的三种方法
说实话,TCP粘包这个问题,我当年刚入行时踩过不少坑。那时候写一个简单的聊天服务器,客户端发了两条消息,服务端一收,咦?怎么拼在一起了?当时我还以为是网卡坏了,折腾了大半天。
后来才明白,这不是bug,这是TCP的固有特性。今天咱们就把粘包这事儿彻底聊透。
粘包是怎么产生的?
先说说本质原因。TCP是流式协议,它不关心你发的是几条消息。它只负责把数据当成一个连续的字节流,从A端送到B端。
你想想看,你调用两次send(),分别发了"Hello"和"World"。TCP底层可能把这6个字节(Hello5个+World5个)攒在一起,一次性扔给接收端。接收端调用recv()时,拿到的就是"HelloWorld"——粘包了。
为什么会攒在一起?因为TCP有Nagle算法,它会等缓冲区攒够一定量再发,提高网络利用率。另外,接收端也可能因为没及时读取,导致多个包在缓冲区里粘在一起。
我在项目中遇到过最典型的情况:一个游戏服务器,客户端每帧发送玩家操作指令。因为操作指令很短,Nagle算法一合并,服务端收到的就是一坨乱码。后来排查了好久才发现是粘包导致的解析错误。
解决粘包的三种方法
嗯,既然知道了原因,那怎么解决?说白了就一句话:让接收方能知道每条消息从哪里开始、到哪里结束。
业界常用的方法有三种,我一个个说。
方法一:定长协议
这个方法最简单粗暴。双方约定好,每条消息固定长度。比如每条消息都是100字节。发送方如果不足100字节,就补空格或\0。接收方每次就读100字节,读完一条再读下一条。
// 发送方
#define MSG_LEN 100
char buf[MSG_LEN] = {0};
snprintf(buf, MSG_LEN, "Hello");
send(sockfd, buf, MSG_LEN, 0);
// 接收方
char buf[MSG_LEN];
int n = recv(sockfd, buf, MSG_LEN, 0);
// 此时buf里就是完整的一条消息
优点:实现简单,解析快。缺点也很明显:浪费带宽。如果消息只有10字节,你也得发100字节。而且消息长度不能超过定长,灵活性差。
我个人习惯在嵌入式设备通信中用这种方法。因为嵌入式设备CPU弱,内存小,定长协议解析起来几乎不消耗计算资源。
方法二:分隔符协议
这个方法也很常见。每条消息末尾加一个特殊分隔符,比如\n或\r\n。接收方读到分隔符,就知道一条消息结束了。
// 发送方
char *msg = "Hello\n";
send(sockfd, msg, strlen(msg), 0);
// 接收方(伪代码)
char buf[1024];
int n = recv(sockfd, buf, sizeof(buf), 0);
// 遍历buf,找到'\n',切分出完整消息
for (int i = 0; i < n; i++) {
if (buf[i] == '\n') {
// 从上次位置到i是一条完整消息
process_message(buf + last, i - last);
last = i + 1;
}
}
优点:实现简单,消息长度灵活。缺点:消息内容里不能包含分隔符,否则需要转义。另外,如果网络丢包导致分隔符丢失,会引发粘包错乱。
方法三:长度前缀协议
这是我最推荐的方法,也是工业界用得最多的。每条消息前面加一个固定长度的头部,头部里记录消息体的长度。接收方先读头部,拿到长度,再读对应长度的消息体。
// 发送方
char *msg = "Hello";
uint32_t len = htonl(strlen(msg)); // 网络字节序
send(sockfd, &len, sizeof(len), 0);
send(sockfd, msg, strlen(msg), 0);
// 接收方
uint32_t len;
recv(sockfd, &len, sizeof(len), MSG_WAITALL);
len = ntohl(len);
char *buf = malloc(len);
recv(sockfd, buf, len, MSG_WAITALL);
// 此时buf里就是完整消息
优点:精确、高效、支持任意长度消息。缺点:实现稍微复杂一点,需要处理半包问题(头部没读完、消息体没读完)。
htonl/ntohl的字节序转换。结果在x86小端机器上测试没问题,一部署到ARM大端服务器上就全乱了。嗯,这个坑我记了好几年。
三种方法对比
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 定长协议 | 实现简单,解析快 | 浪费带宽,灵活性差 | 嵌入式、固定长度指令 |
| 分隔符协议 | 消息长度灵活 | 内容需转义,可能丢包错乱 | 文本协议、HTTP头部 |
| 长度前缀协议 | 精确、高效、通用 | 实现稍复杂,需处理半包 | 通用场景、二进制协议 |
知识体系图
下面这张图帮你理清粘包问题的全貌:
我的建议
如果你刚开始学网络编程,我建议先从定长协议入手,简单直接,不容易出错。等熟悉了TCP的收发机制,再切换到长度前缀协议。
如果你在写生产级代码,直接上长度前缀协议。虽然实现起来多几行代码,但它是目前最通用、最可靠的做法。像Redis、Memcached这些高性能中间件,底层用的都是类似思路。
至于分隔符协议,我一般只在调试工具或简单文本协议中用。比如写一个日志收集客户端,用换行符分隔日志条目,简单够用。
公众号:蓝海资料掘金营,微信deep3321