1876 字
约 6 分钟
6
S02-B05 · 粘包与拆包:应用层为什么必须自己划边界

适用读者:写过 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. 自检

  1. 为什么"客户端 send 两次、服务端 recv 一次"是正常的?
  2. 长度前缀方案里,哪两个边界必须显式处理?
  3. 长度字段为什么必须校验上限?
  4. HTTP/2 与 WebSocket 是怎么解决边界的?
  5. 给你一个自定义协议"每条消息以 \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 实测。

S02-B05 · 粘包与拆包:应用层为什么必须自己划边界
https://www.clxhxhhr.top/posts/5186/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。