什么是粘包,拆包?
- TCP的粘包和拆包问题往往出现在基于TCP协议的通讯中,比如RPC框架
- 在使用TCP进行数据传输时,由于TCP是基于字节流的协议,而不是基于消息的协议,可能会出现粘包(多个消息粘在一起)和拆包(一个消息被拆分成多个部分)的问题。这些问题可能会导致数据解析错误或数据不完整。
为什么UDP没有粘包?
- 由于UDP没有像TCP那样的流控制和拥塞控制机制,它不会对数据进行缓冲或重组。因此,在UDP中,每个数据报都是独立传输的(接收端一次只能接受一条独立的消息),不存在多个消息粘在一起的问题,也就没有粘包的概念。
- 由于UDP是不可靠的传输协议,它无法保证数据的可靠传输和顺序传输。数据包可能会丢失、重复或乱序到达。在使用UDP时,应该自行处理这些问题,比如使用应答机制、超时重传等手段来保证数据的可靠性和正确性。
粘包拆包发生场景
因为TCP是面向流,没有边界,而操作系统在发送TCP数据时,会通过缓冲区来进行优化,例如缓冲区为1024个字节大小。
- 如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP则会将多个请求合并为同一个请求进行发送,这就形成了粘包问题。
- 如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP就会将其拆分为多次发送,这就是拆包。
关于粘包和拆包可以参考下图的几种情况:
- 理想状况:两个数据包逐一分开发送
- 粘包:两个包一同发送,
- 拆包:Server接收到不完整的或多出一部分的数据包
常见的解决方案
- 固定长度:发送端将每个消息固定为相同的长度,接收端按照固定长度进行拆包。这样可以确保每个消息的长度是一致的,但是对于不同长度的消息可能会浪费一些空间。
- 分隔符:发送端在每个消息的末尾添加一个特殊的分隔符(比如换行符或特殊字符),接收端根据分隔符进行拆包。这种方法适用于消息中不会出现分隔符的情况。
- 消息长度前缀:发送端在每个消息前面添加一个固定长度的消息长度字段,接收端先读取消息长度字段,然后根据长度读取相应长度的数据。这种方法可以准确地拆分消息,但需要保证消息长度字段的一致性。
代码实现
固定长度
发送端将每个包都封装成固定的长度,比如20字节大小。如果不足20字节可通过补0或空等进行填充到指定长度;
服务端
package main
import (
"fmt"
"log"
"net"
)
func main() {
// 监听指定的TCP端口
listener, err := net.Listen("tcp", "localhost:8080")
if err != nil {
log.Fatal(err)
}
defer listener.Close()
fmt.Println("Server started. Listening on localhost:8080...")
// 接收客户端连接
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal(err)
}
// 启动一个并发的goroutine来处理连接
go handleConnection(conn)
}
}
// 处理连接
func handleConnection(conn net.Conn) {
defer conn.Close()
// 读取固定长度的数据
fixedLength := 20 // 假设要读取的数据固定长度为20字节
buffer := make([]byte, fixedLength)
_, err := conn.Read(buffer)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Received data: %s\n", string(buffer))
// 可以在这里对接收到的数据进行处理和响应
// ...
// 发送响应给客户端
response := "Hello, Client!"
_, err = conn.Write([]byte(response))
if err != nil {
log.Fatal(err)
}
fmt.Println("Response sent successfully!")
}
客户端
package main
import (
"fmt"
"log"
"net"
)
func main() {
// 建立TCP连接
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 发送固定长度的数据
message := "Hello, Server!"
fixedLength := 20 // 假设要发送的数据固定长度为20字节
// 如果消息长度小于固定长度,则使用空字符填充
if len(message) < fixedLength {
padding := make([]byte, fixedLength-len(message))
message += string(padding)
}
_, err = conn.Write([]byte(message))
if err != nil {
log.Fatal(err)
}
fmt.Println("Data sent successfully!")
}
分隔符
发送端在每个包的末尾使用固定的分隔符,例如\n
服务端
package main
import (
"bufio"
"fmt"
"log"
"net"
"strings"
)
func main() {
// 监听指定的TCP端口
listener, err := net.Listen("tcp", "localhost:8080")
if err != nil {
log.Fatal(err)
}
defer listener.Close()
fmt.Println("Server started. Listening on localhost:8080...")
// 接收客户端连接
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal(err)
}
// 启动一个并发的goroutine来处理连接
go handleConnection(conn)
}
}
// 处理连接
func handleConnection(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
// 读取一行数据,以分隔符"\n"作为结束标志
message, err := reader.ReadString('\n')
if err != nil {
log.Println(err)
break
}
// 去除消息中的换行符
message = strings.TrimRight(message, "\n")
fmt.Printf("Received message: %s\n", message)
// 可以在这里对接收到的消息进行处理和响应
// ...
// 发送响应给客户端
response := "Hello, Client!\n"
_, err = conn.Write([]byte(response))
if err != nil {
log.Println(err)
break
}
}
fmt.Println("Connection closed.")
}
客户端
package main
import (
"bufio"
"fmt"
"log"
"net"
"os"
)
func main() {
// 建立TCP连接
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
reader := bufio.NewReader(os.Stdin)
for {
// 读取用户输入的消息
fmt.Print("Enter message: ")
message, err := reader.ReadString('\n')
if err != nil {
log.Println(err)
break
}
// 发送消息给服务器
_, err = conn.Write([]byte(message))
if err != nil {
log.Println(err)
break
}
// 读取服务器的响应
response, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
log.Println(err)
break
}
fmt.Printf("Server response: %s", response)
}
fmt.Println("Connection closed.")
}
消息长度前缀
将消息分为头部和消息体,头部中保存整个消息的长度,只有读取到足够长度的消息之后才算是读到了一个完整的消息;
代码实现
package main
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"net"
)
const headerSize = 4 // 头部长度的字节数
func main() {
// 启动服务器
go startServer()
// 连接到服务器
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
fmt.Println("连接服务器失败:", err)
return
}
defer conn.Close()
// 发送消息
message := "Hello, Server!"
sendMessage(conn, message)
// 读取服务器响应
response, err := readMessage(conn)
if err != nil {
fmt.Println("读取消息失败:", err)
return
}
fmt.Println("服务器响应:", response)
}
func startServer() {
listener, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("启动服务器失败:", err)
return
}
defer listener.Close()
fmt.Println("服务器已启动,等待连接...")
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("接受连接失败:", err)
return
}
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
fmt.Printf("客户端 %s 已连接\n", conn.RemoteAddr().String())
defer conn.Close()
// 读取消息
message, err := readMessage(conn)
if err != nil {
fmt.Println("读取消息失败:", err)
return
}
fmt.Println("收到消息:", message)
// 发送响应
response := "Hello, Client!"
sendMessage(conn, response)
}
func sendMessage(conn net.Conn, message string) error {
// 计算消息长度
messageLength := len(message)
// 将消息长度写入头部
header := make([]byte, headerSize)
binary.BigEndian.PutUint32(header, uint32(messageLength))
if _, err := conn.Write(header); err != nil {
return fmt.Errorf("写入消息头部失败: %v", err)
}
// 写入消息体
if _, err := conn.Write([]byte(message)); err != nil {
return fmt.Errorf("写入消息体失败: %v", err)
}
return nil
}
func readMessage(conn net.Conn) (string, error) {
// 读取消息头部
header := make([]byte, headerSize)
if _, err := io.ReadFull(conn, header); err != nil {
return "", fmt.Errorf("读取消息头部失败: %v", err)
}
// 解析消息长度
messageLength := binary.BigEndian.Uint32(header)
// 读取消息体
message := make([]byte, messageLength)
if _, err := io.ReadFull(conn, message); err != nil {
return "", fmt.Errorf("读取消息体失败: %v", err)
}
return string(message), nil
}
- 这段代码中,我们启动了一个TCP服务器,等待客户端连接。客户端在连接成功后,发送消息给服务器,服务器接收到消息后,返回一个响应。
- 在发送消息时,我们首先计算消息的长度,并将长度以4字节的大端字节序写入到头部。然后,将消息体写入
总结
- TCP 不管发送端要发什么,都基于字节流把数据发到接收端。这个字节流里可能包含上一次想要发的数据的部分信息。接收端根据需要在消息里加上识别消息边界的信息。不加就可能出现粘包问题
- UDP 是基于数据报的传输协议,每个数据报都是独立传输的(接收端一次只能接受一条独立的消息),不会有粘包问题。
参考
- 硬核图解|tcp为什么会粘包?背后的原因让人暖心