TCP的半包粘包问题

  • TCP Socket 封装:C++ 高性能服务器中采用面向对象封装 Socket 类,解耦网络操作、统一错误处理、封装核心接口,是项目的基础;
  • TCP 三次握手:建立可靠 TCP 连接的必经过程,核心是「同步序列号 + 验证双方收发能力」,最终双方进入ESTABLISHED状态,服务器accept()返回可用的 connfd;
  • TCP 四次挥手:关闭全双工 TCP 连接的必经过程,核心是「TCP 全双工特性导致的双向关闭」,最终释放连接资源,存在TIME_WAIT等关键状态;
  • 粘包 / 半包本质:TCP 是面向字节流的无边界协议(核心根源,不是 TCP 的缺陷,是特性),导致接收方无法区分数据包的边界,出现数据粘连 / 截断;
  • 高性能服务器解决粘包 / 半包的唯一标准答案应用层缓冲区暂存 + 基于业务协议的「边界识别 / 长度标定」解包,无任何例外,所有大厂服务器均采用此方案。

一、高性能服务器中「TCP Socket 的 C++ 封装」

1. 封装的核心目的

Linux 原生的 Socket 是一堆零散的 C 语言系统调用socket()/bind()/listen()/accept()/connect()/recv()/send()),直接使用会导致:代码冗余、错误处理不统一、网络操作与业务逻辑耦合严重、可读性差。

在 C++ 高性能服务器中,必须对 TCP Socket 做面向对象的封装 ,这是工业级项目的标准做法,核心目的:

  • 解耦网络层与业务层,网络操作全部封装在类内,业务层无需关心底层 Socket 细节;
  • 统一错误处理(如返回值校验、errno 处理),避免重复写错误判断代码;
  • 封装核心接口,简化调用(如listen()只需一行代码,无需传参);
  • 支持非阻塞模式、地址复用、端口复用等高性能配置,一键开启。
2. 核心封装设计

TCP 是面向连接的协议 ,分为「服务端 Socket」和「客户端 Socket」,服务器中会封装两个核心类(也可封装为一个基类 + 两个子类),所有接口均为非阻塞模式(高性能服务器标配,配合 epoll ET):

(1)TcpserverSocket 服务端监听套接字类

封装服务端的「创建 - 绑定 - 监听」逻辑,只负责监听客户端的连接请求,不处理具体的业务数据传输,核心成员 + 接口:

cpp 复制代码
class TcpServerSocket {
private:
    int listen_fd_;          // 监听fd,唯一
    struct sockaddr_in addr_;// 服务器地址(ip+port)
    int port_;               // 监听端口
public:
    TcpServerSocket(int port);
    ~TcpServerSocket();
    void setReuseAddr();     // 开启地址复用 SO_REUSEADDR
    void setReusePort();     // 开启端口复用 SO_REUSEPORT
    void setNonBlock();      // 设置为非阻塞模式(核心)
    void bind();             // 绑定ip+port
    void listen(int backlog = 128); // 开始监听,backlog为半连接队列大小
    int accept(struct sockaddr_in &client_addr); // 接收连接,返回conn_fd
};

(2)TcpConnectionSocket 客户端连接套接字类

cpp 复制代码
class TcpConnectionSocket {
private:
    int conn_fd_;            // 通信fd,每个客户端唯一
    struct sockaddr_in peer_addr_; // 客户端地址
    char read_buf_[BUFFER_SIZE];   // 读缓冲区(核心,解决粘包半包)
    int buf_len_;            // 缓冲区中有效数据长度
public:
    TcpConnectionSocket(int conn_fd, struct sockaddr_in &addr);
    ~TcpConnectionSocket();
    void setNonBlock();      // 非阻塞模式
    ssize_t readData();      // 读取数据到缓冲区,返回读取字节数
    ssize_t sendData(const char *data, size_t len); // 发送数据
    char* getReadBuf();      // 获取读缓冲区指针
    int getBufLen();         // 获取缓冲区有效长度
    void updateBuf(int len); // 更新缓冲区(摘除已解析的有效数据)
    void close();            // 关闭连接
};
  • 所有 Socket 均默认设置为非阻塞模式,配合 epoll 的 ET 边缘触发,这是高性能的核心;
  • 必须开启SO_REUSEADDR + SO_REUSEPORT,解决服务器重启时的端口占用问题、TIME_WAIT 端口复用问题;
  • 每个 conn_fd 绑定独立的读缓冲区 ------ 这是解决粘包半包的物理基础,重中之重!

二、TCP 三次握手

TCP 是可靠的、面向连接的、字节流传输协议 ,客户端和服务器在进行数据传输前,必须先通过三次握手建立可靠的 TCP 连接,这是 TCP 的核心特性,无任何例外。

前置基础

  1. TCP 连接的建立是客户端主动发起,服务器被动接受
  2. 核心标志位:SYN(同步报文段,发起连接请求)、ACK(确认报文段,确认收到数据);
  3. 核心序号:seq(发送方的序列号)、ack(确认号,= 对方上一次的 seq + 1,表示收到该序号之前的所有数据);
  4. 服务器状态:调用listen()后,监听 fd 进入LISTEN(监听) 状态,等待客户端连接。

三次握手完整过程

假设:服务器已完成bind()+listen(),处于LISTEN状态;客户端调用connect()发起连接请求。

第一次握手:客户端 → 服务器,发送 SYN 报文
  • 客户端状态:从 CLOSEDSYN_SENT(同步已发送);
  • 客户端行为:向服务器发送一个SYN标志位为 1 的 TCP 报文,随机生成一个初始序列号 seq = x,无数据;
  • 服务器行为:收到 SYN 报文,验证通过后,记录客户端的 seq=x。
第二次握手:服务器 → 客户端,发送 SYN+ACK 报文
  • 服务器状态:从 LISTENSYN_RCVD(同步已接收,半连接状态);
  • 服务器行为:向客户端发送一个SYN=1 + ACK=1的 TCP 报文,包含两个核心信息: 确认号 ack = x + 1 (确认收到客户端的 SYN 报文);服务器随机生成自己的初始序列号 seq = y
  • 客户端行为:收到 SYN+ACK 报文,验证 ack=x+1 正确后,确认「自己的发送能力、服务器的收发能力均正常」。
第三次握手:客户端 → 服务器,发送 ACK 报文
  • 客户端状态:从 SYN_SENTESTABLISHED(已建立连接,可用);
  • 客户端行为:向服务器发送一个ACK=1的 TCP 报文,核心信息:确认号 ack = y + 1(确认收到服务器的 SYN 报文),seq = x + 1;
  • 服务器行为:收到 ACK 报文,验证 ack=y+1 正确后,状态从 SYN_RCVDESTABLISHED

关键节点:服务器的accept()函数,在第三次握手完成后才会返回 ,返回可用的conn_fd,此时双方正式进入数据传输阶段。

三次握手的核心目的

不是两次,也不是四次,必须三次,核心目的有 2 个,缺一不可:

  1. 同步双方的序列号和确认号:TCP 是可靠传输,依赖序列号实现数据的有序、去重、重传,三次握手是双方交换初始序列号的唯一方式;
  2. 验证双方的收发能力均正常:客户端确认「自己能发、服务器能收」,服务器确认「自己能发、客户端能收」,确保连接建立后数据能可靠传输。

补充:两次握手的缺陷 ------ 服务器无法确认客户端的接收能力,会导致服务器为无效连接分配资源,引发 SYN 洪水攻击。

三、TCP 四次挥手

TCP 连接是全双工 的 ------ 客户端和服务器都有「独立的发送通道和接收通道」,数据可以双向同时传输。因此,TCP 连接的关闭不是单方面的 ,而是双方各自关闭自己的发送通道,再确认对方的发送通道已关闭,这个过程需要四次报文交互,即「四次挥手」。

前置基础

  1. 挥手可以由客户端主动发起 (如浏览器关闭页面),也可以由服务器主动发起(如超时断开),绝大多数场景是客户端先发;
  2. 核心标志位:FIN(结束报文段,关闭发送通道)、ACK(确认报文段);
  3. 核心状态:FIN_WAIT1/2CLOSE_WAITLAST_ACKTIME_WAIT;
  4. 双方初始状态:均为 ESTABLISHED 已连接状态。

四次挥手完整过程

假设:客户端主动发起关闭连接请求,双方无未传输的剩余数据。

第一次挥手:客户端 → 服务器,发送 FIN 报文
  • 客户端状态:从 ESTABLISHEDFIN_WAIT1(等待服务器确认关闭);
  • 客户端行为:向服务器发送FIN=1的 TCP 报文,seq = u(当前发送序列号),表示「我没有数据要发给你了,关闭我的发送通道」;
  • 核心意义:客户端不再发送任何数据 ,但仍可以接收服务器的数据(全双工特性)。
第二次挥手:服务器 → 客户端,发送 ACK 报文
  • 服务器状态:从 ESTABLISHEDCLOSE_WAIT(关闭等待,核心状态);
  • 服务器行为:收到 FIN 报文后,立即发送ACK=1的确认报文,ack = u + 1,seq = v(服务器当前序列号);
  • 客户端状态:收到 ACK 后,从 FIN_WAIT1FIN_WAIT2(等待服务器的 FIN 报文);
  • 核心意义:服务器确认「收到客户端的关闭请求」,此时服务器的接收通道关闭,发送通道仍可用,服务器可以继续向客户端发送剩余数据(如未完成的响应)。
第三次挥手:服务器 → 客户端,发送 FIN 报文
  • 服务器行为:当服务器无剩余数据要发送时,主动发送FIN=1的报文,seq = w,ack = u + 1,表示「我也没有数据要发给你了,关闭我的发送通道」;
  • 服务器状态:从 CLOSE_WAITLAST_ACK(等待客户端确认关闭);
  • 核心意义:服务器不再发送任何数据,双方的发送通道均已关闭。
第四次挥手:客户端 → 服务器,发送 ACK 报文
  • 客户端状态:收到 FIN 后,发送ACK=1的确认报文,ack = w + 1,seq = u + 1;然后从 FIN_WAIT2TIME_WAIT(核心状态,重点!);
  • 服务器状态:收到 ACK 后,从 LAST_ACKCLOSED,立即释放所有连接资源(fd、缓冲区等);
  • 关键节点:客户端在 TIME_WAIT 状态停留 2MSL 时间后 ,才会进入CLOSED状态,释放资源。
1. 为什么 TCP 关闭需要四次挥手,而建立连接只需要三次?

核心答案:TCP 的全双工特性

  • 建立连接时:服务器的SYN+ACK可以合并成一个报文发送,一次完成「同步 + 确认」,所以少一次;
  • 关闭连接时:服务器收到客户端的FIN后,不能立即发送 FIN ------ 因为服务器可能还有数据要发送,必须先回一个ACK确认关闭,等数据发送完毕后再发FIN,因此ACKFIN必须分开发送,所以需要四次。
2. TIME_WAIT 状态是什么?有什么作用?服务器如何解决 TIME_WAIT 过多问题?

TIME_WAIT客户端在第四次挥手后进入的状态 ,会停留 2MSL(报文最大生存时间,Linux 默认 1 分钟) ,是 TCP 的保护机制 ,也是服务器开发中端口被占用、连接数耗尽的核心原因(服务器主动挥手时,服务器会进入 TIME_WAIT)。

TIME_WAIT 的核心作用

  1. 确保最后一个 ACK 报文能被服务器收到:如果 ACK 丢失,服务器会重发 FIN 报文,客户端在 TIME_WAIT 期间能收到并重发 ACK,避免服务器一直处于 LAST_ACK 状态;
  2. 防止失效的 TCP 报文段:等待网络中残留的过期报文段自然失效,避免新的连接收到旧连接的垃圾数据。
服务器解决 TIME_WAIT 过多的方案
cpp 复制代码
// 开启两个socket选项,在服务器的listen_fd上设置即可
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
  • SO_REUSEADDR:允许端口在 TIME_WAIT 状态下被重新绑定和复用;
  • SO_REUSEPORT:允许多个进程监听同一个端口,同时解决 TIME_WAIT 问题。

四、TCP 粘包 / 半包问题

粘包 / 半包的【唯一根源】

TCP 是面向字节流的、无消息边界的传输层协议

TCP 的设计目标是「可靠的字节流传输 」,它只保证:发送的字节序列会完整、有序地到达接收方 ,但不会为字节流划分任何边界,也不会保留「发送方一次 send () 的数据是一个独立数据包」的信息。

对 TCP 来说,send("abc")+send("def")send("abcdef") 是完全等价的,接收方收到的都是连续的字节流 abcdef ------ 这就是粘包 / 半包的本质原因不是 TCP 的 bug,是 TCP 的核心特性

1. 粘包 / 半包的定义 + 现象

粘包(数据粘连)

多个独立的数据包,被接收方当成一个连续的字节流接收,无法区分边界。

  • 发送方:send("hello")send("world") 两次发送;
  • 接收方:一次recv()收到 helloworld,两个数据包粘在一起。

半包(数据截断)

一个完整的数据包,被 TCP 拆分成多个片段,接收方分多次收到,单次收到的数据不完整。

  • 发送方:send("helloworld") 一次发送;
  • 接收方:第一次recv()收到 hello,第二次recv()收到 world,一个数据包被截断成两半。

补充:粘包和半包可以同时出现,比如发送方发了 3 个包,接收方收到「包 1 + 包 2 的一半」+「包 2 的另一半 + 包 3」,这是高并发下的常见场景。

2. 粘包 / 半包的产生原因

粘包和半包的产生是发送方、接收方、网络层三方共同作用的结果,所有原因都是 TCP 字节流特性的延伸,无任何例外:

产生粘包的主要原因

  1. 发送方的 Nagle 算法:TCP 默认开启 Nagle 算法,会将多个小的数据包合并成一个大的数据包发送(减少网络传输次数,提升效率),导致粘包;
  2. 接收方的缓冲区未及时读取 :接收方的内核缓冲区有数据,但应用层没有及时调用recv()读取,内核会将后续到达的数据拼接在缓冲区中,导致粘包;
  3. 网络拥塞:网络带宽不足时,多个数据包会被网络层缓存,一起到达接收方,导致粘包。

产生半包的主要原因

  1. TCP 的 MSS 限制:TCP 有最大报文段长度(MSS,默认 1460 字节),超过 MSS 的数据包会被 TCP 拆分成多个片段发送,导致半包;
  2. IP 层的 MTU 分片:IP 层有最大传输单元(MTU,默认 1500 字节),超过 MTU 的数据包会被 IP 层分片,接收方需要重组,若重组失败则出现半包;
  3. 接收方的缓冲区大小不足 :应用层的recv(buf, len)中,缓冲区len设置过小,单次只能读取部分数据,剩余数据留在内核缓冲区,导致半包;
  4. 发送方的发送缓冲区满:发送方的内核缓冲区满时,会分批发送数据,导致接收方分批接收,出现半包。

关键补充:UDP 协议不会出现粘包 / 半包------ 因为 UDP 是面向数据报的协议,有明确的消息边界,发送方一次 send () 对应接收方一次 recv (),但 UDP 是不可靠的,无重传、无有序保证,高性能服务器中一般不用 UDP。

五、高性能服务器中【解决 TCP 粘包 / 半包的核心方案】

  • TCP 层无法解决粘包 / 半包:TCP 是字节流协议,本身没有边界,内核也不会为应用层做边界划分,任何试图在 TCP 层解决的方案都是无效的;
  • 唯一可行的解决思路在应用层解决 ------应用层协议必须定义「消息的边界规则」 ,接收方根据这个规则,从 TCP 的字节流中「切割 / 拼接」出完整的、独立的业务数据包,这个过程称为 「解包」
  • 核心前提为每个 conn_fd 分配独立的应用层读缓冲区 ------ 这是解决粘包 / 半包的物理基础,没有缓冲区,就无法暂存半包数据、无法拼接粘包数据。
通用核心解决方案:三板斧

高性能服务器解决粘包 / 半包的唯一标准答案,无任何例外,Nginx/Redis/muduo 全部采用此方案:

核心三板斧 = 应用层独立读缓冲区 + 流式数据拼接暂存 + 基于应用层协议的「边界识别解包」

为每个客户端 conn_fd 分配独立的读缓冲区

在你的服务器中,每个TcpConnectionSocket实例都有一个专属的读缓冲区(char read_buf []),作用如下:

  1. 每次调用recv()读取 TCP 数据时,不管数据是否完整,都将数据追加到缓冲区的末尾
  2. 缓冲区中始终存放「未被解析的、不完整的 TCP 字节流数据」;
  3. 解析出完整的业务数据包后,从缓冲区头部摘除该数据包,剩余的未解析数据留在缓冲区中,等待下一次新数据到来后继续解析;
  4. 缓冲区会被循环复用,无需频繁分配内存,效率极高。

核心解包策略

所有解包策略的核心都是「给应用层协议定义明确的边界」,接收方根据边界规则,从缓冲区的字节流中提取完整的数据包。

策略一:基于「分隔符」的解包(文本协议首选,项目中用的最多)

适用场景

文本类应用层协议 ,比如:HTTP 协议、Redis 协议、自定义的文本指令协议 ,这类协议的特点是:有固定的、唯一的分隔符标识消息结束

核心规则

在应用层协议中,定义一个不会出现在业务数据中的特殊分隔符 ,发送方在每个完整的业务数据包末尾,追加该分隔符 ;接收方从缓冲区中查找该分隔符,找到后,从缓冲区头部到分隔符的位置,就是一个完整的数据包。

经典案例

HTTP 协议的分隔符解包

HTTP 协议是典型的文本协议,定义了明确的分隔符:

  • 行结束符:\r\n(回车 + 换行),用于分隔请求首行、请求头部的每一行;
  • 头部与正文的分界符:\r\n\r\n(连续两个行结束符),表示头部结束;
  • muduo服务器中,就是从缓冲区中查找\r\n\r\n\r\n,切割出完整的 HTTP 请求首行、头部、正文,完美解决 HTTP 的粘包 / 半包问题!

实现步骤

  • 从缓冲区的起始位置开始,循环查找分隔符
  • 找到分隔符后,截取缓冲区头部到分隔符前的所有数据,作为一个完整的业务数据包;
  • 调用业务逻辑处理该数据包;
  • 更新缓冲区:将缓冲区的指针向后挪动「数据包长度 + 分隔符长度」,摘除已解析的数据,剩余数据前移;
  • 如果缓冲区中没有找到分隔符 ,说明数据不完整(半包),不做任何处理,等待下一次recv()新数据追加到缓冲区后再解析。

优点 & 缺点

  • 优点:实现简单、灵活,无需提前知道数据包长度,适合文本协议;
  • 缺点:分隔符不能出现在业务数据中,否则会误判边界(如 HTTP 的正文是二进制数据时,不会包含\r\n,所以安全)。
策略二:基于「长度字段」的定长 / 变长解包(二进制协议首选,高性能场景必备)

适用场景

二进制类应用层协议 ,比如:自定义的 RPC 协议、游戏服务器协议、视频流协议 ,这类协议的特点是:数据是二进制的,无固定分隔符,数据包长度不固定 ,是高性能服务器的核心解包策略

核心规则

这是工业级高性能服务器的标配解包策略 ,也是最通用、最安全的方案,核心思路是:给每个业务数据包定义一个「协议头」 ,协议头中包含一个固定长度的字段(如 4 字节 int),该字段的值表示「整个数据包的总长度(协议头 + 协议体)」。

协议通用结构

cpp 复制代码
| 协议头(固定长度,如4字节) | 协议体(变长,长度由协议头指定) |
| 总长度 len (uint32_t)       | 业务数据(二进制/文本)|

例如:协议头是 4 字节,值为104,表示整个数据包的总长度是 104 字节,协议体长度 = 104-4=100 字节。

实现步骤

所有变长数据包的解析都分为两步递进式解析,先解析头部,再解析体部,无任何例外:

第一步:解析「协议头」,获取数据包总长度

  1. 判断缓冲区中的数据长度 是否 ≥ 协议头长度(如 4 字节);
  2. 如果是:从缓冲区头部读取协议头,解析出总长度len
  3. 如果否:数据不足,等待新数据追加后再解析。

第二步:解析「协议体」,获取完整数据包

  1. 判断缓冲区中的数据长度 是否 ≥ 总长度 len
  2. 如果是:从缓冲区头部读取len字节的数据,就是一个完整的业务数据包;
  3. 如果否:数据不足(半包),等待新数据追加后再解析;
  4. 解析完成后,更新缓冲区,摘除已解析的数据,循环处理剩余数据。

优点 & 缺点

  1. 优点:绝对安全、无边界误判、性能极高,适合任何二进制 / 文本协议,是高性能服务器的最优解;
  2. 缺点:需要提前定义协议头,实现比分隔符稍复杂,但一旦定义好,后续无任何坑。
  • 缓冲区扩容策略:缓冲区的初始大小设置为 8192/16384 字节,当缓冲区满时,自动扩容为原来的 2 倍,避免内存溢出;
  • epoll ET 模式的循环读 :在 ET 模式下,必须循环调用 recv () 直到返回 EAGAIN,确保把内核缓冲区中的数据全部读取到应用层缓冲区,避免数据残留;
  • 无效数据处理:如果缓冲区中存在无法解析的无效数据(如协议错误),直接关闭该 conn_fd,释放资源,避免缓冲区被垃圾数据填满;
  • 半包数据的拼接:半包数据会一直留在缓冲区中,直到新数据到来后拼接成完整的数据包,无需任何额外处理,缓冲区会自动完成拼接。
相关推荐
MLGDOU2 小时前
Chatsdk模型接口的设计
网络·c++
上海云盾-小余2 小时前
能用到高防ip的业务类型都有哪些
网络·网络协议·tcp/ip
小李独爱秋3 小时前
计算机网络经典问题透视:狭义与广义IP电话的深度解析及连接方式全览
网络·tcp/ip·计算机网络·信息与通信·ip·电话
dddddppppp1233 小时前
linux 块设备驱动程序之helloworld
linux·服务器·网络
一颗青果3 小时前
DNS | ICMP
linux·网络
乐迪信息3 小时前
乐迪信息:智能识别船舶种类的AI解决方案
大数据·网络·人工智能·算法·无人机
boneStudent3 小时前
STM32L476 LoRaWAN网关项目分享
服务器·网络·stm32
fo安方3 小时前
软考~系统规划与管理师考试——真题篇——章节——第5章 应用系统规划——解析版
java·运维·网络
wechat_Neal3 小时前
车载以太网技术全景-TCP/IP 协议栈篇
网络·网络协议·tcp/ip