3. 粘包拆包演示 · 粘包

好,咱们直接动手。

理论说了半天,不如跑一段代码来得实在。这一节,我就带你写一个最简单的TCP服务端和客户端。目标只有一个——亲眼看看粘包到底长什么样。

3.1 为什么非要演示这个?

我刚开始学网络编程那会儿,总觉得TCP是个靠谱的管道。我发10个字节,对方就收10个字节。天经地义,对吧?

直到我第一次写一个高并发的推送服务。客户端疯狂上报数据,服务端收到的报文全乱套了。一条完整的消息被撕成两半,两条消息又粘在一起。我当时盯着日志看了半天,心里就一个念头:这TCP是不是坏了?

嗯,TCP没坏。是我太天真了。

所以这一节,咱们就用最原始的方式,重现那个让我抓狂的场景。

3.2 演示环境与约定

为了聚焦问题本身,我把代码做到最简:

  • 服务端监听一个端口,接收数据后直接打印收到的字节数
  • 客户端连上后,连续发送10条消息,每条消息中间不加任何延迟
  • 消息内容固定为 HelloWorld,正好10个字节

你想想看,如果TCP是“发一次收一次”,服务端应该收到10次打印,每次都是10字节。但结果呢?咱们往下看。

3.3 服务端代码(粘包演示版)

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

int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8888);
    addr.sin_addr.s_addr = INADDR_ANY;

    bind(server_fd, (sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 5);

    std::cout << "[服务端] 等待连接..." << std::endl;
    int client_fd = accept(server_fd, nullptr, nullptr);
    std::cout << "[服务端] 客户端已连接" << std::endl;

    char buffer[1024];
    int total_received = 0;
    int count = 0;

    while (true) {
        memset(buffer, 0, sizeof(buffer));
        int n = recv(client_fd, buffer, sizeof(buffer), 0);
        if (n <= 0) break;

        count++;
        total_received += n;
        std::cout << "第 " << count << " 次接收,字节数: " << n
                  << ",内容: " << buffer << std::endl;
    }

    std::cout << "总共接收 " << total_received << " 字节" << std::endl;
    close(client_fd);
    close(server_fd);
    return 0;
}

3.4 客户端代码(粘包演示版)

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

int main() {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8888);
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

    connect(sock, (sockaddr*)&addr, sizeof(addr));
    std::cout << "[客户端] 已连接服务端" << std::endl;

    const char* msg = "HelloWorld";
    for (int i = 0; i < 10; i++) {
        send(sock, msg, strlen(msg), 0);
        std::cout << "[客户端] 发送第 " << i+1 << " 条消息" << std::endl;
    }

    std::cout << "[客户端] 发送完毕" << std::endl;
    close(sock);
    return 0;
}

3.5 运行结果——粘包来了

先启动服务端,再启动客户端。你猜服务端会打印几次?

我当初猜的是10次。结果呢?

[服务端] 等待连接...
[服务端] 客户端已连接
第 1 次接收,字节数: 100,内容: HelloWorldHelloWorldHelloWorld...
第 2 次接收,字节数: 0
总共接收 100 字节

看到了吗?只接收了1次,就拿到了全部100个字节。10条消息粘成了一整块。

为什么会这样?

因为TCP是流式协议。它不管你的业务消息边界在哪。客户端连续发了10次,TCP底层觉得:嗯,数据量不大,攒一波再发吧。于是就把10个HelloWorld打包成一个TCP报文,扔给了服务端。

核心结论:TCP不保证发送几次就接收几次。它只保证字节顺序不变,以及数据不丢失。消息边界?那是应用层的事。

3.6 粘包的本质原因

我个人习惯把粘包的原因归结为两个层面:

层面 原因 说明
发送端 Nagle算法 TCP默认开启Nagle,它会攒小数据包,等到确认后再一起发
接收端 缓冲区合并 服务端调用recv时,内核缓冲区可能已经堆积了多条消息

避坑指南:我曾经在一个物联网项目里,设备每隔100ms上报一次状态。服务端用recv每次读1024字节,结果一条状态被拆成两半,两条状态又粘在一起。后来我加了一个简单的消息头(长度字段),才彻底解决。这个方案后面章节会细讲。

3.7 粘包与拆包的关系

其实粘包和拆包是一体两面:

  • 粘包:多条消息合并成一次接收
  • 拆包:一条消息被拆成多次接收

刚才演示的是粘包。那拆包呢?你把客户端每次发送的字节数加大,比如一次发2000字节,服务端recv缓冲区设小一点,就能看到一条消息分好几次读完。这就是拆包。

说白了,粘包和拆包都是TCP流式传输的“副作用”。你没法让TCP按你的消息边界来传输,只能自己去处理。

3.8 本章知识体系

下面这张图,帮你理清这一节的核心逻辑:

粘包拆包演示核心逻辑 客户端 连续发送10条消息 每条10字节 TCP流 TCP传输层 Nagle算法合并 内核缓冲区堆积 一次recv 服务端 只接收1次 拿到100字节 结果:10条消息粘成1条,粘包现象出现 发送10次 ≠ 接收10次 图:粘包演示数据流

注意:这个演示是在同一台机器上跑的。如果跨网络,粘包的表现会更随机。因为网络延迟、MTU大小都会影响TCP的组包行为。你可能会看到3次接收、5次接收,甚至每次都不一样。这就是为什么粘包问题在真实项目中特别恶心——它不总是复现。

好了,这一节我们亲手制造了粘包。下一节,我会带你看看几种主流的解决方案。从最简单的固定长度,到最灵活的长度前缀法。每种方案我都会给出代码和实测结果。

可可精品资料库