TCP粘包原因和解决方法
约 5936 字大约 20 分钟
2025-01-21
进行技术面试时,面试官经常会问:“网络通信时,如何解决粘包、丢包或者包乱序问题?” 来考察的就是网络基础知识。
如果使用 TCP
进行通信,则在大多数场景下是不存在丢包和包乱序问题的。因为TCP通信是可靠的通信方式,TCP栈通过序列号和包重传确认机制保证数据包的有序和一定被正确发送到目的地。
如果使用 UDP
进行通信,且不允许少量丢包(有些通信是允许少量丢包的,比如视频,少量的丢包不太影响观看,对响应性要求高,如果是重传,则可能收到了包之后,却错过了播放的时间),就要自己在UDP的基础上实现类似TCP这种有序和可靠的传输机制了(例如RTP
、RUDP
)。在流传输中出现,UDP不会出现粘包,因为它有消息边界。
所以将“网络通信时,如何解决粘包、丢包或者包乱序问题?”问题拆解后,就只剩下如何解决粘包的问题。
什么是粘包
粘包就是连续向对端发送两个或者两个以上的数据包,对端在一次收取中收到的数据包数量可能大于1个,当大于1个时,可能是几个(包括一个)包加上某个包的部分,或者干脆几个完整的包在一起。当然,也可能收到的数据只是一个包的部分,这种情况一般也叫作半包。
无论是半包问题还是粘包问题,因为TCP是流式数据格式,所以其解决思路还是从收到的数据中把包与包的边界区分出来。
举个简单的例子,假设客户端连续发送两条消息 "Hello" 和 "World",由于粘包现象,接收端可能会一次性接收到 "HelloWorld",而不是分开接收到 "Hello" 和 "World" 两条消息。
TCP粘包原因
在流传输中出现,UDP不会出现粘包,因为它有消息边界。TCP是流式协议,消息无边界,所以可能会出现粘包。
TCP 是面向字节流的协议:TCP 不会关心数据包的边界,它只会将数据按字节流的形式进行传输。因此,应用层发送的多次消息可能会被 TCP 组合成一个数据包进行发送,也可能会被拆分成多个数据包。
造成TCP粘包的原因可能是发送方的原因,也可能是接收方的原因。
发送方
Nagle 算法:TCP
默认使用 Nagle
算法(主要作用:减少网络中报文段的数量):收集多个小分组,在一个确认到来时一起发送、导致发送方可能会出现粘包问题。
发送端需要等缓冲区满才发送出去,造成粘包,这种就是接收到收到的包就已经是粘连的了。
接收方
接收端缓存机制:TCP
将接收到的数据包保存在接收缓存里,如果TCP
接收数据包到缓存的速度大于应用程序从缓存中读取数据包的速度,多个包就会被缓存,应用程序就有可能读取到多个首尾相接粘到一起的包。
接收方不及时接收缓冲区的包,造成多个包接收,这种每次收到的包都是正常的。
HTTP有粘包问题么?
HTTP是基于TCP的。那么HTTP有粘包的问题么?
HTTP虽然是基于TCP的,但它通过设计和协议规范解决了TCP粘包问题,确保了数据的正确传输和解析。以下是HTTP如何处理粘包问题的关键点:
消息的明确边界,Content-Length
头部
HTTP请求和响应通常包含一个 Content-Length 头部,该头部明确指示了消息体的长度(以字节为单位)。接收方通过读取这个头部信息,知道需要读取多少字节的数据来获取完整的消息体。
Chunked Transfer-Encoding
对于无法提前确定内容长度的情况,HTTP/1.1
引入了分块传输编码(Chunked Transfer-Encoding
)。在这种模式下,消息体被分成多个块,每个块都有自己的长度标识,最后一个块的长度为0表示消息结束。
通过这些机制,HTTP确保了每个请求和响应都有明确的边界,避免了TCP粘包问题。
什么时候需要考虑粘包问题
首先在流传输中出现,UDP不会出现粘包,因为它有消息边界,所以只要使用UDP进行通信,就不用担心粘包问题。
1:如果利用tcp每次发送数据,就与对方建立连接,然后双方发送完一段数据后,就关闭连接,这样就不会出现粘包问题(因为只有一种包结构,类似于http协议)。关闭连接主要要双方都发送close(参考tcp关闭协议)。如:A需要发送一段字符串给B,那么A与B建立连接,然后发送双方都默认好的协议字符如"hello give me sth abour yourself",然后B收到报文后,就将缓冲区数据接收,然后关闭连接,这样粘包问题不用考虑到,因为大家都知道是发送一段字符。
2:如果发送数据无结构,如文件传输,这样发送方只管发送,接收方只管接收存储就ok,也不用考虑粘包。这和之前在HTTP 请求流式响应中提到都得每包都传输字符串一样。
3:如果双方建立连接,需要在连接后一段时间内发送不同结构数据,如连接后,有好几种结构:
"hello give me sth abour yourself"
"Don't give me sth abour yourself"
那这样的话,如果发送方连续发送这个两个包出去,接收方一次接收可能会是 "hello give me sth abour yourselfDon't give me sth abour yourself"
这样接收方就傻了,到底是要干嘛?不知道,因为协议没有规定这么诡异的字符串,所以要处理把它分包,怎么分也需要双方组织一个比较好的包结构,所以一般可能会在头加一个数据长度之类的包,以确保接收。
总结一下就是,我们在使用TCP进行通信时,如果发送数据是有结构的,就需要考虑粘包问题。
TCP粘包解决方法
TCP 粘包是一个常见而又令人头疼的问题。对于刚接触网络编程的开发者来说,粘包问题可能会引发数据混乱,导致程序运行异常。因此,了解 TCP 粘包现象及其解决方法,对于开发稳定可靠的网络应用至关重要。
尽量避免粘包
为了避免粘包现象,可采取以下几种措施。
- 对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满。但它关闭了优化算法,降低了网络发送效率,影响应用程序的性能,一般不建议使用。和HTTP 请求流式响应中的禁用Nginx的响应缓冲差不多。
- 对于接收方引起的粘包,则可通过优化程序设计、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象。这只能减少出现粘包的可能性,但并不能完全避免粘包,当发送频率较高时,或由于网络突发可能使某个时间段数据包到达接收方较快,接收方还是有可能来不及接收,从而导致粘包。
- 由接收方控制,将一包数据按结构字段,人为控制分多次接收,然后合并,通过这种手段来避免粘包。虽然避免了粘包,但应用程序的效率较低,对实时应用的场合不适合。
以上提到的三种措施,都有各自的不足之处。
控制包结构
粘包问题通常需要通过应用层协议来解决,一般可以通过定长消息、分隔符法、消息头部加长度字段 方法来解决
- 固定包长的数据包。
固定包长,即每个协议包的长度都是固定的。假如我们规定每个协议包的大小都是64字节,每收满64字节,就取出来解析(如果不够,就先存起来),则这种通信协议的格式简单但灵活性差。如果包的内容长度小于指定的字节数,对剩余的空间就需要填充特殊的信息,例如\0(如果不填充特殊的内容,那么如何区分包里面的正常内容与填充信息呢),如果包的内容超过指定的字节数,又得分包分片,则需要增加额外的处理逻辑——在发送端进行分包分片,在接收端重新组装包片。
- 以指定的字符(串)为包的结束标志。
这种协议包比较常见,即在字节流中遇到特殊的符号值时就认为到一个包的末尾了。例如 FTP
或 SMTP
,在一个命令或者一段数据后面加上\r\n(即CRLF)表示一个包的结束。对端收到数据后,每遇到一个“\r\n”,就把之前的数据当作一个数据包。这种协议一般用于一些包含各种命令控制的应用中,其不足之处就是如果协议数据包的内容部分需要使用包结束标志字符,就需要对这些字符做转码或者转义操作,以免被接收方错误地当成包结束标志而误解析。
- 包头+包体格式。
这种格式的包一般分为两部分,即包头和包体,包头是固定大小的,且包头必须包含一个字段来说明接下来的包体有多大。例如:
struct msg_header{ int32_t bodySize; int32_t cmd;};
就是一个典型的包头格式,bodySize指定了这个包的包体是多大。
由于包头的大小是固定的(这里是size(int32_t) + sizeof(int32_t) = 8字节
),所以对端先收取包头大小的字节内容(当然,如果不够,则还是将其先缓存起来,直到收够为止),然后解析包头,根据包头中指定的包体大小收取包体,等包体收够了,就组装成一个完整的包来处理。
在某些实现中,包头中的bodySize
可能被另一个叫作packageSize
的字段代替,这个字段用于表示整个包的大小(即包头加上包体的大小),这时,我们只要用packageSize
减去包头大小(这里是sizeof(msg_header)
)就能算出包体的大小,原理同上。
提示 消息封装可以采用经典的TLV(Type-Len-Value)
封包格式来解决TCP
黏包问题。
在使用大多数网络库时,我们通常需要根据协议的格式自己对数据包分界和解析,一般的网络库不提供这种功能是因为需要支持不同的协议。
由于协议的不确定性,网络库无法预先提供具体的解包代码。当然,这不是绝对的,也有一些网络库提供了这种功能。
在Java Netty
网络框架中提供了FixedLengthFrameDecoder
类处理长度是定长的协议包,提供了DelimiterBasedFrameDecoder
类处理将特殊字符作为结束符的协议包,提供了ByteToMessageDecoder
类处理自定义格式的协议包(可用来处理包头+包体这种格式的数据包)。然而,在继承ByteToMessageDecoder
的子类中,我们需要根据自己的协议的具体格式重写decode方
法对数据包进行解包。
封包和拆包
为什么基于TCP的通讯程序需要进行封包和拆包
TCP是个"流"协议,所谓流,就是没有界限的一串数据。大家可以想想河里的流水,是连成一片的,其间是没有分界线的。但一般通讯程序开发是需要定义一个个相互独立的数据包的,比如用于登陆的数据包,用于注销的数据包。由于TCP"流"的特性以及网络状况,在进行数据传输时会出现以下几种情况。
注意 对于UDP来说就不存在拆包的问题,因为UDP是个"数据包"协议,也就是两段数据间是有界限的,在接收端要么接收不到数据要么就是接收一个完整的一段数据,不会少接收也不会多接收。
假设我们连续调用两次send分别发送两段数据data1和data2,在接收端有以下几种接收情况(当然不止这几种情况,这里只列出了有代表性的情况)。
A.先接收到data1,然后接收到data2 B.先接收到data1的部分数据,然后接收到data1余下的部分以及data2的全部 C.先接收到了data1的全部数据和data2的部分数据,然后接收到了data2的余下的数据 D.一次性接收到了data1和data2的全部数据.
对于A这种情况正是我们需要的。对于B,C,D的情况就是大家经常说的"粘包",就需要我们把接收到的数据进行拆包,拆成一个个独立的数据包。为了拆包就必须在发送端进行封包。
为什么会出现B.C.D的情况?
"粘包"可发生在发送端也可发生在接收端.
1.由Nagle算法
造成的发送端的粘包:Nagle算法是一种改善网络传输效率的算法。简单的说,当我们提交一段数据给TCP发送时,TCP并不立刻发送此段数据,而是等待一小段时间,看看在等待期间是否还有要发送的数据,若有则会一次把这两段数据发送出去。这是对Nagle
算法一个简单的解释,像C和D的情况就有可能是Nagle
算法造成的。
2.接收端接收不及时造成的接收端粘包:TCP会把接收到的数据存在自己的缓冲区中,然后通知应用层取数据。当应用层由于某些原因不能及时的把TCP的数据取出来,就会造成TCP缓冲区中存放了几段数据。
怎样封包和拆包
最初遇到"粘包"的问题时,可以尝试通过在两次send之间调用sleep来休眠一小段时间来解决。这个解决方法的缺点是显而易见的,使传输效率大大降低,而且也并不可靠。后来就是通过应答的方式来解决,尽管在大多数时候是可行的,但是不能解决象B的那种情况,而且采用应答方式增加了通讯量,加重了网络负荷。再后来就是对数据包进行封包和拆包的操作。
封包: 封包就是给一段数据加上包头,这样一来数据包就分为包头和包体两部分内容了(以后讲过滤非法包时封包会加入"包尾"内容)。包头其实上是个大小固定的结构体,其中有个结构体成员变量表示包体的长度,这是个很重要的变量,其他的结构体成员可根据需要自己定义。根据包头长度固定以及包头中含有包体长度的变量就能正确的拆分出一个完整的数据包。
对于拆包目前我最常用的是以下两种方式.
- 动态缓冲区暂存方式。
之所以说缓冲区是动态的是因为当需要缓冲的数据长度超出缓冲区的长度时会增大缓冲区长度。大概过程描述如下: A,为每一个连接动态分配一个缓冲区,同时把此缓冲区和SOCKET关联,常用的是通过结构体关联. B,当接收到数据时首先把此段数据存放在缓冲区中. C,判断缓存区中的数据长度是否够一个包头的长度,如不够,则不进行拆包操作. D,根据包头数据解析出里面代表包体长度的变量. E,判断缓存区中除包头外的数据长度是否够一个包体的长度,如不够,则不进行拆包操作. F,取出整个数据包.这里的"取"的意思是不光从缓冲区中拷贝出数据包,而且要把此数据包从缓存区中删除掉.删除的办法就是把此包后面的数据移动到缓冲区的起始地址.
这种方法有两个缺点: 1.为每个连接动态分配一个缓冲区增大了内存的使用 2.有三个地方需要拷贝数据,一个地方是把数据存放在缓冲区,一个地方是把完整的数据包从缓冲区取出来,一个地方是把数据包从缓冲区中删除.第二种拆包的方法会解决和完善这些缺点.
前面提到过这种方法的缺点.下面给出一个改进办法, 即采用环形缓冲.但是这种改进方法还是不能解决第一个缺点以及第一个数据拷贝,只能解决第三个地方的数据拷贝(这个地方是拷贝数据最多的地方).第2种拆包方式会解决这两个问题. 环形缓冲实现方案是定义两个指针,分别指向有效数据的头和尾.在存放数据和删除数据时只是进行头尾指针的移动.
2.利用底层的缓冲区来进行拆包
由于TCP也维护了一个缓冲区,所以我们完全可以利用TCP的缓冲区来缓存我们的数据,这样一来就不需要为每一个连接分配一个缓冲区了。另一方面我们知道recv或者wsarecv都有一个参数,用来表示我们要接收多长长度的数据。利用这两个条件我们就可以对第一种方法进行优化。对于阻塞SOCKET来说,我们可以利用一个循环来接收包头长度的数据,然后解析出代表包体长度的那个变量,再用一个循环来接收包体长度的数据。
相关代码如下:
char PackageHead[1024];
char PackageContext[1024*20];
int len;
PACKAGE_HEAD *pPackageHead;
while( m_bClose == false )
{
memset(PackageHead,0,sizeof(PACKAGE_HEAD));
len = m_TcpSock.ReceiveSize((char*)PackageHead,sizeof(PACKAGE_HEAD));
if( len == SOCKET_ERROR )
{
break;
}
if(len == 0)
{
break;
}
pPackageHead = (PACKAGE_HEAD *)PackageHead;
memset(PackageContext,0,sizeof(PackageContext));
if(pPackageHead- >nDataLen >0)
{
len = m_TcpSock.ReceiveSize((char*)PackageContext,pPackageHead- >nDataLen);
}
}
m_TcpSock是一个封装了SOCKET的类的变量,其中的ReceiveSize用于接收一定长度的数据,直到接收了一定长度的数据或者网络出错才返回.
int winSocket::ReceiveSize( char* strData, int iLen )
{
if( strData == NULL )
return ERR_BADPARAM;
char *p = strData;
int len = iLen;
int ret = 0;
int returnlen = 0;
while( len > 0)
{
ret = recv( m_hSocket, p+(iLen-len), iLen-returnlen, 0 );
if ( ret == SOCKET_ERROR || ret == 0 )
{
return ret;
}
len -= ret;
returnlen += ret;
}
return returnlen;
}
对于非阻塞的SOCKET,比如完成端口,我们可以提交接收包头长度的数据的请求,当 GetQueuedCompletionStatus 返回时,我们判断接收的数据长度是否等于包头长度,若等于,则提交接收包体长度的数据的请求,若不等于则提交接收剩余数据的请求。当接收包体时,采用类似的方法。
示例代码
下面我们写两个个简单的例子,分别说明发送方粘包和接收方粘包,以及两种粘包的解决方法
发送方粘包
服务端代码
static void Main(string[] args)
{
TcpListener listener = new TcpListener(IPAddress.Any, 8888);
listener.Start();
Console.WriteLine("Server Start...");
using( TcpClient client = listener.AcceptTcpClient() )
using( NetworkStream stream = client.GetStream() ) {
byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
string receivedData = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine("Received Data:\r\n " + receivedData);
}
listener.Stop();
Console.ReadLine();
}
客户端代码
static void Main(string[] args)
{
using( TcpClient client = new TcpClient("127.0.0.1", 8888) )
using( NetworkStream stream = client.GetStream() ) {
string[] messages = { "Message 1\r\n", "Message 2\r\n", "Message 3\r\n" };
foreach( var msg in messages ) {
byte[] data = Encoding.UTF8.GetBytes(msg);
stream.Write(data, 0, data.Length);
}
Console.WriteLine("Messages sent.");
}
Console.ReadLine();
}
在接收方,我们看到的消息是一个连接的字符串,如 Message 1Message 2Message 3。这是因为发送方连续发送了多个消息,TCP协议将这些消息粘包在一起,导致接收方在一次读取操作中读取到多个消息。
使用消息长度前缀
客户端代码:
static void Main(string[] args)
{
using( TcpClient client = new TcpClient("127.0.0.1", 8888) )
using( NetworkStream stream = client.GetStream() ) {
string[] messages = { "Message 1", "Message 2", "Message 3" };
foreach( var msg in messages ) {
byte[] messageData = Encoding.UTF8.GetBytes(msg);
byte[] lengthPrefix = BitConverter.GetBytes(messageData.Length);
// 发送长度前缀
stream.Write(lengthPrefix, 0, lengthPrefix.Length);
// 发送实际消息
stream.Write(messageData, 0, messageData.Length);
}
Console.ReadLine();
}
}
接收方粘包
服务端代码:
static void Main(string[] args)
{
TcpListener listener = new TcpListener(IPAddress.Any, 8888);
listener.Start();
Console.WriteLine("Server Start...");
using( TcpClient client = listener.AcceptTcpClient() )
using( NetworkStream stream = client.GetStream() ) {
byte[] buffer = new byte[20]; // 小缓冲区,故意分多次接收
int bytesRead;
while( (bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0 ) {
string part = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine("Receive: " + part );
if( bytesRead < buffer.Length )
break; // 假设消息的最后一部分已经接收完
}
}
listener.Stop();
Console.ReadLine();
}
客户端代码:
static void Main(string[] args)
{
using( TcpClient client = new TcpClient("127.0.0.1", 8888) )
using( NetworkStream stream = client.GetStream() ) {
string message = "This is a longer message that may be split across multiple packets.";
byte[] data = Encoding.UTF8.GetBytes(message);
stream.Write(data, 0, data.Length);
Console.WriteLine("Message sent.");
}
Console.ReadLine();
}
实现一个消息缓冲机制
接收方需要实现一个缓冲机制,将每次接收到的数据存入缓冲区中,直到缓冲区中包含完整的消息为止。
static void Main(string[] args)
{
TcpListener listener = new TcpListener(IPAddress.Any, 8888);
listener.Start();
Console.WriteLine("Server start... ");
using( TcpClient client = listener.AcceptTcpClient() )
using( NetworkStream stream = client.GetStream() ) {
byte[] buffer = new byte[20];
StringBuilder completeMessage = new StringBuilder();
int bytesRead;
while( (bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0 ) {
// 每次接收数据并追加到消息缓冲区
string part = Encoding.UTF8.GetString(buffer, 0, bytesRead);
completeMessage.Append(part);
// 假设消息以特定结束符结束,判断完整消息的逻辑
if( completeMessage.ToString().Contains("...") ) // 示例中的结束符
{
break;
}
}
Console.WriteLine("Complete Message: " + completeMessage.ToString());
}
listener.Stop();
Console.ReadLine();
}
总结
TCP 粘包是 TCP 协议本身特性导致的常见问题之一,通常需要通过应用层的协议设计来解决。通过对数据包添加定长、分隔符或长度字段等方法,开发者可以有效避免粘包现象,从而保证数据的正确性与完整性。在实际开发中,合理设计应用层协议对于网络程序的稳定性至关重要。