飞翔飞翔
主页
  • 计算机基础

    • TCP协议
  • 数据库

    • SQL教程
  • 工具

    • Markdown指南
  • Git

    • GitFlow
  • Quartz

    • Quartz教程
  • Java

    • Java设计模式
  • 缓存

    • Redis教程
联系
阿里云
主页
  • 计算机基础

    • TCP协议
  • 数据库

    • SQL教程
  • 工具

    • Markdown指南
  • Git

    • GitFlow
  • Quartz

    • Quartz教程
  • Java

    • Java设计模式
  • 缓存

    • Redis教程
联系
阿里云
  • TCP协议

    • TCP 简介与分层模型
    • TCP 连接管理
    • TCP 可靠传输机制
    • TCP 粘包与拆包
    • TCP 拥塞控制
    • TCP 头部字段详解
    • TCP 与 UDP 对比
    • TCP Socket 编程实践
    • TCP 抓包实战
    • TCP 安全与面试综合题

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.1Content-Length / 分隔符用 Content-Length 头告诉浏览器消息体长度;或者 Transfer-Encoding: chunked 分块传输
HTTP/2TLV(帧格式)每个帧有 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 帧格式
上一页
TCP 可靠传输机制
下一页
TCP 拥塞控制