TCP 粘包与拆包
TCP 是面向字节流的协议,不保留消息边界。这带来了一个经典问题——粘包和拆包。每个用 TCP 编程的人都会遇到它。
1. 什么是粘包和拆包?
1.1 核心原因
TCP 把应用层的数据看作一连串无结构的字节流,没有"消息边界"的概念。发送方调用了几次 send(),接收方不一定通过相同次数的 recv() 收到——可能合并,也可能拆开。
打个比方:你往水管里倒了三杯水(三次 send),对面用桶接水(recv),可能一桶接住三杯(粘包),也可能三桶才接完一杯(拆包)。
1.2 粘包(数据合并)
发送方发送了两个独立的消息,接收方一次性收到了合并后的数据:
发送方:
send("Hello") → 消息1
send("World") → 消息2
接收方:
recv() → "HelloWorld" ← 两条消息粘在一起了!
原因:Nagle 算法等优化会将多个小段合并发送;接收方也可能一次读取缓冲区中的全部数据。
1.3 拆包(数据拆分)
发送方发送一个大消息,接收方分多次才收完整:
发送方:
send("HelloWorld你好世界") → 一个大消息
接收方:
recv() → "HelloWo" ← 第一次只收到了一部分
recv() → "rld你好世" ← 第二次
recv() → "界" ← 第三次
原因:数据超过 MSS 被分片传输;接收缓冲区不够大;TCP 按字节流分段。
1.4 图解四种情况
发送方发送: [消息1] [消息2] [消息3]
情况1 - 理想情况(一一对应):
接收:[消息1] [消息2] [消息3]
情况2 - 粘包(两个小消息合并):
接收:[消息1消息2] [消息3]
情况3 - 拆包(一个大消息拆开):
接收:[消息1前半] [消息1后半] [消息2] [消息3]
情况4 - 混合(粘包+拆包):
接收:[消息1后半+消息2] [消息3]
2. 为什么 UDP 没有这个问题?
UDP 是面向报文的协议,每个 sendto() 对应一个完整的 UDP 数据报。接收方的 recvfrom() 每次只返回一个完整的报文,保留消息边界。
发送方:
sendto("Hello")
sendto("World")
接收方:
recvfrom() → "Hello" ← 完整的一条消息
recvfrom() → "World" ← 完整的一条消息
所以:粘包/拆包是 TCP 字节流特性的必然结果,不是 bug,而是 TCP 的设计选择。
3. 解决方案
既然 TCP 不保留消息边界,就需要应用层自己定义边界。以下是四种经典方案:
3.1 方案一:固定长度
每条消息固定占 N 个字节,不够就补齐。
约定每条消息 10 字节,不足补空格
发送:"Hello " + "World "
接收:每次 read 10 字节,就是一条完整消息
优点:实现简单 缺点:浪费带宽(短消息要补齐),不够灵活
3.2 方案二:分隔符
用特殊字符作为消息之间的分隔符。
用 \n 作为分隔符
发送:"Hello\nWorld\n"
接收:按 \n 切分 → ["Hello", "World"]
优点:直观,文本协议常用(HTTP 头部用 \r\n\r\n,FTP 用 \r\n) 缺点:消息内容不能包含分隔符,或者需要转义;二进制数据不适用
3.3 方案三:长度前缀(最常用)
每条消息前面加一个长度字段,告诉接收方这条消息有多长。
格式:[长度(4字节)] + [消息内容]
发送:"Hello" → [0x00 0x00 0x00 0x05] + "Hello"
"World" → [0x00 0x00 0x00 0x05] + "World"
接收:
1. 先读 4 字节 → 得到长度 5
2. 再读 5 字节 → 得到 "Hello"
3. 重复
优点:精确、高效、支持二进制数据,绝大多数协议采用此方案 缺点:需要额外处理长度字段本身的解析
3.4 方案四:TLV 结构
Type-Length-Value,在长度前缀的基础上加上类型字段。
格式:[类型(1~4字节)] + [长度(4字节)] + [值(变长)]
示例:
[0x01][0x00 0x00 0x00 0x05][Hello] → 类型1,长度5,值Hello
[0x02][0x00 0x00 0x00 0x03][ Bye] → 类型2,长度3,值Bye
优点:可扩展性强,支持多种消息类型,Protobuf、HTTP/2 等使用类似结构 缺点:实现稍复杂
方案对比
| 方案 | 实现难度 | 带宽效率 | 适用场景 |
|---|---|---|---|
| 固定长度 | 最简单 | 低 | 简单固定格式 |
| 分隔符 | 简单 | 中 | 文本协议(HTTP、FTP) |
| 长度前缀 | 中等 | 高 | 大多数二进制协议 |
| TLV | 较复杂 | 高 | 复杂协议(Protobuf、HTTP/2) |
4. 长度前缀方案的代码示例(Java)
4.1 编码:发送时加长度前缀
// 消息编码工具:在消息前加上 4 字节长度
public class MessageEncoder {
public static byte[] encode(String message) throws Exception {
byte[] body = message.getBytes("UTF-8");
int length = body.length;
// 结果 = 4字节长度 + 消息体
byte[] result = new byte[4 + length];
// 大端序写入长度(高位在前)
result[0] = (byte) (length >> 24);
result[1] = (byte) (length >> 16);
result[2] = (byte) (length >> 8);
result[3] = (byte) length;
// 拷贝消息体
System.arraycopy(body, 0, result, 4, length);
return result;
}
}
4.2 解码:接收时按长度读取
// 消息解码工具:先读长度,再读对应长度的消息体
public class MessageDecoder {
private InputStream in;
private byte[] lengthBuffer = new byte[4]; // 缓冲:存长度字段
public MessageDecoder(InputStream in) {
this.in = in;
}
public String decode() throws Exception {
// 第一步:读取 4 字节长度
readFully(lengthBuffer, 4);
int length = ((lengthBuffer[0] & 0xFF) << 24)
| ((lengthBuffer[1] & 0xFF) << 16)
| ((lengthBuffer[2] & 0xFF) << 8)
| (lengthBuffer[3] & 0xFF);
// 第二步:读取 length 字节的消息体
byte[] body = new byte[length];
readFully(body, length);
return new String(body, "UTF-8");
}
// 确保读取完整的 n 个字节(处理拆包)
private void readFully(byte[] buffer, int n) throws Exception {
int offset = 0;
while (offset < n) {
int bytesRead = in.read(buffer, offset, n - offset);
if (bytesRead == -1) {
throw new Exception("连接已关闭");
}
offset += bytesRead;
}
}
}
4.3 使用示例
// 发送方
Socket socket = new Socket("127.0.0.1", 8080);
OutputStream out = socket.getOutputStream();
out.write(MessageEncoder.encode("Hello"));
out.write(MessageEncoder.encode("World"));
out.write(MessageEncoder.encode("这是一条中文消息"));
// 接收方
ServerSocket serverSocket = new ServerSocket(8080);
Socket client = serverSocket.accept();
MessageDecoder decoder = new MessageDecoder(client.getInputStream());
System.out.println(decoder.decode()); // Hello
System.out.println(decoder.decode()); // World
System.out.println(decoder.decode()); // 这是一条中文消息
5. 常见协议怎么解决粘包的?
| 协议 | 方案 | 说明 |
|---|---|---|
| HTTP/1.1 | Content-Length / 分隔符 | 用 Content-Length 头告诉浏览器消息体长度;或者 Transfer-Encoding: chunked 分块传输 |
| HTTP/2 | TLV(帧格式) | 每个帧有 Length + Type + Flags + Stream ID,精确定界 |
| Redis | 分隔符(\r\n) | RESP 协议用 \r\n 分隔每行,简单文本协议 |
| MySQL | 长度前缀 | 每个包前 3 字节是 payload 长度,1 字节是序列号 |
| WebSocket | 长度前缀 | 帧头包含 7 位或 64 位 payload 长度 |
| Protobuf | 长度前缀 | 通常用 VarInt 编码的长度前缀 + 序列化的消息体 |
| Dubbo | 长度前缀 | 16 字节协议头,其中 8 字节为消息体长度 |
6. 小结
粘包/拆包不是 TCP 的缺陷,而是 TCP 面向字节流设计的自然结果。解决方案的核心就是应用层自己定义消息边界——固定长度、分隔符、长度前缀、TLV 都是这一思路的不同实现。其中长度前缀是最通用的方案。
本篇要点
- TCP 面向字节流不保留消息边界,粘包/拆包是字节流特性的自然结果,不是 bug
- UDP 面向报文天然保留消息边界,不会出现粘包/拆包问题
- 解决粘包的核心:应用层自己定义消息边界,四种方案——固定长度、分隔符、长度前缀、TLV
- 长度前缀是最通用的方案:发送时在消息前加长度字段,接收时先读长度再读对应字节数
- 接收方需要循环读取确保完整拿到长度字段和消息体(处理拆包的 readFully 模式)
- HTTP/1.1 用 Content-Length(长度前缀)或 chunked(分块),HTTP/2 用 TLV 帧格式