技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTNET

什么是 TCP 数据粘包,该如何解决

所属馆藏
NET Protocol
文件格式
Markdown
原始路径
NET Protocol/15-什么是 TCP 数据粘包,该如何解决
本文目录25 个章节
  1. 1. 背景
  2. 2. 什么是“粘包”?
  3. 3. 为什么不能按固定大小读取?
  4. 4. UDP 与 TCP 粘包的区别
  5. 1. 定长消息
  6. 2. 尾部标记或分隔符
  7. 3. 固定报头 + 长度字段
  8. 4. 自描述格式或 TLV
  9. 1. 一条连接只传一条消息
  10. 2. 单个文件流式传输
  11. 3. 同一连接传输多种结构
  12. 1. 发送端缓冲和 Nagle 算法
  13. 2. 接收端读取不及时
  14. 3. TCP 分段、重传和网络设备优化
  15. 4. 不要依赖 PSH
  16. 1. TCP 连接过程
  17. 2. TCP 连接关闭与消息结束
  18. 1. 不要使用 sleep
  19. 2. 不要使用 ACK 代替消息边界
  20. 3. 使用缓冲区和状态机解析
  21. 4. 动态缓冲区
  22. 5. 阻塞 Socket 的定长读取
  23. 6. 非阻塞 Socket 和 IOCP
  24. 7. 环形缓冲区
  25. 参考资料

什么是 TCP 数据粘包,该如何解决

Category(分类): NET Protocol Status: 未知

一、粘包问题概述

1. 背景

采用 TCP 进行网络通信的软件,经常会遇到所谓的“粘包”和“拆包”问题。这个说法容易让人误以为 TCP 把多个完整数据包错误地粘到了一起,或者把一个数据包真的拆坏了。

更准确地说,TCP 提供的是可靠、有序的字节流,并不保留应用层 send()write() 调用之间的消息边界。发送端连续发送两段数据:

send(data1)
send(data2)

接收端可能出现以下任意情况:

读取到 data1
读取到 data1 的一部分
一次读取到 data1 + data2
读取到 data1 + data2 的一部分

这些都是 TCP 字节流的正常表现,不是 TCP 协议出错。应用层如果需要一个个独立的消息,就必须自行设计消息边界,也就是进行封包和拆包。

TCP 内核通常维护发送缓冲区和接收缓冲区。数据可能受到发送缓冲、接收缓冲、TCP 分段、重传、拥塞控制、延迟 ACK、Nagle 算法以及网卡卸载等因素影响,因此不能根据一次 send()、一次 TCP 段或一次 recv() 来推断应用消息边界。

TCP 字节流中的粘包和拆包示意图

2. 什么是“粘包”?

“粘包”并不是严格的 TCP 规范术语,通常是应用开发者对以下现象的统称:

  • 多条应用消息被一次读取到;
  • 一条应用消息被分多次读取;
  • 一次读取到一条完整消息和下一条消息的一部分。

例如,发送端有两条应用消息 pkg1pkg2,接收端一次调用 recv() 读到了它们的拼接结果:

pkg1 + pkg2

如果应用程序把这次读取结果直接当成一条消息处理,就会出现协议解析错误。反过来,如果一次只读到 pkg1 的一部分,应用也不能因此认为消息已经结束。

接收端读取较少不会自动丢失后续数据。只要连接没有异常关闭,尚未读取的字节通常还会留在接收缓冲区中,等待后续读取。

3. 为什么不能按固定大小读取?

网络应用中的消息大小往往是不固定的。例如:

  • 聊天消息可能只有几十字节;
  • 登录请求可能包含不同数量的字段;
  • 文件、图片和视频可能非常大;
  • 同一条长连接中可能交替传输多种类型的消息。

因此,接收方不能通过猜测一个“合理长度”来判断消息边界。网卡 MTU、TCP MSS 或某次读取的长度,都不能作为应用层消息边界。应用协议必须明确规定消息如何开始、如何结束以及正文有多长。

4. UDP 与 TCP 粘包的区别

UDP 是面向数据报的传输协议,通常保留每个数据报的边界,因此不存在 TCP 意义上的粘包和拆包。接收端通常一次接收一个 UDP 数据报,但如果接收缓冲区太小,数据报可能被截断或丢弃,具体行为取决于所使用的 Socket API。

UDP 仍然可能丢包、重复、乱序或遭遇 IP 分片,消息边界存在不代表数据可靠。需要可靠性、顺序和重传时,应由应用层实现,或使用 QUIC 等在 UDP 之上提供可靠能力的协议。

UDP 尽力而为传输示意图

UDP 首部固定为 8 字节,包含源端口、目的端口、长度和校验和字段。校验和用于检测错误,不负责恢复丢失的数据。

UDP 首部结构

二、粘包和拆包的设计方案

1. 定长消息

发送端把每条消息都编码为固定长度 LEN

消息长度不足 LEN 时填充
消息长度超过 LEN 时拆成多个固定长度块

接收端每次读取恰好 LEN 字节,再把这一段作为一个固定大小的消息处理。

这种方式适合消息结构和大小比较稳定的协议,例如固定长度的设备指令或二进制记录。

注意事项

  1. 一次 recv() 不保证返回 LEN 字节,接收端仍然必须循环读取,直到收满 LEN 字节或连接关闭;
  2. 最后一条消息的填充区域必须有明确规则,不能把“空白字节”当作通用结束标志;
  3. 二进制数据可能天然包含 0、空格或其他填充字节,建议在固定消息中增加有效长度或状态字段;
  4. 当消息长度差异很大时,大量填充会浪费带宽。

2. 尾部标记或分隔符

在每条消息末尾增加特殊分隔符,例如:

message-1\n
message-2\n

接收方不断读取数据并搜索分隔符,找到分隔符后取出一条完整消息,剩余数据继续留在缓冲区中等待解析。

这种方案适合文本协议,例如按行传输的日志或命令。

注意事项

  1. 分隔符可能出现在正文中,不能简单假设某个字节永远不会出现;
  2. 二进制协议通常需要转义、字节填充、Base64、COBS 等方式避免正文与分隔符冲突;
  3. 字符串 \0 只是 C 字符串的结束符,不是通用网络消息结束符;
  4. 必须限制单条消息的最大长度,避免攻击者发送永远不结束的内容导致内存持续增长。

3. 固定报头 + 长度字段

这是二进制协议中最常用的方案之一。每条消息由固定长度的报头和可变长度的正文组成:

+----------------------+----------------------+
| 固定长度 Header      | Body                 |
| body_length = N      | N 个字节             |
+----------------------+----------------------+

报头中记录正文长度。接收端的流程是:

  1. 循环读取固定长度的报头,直到报头完整;
  2. 解析正文长度;
  3. 校验长度是否超过协议允许的最大值;
  4. 循环读取指定长度的正文;
  5. 得到完整消息后交给业务层处理;
  6. 继续解析缓冲区中可能存在的下一条消息。

长度前缀封包示意图

报头设计建议

报头应使用明确的网络格式,不要直接把语言运行时的结构体内存布局发送到网络。例如:

magic       4 字节:协议标识
version     1 字节:协议版本
type        1 或 2 字节:消息类型
body_length 4 字节:正文长度,网络字节序
request_id  4 或 8 字节:请求标识

需要特别注意:

  • 使用网络字节序(大端)或明确的序列化格式;
  • 校验 magic、版本、消息类型和长度;
  • 限制最大正文长度,避免内存耗尽;
  • 不要把未经验证的长度直接用于内存分配;
  • 不要假设一个报头对应一次 recv(),或一个正文对应一次 recv()

长度前缀方案并不要求一定执行两次系统调用。内核缓冲区中可能已经同时包含报头和正文,应用可以先读入缓冲区,再在内存中解析;也可以使用循环读取函数逐步获取指定长度。

4. 自描述格式或 TLV

一些协议使用自描述的格式,例如 TLV:

Type | Length | Value

JSON、CBOR、Protobuf、MessagePack 等格式也可以作为应用层消息内容,但它们本身不一定自动解决 TCP 字节流的边界问题。通常仍需要外层长度前缀、分隔符或基于协议的流式解析规则。

三、什么时候需要考虑粘包?

1. 一条连接只传一条消息

如果双方约定一条 TCP 连接只传输一份数据,接收方可以把以下条件之一作为结束依据:

  • 预先知道数据长度;
  • 读取到协议规定的 EOF;
  • 对端关闭发送方向并发送 FIN。

这不是 TCP 自动解决了粘包,而是应用协议把连接关闭或已知长度定义成了消息边界。缺点是连接不能继续复用来传输下一条消息。

2. 单个文件流式传输

如果一条连接只负责传输一个文件,并且接收方知道文件长度或以连接关闭作为文件结束,那么应用不一定需要再为文件内部划分多条消息。

但接收方仍必须处理:

  • 部分读取;
  • 断点和超时;
  • 提前断开;
  • 文件长度校验;
  • 完整性校验。

如果同一连接连续传输多个文件,就必须增加文件长度、文件名、类型或其他封包信息。

3. 同一连接传输多种结构

假设连接建立后连续发送两条不同类型的消息:

hello give me something about yourself
Don't give me something about yourself

如果协议没有规定长度、类型或分隔符,接收方看到拼接后的字节流时,就无法判断两条消息的边界和类型。这时必须设计应用层协议,例如:

[type=1][length=32][body]
[type=2][length=28][body]

四、粘包出现的常见原因

“粘包”现象可能表现为发送端多次写入被一次读取,也可能表现为一次写入被分多次读取。常见影响因素包括:

1. 发送端缓冲和 Nagle 算法

Nagle 算法用于减少大量小 TCP 段。当存在未确认数据且应用又提交了较小的数据时,TCP 可能暂时缓存新数据,等待 ACK 或积累到合适大小后再发送。

Nagle 只是可能影响发送时机的因素之一,不能作为粘包的唯一原因。TCP 仍然不会保留应用层 send() 的边界。

如果确实需要降低小消息的等待延迟,可以考虑 TCP_NODELAY,但它只影响 Nagle 行为,不能解决消息边界问题,也不能保证一次 send() 对应一次 recv()。关闭 Nagle 还可能增加小包数量和网络开销,应根据实际测量决定。

2. 接收端读取不及时

TCP 会把收到的字节放入接收缓冲区,应用程序稍后读取时可能一次读到多条消息。这个现象并不是数据被错误合并,而是应用尚未及时读取的字节现在一起可见。

优化接收线程的调度、减少阻塞操作可以降低延迟,但不能替代应用层封包协议。

3. TCP 分段、重传和网络设备优化

TCP 段大小和应用消息大小没有一一对应关系。操作系统和网卡还可能使用 TSO、GSO、GRO 等优化。因此,不能通过抓包看到的 TCP 段数量推断应用消息数量。

4. 不要依赖 PSH

TCP 的 PSH 标志用于提示接收端尽快把已有数据交给应用,但 RFC 9293 明确指出 PSH 不是应用记录标记,也不等同于消息边界。不同操作系统和 Socket API 对 PSH 的暴露方式也不同,因此不能用 PSH 进行拆包。

TCP 没有一个可以可靠地把每次 send() 立即变成独立消息的通用 push 指令。应用层必须自己定义消息边界。

五、TCP 连接过程与消息边界

1. TCP 连接过程

TCP 建立连接通常需要三次握手。握手只负责建立传输连接、同步序列号和协商 TCP 选项,并不会为应用层消息自动创建边界。

客户端 -> 服务端:SYN(seq=x)
服务端 -> 客户端:SYN+ACK(seq=y, ack=x+1)
客户端 -> 服务端:ACK(seq=x+1, ack=y+1)

TCP 三次握手过程

三次握手之后,双方得到的是连续的 TCP 字节流。即使握手成功,后续每次 send() 仍然可能在接收端被拆分或合并。

如果只有两次握手,服务端无法确认客户端是否收到了自己的 SYN-ACK,延迟到达的旧 SYN 可能被误认为新的连接请求。第三次 ACK 能让服务端确认客户端已经收到第二次握手。

两次握手与三次握手的差异

2. TCP 连接关闭与消息结束

TCP 的 FIN 只表示一个方向不再发送数据,不等于应用消息边界。典型的四次挥手是:

主动关闭方 FIN -> 被动关闭方 ACK
被动关闭方 FIN -> 主动关闭方 ACK

如果一条 TCP 连接只传输一个文件,应用可以把预先知道的长度或对端关闭连接作为结束条件;但如果连接还要继续传输多条消息,就必须使用长度字段、分隔符或其他封包规则。

TCP 四次挥手过程

六、为什么 TCP 通信程序需要封包和拆包?

TCP 是字节流协议,像一条连续的河流,没有天然的消息分界线。通信程序通常需要定义一个个独立的数据包,例如登录、查询、注销等业务消息,因此必须在应用层增加封包格式。

假设连续调用:

send(data1)
send(data2)

接收端可能出现:

  • A:先接收到 data1,再接收到 data2
  • B:先接收到 data1 的一部分,再接收到 data1 的剩余部分和 data2
  • C:一次接收到完整的 data1data2 的一部分,再收到 data2 的剩余部分;
  • D:一次接收到 data1data2 的全部。

A 只是恰好符合应用的预期,不能作为协议保证。B、C、D 都是合法的 TCP 行为,接收方必须通过协议规则拆出独立消息。

七、怎样封包和拆包?

1. 不要使用 sleep

最初遇到粘包问题时,有人会在两次 send() 之间调用 sleep(),希望让接收端刚好分开读取。这种方法只改变时序,不定义边界,在不同机器、网络和负载下都不可靠,而且会降低传输效率。

2. 不要使用 ACK 代替消息边界

TCP ACK 确认的是已经收到的字节范围,不是应用层消息。应用层可以增加业务 ACK,但业务 ACK 只能确认业务消息处理状态,不能替代长度字段或分隔符。

3. 使用缓冲区和状态机解析

推荐为每个 TCP 连接维护一个接收缓冲区:

  1. 把每次收到的任意字节追加到缓冲区;
  2. 如果缓冲区不足一个固定报头,则继续接收;
  3. 解析报头中的正文长度;
  4. 如果正文还没有接收完整,则继续等待;
  5. 取出一条完整消息;
  6. 继续解析缓冲区中剩余的字节。

4. 动态缓冲区

动态缓冲区的基本流程是:

A. 为每个连接维护一个缓冲区和解析状态;

B. 收到网络数据时追加到缓冲区;

C. 判断缓冲区是否至少包含完整报头;

D. 解析正文长度,并校验长度范围;

E. 判断缓冲区是否包含完整正文;

F. 取出完整消息,同时从缓冲区删除已消费的数据;

G. 循环解析同一批数据中可能存在的多条消息。

简单实现可能需要移动未消费数据,产生额外拷贝。可以使用读写游标、环形缓冲区、分段缓冲区或零拷贝解析降低开销。无论使用哪种缓存结构,都必须限制缓冲区增长速度,防止恶意客户端发送超大长度或永不完整的消息。

5. 阻塞 Socket 的定长读取

对于阻塞 Socket,可以使用循环函数读取指定长度。recv() 默认可能返回少于请求长度的数据,不能假设一次调用就能读满。

下面是 Windows 风格 Socket 的示意代码,代码中的协议报头使用显式字段读取,避免直接发送结构体内存布局:

#include <cstdint>
#include <vector>
#include <winsock2.h>

constexpr uint32_t kMaxBodySize = 20 * 1024;

bool recv_exact(SOCKET socket, char* buffer, int length) {
    int received = 0;

    while (received < length) {
        const int count = recv(
            socket,
            buffer + received,
            length - received,
            0
        )

        if (count == 0) {
            // 对端正常关闭,尚未读满 length
            return false
        }

        if (count == SOCKET_ERROR) {
            return false
        }

        received += count
    }

    return true
}

bool receive_package(SOCKET socket) {
    // 示例报头:4 字节 magic + 4 字节 body_length
    char header[8]

    if (!recv_exact(socket, header, sizeof(header))) {
        return false
    }

    uint32_t network_length = 0
    memcpy(&network_length, header + 4, sizeof(network_length))
    const uint32_t body_length = ntohl(network_length)

    if (body_length > kMaxBodySize) {
        // 长度非法,不能直接据此分配内存
        return false
    }

    std::vector<char> body(body_length)
    if (body_length > 0 &&
        !recv_exact(socket, body.data(), static_cast<int>(body_length))) {
        return false
    }

    // 这里处理一条完整消息
    return true
}

实际代码还需要检查 magic、版本、消息类型、超时、错误码、整数溢出和连接取消。MSG_WAITALL 可以请求系统等待指定长度,但它仍可能因为信号、错误或对端关闭而提前返回,也不能替代应用层协议解析。

6. 非阻塞 Socket 和 IOCP

对于非阻塞 Socket 或 Windows IOCP:

  • 一次完成通知可能只包含报头的一部分;
  • 也可能包含完整报头和部分正文;
  • 还可能包含多条消息;
  • 应把收到的数据追加到连接对应的缓冲区,再由状态机持续解析;
  • 不能因为一次异步操作完成就认为一条应用消息已经完整到达。

7. 环形缓冲区

环形缓冲区通常维护读指针和写指针:

  • 新数据写入写指针位置;
  • 已解析数据通过移动读指针释放;
  • 不需要频繁把未消费的数据整体移动到缓冲区起始位置。

环形缓冲区可以减少内存拷贝,但仍然需要处理空间不足、消息跨越缓冲区尾部、最大消息长度和协议解析状态等问题。

八、常见误区总结

  • TCP 是字节流,不是消息队列;
  • 一次 send() 不对应一次 recv()
  • 一次 TCP 段不对应一条应用消息;
  • 一次 recv() 可能返回半条、多条或一条半消息;
  • 读取长度小不会自动丢失后续数据;
  • sleep()、TCP ACK、PSH 和 TCP_NODELAY 都不能替代消息 framing;
  • MTU/MSS 不能作为应用层消息边界;
  • UDP 没有 TCP 粘包,但仍可能丢包、乱序、重复或截断;
  • 长度前缀、分隔符、固定长度和自描述格式都可以解决边界问题,具体取决于协议需求;
  • 读取固定长度必须循环,解析长度字段必须做上限校验。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS