TCP 粘包:为什么你的 onMessage 收到两条消息

TCP 粘包:为什么你的 onMessage 收到两条消息
隔壁老康难度 ★★★★★ · 前置:select、poll、epoll:三个接口,一次说清 | 一句话结论:TCP 是字节流,不保留任何应用层的边界。
recv()一次返回多少字节,跟发送方send()了几次没有任何关系。所谓「粘包」,是应用层协议的边界没有被正确重建。
压测十分钟,日志才开始冒错
写自研 RPC 那会儿,读回调是照着教程抄的,长得像个教科书例子,一次 recv 直接 json.loads,跑通了我当它会一直跑通,还把这段抄进了组里另一个项目的模板里。单机自测跑了一整天都没事,压测一开,压到 5000 QPS,日志开始往外吐这个东西。
1 | 2026-05-11 14:22:31.482 ERROR rpc: decode failed: unexpected 0x7b after frame |
失败率 0.3%,随机出现,重启能好一阵,过一会儿又冒。当时的读回调就是下面这几行,短到我一度觉得没什么可怀疑的。
这篇的实验大部分用 Python 写,粘包这事跟语言一点关系都没有,选 Python 只是因为造两个互发数据的进程只需要几十行。C 的那几段单独标出来了,用 gcc -O2 -o x x.c && ./x 编译。
1 | def on_readable(sock): |
recv(65536) 可能一次返回两条 JSON,也可能只返回半条,两种都能让 json.loads 当场抛异常。它能在一整天里不出错,原因其实很具体,每条请求才 200 字节,客户端是发一条、等响应、再发下一条,同一时刻只有一个包在路上,本地回环的 MTU 又大,一个 send() 通常就对应一个 recv()。压测把这三个条件一次性全改掉了,客户端并发发、包更大、网络更繁忙,于是收到两条和只收到半条开始同时冒头,失败率低得像是抖动,其实是两个不同的问题在同一个回调里轮流出现。现在回头看那条报错,0x7b 就是 {,日志说的是它刚解析完一个帧,紧接着又撞上一个 {,等于把答案摆在脸上了,只是当时我盯着 5000 QPS 这个数字,完全没往边界那个方向想,只当是哪个客户端把报文格式写错了,把服务端的校验规则改了两版,一次都没打中。
至于粘包这个名字,它起得相当误导。
它给人的画面是两条消息先各自独立、后来被粘到一起,实际发生的事正好相反,从一开始就没有分开过,recv 拿到的只是一段连续字节,消息边界这个东西在内核里根本不存在,所以它不是被谁弄坏的,是从来就没被建立过。
TCP 卖的是字节流,不是消息
TCP 提供的是有序的字节流,不是消息序列,这两个东西差在哪,一张表比讲十分钟清楚。
| 字节流(TCP) | 消息序列(UDP / SCTP / 消息队列) | |
|---|---|---|
| 边界 | 没有 | 每个消息独立 |
send 100 字节两次 |
对方可能一次 recv 收到 200 |
对方必须 recv 两次 |
send 1000 字节一次 |
对方可能分 3 次收到 | 对方一次收全或丢弃 |
| 责任 | 应用层自己划边界 | 传输层保证 |
粘包和拆包说的是同一件事的两个方向,两个 send 的数据被一次 recv 读到叫粘包,一个 send 的数据被两次 recv 读到叫拆包,也有人叫半包,两个名字经常混着用,指的是同一个现象。两边的成因完全一样,TCP 没有消息边界,recv 返回多少字节,只由内核缓冲区里此刻有什么决定,你的应用层写了多少次 send、每次写了多少,它一概不知道。
两次 send 进的是同一个缓冲区
发送侧,send() 把数据拷进 socket 的发送缓冲区(sk_write_queue),之后什么时候发、发多大的段,全由 TCP 自己决定,你把数据交出去的那一刻,控制权就不在你手里了。
graph LR
A["send() 200B"] --> B["发送缓冲区"]
C["send() 200B"] --> B
B --> D["TCP 分段<br/>MSS 决定"]
D --> E["网卡"]
两次 send() 的数据进的是同一个缓冲区,TCP 做分段的时候完全不知道哪一段对应哪一次 send(),它只看见一堆等着发出去的字节。如果两次 send() 间隔很短,Nagle 算法(tcp_nodelay=0 时开着)会把它们合并成一个段发出去,这是粘包最常见的成因,也是唯一一个在发送侧就能动手改的成因,所以它被讨论得最多,关 Nagle 就是一行。
1 | int one = 1; |
Nagle 的规则是,如果还有没被 ACK 的小段(小于 MSS),就先等它 ACK 了再发,后果是写、写、写三次很快的 send(),前两个很可能被合并成一段,这就是明明发了三条、对方只收到一条最典型的来源。(我在这上面栽过一次,查了两天才发现是 Nagle 和延迟 ACK 撞一块,40ms 的延迟看起来特别像网络抖动)
什么时候该关,看场景,不用记口诀。
| 场景 | 建议 |
|---|---|
| 请求-响应、延迟敏感 | 关(TCP_NODELAY=1)。Redis、MySQL 客户端都关 |
| 批量传输、吞吐优先 | 可以开 |
| 小包高频(游戏、行情) | 必须关 |
现代服务几乎都默认关掉 Nagle,Nginx 的 tcp_nodelay 默认就是 on,Go 的 net.TCPConn 里 SetNoDelay(true) 也是默认值,这两个默认值救了很多不知道自己需要它的人。但关掉 Nagle 并不能解决粘包,它只减少发送方主动合并这一种情况,接收方的 recv 仍然可能返回任意长度,这一点极其重要,TCP_NODELAY 是延迟优化,不是粘包解药,把这两件事分清楚,后面很多实验现象才不会把人绕进去。
Nagle 的另一面:TCP_CORK
TCP_CORK 是 Nagle 的加强版,方向正好相反,它不是别合并,而是先别发,攒够了再说。
1 | int one = 1, zero = 0; |
典型的用法是先构一个 header 再顺着写 body 的场景,不开 cork 的话,header 那几字节会被立刻发出去,白占一个 40 字节的 TCP 头,开了 cork 就等最后一趟车一起走。这里有两个细节值得记住,TCP_CORK 和 TCP_NODELAY 同时设的时候是 cork 说了算,TCP_NODELAY 只被记下来,要等拔了塞子才生效,很多人把它搞反(do_tcp_setsockopt() 里那段注释写得很直白,TCP_NODELAY is weaker than TCP_CORK)。内核还有兜底,cork 之后即使不主动拔塞子,最多攒 200ms 也会自己发出去,所以不用担心忘了 uncork 数据就永远发不出去。(include/net/tcp.h 里的 TCP_NAGLE_CORK 是 nonagle 上的一个标志位,不是那个超时本身,超时是 man 7 tcp 里写的 200 毫秒上限。)TCP_CORK 也不是给应用独占的,内核自己也会用,比如 sendfile 那条路径,所以它本质上是建议,不是命令,你设了它也只是告诉内核我这会儿想攒着,内核该发的时候照发。(我当初以为 cork 是不发送,试了一下才发现只是先别发,塞子到点自己会松)
想知道还有多少没发出去
send() 返回成功只代表数据进了发送缓冲区,不代表对端收到了,想看还剩多少积压,用 SIOCOUTQ。
1 |
|
这个数在排查对端说收不到数据的时候特别有用,如果它一直不降,说明 TCP 根本没在发,可能是拥塞窗口为 0,也可能是对端通告窗口为 0,两种情况在 ss -ti 里都看得见。如果它很快归零,那问题就在对端读得太慢,或者应用层自己把包丢了。
recv 只报字节数,不报消息数
接收侧是另一半。
graph LR
A["网卡"] --> B["接收队列"]
B --> C["recv(65536)"]
C --> D["返回当前所有可用字节<br/>可能是 400,也可能是 137"]
recv 的返回值等于 min(你的缓冲区大小, 当前接收队列里的字节数),就这一句,没有别的规则。没有任何字段告诉内核这 400 字节是两次 send 还是一次,这个信息在 TCP 头里根本不存在,所以它也无从转达给你,指望从协议头里把边界找回来,方向就错了。
边界只能自己在应用层补
应用层协议得自己定义怎么从字节流里切出消息,主流做法就三种,各有各的代价。
| 方案 | 怎么切 | 优点 | 缺点 |
|---|---|---|---|
| 定长 | 每条固定 N 字节 | 最简单、零解析 | 消息长度差异大时浪费 |
| 分隔符 | 找 \n 或 \0 |
可读、易调试 | 消息内容不能含分隔符 |
| 长度前缀 | 前 4 字节是长度 | 通用、高效 | 需要处理长度字段本身的半包 |
长度前缀是绝大多数二进制协议的选择,HTTP/2 的帧、gRPC、Thrift、Kafka、Protobuf 的 writeDelimitedTo,全是这一套,所以它值得单独讲透,另外两种看一眼就够。
定长
定长的做法是把每条消息都补到固定的 N 字节,收的时候照着 N 死读就行。
1 | MAX_MSG = 512 |
关键就是那个 while len(buf) < n 循环,所有半包都在这里被处理掉,读不满就继续读,读满了才返回,之前那种一次 recv 就当一条消息的写法,缺的正是这个循环。它的代价也很直接,每条消息都占 MAX_MSG 字节,如果实际平均 20 字节,你要传 512 字节,带宽浪费 25 倍。用它的一般是这几类,定长结构体(C 语言里 struct 直接传)、硬件协议、极其简单的内部工具,消息长度本来就固定的场合,这个方案最省心。
分隔符
分隔符方案是找一个不会出现在内容里的字符,最常见的是换行。
1 | def recv_line(sock): |
这个写法有个 bug,recv(4096) 可能一次读到 line1\nline2\n,endswith 判断失败后继续读,把 line2 也吃进去了,所以正确做法要维护一个跨调用的缓冲区,每次只切一条出来,剩下的留着。
1 | class LineReader: |
split(b'\n', 1) 那一行是整个方案的核心,只切第一个,剩下的留在缓冲区里等下一次,那个 1 就是这个方案的全部秘密。HTTP/1.0 的请求行、Redis 的 RESP 协议(\r\n 分隔)、SMTP、FTP 都是分隔符方案。
长度前缀
二进制协议的标准做法,前面 4 字节是长度,后面跟着 payload,实现起来有三个关键点。
1 | +--------+------------------+ |
1 | import struct |
- 长度字段本身也可能是半包(第 1 个
while)。 - 必须限制最大长度,否则恶意客户端发一个
len = 0xFFFFFFFF,你while循环就卡死了,这是一个真实的 DoS 面。 - 用
del buf[:...]而不是重建 buffer,避免 O(n²) 拷贝。
缓冲区用 bytes 还是 bytearray
上面 recv_frame 里,如果 buf 已经是 bytearray,buf += chunk 就是原地扩展,均摊 O(1),如果 buf 是 bytes,每次 += 都新建一个对象,8000 字节的包就要拷 8000 次,这个差别在单机自测里看不出来,压测一开就是几倍的差距。Python 里这是真实存在的性能陷阱,所以用 bytearray,或者用 collections.deque 收集起来最后 b''.join() 一次,两种都比 bytes 上反复 += 强。Go 里对应的坑是 buf = append(buf, chunk...),虽然均摊也是 O(1),但大 buffer 会一直占着内存,append 扩容之后那个旧的底层数组要等 GC 才还。(buf += chunk 这个写法我用了很久,直到有人让我量一下 8000 字节的包到底拷了几次,我才发现这不是写法问题,是量级问题)
谁该负责:三层各写了一半的答案
粘包不是一个问题,是三个层面各有一半责任,把这三层分开看,就不会再纠结到底该谁修。
graph TD
A["应用层<br/>必须重建边界"] --> B["长度前缀 / 分隔符 / 定长"]
C["传输层<br/>TCP_NODELAY 减少合并"] --> D["降低频率,但不解决"]
E["框架层<br/>Netty / gRPC / Thrift"] --> F["编解码器已经内置"]
优先级很清楚,应用层是必须做的,传输层是优化,框架层是省事。如果你在用 gRPC、Thrift、Dubbo、Protobuf over HTTP/2,它们都已经处理了粘包,你不用管,只有裸 TCP 才需要自己动手,所以这个知识点现在多半在写自研协议或者维护老服务的时候才用得上。
UDP 那边为什么没这回事
因为 UDP 保留消息边界。
1 | sendto(sock, buf, 100, 0, ...); |
接收方的 recvfrom 一定返回 100 和 200 各一次,一次对一条,多出来的字节不会混进来。但 UDP 有相反的问题,要么整条收到,要么丢弃,一个 2000 字节的 UDP 包在 MTU 1500 的链路上会被 IP 层分片,任何一个分片丢了整条都收不到,所以 UDP 没有粘包,不代表 UDP 更好用,它只是把边界换成了别的东西在替你守,代价写在别处。
本机上造一次粘包,再造一次半包
三次 send,一次 recv
先写服务端,每次 recv 把实际长度和内容打出来,免得自己猜。
1 | # server_sticky.py |
客户端开好连接以后连发 3 条小消息,中间不 sleep,也不等任何 ACK。
1 | # client_sticky.py |
1 | python3 server_sticky.py & |
跑一遍,典型输出是三次 send 一次 recv 全收到,这就是粘包。
1 | recv 18 bytes: b'MSG-0;MSG-1;MSG-2;' |
但它不是必然的,多跑几次,可能看到另一种切法。
1 | recv 12 bytes: b'MSG-0;MSG-1;' |
这种不稳定正是粘包最难排查的地方,同一份代码,跑两次给你两种结果,本地跑不出来,压测一开就全冒出来。
关掉 Nagle 再跑一次
在客户端加一行。
1 | c.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) |
加之前我的预期非常明确,关掉 Nagle,三次 send 就该对应三次 recv,一次收全的那种输出应该彻底消失,这样我就能把压测里的报错归到 Nagle 头上。连着重跑,一次收全确实明显少了,可它没有绝迹,还是能碰上,我的预期当场就塌了。(后来才想明白接收侧也在合并,两个段前后脚到,你的进程被调度起来的时候接收队列里两段都在,recv 一次全给你,这事跟发送方开没开 nodelay 一点关系都没有)这个实验的意义就是让你亲眼看到 TCP_NODELAY 不能解决粘包,它只是让发送侧少合并一点,剩下的部分得靠应用层自己补。
一次 send,十几次 recv
换个大包反过来做,发一条大消息,接收方用小缓冲区读。
1 | # server_halfpack.py |
1 | # client_big.py |
1 | python3 server_halfpack.py & |
输出会是十几行 got 100,一次 send 被拆成了很多次 recv,这就是拆包,注意它跟粘包的成因是同一个,只是站在了另一边。
能上生产的帧解码器
1 | # framed.py |
注意那个内层 while,它把一次 recv 收到三帧的情况全部解析出来,外层管半包,内层管粘包,这是所有帧解码器的标准结构,Netty 的 LengthFieldBasedFrameDecoder 拆开看也是这两层。
用 tcpdump 看段是怎么切的
1 | sudo tcpdump -i lo -nn -X 'tcp port 9810' -c 5 |
会看到每次 recv 对应一个或多个 TCP 段,段的大小由 MSS 和发送缓冲决定,跟 send 的调用次数无关,这一点跟前面说的完全对得上。
量一下 Nagle 值多少
同一段代码,分别带和不带 TCP_NODELAY,跑 1000 次小消息。
1 | import socket, time |
在回环上差异可能不明显(RTT 极短),跨机测试差异会很明显,这个脚本你改改端口就能拿去自己跑。
线上服务开没开 nodelay,ss 看不出来
1 | ss -ti 'dport = :3306' |
ss 看不到 TCP_NODELAY,-i 给的是内部 TCP 信息(ts/sack/cong/wscale/rto/backoff/rtt/ato/mss/cwnd/ssthresh/bytes_*/segs_* 这一套),-o 给的是定时器(timer:(keepalive,119min,0) 之类),都不是 socket 选项。想确认 nodelay 只能看代码里的 setsockopt(IPPROTO_TCP, TCP_NODELAY, ...),或者用 bpftrace、SystemTap 挂钩 tcp_setsockopt 现场抓。(ss 那套输出我到现在也只认得出几个字段,剩下的是翻 man 翻出来的)
关掉 Nagle,粘包照旧
关掉 Nagle 就没有粘包了,这是最常见的一个误判,Nagle 只是发送方主动合并这一种成因,接收方一次 recv 拿到两条消息,跟它一点关系都没有,把这条当成结论,下次还会在同一个坑里站着。用 send 一次发完就一定有边界,这个也没有,send 只是往发送缓冲区里拷数据,内核分段时不管你的调用边界,粘包是 TCP 的 bug 同样不是,TCP 的设计目标就是字节流,需要消息边界的协议(SCTP、消息队列)是另外一套东西,它没坏,它只是没答应你这件事。
recv 返回的长度等于我 send 的长度,这句只在极特殊的情况下成立,比如小数据加无并发加回环,任何生产代码都不能依赖这个假设,UDP 没有粘包所以更简单,这个说法漏了后半句,UDP 有大小限制(65KB)、有丢包、有乱序,分片时丢一个分片整包就没了,简单的是边界,复杂的是可靠性。HTTP 没有粘包,HTTP/1.1 有 Content-Length 或 Transfer-Encoding: chunked,HTTP/2 有帧头(9 字节,含长度),它们都在应用层重建了边界,只是不用你动手写。用 readline() 就不会有问题,语言标准库的 readline 通常在内部维护缓冲区,所以是对的,但如果你自己 recv 然后 split(b'\n'),照样会出问题。粘包只在长连接出现,也不是,短连接同样有,只是连接关掉之后残留数据被丢弃了,问题被掩盖住而已。
抄进笔记的几条
- TCP 是字节流,
recv返回多少字节只取决于内核缓冲区里有什么,跟send了几次无关。 - 粘包(多条合成一条)和拆包(一条拆成多条)是同一件事的两个方向,原因相同。
TCP_NODELAY是延迟优化,不是粘包解药。它只减少发送端合并的概率。- 三种拆包方案:定长(简单但浪费)、分隔符(高效但内容不能含分隔符)、长度前缀(通用,二进制协议标准)。
- 长度前缀实现有三个必须:处理长度字段本身的半包、限制最大长度防 DoS、用
bytearray或等价物避免 O(n²) 拷贝。 - 帧解码器的标准结构:外层循环管半包,内层循环管粘包。
- 用 gRPC / Thrift / Protobuf-over-HTTP2 就不用自己处理,它们已经做了。
- UDP 没有粘包,但代价是丢包、乱序、大小限制。
我后来再看到 recv 返回 0、send 报 EPIPE、连接半关闭这些现象,反而不慌了,它们都发生在同一条字节流上,边界在谁手里,责任就在谁手里。










