一:TCP粘包介绍
1.1 TCP介绍
如上图,TCP具有面向连接、可靠、基于字节流三大特点。
字节流可以理解为一个双向的通道里流淌的数据,这个数据其实就是我们常说的二进制数据,简单来说就是一大堆 01 串。纯裸TCP收发的这些 01 串之间是没有任何边界的,你根本不知道到哪个地方才算一条完整消息。
正因为这个没有任何边界的特点,所以当我们选择使用TCP发送"夏洛"和"特烦恼"的时候,接收端收到的就是"夏洛特烦恼",这时候接收端没发区分你是想要表达"夏洛"+“特烦恼"还是"夏洛特”+“烦恼”。
1.2 粘包/拆包介绍
根据计算机网络的TCP/IP协议,粘包和拆包问题在数据链路层、网络层以及传输层都有可能发生。
-
在数据链路层,数据被封装成帧进行传输。帧是数据链路层的传输单位,含了数据和帧头部信息。在数据链路层中,粘包和拆包问题可能发生在帧的传输过程中,例如在以太网中多个数据帧可能会被合并成一个较大的帧进行传输,导致粘包问题;或者一个数据帧被拆分成多个较小的帧进行传输,导致拆包问题。
-
在网络层,数据被装成IP数据报进行传输。IP数据报包含了IP头部和数据部分。在网络层中,粘包和拆包问题可能发生在IP数据报的传输过程中例如在路由器中,多个IP数据报可能会被合并成一个较大的IP数据报进行传输,导致粘包问题;或一个数据报被拆分成多个较小的IP数据报进行传输,导致问题。
-
在传输层,数据被封装成TCP报文段进行传输。TCP报文段包含了TCP头部和数据部分在传输层中,粘包和拆包问题主要发生在TCP报文段的传输过程中。由于TCP是面的可靠传输议,它将应用层的数据流一系的数据段,然后将这数据段封装成TCP报文段进行传输。发送方可能会将多个应用层的数据段合并成一个TCP报段进行传输,导致粘包问题;而接收方可能一次性接收到多个应用层的数据段,导致拆问题。
因此,粘包和拆包问题在数据链路层、网络层以及传输层都有可能发生,不过数据链路层,网络层的粘包和拆包问题都由协议进行处理了,日常应用开发都是应用层对接传输层,因此在实际开发中,面临的都是TCP粘包拆包问题。
而应用层另一个协议UDPUDP是一个无连接协议,客户端和服务端之间没有建立持久的连接。通信中,客户端只负责发送数据,而不需要关心服务端是否正常接收或处理数据。
由于UDP不提供连接状态的维护和不保证数据传输的可靠性,因此UDP通常用于实时较高、对数据准确性要求相对较低的场景,如直播行业、音视频传输等。在这些领域,数据的实时性比传输的可靠性更为重要,而丢失部些误差对于用户体验的影响相对较小。
与TCP不同,UDP没有内置的分包和粘包处理机制。由于UDP以独立的数据报形式传输数据,每个UDP数据包都是一个完整的单元,不存在粘包问题。每个UDP数据包都独立发送、接收和处理,因此不会发生TCP中常见的粘包现象。尽管UDP不存在粘包问题,但应用程序仍需自行处理可能存在的数据分片和重组,以确保数据的完整性和正确性,特别是在进行大型数据包的传输时。此外,由于UDP的不可靠性,应用程序也需要考虑丢包、重复和顺序错乱等问题,对数据进行适当的处理和恢复机制,以满足实时性要求。
TCP粘包并不是TCP协议造成的问题,因为TCP协议本就规定字节流式传输(算法决定:利用缓冲区,有拥塞控制,大小包合并),它不含消息、数据包等概念,需要应用层自己设计消息边界。
1.3 何时出现TCP粘包
TCP粘包问题可能发生在以下情况下:
- 发送方连续发送多个小数据包:如果发送方快速发送多个较小的数据包,TCP协议在发送端可能会将它们组合成一个较大的数据包进行传输,导致接收端收到的数据粘在一起。
- 接收方读取数据不及时:如果接收方的读取速度较慢,无法及时消耗完接收缓冲区中的数据,TCP协议可能会将多个数据包累积在接收缓冲区中,导致粘包现象。
- TCP拥塞控制机制:当TCP因为网络拥塞而启动拥塞控制机制时,发送方会减少发送窗口的大小,从而导致数据包发送频率减缓。这可能导致发送方将多个数据包打包在一起,形成粘包。
- 网络延迟或抖动:网络延迟或抖动可能导致数据包的到达时间不确定,TCP协议在接收端可能会将到达的数据包缓存在接收缓冲区中,并等待后续数据包的到达,从而引发粘包问题。
需要注意的是,即使是连续发送大量数据,在正常的网络环境和合理的数据处理方式下,TCP通常可以保持数据包的完整性和顺序,不会出现粘包。当发送方过于迅速发送数据而使接收方无法跟上或处理不当,此时可能发生粘包问题。注意,TCP粘包问题并非TCP协议设计上的缺陷,而是由于TCP协议的数据传输特性和网络条件的影响所致。解决TCP粘包问题通常需要应用层协议设计或使用特定的数据分隔方式来确的数据传输和解析。
TCP粘包问题在同一主机上的不同端口之间通常不会发生。当在同一主机上的不同端口之间建立TCP连接时,数据传输是在主机内部进行的,而不经过网络。
- 内部数据传输:当两个应用程序通过不同端口在同一主机上建立TCP连接时,数据传输并不离开主机。数据从发送方应用程序通过使用套接字(socket)发送到操作系统内核的TCP层,然后由TCP层直接传递给接收方应用程序的套接字。这个过程在内部完成,不涉及实际的网络传输和网络协议的影响。
- 操作系统缓冲:发送方应用程序将数据写入操作系统内核的发送缓冲区。接收方应用程序从操作系统内核的接收缓冲区读取数据。这些缓冲区被设计成大小合适且独立的,以确保数据的完整性和正确性。因此,在同一主机上的不同端口之间的TCP连接中,数据在这些缓冲区中被正确地划分、存储和传输。
- 数据处理:接收方应用程序可以根据自己的需求和协议规范,对接收到的数据进行解析和处理。由于数据传输在主机内,没有网络延迟、传输错误或网络拥塞等因素的影响。这意味着接收方应用程序可以按照预期的数据格式进行解析和处理,而不需要考虑TCP粘包问题。
虽然同一主机上不同端口之间的TCP连接不会导致TCP粘包问题,但仍需要确保接收方应用程序能够正确解析处理预期的数据格式,以免发生解析错误。这需要应用程序在设计中考虑到数据格式的规范,并采取适当的解析方法来确保数据的正确解释和使用。
1.4 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()进行拆包。