Files
cs-note/hhs/NETWORK/04-传输层/05-TCP粘包与拆包.md
T
2026-05-24 11:42:38 +08:00

223 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [计算机网络, TCP粘包, TCP拆包, 应用层协议]
create time: 2026-05-18 02:10
---
# TCP 粘包与拆包
## 概述
TCP 是字节流协议(Byte Stream Protocol),没有"消息边界"的概念。这导致发送方多次 write 可能合并成一个段发出(粘包),也可能一个 write 被分成多个段到达(拆包)。**这不是 Bug,这是 TCP 的设计特性**——需要在应用层解决。
> [!QUESTION] 为什么 HTTP/gRPC 不需要处理粘包?
> HTTP/1.1 用 `Content-Length` 或 `Chunked Transfer Encoding` 定义边界;gRPC/Protobuf 每个消息带长度前缀;WebSocket 有 Frame Header。它们都在 TCP 之上抽象了一层消息协议,隐式解决了这个问题。
## 为什么会产生粘包和拆包
### 粘包的成因
```
发送端操作:
write("HELLO") → 进入发送缓冲区
write("WORLD") → Nagle 算法延迟合并
read()/close() → 触发 Fin
实际网络上的 TCP 段: "HELLOWORLD" (一次性发出)
```
触发条件:
| 条件 | 说明 |
|------|------|
| **Nagle 算法** | 小数据合并后统一发送以减少小包数量 |
| **接收端读取慢** | 发送端的多个 write 堆积在发送缓冲区 |
| **SendBuffer 累积** | 多个连续 write 都未触发 flush |
### 拆包的成因
```
发送端操作:
write("A very long message that exceeds MTU...") // 超过 MSS(1460B)
实际网络上的 TCP 段:
Segment 1: [Bytes 0..1459] ← MSS = 1500 - 20(IP) - 20(TCP) = 1460
Segment 2: [Bytes 1460..end] ← 剩余部分
接收端 read(1024): ← 只读了前一部分
返回: "A very long m..." ← 不完整!
```
触发条件:
| 条件 | 说明 |
|------|------|
| **数据包 > MSS** | 超过路径 MTU 自动分片 |
| **接收端 read 大小太小** | 一次只读了一部分 |
| **网络抖动 / RTT 变化** | 数据到达时间不均匀 |
## 四种解决策略
### 方法一:固定长度
每条消息固定 N 字节,接收端按 N 字节切割:
```go
const msgLen = 1024 // 每条消息 1KB
func ReadFixedLength(conn net.Conn) ([]byte, error) {
buf := make([]byte, msgLen)
n, err := io.ReadFull(conn, buf)
if err != nil {
return nil, err
}
return buf[:n], nil
}
```
| 优点 | 缺点 |
|------|------|
| 实现简单 | 短消息浪费带宽,长消息不够用 |
| 性能最高 | — |
适用场景:**游戏协议、内部 RPC(如 Dubbo 早期版本)**
### 方法二:特殊分隔符
用特定字符(如 `\r\n\r\n`、`\0`)作为消息结束标志:
```go
func ReadDelimited(conn net.Conn) ([]byte, error) {
var buf bytes.Buffer
for {
b := make([]byte, 1)
_, err := conn.Read(b)
if err != nil {
return nil, err
}
buf.Write(b)
if bytes.HasSuffix(buf.Bytes(), []byte("\r\n\r\n")) {
return buf.Bytes()[:buf.Len()-4], nil
}
}
}
```
| 优点 | 缺点 |
|------|------|
| 简单直观 | 消息体中不能包含分隔符 |
| 类似 HTTP 协议 | — |
适用场景:**HTTP/1.1、SMTP、IMAP**
### 方法三:长度前缀 ⭐(推荐)
在消息开头加上表示长度的字段,最常见的有两种变体:
#### 方案 A:4 字节整数 + 消息体
```go
// 最常用格式: [4-byte length][payload...]
// Go encoding/binary 处理
func WriteWithLength(conn net.Conn, data []byte) error {
length := uint32(len(data))
binary.Write(conn, binary.BigEndian, length) // 大端序保证跨语言兼容
conn.Write(data)
return nil
}
func ReadWithLength(conn net.Conn) ([]byte, error) {
var length uint32
binary.Read(conn, binary.BigEndian, &length)
buf := make([]byte, length)
if length > 0 {
_, err := io.ReadFull(conn, buf)
if err != nil {
return nil, err
}
}
return buf, nil
}
```
```
内存布局:
┌─────────────┬──────────────────────────┐
│ Length(uint32) │ Payload │
│ 4 bytes │ variable bytes │
│ 100 │ [actual data of 100B] │
└─────────────┴──────────────────────────┘
```
#### 方案 B:protobuf 风格(L-Type-V)
```
┌────────┬───────┬──────────────────────────┐
│ Length │ Type │ Value │
│ VarInt │ VarInt │ variable bytes │
│ (1-10B) │(1-10B) │ │
└────────┴───────┴──────────────────────────┘
```
| 优点 | 缺点 |
|------|------|
| ✅ 最灵活,支持变长消息 | ❌ 比固定长度略复杂 |
| ✅ gRPC/Protobuf/Hessian2 都用此方式 | ❌ 需要处理超大消息 |
| ✅ 跨语言天然兼容 | — |
| ✅ 无分隔符冲突问题 | — |
### 方法四:两者结合(工业级)
```go
// 工业级协议帧结构
// ┌──────┬──────┬──────┬──────────┬──────┬──────┐
│ Magic │Ver │Type │RequestID │Length│Payload │CRC │
│ 4B │1B │1B │ 4B │4B │Var │4B │
│ 0xABCD│ 1.0 │REQ │ 0x0001 │1024 │data...│ CRC32│
│ EF header │ │ Footer │
└──────┴──────┴──────┴──────────┴──────┴────────┘
总开销: 4+1+1+4+4+4 = 18 bytes overhead
```
```go
type FrameHeader struct {
Magic uint32 // 魔数,防解析错误
Version byte // 协议版本
MessageType byte // 请求/响应/心跳等
RequestID uint32 // 关联请求响应
Length uint32 // Payload 长度
Checksum uint32 // CRC32 校验
}
```
## 各语言的内置支持
| 语言/框架 | 粘包处理 | 说明 |
|-----------|---------|------|
| **Go** stdlib `net` | ❌ 零拷贝,完全手动 | 但 `bufio.Scanner` 可帮你 |
| Go `encoding/binary` | ✅ Binary 编解码 | 配合 length-prefix |
| Java Netty | ✅ 内建 | `DelimiterBasedFrameDecoder`、`LengthFieldBasedFrameDecoder` |
| Python asyncio | ❌ 需手动 | 或用 `asyncio.Protocol` |
| C++ Boost.Asio | ❌ 需手动 | 或用自定义解包器 |
| Rust tokio | ❌ 需手动 | `tokio_util::codec` crate 支持 |
## Nagle 算法的影响
Nagle 算法的初衷是减少小包的发送数量——它规定:**如果有一个未被确认的小包,就不要发送新的小包**。
```go
// Go net.TCPConn 中关闭 Nagle
conn.(*net.TCPConn).SetNoDelay(true) // true = 禁用 Nagle
```
| 场景 | 建议 |
|------|------|
| HTTP/Web API | 关闭 (减少首包延迟) |
| 实时游戏/金融 | 关闭 (每毫秒都很重要) |
| 大数据传输 | 开启 (减少小包开销) |
| SSH | 关闭 (交互性要求高) |
## 关联笔记
- [[hhs/NETWORK/TCP段结构与状态机]] — TCP 为何是字节流而非消息流
- [[hhs/NETWORK/HTTP请求响应]] — HTTP 如何用 Content-Length 解决此问题
- [[hhs/NETWORK/WebSocket全双工通信]] — WebSocket Frame Header 自带 Length