📦 TCP粘包和拆包:消息的"边界"问题

83 阅读5分钟

知识点编号:012
难度等级:⭐⭐⭐(必掌握)
面试频率:🔥🔥🔥🔥🔥


🎯 一句话总结

TCP把数据当成字节流,不保留消息边界,导致粘包拆包!📦


🤔 什么是粘包和拆包?

TCP的特性

TCP = 字节流协议
把数据看作连续的字节流,没有消息边界!

发送方:
send("Hello")
send("World")

接收方可能收到:
1️⃣ "HelloWorld"     (粘包)
2️⃣ "Hel" + "loWorld" (拆包)
3️⃣ "Hello" + "World"  (正常)

🎭 粘包示例

发送方:
第1次:send("消息1")
第2次:send("消息2")
第3次:send("消息3")

接收方recv(100)可能收到:
"消息1消息2消息3"  ← 三个消息粘在一起了!

无法区分消息边界!
不知道哪里是消息1的结尾,哪里是消息2的开始!

🎭 拆包示例

发送方:
send("这是一条很长很长很长的消息...")(1000字节)

接收方:
第1recv(100):"这是一条很长很长"2recv(100):"很长的消息..."

一条消息被拆成多次接收!

💡 为什么会粘包拆包?

原因1:Nagle算法(粘包)

Nagle算法:
- 优化网络效率
- 将小数据包合并后发送
- 减少网络传输次数

示例:
send("A")   |
send("B")   |→ 合并成 "ABC" 一起发
send("C")   |

结果:接收方收到 "ABC"(粘包)

原因2:TCP缓冲区(粘包)

发送缓冲区:
send("A") → [A]
send("B") → [AB]
send("C") → [ABC]

TCP层一次性发送 "ABC"
接收方一次性收到 "ABC"(粘包)

原因3:接收缓冲区大小(拆包)

发送:1000字节数据
接收缓冲区:只有500字节

第1次读:500字节
第2次读:500字节

一条消息被拆成两次接收(拆包)

原因4:MSS限制(拆包)

MSS(Maximum Segment Size):
通常1460字节

发送5000字节:
TCP自动拆分:
包1:1460字节
包2:1460字节
包3:1460字节
包4:620字节

接收方分多次收到(拆包)

🔧 解决方案

方案1:固定长度

// 每条消息固定100字节
byte[] buffer = new byte[100];

while (true) {
    int len = inputStream.read(buffer);
    if (len == 100) {
        String message = new String(buffer);
        System.out.println("收到消息:" + message);
    }
}

优点:简单
缺点:浪费空间(短消息要填充)

方案2:特殊分隔符

// 使用\n作为分隔符
BufferedReader reader = new BufferedReader(
    new InputStreamReader(inputStream)
);

while (true) {
    String message = reader.readLine(); // 读到\n为止
    System.out.println("收到消息:" + message);
}

// 发送时
PrintWriter writer = new PrintWriter(outputStream, true);
writer.println("消息1"); // 自动加\n
writer.println("消息2");

优点:简单,节省空间
缺点:消息内容不能包含分隔符

方案3:消息头+长度(推荐)

// 协议格式:[长度(4字节)][消息内容]

// 发送消息
public void sendMessage(OutputStream out, String msg) throws IOException {
    byte[] data = msg.getBytes("UTF-8");
    
    // 写入长度(4字节)
    DataOutputStream dos = new DataOutputStream(out);
    dos.writeInt(data.length);
    
    // 写入内容
    dos.write(data);
    dos.flush();
}

// 接收消息
public String receiveMessage(InputStream in) throws IOException {
    DataInputStream dis = new DataInputStream(in);
    
    // 读取长度
    int length = dis.readInt();
    
    // 读取内容
    byte[] data = new byte[length];
    dis.readFully(data); // 确保读满length字节
    
    return new String(data, "UTF-8");
}

// 使用示例
sendMessage(out, "Hello");
sendMessage(out, "World");

String msg1 = receiveMessage(in); // "Hello"
String msg2 = receiveMessage(in); // "World"

优点:
- 可以处理任意内容
- 长度明确
- 不浪费空间

缺点:
- 需要约定协议格式

方案4:使用Netty(工业级)

// Netty自带粘包拆包解决方案

// 1. 固定长度
pipeline.addLast(new FixedLengthFrameDecoder(100));

// 2. 分隔符
pipeline.addLast(new DelimiterBasedFrameDecoder(1024, 
    Delimiters.lineDelimiter()));

// 3. 长度字段
pipeline.addLast(new LengthFieldBasedFrameDecoder(
    1024,  // 最大帧长度
    0,     // 长度字段偏移量
    4,     // 长度字段长度
    0,     // 长度调整
    4      // 跳过的字节数
));

// 业务Handler
pipeline.addLast(new SimpleChannelInboundHandler<ByteBuf>() {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
        // msg已经是完整的一条消息了!
        String message = msg.toString(CharsetUtil.UTF_8);
        System.out.println("收到:" + message);
    }
});

🐛 常见面试题

Q1:什么是TCP粘包和拆包?怎么解决?

答案:

粘包:多条消息合并成一条
拆包:一条消息拆分成多条

原因:
1. TCP是字节流协议,无消息边界
2. Nagle算法合并小包
3. TCP缓冲区
4. MSS限制

解决方案:

1. 固定长度
   - 每条消息固定字节数
   - 简单但浪费空间

2. 特殊分隔符
   - 如\n、\r\n
   - 简单但内容不能包含分隔符

3. 消息头+长度(推荐)
   - 消息头包含长度信息
   - [长度][内容]
   - 灵活,工业标准

4. 使用框架
   - Netty提供现成解决方案
   - LengthFieldBasedFrameDecoder

项目经验:
我们项目使用长度+内容的方案
协议格式:[4字节长度][消息内容]
完美解决了粘包拆包问题

Q2:UDP有粘包拆包问题吗?

答案:

UDP没有粘包问题!

原因:
1. UDP是数据报协议
   - 有明确的消息边界
   - 每个send()对应一个独立的数据报
   
2. UDP保证边界
   - send("A") → 一个数据报
   - send("B") → 另一个数据报
   - 接收方一定是分开收到

3. UDP可能丢包、乱序,但不会粘包

但是:
UDP有大小限制(通常64KB)
超过限制会被拆分(IP层分片)
但这是IP层的行为,不是UDP的粘包

结论:
TCP有粘包拆包 → 需要自己处理边界
UDP无粘包 → 天然有边界,但可能丢包

🎓 总结

粘包拆包的关键点:

  1. 原因:TCP是字节流,无消息边界
  2. 粘包:多条消息合并
  3. 拆包:一条消息拆分
  4. 解决:固定长度、分隔符、长度字段

记忆口诀

TCP字节流无边界 📦
粘包拆包常出现 ⚠️
固定长度或分隔 ✂️
长度字段最推荐 ✅

文档创建时间:2025-10-31