目录
摘要
TCP 是面向字节流的协议。应用层在 socket 上调用 send 与 recv 时,所面对的是一串无结构、无边界的字节序列,并非”一条消息对应一个包”的语义。“粘包”与”拆包”本质上均来自应用层未能在字节流上正确建立消息边界。
什么是粘包
“粘包”通常指两种现象:
- 发送端连续发送的多条消息,被接收端一次 recv 一起返回。
- 发送端一次发送的长消息,被接收端多次 recv 分段返回(拆包)。
RFC 9293 在 Key TCP Concepts 一节中写明:“TCP provides a reliable, in-order, byte-stream service to applications”,TCP 给应用层提供的是可靠的、有序的字节流,不保证发送边界与接收边界一一对应[1]。Linux tcp(7) 手册也指出:“TCP does not preserve record boundaries”,即 TCP 不保留记录边界[2]。《UNIX 网络编程 卷1》第 2 章同样将 TCP 描述为无记录边界的字节流服务[10]。因此,粘包不是 TCP 的 bug,而是应用层把”字节流”误当成”消息报文”所产生的理解偏差。
UDP 则不同。Linux udp(7) 手册明确写道:“All receive operations return only one packet.”,即每次接收操作只返回一个数据报;当数据报大于接收缓冲区时会被截断并置 MSG_TRUNC 标志,不存在 TCP 那样的流式粘包问题[9]。
图 1 示意这一点:发送端多次 send 与接收端 recv 的次数没有对应关系。
图1:TCP 字节流没有消息边界,recv 次数与 send 次数无对应
为什么会产生粘包
产生粘包的原因同时存在于发送端与接收端。
发送侧:Nagle 算法与写缓冲合并
RFC 9293 §3.7.4 对 Nagle 算法的描述是:“If there is unacknowledged data (i.e., SND.NXT > SND.UNA), then the sending TCP endpoint buffers all user data (regardless of the PSH bit) until the outstanding data has been acknowledged or until the TCP endpoint can send a full-sized segment (Eff.snd.MSS bytes).” 即当存在未确认的数据时,发送端会缓冲后续所有用户数据,直到收到确认或能凑成一个满尺寸段[1]。Linux tcp(7) 把这一行为表述为”data is buffered until there is a sufficient amount to send out, thereby avoiding the frequent sending of small packets”,并通过 TCP_NODELAY 提供关闭入口[2]。Nagle 算法最早在 RFC 896 中提出[4],与 RFC 1122 §4.2.3.2 中的 delayed ACK 相互作用,可能加剧小包合并的程度[5];《TCP/IP 详解 卷1》第 19 章对这一交互过程有经典的抓包分析[11]。
图 2 给出发送侧把多次小写合并成一个 TCP 段的过程。
图2:发送侧写缓冲与 Nagle 算法把多次 write 合并成一个 TCP 段
接收侧:socket 接收缓冲与 recv 的字节流语义
接收侧把到达的 TCP 段按序放入 socket 接收缓冲区,然后以 FIFO 字节流方式交给应用层。Linux recv(2) 手册对 SOCK_STREAM 套接字的表述是:“The receive calls normally return any data available, up to the requested amount, rather than waiting for receipt of the full amount requested.”[3] 一次 recv 返回多少字节,取决于当前接收缓冲区里有多少可用字节,以及本次请求读取多少字节,与发送端调用 send 的次数没有一一对应关系。
图 3 给出同一接收缓冲在不同 recv 请求下的交付结果。
图3:同一接收缓冲在不同 recv 请求大小下,一次取完或分次取完
常见触发条件包括:
- 发送端连续 send 多条小消息,被 Nagle 算法或发送缓冲合并。
- 接收端一次 recv 请求较大,把多条消息一并取走。
- 接收端一次 recv 请求较小,把一条长消息拆成多次取走。
- 网络延迟或拥塞控制导致多个段一起到达接收缓冲。
图 4 对比两种现象,说明粘包与拆包同源:都来自 TCP 不维护消息边界,差异只在于接收端 recv 请求的大小。
图4:粘包与拆包同源,差别仅在于 recv 请求大小
解决方法:在应用层建立消息边界
TCP 只负责按序交付字节流,消息边界必须由应用层协议定义。常见分帧方法有三种。
方法一:定长消息
每条消息固定为 N 字节。服务端每次恰好读取 N 字节,读满后再处理。优点是实现简单,缺点是带宽利用率低,不适合变长数据。
方法二:分隔符分帧
消息之间用特殊分隔符标记结束,例如 HTTP/1.1 头部以 CRLF 结束,正文常以 Content-Length 或 chunked 编码界定[6]。Redis RESP 协议也使用 CRLF 作为简单字符串和 bulk string 的分隔符[8]。分隔符方法灵活,但要求消息体不能出现与分隔符相同的字节序列,或者必须进行转义。
方法三:长度字段分帧(推荐)
消息格式为:4 字节长度(网络字节序)+ payload。接收端先读 4 字节,得到长度 L,再循环读取直到凑齐 L 字节,才交付给上层。图 5 是该方法的格式示意。
图5:长度字段分帧格式,4 字节长度头(网络字节序)加变长 payload
接收端按图 6 的状态机循环读入、解析长度头、凑齐 payload 后交付,从而把无边界字节流还原成完整消息。
图6:长度字段分帧的接收重组流程(循环读满长度头与 payload)
长度字段法的优势是:不限制 payload 内容,可以包含二进制数据;读取状态机清晰,易于处理拆包;方便与 protobuf、gRPC、msgpack 等序列化层配合。RFC 7540 §4.1 把 HTTP/2 通信切成固定 9 字节头部加变长 payload 的帧结构[7],其本质属于长度字段分帧。
开发实践建议
- 始终假设字节流。循环 recv 直到凑足完整一帧再处理,不应假设一次 recv 返回一条消息。
- 长度字段优先。对多数自定义协议,优先采用 4 字节长度头,使用 htonl/ntohl 统一网络字节序。
- 处理部分写。send 可能只发出部分字节,需循环发送剩余部分,或改用 writev。
- 设置长度上限。对长度字段做 sanity check,例如限制单帧最大 16 MiB,防止内存增长失控。
- 正确处理连接关闭。对端关闭时 recv 返回 0,应将其作为连接结束信号,而非错误。
- 序列化层也要边界意识。使用 protobuf、gRPC、msgpack 等库时,它们内部已提供分帧,但仍需在外层 socket 读取时保证”读满一帧”。
常见误区
误区一:设置 TCP_NODELAY 就能避免粘包
TCP_NODELAY 仅用于关闭 Nagle 算法,让小包尽快发出[2]。它改变的是发送端的包合并时机,并不影响接收端从字节流中读取字节的语义,recv 仍然可能一次返回多个 send 的数据。因此 TCP_NODELAY 不能替代应用层分帧。
误区二:使用 PSH 标志可以建立消息边界
RFC 9293 §3.9.1 对 PSH 的语义写得很明确:“The PSH bit is not a record marker and is independent of segment boundaries.”,PSH 仅表达发送方希望数据尽快发出的意图,不具备记录边界语义[1]。Linux recv 仍按字节流交付,不会因为对端设置了 PSH 就按消息边界返回。
误区三:1 次 send 对应 1 次 recv
这是较常见的一种误解。TCP 协议本身并不保证 send 与 recv 一一对应,应用层代码不应依赖这种假设。
C 语言实证
下面三组程序在 Linux 回环地址上运行,内核版本 6.8.0,使用 gcc 13.3.0 编译为 x86-64 可执行文件。实证一复现粘包,实证二复现拆包,实证三展示长度字段分帧如何修复问题。
实证一:5 次 send 被 1 次 recv 粘包
服务端先等待 100 毫秒再读取,模拟消费慢的场景。客户端连续发送 5 条 3 字节消息。
// naive_server.c:服务端只调用一次 recv,复现粘包。
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
// 演示"粘包":服务端一次 recv 把客户端连续发送的多个消息一起读回。
// 这里的 usleep 模拟"接收方读取不及时",此时多个消息已堆积在接收缓冲区。
int main(void)
{
int srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9090);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
// SO_REUSEADDR 便于反复调试,避免 TIME_WAIT 占用端口。
int opt = 1;
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
if (bind(srv, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
listen(srv, 1);
printf("server: 监听 127.0.0.1:9090,等待一个客户端\n");
int cli = accept(srv, NULL, NULL);
// 刻意延迟读取,让客户端连续发送的消息先在接收缓冲区里堆积。
// 这正是生产环境里"消费慢"时粘包出现的典型条件。
usleep(100000);
char buf[256];
ssize_t n = recv(cli, buf, sizeof(buf) - 1, 0);
if (n > 0) {
buf[n] = '\0';
// 一次 recv 返回的内容可能跨越多个 send 的边界。
printf("receipt: 一次 recv() 返回 %zd 字节 => [%s]\n", n, buf);
}
printf("note: 5 次 send 的内容被合并成 1 次 recv\n");
close(cli);
close(srv);
return 0;
}
// naive_client.c:客户端连续发送 5 条独立消息。
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
// 客户端连续发送 5 条独立消息,中间不插入任何停顿。
// 目的:观察服务端是否能把它们作为 5 个独立单元读出来。
int main(void)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9090);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
return 1;
}
// 5 次 send 彼此独立,但 TCP 不保证对端按"5 次"来接收。
const char *msgs[] = {"AAA", "BBB", "CCC", "DDD", "EEE"};
for (int i = 0; i < 5; i++) {
send(fd, msgs[i], 3, 0);
}
printf("client: 已发送 5 条消息:AAA BBB CCC DDD EEE\n");
close(fd);
return 0;
}
真机输出(粘包):
client: 已发送 5 条消息:AAA BBB CCC DDD EEE
server: 监听 127.0.0.1:9090,等待一个客户端
receipt: 一次 recv() 返回 15 字节 => [AAABBBCCCDDDEEE]
note: 5 次 send 的内容被合并成 1 次 recv
5 次 3 字节发送被服务端一次 recv 合并为 15 字节,验证”1 次 send 不等于 1 次 recv”。
实证二:1 次 send 被拆成多次 recv
服务端每次只读 4 字节,演示一条 19 字节消息如何被拆成多次 recv 返回。
// split_server.c:服务端每次只读 4 字节,复现拆包。
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
// 演示"拆包":服务端每次只读 4 字节,一条长消息被拆成多次 recv。
int main(void)
{
int srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9092);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int opt = 1;
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(srv, (struct sockaddr *)&addr, sizeof(addr));
listen(srv, 1);
printf("server: 监听 127.0.0.1:9092\n");
int cli = accept(srv, NULL, NULL);
char buf[4];
int ncall = 0;
while (1) {
ssize_t n = recv(cli, buf, sizeof(buf), 0);
if (n <= 0) {
break;
}
ncall++;
// 每次只取 4 字节,可见一条消息被切成多段。
printf("recv#%d: 返回 %zd 字节 => [%.*s]\n",
ncall, n, (int)n, buf);
}
printf("note: 1 条消息被拆成 %d 次 recv\n", ncall);
close(cli);
close(srv);
return 0;
}
// split_client.c:客户端一次发送一条 19 字节消息。
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
// 客户端发送一条 19 字节消息后关闭连接。
int main(void)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9092);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
return 1;
}
// 消息长 19 字节,便于演示按 4 字节读取时被拆成 5 段。
const char *msg = "SPLIT-DEMO-MSG-1234";
printf("client: 发送 1 条消息(%zu 字节):%s\n",
strlen(msg), msg);
send(fd, msg, strlen(msg), 0);
close(fd);
return 0;
}
真机输出(拆包):
client: 发送 1 条消息(19 字节):SPLIT-DEMO-MSG-1234
server: 监听 127.0.0.1:9092
recv#1: 返回 4 字节 => [SPLI]
recv#2: 返回 4 字节 => [T-DE]
recv#3: 返回 4 字节 => [MO-M]
recv#4: 返回 4 字节 => [SG-1]
recv#5: 返回 3 字节 => [234]
note: 1 条消息被拆成 5 次 recv
19 字节被拆成 5 次 recv,说明 TCP 交付给应用层的只是字节序列。
实证三:用 4 字节长度头修复粘包与拆包
消息格式为 4 字节长度(网络字节序)加 payload。服务端维护一个循环缓冲区,按图 6 的状态机先解析长度头,再凑齐完整 payload 才交付上层。
// frame_server.c:4 字节长度头分帧,把字节流还原为完整消息。
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
// 长度前缀分帧:每条消息 = 4 字节长度(网络字节序) + 负载。
// 无论 TCP 怎么粘包或拆包,按"先读长度、再凑齐负载"都能还原消息。
static void dump_message(uint32_t len, const char *p)
{
if (len <= 24) {
printf("message: [%.*s]\n", (int)len, p);
} else {
// 负载较长时只打印前缀,证明跨多次 recv 仍被正确重组。
printf("message: 长度=%u [%.24s...](已重组)\n",
len, p);
}
}
int main(void)
{
int srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9091);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int opt = 1;
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(srv, (struct sockaddr *)&addr, sizeof(addr));
listen(srv, 1);
printf("server: 监听 127.0.0.1:9091\n");
int cli = accept(srv, NULL, NULL);
// inbuf 累积从 socket 读到的全部字节;filled 记录已缓冲长度。
char inbuf[16384];
size_t filled = 0;
while (1) {
// 先尝试解析已缓冲的字节,能凑出整帧就处理,避免无谓阻塞。
while (filled >= 4) {
uint32_t len;
memcpy(&len, inbuf, 4);
len = ntohl(len);
if (filled < 4 + (size_t)len) {
// 负载未到齐,退出解析循环,继续 recv。
break;
}
dump_message(len, inbuf + 4);
memmove(inbuf, inbuf + 4 + len, filled - 4 - len);
filled -= 4 + len;
}
char tmp[512];
ssize_t n = recv(cli, tmp, sizeof(tmp), 0);
if (n <= 0) {
// 对端关闭或读取出错,结束循环。
break;
}
memcpy(inbuf + filled, tmp, (size_t)n);
filled += (size_t)n;
}
close(cli);
close(srv);
return 0;
}
// frame_client.c:发送 4 字节长度头加 payload。
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
// 发送一条"长度前缀"消息:先发 4 字节长度(网络字节序),再发负载。
static void send_message(int fd, const char *payload, uint32_t len)
{
uint32_t net = htonl(len);
send(fd, &net, 4, 0);
send(fd, payload, len, 0);
}
int main(void)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9091);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
return 1;
}
// 连续发送 5 条短消息 + 1 条较长消息,
// 多条之间不插入停顿,主动制造 TCP 粘包/拆包条件。
const char *msgs[] = {"one", "two", "three", "four", "five"};
for (int i = 0; i < 5; i++) {
send_message(fd, msgs[i], (uint32_t)strlen(msgs[i]));
}
// 较长消息:构造 1024 字节负载,验证跨多次 recv 的重组。
char big[1024];
memset(big, 'x', sizeof(big));
// 加前缀便于确认重组结果是否正确。
memcpy(big, "BIG-START-", 10);
send_message(fd, big, (uint32_t)sizeof(big));
printf("client: 已发送 one two three four five + 1 条 1024 字节消息\n");
close(fd);
return 0;
}
真机输出(长度前缀分帧):
client: 已发送 one two three four five + 1 条 1024 字节消息
server: 监听 127.0.0.1:9091
message: [one]
message: [two]
message: [three]
message: [four]
message: [five]
message: 长度=1024 [BIG-START-xxxxxxxxxxxxxx...](已重组)
5 条短消息与 1 条 1024 字节长消息都被正确还原。即使 1024 字节长消息被底层拆成多次 recv,服务端也能通过长度字段和循环缓冲将其完整重组。
调试技巧
当线上出现粘包嫌疑时,可按以下步骤定位:
- 在 recv 返回点打印实际收到的字节数和内容(十六进制),确认是否真的”粘”或”拆”。
- 用 tcpdump 或 Wireshark 抓包,观察 TCP 段边界,区分是发送端合并还是接收端读取方式导致。
- 用 strace 跟踪 send/recv 调用次数,验证”1 次 send 对应 1 次 recv”的假设是否成立。
- 如果协议本身无分帧,优先引入长度字段状态机,而不是依赖 TCP_NODELAY 或 PSH。
面试高频题
以下问题整理自程序员社区关于 TCP 粘包的高频讨论。
1. 什么是 TCP 粘包?
答题思路:定义 + 两类表现(多粘一、一拆多)+ 强调 TCP 是字节流。
参考答案:TCP 是面向字节流的协议,不保留消息边界。应用层连续发送的多条消息可能被接收端一次 recv 一起返回,称为粘包;一次发送的长消息也可能被多次 recv 分段返回,称为拆包。两者根源一致,都来自应用层未在字节流上建立消息边界。
2. TCP 粘包是 TCP 的 bug 吗?
答题思路:不是 bug,是协议设计如此。
参考答案:不是。RFC 9293 明确指出 TCP 提供”reliable, in-order, byte-stream service”,Linux tcp(7) 也写明”TCP does not preserve record boundaries”。消息边界属于应用层语义,TCP 只负责按序交付字节。
3. TCP 粘包和拆包有什么关系?
答题思路:同源,差异仅在 recv 请求大小。
参考答案:同源。根本原因是 TCP 不维护消息边界。接收端请求缓冲区较大时,多条消息一起返回就是粘包;请求缓冲区较小时,一条消息被分段返回就是拆包。
4. 常见解决方法有哪些?
答题思路:定长、分隔符、长度字段,并举出真实协议。
参考答案:三种。定长消息实现简单但浪费带宽;分隔符适合文本协议,如 HTTP/1.1 用 CRLF、Redis RESP 用 CRLF;长度字段通用性较好,如自定义二进制协议采用 4 字节长度头加 payload,HTTP/2 也采用帧长度字段。
5. 长度字段法需要注意什么?
答题思路:网络字节序、循环读、长度上限、部分写。
参考答案:长度用 htonl/ntohl 转换为网络字节序;recv 循环读取直到凑满一帧;对长度字段做上限校验防止内存增长失控;send 时也要处理部分写,可循环发送或使用 writev。
6. 设置 TCP_NODELAY 能否解决粘包?
答题思路:只影响发送端 Nagle,不影响接收端字节流语义。
参考答案:不能。TCP_NODELAY 仅用于关闭 Nagle 算法,让小包尽快发出。它不改变接收端从字节流中读取的行为,因此不能替代应用层分帧。
7. PSH 标志能用于分帧吗?
答题思路:PSH 仅提示尽快交付,无边界语义。
参考答案:不能。RFC 9293 §3.9.1 明确指出”The PSH bit is not a record marker and is independent of segment boundaries”,Linux recv 仍按字节流交付,不会按 PSH 标志切分消息。
8. 自定义协议如何设计分帧?
答题思路:推荐长度字段 + 魔数/校验和。
参考答案:常见做法是固定 4 字节魔数 + 4 字节长度 + payload + 校验和。魔数用于快速识别协议版本,长度字段决定一帧边界,校验和用于完整性校验。读取时采用状态机:先读头部,再按长度循环读满 payload,最后校验。
参考来源
[1] RFC 9293, Transmission Control Protocol, §2.2 Key TCP Concepts、§3.7.4 Nagle Algorithm、§3.9.1 User/TCP Interface: “The PSH bit is not a record marker and is independent of segment boundaries.” https://www.rfc-editor.org/rfc/rfc9293
[2] Linux man-pages, tcp(7): “TCP does not preserve record boundaries”; TCP_NODELAY (Nagle). https://man7.org/linux/man-pages/man7/tcp.7.html
[3] Linux man-pages, recv(2): “The receive calls normally return any data available, up to the requested amount, rather than waiting for receipt of the full amount requested.” https://man7.org/linux/man-pages/man2/recv.2.html
[4] RFC 896, Congestion Control in IP/TCP (J. Nagle): Nagle 算法。https://www.rfc-editor.org/rfc/rfc896
[5] RFC 1122, Requirements for Internet Hosts, §4.2.3.2: delayed ACK。https://www.rfc-editor.org/rfc/rfc1122
[6] RFC 9112, HTTP/1.1, §6.3 Message Body Length: Content-Length 与 chunked 分帧。https://www.rfc-editor.org/rfc/rfc9112
[7] RFC 7540, HTTP/2, §4 Frame Format: 帧长度字段。https://www.rfc-editor.org/rfc/rfc7540
[8] Redis protocol specification (RESP): CRLF 与 bulk string 分帧。https://redis.io/docs/latest/develop/reference/protocol-spec/
[9] Linux man-pages, udp(7): “All receive operations return only one packet. When the packet is smaller than the passed buffer, only that much data is returned; when it is bigger, the packet is truncated and the MSG_TRUNC flag is set.” https://man7.org/linux/man-pages/man7/udp.7.html
[10] W. Richard Stevens, Bill Fenner, Andrew M. Rudoff, 《UNIX 网络编程 卷1:套接字联网 API》(第 3 版),人民邮电出版社:第 2 章(TCP 字节流服务,无记录边界)。
[11] W. Richard Stevens, 《TCP/IP 详解 卷1:协议》,机械工业出版社:第 19 章(TCP 交互数据流:延迟确认与 Nagle 算法的抓包分析)。