一:TCP粘包介绍
1.1 TCP介绍
如上图,TCP具有面向连接、可靠、基于字节流三大特点。
字节流可以理解为一个双向的通道里流淌的数据,这个数据其实就是我们常说的二进制数据,简单来说就是一大堆 01 串。纯裸TCP收发的这些 01 串之间是没有任何边界的,你根本不知道到哪个地方才算一条完整消息。tcp是流式传输没有数据包的概念,所以每次会先把缓冲池填满再发送,这就会偶尔出现一种一段信息分了两次传输的情况,所以可以在传输协议规定数据长度,让另一端更好的识别传输过来的数据
正因为这个没有任何边界的特点,所以当我们选择使用TCP发送"夏洛"和"特烦恼"的时候,接收端收到的就是"夏洛特烦恼",这时候接收端没发区分你是想要表达"夏洛"+"特烦恼"还是"夏洛特"+"烦恼"。
1.2 粘包/拆包介绍
根据计算机网络的TCP/IP协议,粘包和拆包问题在数据链路层、网络层以及传输层都有可能发生。
-
在数据链路层,数据被封装成帧进行传输。帧是数据链路层的传输单位,含了数据和帧头部信息。在数据链路层中,粘包和拆包问题可能发生在帧的传输过程中,例如在以太网中多个数据帧可能会被合并成一个较大的帧进行传输,导致粘包问题;或者一个数据帧被拆分成多个较小的帧进行传输,导致拆包问题。
-
在网络层,数据被装成IP数据报进行传输。IP数据报包含了IP头部和数据部分。在网络层中,粘包和拆包问题可能发生在IP数据报的传输过程中例如在路由器中,多个IP数据报可能会被合并成一个较大的IP数据报进行传输,导致粘包问题;或一个数据报被拆分成多个较小的IP数据报进行传输,导致拆包问题。
-
在传输层,数据被封装成TCP报文段进行传输。TCP报文段包含了TCP头部和数据部分在传输层中,粘包和拆包问题主要发生在TCP报文段的传输过程中。由于TCP是面的可靠传输议,它将应用层的数据流一系的数据段,然后将这数据段封装成TCP报文段进行传输。发送方可能会将多个应用层的数据段合并成一个TCP报段进行传输,导致粘包问题;而接收方可能一次性接收到多个应用层的数据段,导致拆包问题。
因此,粘包和拆包问题在数据链路层、网络层以及传输层都有可能发生,数据链路层主要由于传输速率和帧长度的原因,网络层主要由于分片和重组,传输层主要由于缺少明显的分段标识。不过数据链路层,网络层的粘包和拆包问题都由协议进行处理了,日常应用开发都是应用层对接传输层,因此在实际开发中,面临的都是TCP粘包拆包问题。
TCP粘包并不是TCP协议造成的问题,因为TCP协议本就规定字节流式传输(算法决定:利用缓冲区,有拥塞控制,大小包合并),它不含消息、数据包等概念,需要应用层根据需求自己设计消息边界,提高可扩展性。
1.3 TCP报文首部格式
首先由上图TCP报文的首部格式可以发现,TCP首部里是没有长度这个信息的,可以通过TCP Data 的长度 = IP 总长度 - IP Header 长度 - TCP Header 长度获得当前包的TCP数据长度。
然而TCP 发送端在发的时候就不保证发的是一个完整的数据报,仅仅看成一连串无结构的字节流,这串字节流在接收端收到时哪怕知道长度也没用,因为它很可能只是某个完整消息的一部分。
而应用层另一个协议UDP是一个无连接协议,客户端和服务端之间没有建立持久的连接。通信中,客户端只负责发送数据,而不需要关心服务端是否正常接收或处理数据。由于UDP不提供连接状态的维护和不保证数据传输的可靠性,因此UDP通常用于实时较高、对数据准确性要求相对较低的场景,如直播行业、音视频传输等。在这些领域,数据的实时性比传输的可靠性更为重要,而丢失部些误差对于用户体验的影响相对较小。
与TCP不同,由于UDP以独立的数据报形式传输数据,每个UDP数据包都是一个完整的单元,在UDP报文头中含有UDP长度字段,每个UDP数据包都独立发送、接收和处理,因此不会发生TCP中常见的粘包现象。
1.4 Nagle 算法
由上图可以发现,如果客户端每次只发送1字节,但在IP成封装后最少需要41字节,因此如果发送方TCP每次接收到1字节的数据就发送,这样一个字节需要形成41字节长的IP数据报。效率很低。而解决方法就是使用Nagle算法。Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。
- 若发送应用进程把发送的数据逐个字节地送到TCP的发送的缓存,则发送方就把第一个数据字节先发送出去,把后面的数据字节都缓存起来
- 当发送方接收到对第一个数据字符的确认后,再把发送缓存中的所有数据装成一个报文发送出去,同时继续对随后到达的数据进行缓存
- 只有在收到对前一个报文段的确认后才继续发送下一个报文段。
- 当到达的数据已达到发送窗口大小的一半或已达到报文段的最大长度时,就立即发送一个报文段。
在 Nagle 算法开启的状态下,数据包在以下两个情况会被发送:
- 如果包长度达到MSS或含有Fin包(MSS:TCP段最大长度)立刻发送,否则等待下一个包到来;如果下一包到来后两个包的总长度超过MSS的话,就会进行拆分发送;
- 等待超时(一般为200ms),第一个包没到MSS长度,但是又迟迟等不到第二个包的到来,则立即发送。
由于TCP协议本身的机制(面向连接的可靠地协议-三次握手机制)客户端与服务器会维持一个连接(Channel),数据在连接不断开的情况下,可以持续不断地将多个数据包发往服务器,但是如果发送的网络数据包太小,那么他本身会启用Nagle算法(可配置是否启用)对较小的数据包进行合并(基于此,TCP的网络延迟要UDP的高些)然后再发送(超时或者包大小足够)。那么这样的话,服务器在接收到消息(数据流)的时候就无法区分哪些数据包是客户端自己分开发送的,这样产生了粘包;服务器在接收到数据后,放到缓冲区中,如果消息没有被及时从缓存区取走,下次在取数据的时候可能就会出现一次取出多个数据包的情况,造成粘包现象
由于启动了Nagle算法,msg1 小于 mss ,此时等待200ms内来了一个 msg2,msg1 + msg2 > MSS,因此把msg2 分为 msg2(1) 和 msg2(2),msg1 + msg2(1) 包的大小为MSS。此时发送出去。
剩余的 msg2(2) 也等到了 msg3,同样 msg2(2) + msg3 > MSS,因此把 msg3分为msg3(1) 和msg3(2),msg2(2) + msg3(1) 作为一个包发送。
剩余的 msg3(2) 长度不足mss,同时在200ms内没有等到下一个包,等待超时,直接发送。
此时三个包虽然在图里颜色不同,但是实际场景中,他们都是一整个 01 串,如果处理开发者把第一个收到的 msg1 + msg2(1)
就当做是一个完整消息进行处理,就会看上去就像是两个包粘在一起,就会导致粘包问题。
当然,随着网络环境的改善,Nagle 算法的优势也不如当初那么明显。主要的变化有以下几点:
总的来说,Nagle 算法在当初面对慢速、少连接的网络环境时非常有效,但随着网络环境的变化,其优势已经不如从前。对于现代应用,尤其是对延迟敏感的应用,很多情况下已经不再需要 Nagle 算法了。
1.5 TCP粘包出现场景
由上可知,就算关闭了Nagle 算法,一样可能产生TCP粘包,TCP 粘包主要是由于发送方和接收方处理数据的速度和能力存在差异导致的,可能发生在以下情况下:
TCP粘包问题在同一主机上的不同端口之间通常不会发生。当在同一主机上的不同端口之间建立TCP连接时,数据是在主机内部进行的传输,这个过程在内部完成,不涉及实际的网络传输和网络协议的影响。接收方应用程序也可直接从操作系统内核的接收缓冲区读取数据。
需要注意的是,即使是连续发送大量数据,在正常的网络环境和合理的数据处理方式下,TCP通常可以保持数据包的完整性和顺序,不会出现粘包。当发送方过于迅速发送数据而使接收方无法跟上或处理不当,此时可能发生粘包问题。
粘包是由于开发人员没有正确理解 TCP 面向字节流的数据传输方式,本身并不是 TCP 的问题,是开发者的问题。TCP 不管发送端要发什么,都基于字节流把数据发到接收端。这个字节流里可能包含上一次想要发的数据的部分信息。接收端根据需要在消息里加上识别消息边界的信息。不加就可能出现粘包问题。
解决TCP粘包问题通常需要使用特定的数据分隔方式或应用层协议设计来确的数据传输和解析。
1.6 Go语言TCP粘包示例
客户端如上图所示,服务端通过bufio.NewReader(conn)进行读取,正常情况应该如下:
但如果将time.Sleep(time.Second)注释掉,可能会出现下图的结果:
主要原因是因为我们是应用层软件,是跑在操作系统之上的软件,当我们向服务器发送一个数据时,是调用操作系统的相关接口发送的,操作系统再经过各种复杂的操作,发送到对方机器。但是操作系统有一个发送数据缓冲区,默认情况如果缓冲区是有大小的,如果缓冲区没满,是不会发送数据的。
这就导致了TCP粘包问题的出现。当我们连续发送多个数据包时,如果这些数据包在发送过程中没有填满操作系统的发送缓冲区,它们会被缓存在缓冲区中,直到缓冲区满或者满足一定条件才会一次性发送到对方机器。因此,接收方可能会一次性接收到多个数据包的内容,从而导致粘包问题。
而为什么使用sleep(1s)可以解决这个问题。这是因为发送缓冲区不仅仅被我们的应用程序使用,还可能被其他程序使用。当我们使用sleep(1s)时,等待1秒的时间足够让其他程序将缓冲区填满,然后各自发送自的数据。这样,即使我们的应用程序连续发送多个小数据包,由于缓冲区已经被其他程序填满,我们的数据也及时发送出发。
二、解决TCP粘包
2.1 应用层常用解决TCP粘包协议
应用层常用的几种解决TCP粘包问题的协议和技术如下:
定长包协议(Fixed-Length Protocol):发送方将每个数据包固定长度,不足部分用补齐字符填充。接收方按照固定长度截取数据,确保每个数据包长度一致,从而避免粘包问题。
特殊标志协议(Delimiter-based Protocol):发送方在每个数据包尾部添加特殊标志如特定的换行符(如'n'),接收方通过识别换行符来划分不同的数据包。
长度字段协议(Length-Field Protocol):发送方在每个数据包前部添加一个表示数据包长度的字段,接收方根据该长度字段来解析数据包边界,确保正确分离每个数据包。长度字段可以是固定长度,也可以采用变长编码方式。
自定义协议:应用层可以设计自定义的协议,通过在数据包中使用特定的标识符、头部信息或其他约定进行数据分隔和解析协议和技术可以在应用层上增加更高级的数据隔机制,使得应用程序能够正确解析和处理数据,避免粘包问题。选择适合具体应用场景和需求的协议,可以提高数据传输的可靠性和正确性。
这些协议和技术需要发送方和接收方之间达成一致并正确实现,以确保数据的准确分隔和解析。双方都应按照协议规范进行数据的发送和接收处理,以免仍然出现数据解析错误的情况。
2.2 长度字段协议解决TCP粘包工具包
package stick
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
)
// Pack 封包,输入消息实体,返回消息长度与消息实体组成的字节流,客户端发送消息时调用进行封包
func Pack(message string) ([]byte, error) {
length := int32(len(message)) // 获取消息长度
var pkg = new(bytes.Buffer) // 创建字节缓冲区
// 写入消息头部,使用小端排序,将长度信息写入字节缓冲区,长度字段为int32类型占用4个字节
err := binary.Write(pkg, binary.LittleEndian, length)
if err != nil {
fmt.Println("写入消息头失败", err)
return nil, err
}
// 写入消息实体,将消息实体信息写入字节缓冲区
err = binary.Write(pkg, binary.LittleEndian, []byte(message))
if err != nil {
fmt.Println("写入消息实体失败", err)
return nil, err
}
return pkg.Bytes(), nil // 返回编码结果
}
// UnPack 拆包,输入二进制字节流,前4个字节为消息长度,后面为消息实体,返回消息实体,服务端接收消息时调用进行解包
func UnPack(reader *bufio.Reader) (string, error) {
// 读取信息长度,前4个字节
lengthByte, _ := reader.Peek(4)
lengthBuff := bytes.NewBuffer(lengthByte)
var length int32
err := binary.Read(lengthBuff, binary.LittleEndian, &length)
if err != nil {
return "", err
}
// 检查缓冲区中可读的字节数是否足够容纳该消息
if int32(reader.Buffered()) < length+4 {
return "", err
}
// 读取消息真正的内容
pack := make([]byte, int(4+length))
_, err = reader.Read(pack)
if err != nil {
return "", err
}
return string(pack[4:]), nil // 返回解码结果
}
以上是进行封包与拆包的go函数,在客户端发送数据时,使用Pack()进行封包;在服务端读取数据时,使用UnPack()进行拆包。
三、使用TCP的应用层协议设计
这些协议都使用TCP作为传输层协议,TCP负责提供可靠的字节流传输。而这些应用层协议则定义了具体的数据格式和交互规则,以实现不同的功能。TCP 为这些应用层协议提供了可靠的通信基础,而应用层协议则根据自己的需求使用和定制TCP,在设计时的主要考虑因素是其自身的报文格式、结构、兼容性和效率等方面,选择的方式能最好地适应该协议自身的需求。这种分层设计使得网络协议栈更加模块化和可扩展。
上图分别为HTTP的请求报文与响应报文,可以在HTTP报文中,通过换行符分离请求行、首部行、报文主体部分,且在报文内部有Content-length首部字段,而这个字段就表示报文主体的长度,即HTTP通过长度字段协议解决了TCP粘包。