适用读者:写过 socket 通信、被"消息粘在一起"或"半条消息"坑过的人。
前置:S02-B04(字节流与可靠传输)。
实测环境:Ubuntu 24.04.1 / Python 3.12.3(socket 示例);实测日期 2026-11-05。
覆盖旧文:A-1040(TCP 粘包、拆包问题,82 点)。
覆盖扩展:E-S02-009(三种解法与 Netty 解码器)。
上一篇:S02-B04 · TCP 可靠传输与 UDP:谁在保证"不丢、不乱、不重"
下一篇:S02-B06 · HTTP 与 HTTPS。
1. 一句话结论
"粘包"这个说法本身是误导:TCP 是字节流,没有"包"的概念,所以谈不上"粘"。
真相是:TCP 只保证字节按顺序到达,不保证"你写几次,对方就读几次"。所以消息边界必须由应用层协议定义。
2. 实测:两条消息被合并成一次读取
2.1 代码(服务端与客户端)
# srv.py
import socket, time
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 19001)); s.listen(1)
c, a = s.accept()
print("server: accept from", a)
time.sleep(0.5) # 等客户端把两条消息都写进来
data = c.recv(4096)
print("server: 一次 recv 收到 =", data, "长度", len(data))
c.close(); s.close()
# cli.py
import socket, time
c = socket.create_connection(("127.0.0.1", 19001))
c.sendall(b"hello"); print("client: send hello")
c.sendall(b"world"); print("client: send world")
time.sleep(1); c.close()
2.2 实测输出
server: accept from ('127.0.0.1', 57104)
client: send hello
client: send world
server: 一次 recv 收到 = b'helloworld' 长度 10
客户端调了两次 sendall,服务端一次 recv 收到 10 字节。 这就是所谓的"粘包"。
2.3 反过来也会发生:一次写、多次读
# 客户端发 100 KB,服务端每次 recv 4096
# 服务端会收到 25 次左右,每次 4096 字节(最后一次不足)
这叫"拆包"(半包)。同一个原因:recv 的边界与 send 的边界无关。
3. 为什么会这样:三个真实原因
| 原因 | 说明 |
|---|---|
| TCP 是字节流 | 没有消息边界概念,只有字节序号 |
| Nagle 算法 | 小包合并发送(默认开启),把小消息攒在一起发 |
| 接收缓冲区 | recv 返回的是"缓冲区里当前有多少给多少",与应用写入次数无关 |
"粘包"不是 bug,是特性。任何基于 TCP 的自定义协议都必须自己解决边界问题——HTTP、Redis 协议、MySQL 协议都做了这件事。
4. 三种划边界方案(E-S02-009)
4.1 长度前缀(Length-Prefixed)——最通用
[4 字节长度][N 字节消息体]
| 优点 | 缺点 |
|---|---|
| 解析简单、可随机访问、支持二进制 | 需要固定长度字段;要考虑字节序(大端/小端) |
Java/Netty 对应:LengthFieldBasedFrameDecoder(maxFrameLength, lengthFieldOffset, lengthFieldLength, ...)——这是 Netty 里最常用的解码器,也是自定义协议的事实标准做法。
4.2 分隔符(Delimiter)——文本协议常用
消息1\n消息2\n消息3\n
| 优点 | 缺点 |
|---|---|
人类可读、实现简单(readline) |
消息体里不能出现分隔符(需转义);单条消息不能过长 |
Netty 对应:LineBasedFrameDecoder、DelimiterBasedFrameDecoder。
HTTP/1.x 的头部就靠 \r\n 分隔,body 则靠 Content-Length 或 Transfer-Encoding: chunked。
4.3 定长(Fixed-Length)
每条消息固定 128 字节,不足补 0
| 优点 | 缺点 |
|---|---|
| 解析最快(偏移即可算) | 浪费带宽;不适合变长消息 |
Netty 对应:FixedLengthFrameDecoder。
4.4 选型判据
| 场景 | 选 |
|---|---|
| 二进制协议、RPC(Dubbo/gRPC/自定义) | 长度前缀 |
| 文本日志流、简单命令行协议 | 分隔符 |
| 定长记录(传感器、行情快照) | 定长 |
| 已有标准协议 | 别自己造(HTTP/2 有帧头,WebSocket 有帧头) |
5. 一个完整的最小实现(长度前缀)
5.1 服务端解析逻辑(伪代码)
while True:
if buffer.length < 4: 继续读 # 半包:长度头都没收全
length = readInt(buffer[0:4])
if buffer.length < 4 + length: 继续读 # 半包:体没收全
msg = buffer[4:4+length]
buffer = buffer[4+length:] # 粘包:一次可能解析出多条
handle(msg)
两个必须处理的边界:
| 情形 | 处理 |
|---|---|
| 头没收全 | 等待,不要解析 |
| 一次收到多条 | 循环解析,直到不足一条 |
第三个隐藏风险:长度字段不可信。length 来自网络,必须校验上限(如 16 MB),否则一个伪造的 length=2GB 就能把服务端内存打爆——这是协议设计的必做项,不是可选项。
6. 排障:怎么判断"是粘包还是应用层 bug"
现象:消息乱、丢、错位
│
1. 抓一次收发(或加日志打印 recv 的字节数)
│ 客户端 send 长度 vs 服务端 recv 长度是否对得上?
↓
2. 服务端是否"一次 recv 里出现多条完整消息"? → 正常(粘包),看解析循环
3. 服务端是否"收到半个长度头/半个体"? → 正常(半包),看等待逻辑
4. 解析出乱码或错位? → 边界定义错(长度偏移/字节序/转义)
5. 长度字段为负/超大? → 协议校验缺失(安全漏洞)
判据:打印"每次 recv 的字节数与前 16 字节十六进制",比任何猜测都快。
7. 与 HTTP/WebSocket 的对照(它们怎么解决的)
| 协议 | 边界怎么定 |
|---|---|
| HTTP/1.1 响应 | Content-Length 或 chunked 分块 |
| HTTP/2 | 帧头(长度 + 类型 + 标志 + 流 ID),天然分帧 |
| WebSocket | 帧头(FIN/opcode/掩码/长度),天然分帧 |
| gRPC | HTTP/2 帧 + 消息长度前缀(双重) |
结论:越是"高级"的协议,越是在协议层就把边界定义死了。自定义 TCP 协议如果想省事,最稳的路径是"长度前缀"。
8. 五个误区
| 误区 | 真相 |
|---|---|
| "粘包是 TCP 的 bug" | 字节流无边界,是设计如此 |
| "关掉 Nagle 就没有粘包了" | TCP_NODELAY 只减少延迟,不能消除合并读取 |
"加个 \n 就安全了" |
消息体里出现 \n 就断错;必须转义或改长度前缀 |
| "长度字段可以信任" | 必须校验上限与合法性 |
| "UDP 没这个问题" | UDP 保留边界,但会丢、会乱,是另一个问题 |
9. 自检
- 为什么"客户端 send 两次、服务端 recv 一次"是正常的?
- 长度前缀方案里,哪两个边界必须显式处理?
- 长度字段为什么必须校验上限?
- HTTP/2 与 WebSocket 是怎么解决边界的?
- 给你一个自定义协议"每条消息以
\r\n\r\n结尾",你会指出什么问题?
10. 下一篇
S02-B06 · HTTP 与 HTTPS:加密发生在哪一层
字节流边界解决之后,进入最常用的应用层协议。下一篇用 curl -v 与 openssl s_client 的真实输出,讲清 HTTP 报文结构、HTTPS 的握手位置、证书链如何验证,以及"HTTPS 到底挡得住什么、挡不住什么"。
本篇属于系列《S02 · 计算机与网络基础》。旧文来源:A-1040;扩展点 E-S02-009。b'helloworld' 合并读取为 2026-11-05 实测。