4. 粘包拆包演示 · 拆包

4.1 为什么要亲手演示拆包?

说实话,我当年刚接触TCP网络编程时,看了不少理论文章。什么“TCP是流协议”、“没有消息边界”之类的,背得滚瓜烂熟。但真正上手写代码,第一次遇到数据对不上的情况,还是懵了。

你想想看,客户端发了“HelloWorld”,服务端收的时候,可能只收到“Hello”,也可能收到“HelloWorldExtraData”。这就是典型的拆包和粘包。

我个人习惯,学任何网络知识,先写个demo跑一遍。亲眼看到问题,比读十篇文章都管用。这一节,我们就来亲手制造一个拆包场景。

核心概念:拆包,指的是一个完整的数据包,被TCP分成了多次接收。原因通常是发送数据大于接收缓冲区,或者MSS(最大报文段长度)限制。

4.2 服务端代码:故意用小缓冲区接收

为了演示拆包,我故意把服务端的接收缓冲区设得很小。比如每次只读5个字节。这样客户端发一个20字节的消息,服务端就得分4次读完。

// tcp_server.cpp
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

int main() {
    // 1. 创建socket
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        std::cerr << "socket创建失败" << std::endl;
        return -1;
    }

    // 2. 绑定地址
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        std::cerr << "bind失败" << std::endl;
        close(listen_fd);
        return -1;
    }

    // 3. 监听
    if (listen(listen_fd, 5) == -1) {
        std::cerr << "listen失败" << std::endl;
        close(listen_fd);
        return -1;
    }

    std::cout << "服务端启动,监听端口8888..." << std::endl;

    // 4. 接受连接
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
    if (conn_fd == -1) {
        std::cerr << "accept失败" << std::endl;
        close(listen_fd);
        return -1;
    }

    std::cout << "客户端已连接" << std::endl;

    // 5. 故意用小缓冲区接收,演示拆包
    char buffer[5];  // 每次只读5字节!
    int total_received = 0;
    int bytes_read;

    while ((bytes_read = recv(conn_fd, buffer, sizeof(buffer), 0)) > 0) {
        total_received += bytes_read;
        std::cout << "收到 " << bytes_read << " 字节: \"";
        std::cout.write(buffer, bytes_read);
        std::cout << "\" (累计 " << total_received << " 字节)" << std::endl;
    }

    std::cout << "连接关闭,共接收 " << total_received << " 字节" << std::endl;

    close(conn_fd);
    close(listen_fd);
    return 0;
}

关键点:char buffer[5] 就是拆包的元凶。TCP不知道你的消息边界,它只管把数据流塞给你。你一次要5字节,它就给你5字节。剩下的数据留在内核缓冲区,下次再读。

4.3 客户端代码:发送一个完整消息

客户端就简单了。我发一个20字节的字符串,看看服务端怎么拆。

// tcp_client.cpp
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
    // 1. 创建socket
    int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (sock_fd == -1) {
        std::cerr << "socket创建失败" << std::endl;
        return -1;
    }

    // 2. 连接服务端
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        std::cerr << "connect失败" << std::endl;
        close(sock_fd);
        return -1;
    }

    // 3. 发送一个20字节的消息
    const char* message = "HelloWorldTCPDemo!";
    int msg_len = strlen(message);  // 20字节
    std::cout << "发送 " << msg_len << " 字节: " << message << std::endl;

    int bytes_sent = send(sock_fd, message, msg_len, 0);
    if (bytes_sent == -1) {
        std::cerr << "send失败" << std::endl;
    } else {
        std::cout << "成功发送 " << bytes_sent << " 字节" << std::endl;
    }

    // 4. 等待一会,让服务端处理完
    sleep(1);

    close(sock_fd);
    return 0;
}

4.4 运行效果:亲眼看看拆包

先启动服务端,再启动客户端。你会看到类似这样的输出:

服务端启动,监听端口8888...
客户端已连接
收到 5 字节: "Hello" (累计 5 字节)
收到 5 字节: "World" (累计 10 字节)
收到 5 字节: "TCPDe" (累计 15 字节)
收到 5 字节: "mo!"   (累计 20 字节)
连接关闭,共接收 20 字节

看到了吗?客户端明明只发了一次 send(),服务端却收到了4次 recv()。这就是拆包。

注意:实际运行中,拆包的方式不一定每次都是5字节对齐。网络状况、内核调度都会影响。你可能看到第一次收到6字节,第二次收到4字节,第三次收到10字节。但总字节数一定是20。

4.5 拆包的本质:一张图说清楚

下面这张SVG图,把整个流程画出来了。我建议你仔细看看,理解数据是怎么从应用层一路走到接收缓冲区的。

TCP拆包过程示意图 客户端 应用层:send("HelloWorldTCPDemo!") 一次调用,20字节 [HelloWorldTCPDemo!] send() TCP协议栈 将数据拆分成多个 TCP段发送 [Hello] [World] [TCPDe] [mo!] 网络传输 互联网 内核 接收 缓冲区 数据排队 recv(buffer, 5) 服务端应用层 每次recv只读5字节 第1次recv: "Hello" (5字节) 第2次recv: "World" (5字节) 第3次recv: "TCPDe" (5字节) 第4次recv: "mo!" (5字节)

4.6 拆包带来的实际问题

你可能会说:“不就是多收几次嘛,我拼起来不就行了?” 嗯,理论上没错。但实际项目中,问题比这复杂得多。

我曾经在一个物联网项目里踩过坑。设备上报数据,每条消息长度不固定。服务端用固定缓冲区收,结果一条消息被拆成两半,另一半和下一条消息粘在一起。解析出来的数据全是乱码。

拆包带来的典型问题:

  • 消息不完整:你只收到了半个消息,没法解析
  • 消息错位:上半条消息和下半条消息混在一起
  • 业务逻辑混乱:比如收到“转账100”的一半,程序可能误判

我的建议:写网络程序时,永远不要假设一次recv就能收到完整消息。一定要做好消息边界识别和拼包逻辑。后面几节我们会讲具体怎么做。

4.7 小结:拆包不是bug,是特性

拆包不是TCP的bug,而是它的设计特性。TCP保证数据按序到达、不丢失、不重复,但它不保证你一次recv就能拿到完整消息。

理解这一点,是写出健壮网络程序的第一步。今天这个demo虽然简单,但它把拆包现象赤裸裸地摆在了你面前。下次你在项目里遇到类似问题,至少知道根源在哪。

好了,动手跑一下代码吧。亲眼看到拆包,比我说一百遍都管用。

一句话总结:TCP是流,不是消息。拆包是常态,不是异常。