4. 复现粘包问题

好,咱们现在进入实战环节。前面讲了那么多理论,说白了都是纸上谈兵。这一节,我带你亲手把粘包问题“炸”出来。

我个人习惯,遇到网络问题先写个最小复现Demo。这样能快速锁定问题边界,避免被业务逻辑干扰。你想想看,如果在一个几千行的项目里排查粘包,那得多痛苦?

4.1 准备一个“会犯错”的Demo

我特意写了一个有问题的Demo。它的问题很典型——发送端连续发送多条消息,但接收端没有做任何边界处理。嗯,这就是粘包和拆包的温床。

先看服务端代码:

// server.c - 有问题的服务端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int server_fd, client_fd;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE] = {0};

    // 创建socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 绑定端口
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));

    // 监听
    listen(server_fd, 3);
    printf("服务端已启动,监听端口 %d\n", PORT);

    // 接受连接
    client_fd = accept(server_fd, (struct sockaddr *)&address,
                       (socklen_t*)&addrlen);

    // 连续发送3条消息 —— 问题就出在这里!
    char *msg1 = "Hello, this is message 1.";
    char *msg2 = "Hello, this is message 2.";
    char *msg3 = "Hello, this is message 3.";

    send(client_fd, msg1, strlen(msg1), 0);
    sleep(1);  // 故意加延迟,让粘包更明显
    send(client_fd, msg2, strlen(msg2), 0);
    sleep(1);
    send(client_fd, msg3, strlen(msg3), 0);

    printf("三条消息已发送完毕\n");

    close(client_fd);
    close(server_fd);
    return 0;
}

再看客户端代码:

// client.c - 有问题的客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int sock = 0;
    struct sockaddr_in serv_addr;
    char buffer[BUFFER_SIZE] = {0};

    // 创建socket
    sock = socket(AF_INET, SOCK_STREAM, 0);

    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);

    // 连接服务端
    connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

    // 只调用一次recv —— 这就是问题所在!
    int valread = recv(sock, buffer, BUFFER_SIZE, 0);
    buffer[valread] = '\0';

    printf("客户端收到数据: %s\n", buffer);
    printf("实际接收字节数: %d\n", valread);

    close(sock);
    return 0;
}
⚠️ 注意: 这段代码是故意写错的。千万别直接用到生产环境里!我曾经见过一个新手直接把类似代码部署上线,结果线上服务全乱套了。

4.2 编译与运行

编译很简单,两条命令搞定:

gcc -o server server.c
gcc -o client client.c

运行顺序要注意:先启动服务端,再启动客户端。

# 终端1:启动服务端
./server

# 终端2:启动客户端
./client

4.3 观察异常现象

运行后,你会看到客户端输出类似这样的内容:

客户端收到数据: Hello, this is message 1.Hello, this is message 2.Hello, this is message 3.
实际接收字节数: 72

看到了吗?三条消息粘在一起了! 客户端只调用了一次recv,就把三条消息全收进来了。这就是典型的粘包。

为什么会这样?

TCP是流式协议,它不关心你发送了几次数据。它只负责把字节流从A端搬到B端。服务端连续调用了三次send,但TCP可能把这三次的数据合并成一个TCP报文发送出去。客户端这边只调用了一次recv,自然就把所有数据一次性读走了。

核心结论: 粘包的本质是发送端多次send与接收端单次recv之间的不匹配。TCP不保证每次send对应一次recv。

4.4 用GDB验证粘包过程

光看现象还不够。我建议你用GDB跟一下,看看底层到底发生了什么。

先启动服务端,然后用GDB attach到服务端进程:

# 先启动服务端
./server

# 在另一个终端找到服务端PID
ps aux | grep server

# 用GDB attach
gdb -p <服务端PID>

在GDB里设置断点:

(gdb) b send
(gdb) c

每次send被调用时,GDB都会停下来。你可以观察send的返回值:

(gdb) finish
(gdb) p $rax   # x86_64架构下,返回值在rax寄存器

你会发现,三次send的返回值分别是:

  • 第一次:28("Hello, this is message 1."的长度)
  • 第二次:28
  • 第三次:28

服务端确实发送了三次,每次28字节。但客户端一次recv就收走了全部84字节(28*3)。

💡 小技巧: 用GDB的watch命令监视客户端的buffer变量,可以看到数据是如何被一步步填充的。我个人习惯在recv调用前后各设一个断点,对比buffer的变化。

4.5 拆包问题怎么复现?

粘包的反面是拆包。拆包怎么复现?很简单,把客户端改成多次recv,但每次只读一点点:

// 修改客户端,每次只读10字节
for (int i = 0; i < 3; i++) {
    int valread = recv(sock, buffer, 10, 0);
    buffer[valread] = '\0';
    printf("第%d次收到: %s\n", i+1, buffer);
}

运行结果:

第1次收到: Hello, thi
第2次收到: s is messag
第3次收到: e 1.Hello,

看到了吗?一条完整的消息被拆成了好几段! 这就是拆包。客户端每次只读10字节,但消息长度是28字节,所以一条消息被拆成了3次接收。

4.6 粘包与拆包的对比

现象 触发条件 表现 根本原因
粘包 多次send + 单次recv 多条消息合并成一条 TCP流式特性 + 接收端未做边界处理
拆包 单次send + 多次recv 一条消息被拆成多段 TCP流式特性 + 接收缓冲区大小限制

其实说白了,粘包和拆包是同一个问题的两面。问题的根源就是:TCP不维护消息边界。它只保证字节流的顺序和完整性,但不保证你发送的每次数据都能被完整地、独立地接收。

4.7 我踩过的坑

我曾经在一个即时通讯项目里遇到过类似问题。客户端发了一条很长的消息(大概4KB),服务端用recv只读了1024字节,结果消息被截断了。用户收到的是半条消息,直接显示乱码。当时排查了好久,最后发现是recv的缓冲区设得太小了。

还有一次,一个同事写了个文件上传功能。他连续调用了10次send发送文件块,结果客户端一次recv就把10个块全收走了。文件解析直接崩溃。嗯,从那以后,我们团队定了个规矩:所有TCP通信必须做消息边界处理。

4.8 本章小结

这一节我们亲手复现了粘包和拆包问题。你看到了异常现象,也用GDB验证了底层行为。记住两个关键点:

  • 粘包:多条消息合并,客户端一次收走
  • 拆包:一条消息被拆散,客户端多次接收

这两个问题不解决,你的网络程序就是一颗定时炸弹。下一节,我会教你如何用GDB精准定位这些问题的根因,并给出解决方案。

课后练习: 修改Demo,让服务端发送10条消息,客户端只调用一次recv。观察粘包现象是否更严重?再用GDB跟踪一下send和recv的调用次数。