Files

223 lines
7.2 KiB
Markdown
Raw Permalink Normal View History

2026-05-24 11:42:38 +08:00
---
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