计算机网络:什么是 TCP 的粘包、拆包问题?
结论:TCP 是字节流,没有消息边界。发送方多次 write 可能被合并进一个段,一次 write 也可能被拆成多段, 接收方一次 read 拿到的可能是半个消息,也可能是一个半消息。 "粘包 / 拆包"是应用层如何划界的问题,不是 TCP 的缺陷。
1. 边界去哪了
应用写入: write("hello") write("world")
线路上: [hello][world] ← 合并(Nagle / 发送缓冲)
或: [hel][lo][world] ← 拆分(MSS / 窗口不足)
接收方一次 read 可能得到:
"helloworld" 合并
"hello" + "world" 恰好对齐
"hel" + "loworld" 拆开
| 现象 | 触发原因 | 代价 |
|---|---|---|
| 合并 | Nagle 算法攒小包、发送缓冲合并 | 延迟最多增加约 40 ms |
| 合并 | 接收缓冲里堆了多个消息,一次 read 全读走 | 业务必须自己切分 |
| 拆分 | 超过 MSS,或路径 MTU 触发 IP 分片 | 解析要支持半包 |
| 拆分 | 拥塞窗口 / 接收窗口只允许发一部分 | 大消息必然被切开 |
所以"粘包"这个叫法本身不准确:TCP 里没有"包",只有连续的字节,边界必须由协议自己定义。
2. 三种划界方案
定长: [64B 消息][64B 消息][64B 消息]
分隔符: {"a":1}\n{"b":2}\n{"c":3}\n
长度前缀: [len=5][hello][len=5][world]
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 定长 | 解析最简单,可随机访问 | 短消息浪费带宽,长度不灵活 | 传感器、固定格式协议 |
| 分隔符 | 可读性好,抓包易排查 | 内容出现分隔符要转义,需防超长 | 日志、行协议(Redis) |
| 长度前缀 | 可预分配,天然支持二进制 | 要约定字节序与上限 | RPC、IM、私有协议 |
3. 用长度前缀写一个编解码器
const maxFrame = 1 << 20 // 1 MB,防止超长帧把内存打爆
func WriteFrame(w io.Writer, payload []byte) error {
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
_, err := w.Write(payload)
return err
}
func ReadFrame(r io.Reader) ([]byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil { // 一次 Read 不保证读满
return nil, err
}
n := binary.BigEndian.Uint32(hdr[:])
if n == 0 || n > maxFrame {
return nil, fmt.Errorf("invalid frame length: %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
}
两个细节最容易被忽略:用 io.ReadFull 而不是 conn.Read,以及校验长度上限。
前者决定高延迟或小包场景下会不会偶发解析错位,后者决定一个恶意长度字段能不能让进程 OOM。
4. 常见追问
Q:UDP 有粘包吗? 没有。UDP 面向报文,一次发送对应一次接收,边界由协议保留。 但报文超过 MTU 会 IP 分片,丢一片整个报文作废,所以业务通常把单报控制在 1200~1400 字节。
Q:为什么 HTTP 不需要业务代码处理粘包?
HTTP 自带定界:Content-Length 给出长度,Transfer-Encoding: chunked 用块长度,HTTP/2 每帧有 9 字节帧头。
框架把这些解析完了,所以业务层感觉不到边界问题。
Q:Nagle 算法要不要关?
延迟敏感的小包协议(游戏、RPC、IM)通常设 TCP_NODELAY,避免最多 40 ms 的攒包等待;
大流量顺序写则保留 Nagle 更省带宽。Nagle 与延迟 ACK 叠加时会出现经典的"等 40 ms"卡顿。
Q:分隔符方案要注意什么? 内容里出现分隔符要转义,或干脆改用长度前缀;还要限制单条消息的最大长度, 否则客户端可以一直不发分隔符,把服务端缓冲撑爆。
Q:为什么自定义协议偏爱长度前缀? 可以一次预分配缓冲、支持二进制、解析无歧义,还能顺带塞版本号与校验和,后续扩展成本最低。
Q:半包处理放在哪一层?
通常放在编解码器(codec)里,用 bufio.Reader + 状态机维护"已收到多少、还差多少",
业务层只面对完整的 frame,避免每个 handler 都重复处理半包逻辑。
小结
TCP 只保证字节按序到达,不保证"消息"完整到达,边界是应用层协议的责任。
三种方案里长度前缀最通用;实现时盯住两点:io.ReadFull 读满、长度上限设防。
凡是出现"偶发解析错位""高延迟下必现"的故障,先怀疑代码把一次 Read 当成了一条消息。