什么是 TCP 数据粘包,该如何解决
Category(分类): NET Protocol Status: 未知
一、粘包问题概述
1. 背景
采用 TCP 进行网络通信的软件,经常会遇到所谓的“粘包”和“拆包”问题。这个说法容易让人误以为 TCP 把多个完整数据包错误地粘到了一起,或者把一个数据包真的拆坏了。
更准确地说,TCP 提供的是可靠、有序的字节流,并不保留应用层 send()、write() 调用之间的消息边界。发送端连续发送两段数据:
send(data1)
send(data2)
接收端可能出现以下任意情况:
读取到 data1
读取到 data1 的一部分
一次读取到 data1 + data2
读取到 data1 + data2 的一部分
这些都是 TCP 字节流的正常表现,不是 TCP 协议出错。应用层如果需要一个个独立的消息,就必须自行设计消息边界,也就是进行封包和拆包。
TCP 内核通常维护发送缓冲区和接收缓冲区。数据可能受到发送缓冲、接收缓冲、TCP 分段、重传、拥塞控制、延迟 ACK、Nagle 算法以及网卡卸载等因素影响,因此不能根据一次 send()、一次 TCP 段或一次 recv() 来推断应用消息边界。

2. 什么是“粘包”?
“粘包”并不是严格的 TCP 规范术语,通常是应用开发者对以下现象的统称:
- 多条应用消息被一次读取到;
- 一条应用消息被分多次读取;
- 一次读取到一条完整消息和下一条消息的一部分。
例如,发送端有两条应用消息 pkg1 和 pkg2,接收端一次调用 recv() 读到了它们的拼接结果:
pkg1 + pkg2
如果应用程序把这次读取结果直接当成一条消息处理,就会出现协议解析错误。反过来,如果一次只读到 pkg1 的一部分,应用也不能因此认为消息已经结束。
接收端读取较少不会自动丢失后续数据。只要连接没有异常关闭,尚未读取的字节通常还会留在接收缓冲区中,等待后续读取。
3. 为什么不能按固定大小读取?
网络应用中的消息大小往往是不固定的。例如:
- 聊天消息可能只有几十字节;
- 登录请求可能包含不同数量的字段;
- 文件、图片和视频可能非常大;
- 同一条长连接中可能交替传输多种类型的消息。
因此,接收方不能通过猜测一个“合理长度”来判断消息边界。网卡 MTU、TCP MSS 或某次读取的长度,都不能作为应用层消息边界。应用协议必须明确规定消息如何开始、如何结束以及正文有多长。
4. UDP 与 TCP 粘包的区别
UDP 是面向数据报的传输协议,通常保留每个数据报的边界,因此不存在 TCP 意义上的粘包和拆包。接收端通常一次接收一个 UDP 数据报,但如果接收缓冲区太小,数据报可能被截断或丢弃,具体行为取决于所使用的 Socket API。
UDP 仍然可能丢包、重复、乱序或遭遇 IP 分片,消息边界存在不代表数据可靠。需要可靠性、顺序和重传时,应由应用层实现,或使用 QUIC 等在 UDP 之上提供可靠能力的协议。

UDP 首部固定为 8 字节,包含源端口、目的端口、长度和校验和字段。校验和用于检测错误,不负责恢复丢失的数据。

二、粘包和拆包的设计方案
1. 定长消息
发送端把每条消息都编码为固定长度 LEN:
消息长度不足 LEN 时填充
消息长度超过 LEN 时拆成多个固定长度块
接收端每次读取恰好 LEN 字节,再把这一段作为一个固定大小的消息处理。
这种方式适合消息结构和大小比较稳定的协议,例如固定长度的设备指令或二进制记录。
注意事项
- 一次
recv()不保证返回LEN字节,接收端仍然必须循环读取,直到收满LEN字节或连接关闭; - 最后一条消息的填充区域必须有明确规则,不能把“空白字节”当作通用结束标志;
- 二进制数据可能天然包含
0、空格或其他填充字节,建议在固定消息中增加有效长度或状态字段; - 当消息长度差异很大时,大量填充会浪费带宽。
2. 尾部标记或分隔符
在每条消息末尾增加特殊分隔符,例如:
message-1\n
message-2\n
接收方不断读取数据并搜索分隔符,找到分隔符后取出一条完整消息,剩余数据继续留在缓冲区中等待解析。
这种方案适合文本协议,例如按行传输的日志或命令。
注意事项
- 分隔符可能出现在正文中,不能简单假设某个字节永远不会出现;
- 二进制协议通常需要转义、字节填充、Base64、COBS 等方式避免正文与分隔符冲突;
- 字符串
\0只是 C 字符串的结束符,不是通用网络消息结束符; - 必须限制单条消息的最大长度,避免攻击者发送永远不结束的内容导致内存持续增长。
3. 固定报头 + 长度字段
这是二进制协议中最常用的方案之一。每条消息由固定长度的报头和可变长度的正文组成:
+----------------------+----------------------+
| 固定长度 Header | Body |
| body_length = N | N 个字节 |
+----------------------+----------------------+
报头中记录正文长度。接收端的流程是:
- 循环读取固定长度的报头,直到报头完整;
- 解析正文长度;
- 校验长度是否超过协议允许的最大值;
- 循环读取指定长度的正文;
- 得到完整消息后交给业务层处理;
- 继续解析缓冲区中可能存在的下一条消息。

报头设计建议
报头应使用明确的网络格式,不要直接把语言运行时的结构体内存布局发送到网络。例如:
magic 4 字节:协议标识
version 1 字节:协议版本
type 1 或 2 字节:消息类型
body_length 4 字节:正文长度,网络字节序
request_id 4 或 8 字节:请求标识
需要特别注意:
- 使用网络字节序(大端)或明确的序列化格式;
- 校验 magic、版本、消息类型和长度;
- 限制最大正文长度,避免内存耗尽;
- 不要把未经验证的长度直接用于内存分配;
- 不要假设一个报头对应一次
recv(),或一个正文对应一次recv()。
长度前缀方案并不要求一定执行两次系统调用。内核缓冲区中可能已经同时包含报头和正文,应用可以先读入缓冲区,再在内存中解析;也可以使用循环读取函数逐步获取指定长度。
4. 自描述格式或 TLV
一些协议使用自描述的格式,例如 TLV:
Type | Length | Value
JSON、CBOR、Protobuf、MessagePack 等格式也可以作为应用层消息内容,但它们本身不一定自动解决 TCP 字节流的边界问题。通常仍需要外层长度前缀、分隔符或基于协议的流式解析规则。
三、什么时候需要考虑粘包?
1. 一条连接只传一条消息
如果双方约定一条 TCP 连接只传输一份数据,接收方可以把以下条件之一作为结束依据:
- 预先知道数据长度;
- 读取到协议规定的 EOF;
- 对端关闭发送方向并发送 FIN。
这不是 TCP 自动解决了粘包,而是应用协议把连接关闭或已知长度定义成了消息边界。缺点是连接不能继续复用来传输下一条消息。
2. 单个文件流式传输
如果一条连接只负责传输一个文件,并且接收方知道文件长度或以连接关闭作为文件结束,那么应用不一定需要再为文件内部划分多条消息。
但接收方仍必须处理:
- 部分读取;
- 断点和超时;
- 提前断开;
- 文件长度校验;
- 完整性校验。
如果同一连接连续传输多个文件,就必须增加文件长度、文件名、类型或其他封包信息。
3. 同一连接传输多种结构
假设连接建立后连续发送两条不同类型的消息:
hello give me something about yourself
Don't give me something about yourself
如果协议没有规定长度、类型或分隔符,接收方看到拼接后的字节流时,就无法判断两条消息的边界和类型。这时必须设计应用层协议,例如:
[type=1][length=32][body]
[type=2][length=28][body]
四、粘包出现的常见原因
“粘包”现象可能表现为发送端多次写入被一次读取,也可能表现为一次写入被分多次读取。常见影响因素包括:
1. 发送端缓冲和 Nagle 算法
Nagle 算法用于减少大量小 TCP 段。当存在未确认数据且应用又提交了较小的数据时,TCP 可能暂时缓存新数据,等待 ACK 或积累到合适大小后再发送。
Nagle 只是可能影响发送时机的因素之一,不能作为粘包的唯一原因。TCP 仍然不会保留应用层 send() 的边界。
如果确实需要降低小消息的等待延迟,可以考虑 TCP_NODELAY,但它只影响 Nagle 行为,不能解决消息边界问题,也不能保证一次 send() 对应一次 recv()。关闭 Nagle 还可能增加小包数量和网络开销,应根据实际测量决定。
2. 接收端读取不及时
TCP 会把收到的字节放入接收缓冲区,应用程序稍后读取时可能一次读到多条消息。这个现象并不是数据被错误合并,而是应用尚未及时读取的字节现在一起可见。
优化接收线程的调度、减少阻塞操作可以降低延迟,但不能替代应用层封包协议。
3. TCP 分段、重传和网络设备优化
TCP 段大小和应用消息大小没有一一对应关系。操作系统和网卡还可能使用 TSO、GSO、GRO 等优化。因此,不能通过抓包看到的 TCP 段数量推断应用消息数量。
4. 不要依赖 PSH
TCP 的 PSH 标志用于提示接收端尽快把已有数据交给应用,但 RFC 9293 明确指出 PSH 不是应用记录标记,也不等同于消息边界。不同操作系统和 Socket API 对 PSH 的暴露方式也不同,因此不能用 PSH 进行拆包。
TCP 没有一个可以可靠地把每次 send() 立即变成独立消息的通用 push 指令。应用层必须自己定义消息边界。
五、TCP 连接过程与消息边界
1. TCP 连接过程
TCP 建立连接通常需要三次握手。握手只负责建立传输连接、同步序列号和协商 TCP 选项,并不会为应用层消息自动创建边界。
客户端 -> 服务端:SYN(seq=x)
服务端 -> 客户端:SYN+ACK(seq=y, ack=x+1)
客户端 -> 服务端:ACK(seq=x+1, ack=y+1)

三次握手之后,双方得到的是连续的 TCP 字节流。即使握手成功,后续每次 send() 仍然可能在接收端被拆分或合并。
如果只有两次握手,服务端无法确认客户端是否收到了自己的 SYN-ACK,延迟到达的旧 SYN 可能被误认为新的连接请求。第三次 ACK 能让服务端确认客户端已经收到第二次握手。

2. TCP 连接关闭与消息结束
TCP 的 FIN 只表示一个方向不再发送数据,不等于应用消息边界。典型的四次挥手是:
主动关闭方 FIN -> 被动关闭方 ACK
被动关闭方 FIN -> 主动关闭方 ACK
如果一条 TCP 连接只传输一个文件,应用可以把预先知道的长度或对端关闭连接作为结束条件;但如果连接还要继续传输多条消息,就必须使用长度字段、分隔符或其他封包规则。

六、为什么 TCP 通信程序需要封包和拆包?
TCP 是字节流协议,像一条连续的河流,没有天然的消息分界线。通信程序通常需要定义一个个独立的数据包,例如登录、查询、注销等业务消息,因此必须在应用层增加封包格式。
假设连续调用:
send(data1)
send(data2)
接收端可能出现:
- A:先接收到
data1,再接收到data2; - B:先接收到
data1的一部分,再接收到data1的剩余部分和data2; - C:一次接收到完整的
data1和data2的一部分,再收到data2的剩余部分; - D:一次接收到
data1和data2的全部。
A 只是恰好符合应用的预期,不能作为协议保证。B、C、D 都是合法的 TCP 行为,接收方必须通过协议规则拆出独立消息。
七、怎样封包和拆包?
1. 不要使用 sleep
最初遇到粘包问题时,有人会在两次 send() 之间调用 sleep(),希望让接收端刚好分开读取。这种方法只改变时序,不定义边界,在不同机器、网络和负载下都不可靠,而且会降低传输效率。
2. 不要使用 ACK 代替消息边界
TCP ACK 确认的是已经收到的字节范围,不是应用层消息。应用层可以增加业务 ACK,但业务 ACK 只能确认业务消息处理状态,不能替代长度字段或分隔符。
3. 使用缓冲区和状态机解析
推荐为每个 TCP 连接维护一个接收缓冲区:
- 把每次收到的任意字节追加到缓冲区;
- 如果缓冲区不足一个固定报头,则继续接收;
- 解析报头中的正文长度;
- 如果正文还没有接收完整,则继续等待;
- 取出一条完整消息;
- 继续解析缓冲区中剩余的字节。
4. 动态缓冲区
动态缓冲区的基本流程是:
A. 为每个连接维护一个缓冲区和解析状态;
B. 收到网络数据时追加到缓冲区;
C. 判断缓冲区是否至少包含完整报头;
D. 解析正文长度,并校验长度范围;
E. 判断缓冲区是否包含完整正文;
F. 取出完整消息,同时从缓冲区删除已消费的数据;
G. 循环解析同一批数据中可能存在的多条消息。
简单实现可能需要移动未消费数据,产生额外拷贝。可以使用读写游标、环形缓冲区、分段缓冲区或零拷贝解析降低开销。无论使用哪种缓存结构,都必须限制缓冲区增长速度,防止恶意客户端发送超大长度或永不完整的消息。
5. 阻塞 Socket 的定长读取
对于阻塞 Socket,可以使用循环函数读取指定长度。recv() 默认可能返回少于请求长度的数据,不能假设一次调用就能读满。
下面是 Windows 风格 Socket 的示意代码,代码中的协议报头使用显式字段读取,避免直接发送结构体内存布局:
#include <cstdint>
#include <vector>
#include <winsock2.h>
constexpr uint32_t kMaxBodySize = 20 * 1024;
bool recv_exact(SOCKET socket, char* buffer, int length) {
int received = 0;
while (received < length) {
const int count = recv(
socket,
buffer + received,
length - received,
0
)
if (count == 0) {
// 对端正常关闭,尚未读满 length
return false
}
if (count == SOCKET_ERROR) {
return false
}
received += count
}
return true
}
bool receive_package(SOCKET socket) {
// 示例报头:4 字节 magic + 4 字节 body_length
char header[8]
if (!recv_exact(socket, header, sizeof(header))) {
return false
}
uint32_t network_length = 0
memcpy(&network_length, header + 4, sizeof(network_length))
const uint32_t body_length = ntohl(network_length)
if (body_length > kMaxBodySize) {
// 长度非法,不能直接据此分配内存
return false
}
std::vector<char> body(body_length)
if (body_length > 0 &&
!recv_exact(socket, body.data(), static_cast<int>(body_length))) {
return false
}
// 这里处理一条完整消息
return true
}
实际代码还需要检查 magic、版本、消息类型、超时、错误码、整数溢出和连接取消。MSG_WAITALL 可以请求系统等待指定长度,但它仍可能因为信号、错误或对端关闭而提前返回,也不能替代应用层协议解析。
6. 非阻塞 Socket 和 IOCP
对于非阻塞 Socket 或 Windows IOCP:
- 一次完成通知可能只包含报头的一部分;
- 也可能包含完整报头和部分正文;
- 还可能包含多条消息;
- 应把收到的数据追加到连接对应的缓冲区,再由状态机持续解析;
- 不能因为一次异步操作完成就认为一条应用消息已经完整到达。
7. 环形缓冲区
环形缓冲区通常维护读指针和写指针:
- 新数据写入写指针位置;
- 已解析数据通过移动读指针释放;
- 不需要频繁把未消费的数据整体移动到缓冲区起始位置。
环形缓冲区可以减少内存拷贝,但仍然需要处理空间不足、消息跨越缓冲区尾部、最大消息长度和协议解析状态等问题。
八、常见误区总结
- TCP 是字节流,不是消息队列;
- 一次
send()不对应一次recv(); - 一次 TCP 段不对应一条应用消息;
- 一次
recv()可能返回半条、多条或一条半消息; - 读取长度小不会自动丢失后续数据;
sleep()、TCP ACK、PSH 和TCP_NODELAY都不能替代消息 framing;- MTU/MSS 不能作为应用层消息边界;
- UDP 没有 TCP 粘包,但仍可能丢包、乱序、重复或截断;
- 长度前缀、分隔符、固定长度和自描述格式都可以解决边界问题,具体取决于协议需求;
- 读取固定长度必须循环,解析长度字段必须做上限校验。