TCP 粘包拆包解析及示例

先记住重点一句话:**TCP 是流式协议,没有消息边界,所以会发生粘包拆包;UDP 是数据报,自带边界,不会粘包。**本文先讲封包格式与粘包拆包的原理,及一套基于【长度头】方案的完整 PHP 示例代码。

目录

[一、TCP 头部简单格式](#一、TCP 头部简单格式)

二、什么是粘包、什么是拆包

三、粘包拆包的产生原因

[1. 发送端 Nagle 算法(最常见的粘包原因)](#1. 发送端 Nagle 算法(最常见的粘包原因))

[2. 接收端内核缓冲区](#2. 接收端内核缓冲区)

[3. 网络层 MSS 限制(拆包)](#3. 网络层 MSS 限制(拆包))

四、容易踩的误区

五、业务层怎么解决粘包拆包(四种主流方案)

[六、PHP 示例代码(长度头方案)](#六、PHP 示例代码(长度头方案))

公共工具函数(服务端、客户端共用)

[TCP 服务端 server.php](#TCP 服务端 server.php)

[TCP 客户端 client.php](#TCP 客户端 client.php)

使用说明

[七、TCP vs UDP 对比](#七、TCP vs UDP 对比)


一、TCP 头部简单格式

TCP 报文头部最小 20 字节,最多 60 字节。核心字段如下:

|--------------|-------------------------|
| 字段 | 作用 |
| 源端口、目的端口 | 区分是哪个程序在通信 |
| 序列号 seq | 字节流编号,保证有序、重传 |
| 确认号 ack | 确认收到了哪些字节 |
| 数据偏移 | 代表 TCP 头部有多长(头部长度可变) |
| 标志位 | SYN、ACK、FIN、RST、PSH、URG |
| 窗口大小 | 流量控制,告诉对方我还能收多少字节 |
| 校验和 | 校验报文是否损坏 |
| 紧急指针 | 配合 URG 标志,用于紧急数据 |
| 选项 | MSS、窗口扩大、时间戳等 |

|------------------------------------------------------------------------|
| TCP 没有"消息长度"字段!它只负责一串连续的字节流,根本不知道你的业务消息是"一条、两条"。TCP 眼里只有字节,没有包的概念。 |

二、什么是粘包、什么是拆包

假设客户端连续发两条业务消息:msg1:hello 、msg2:world。

  • 粘包 :服务端一次 recv 读到 helloworld,两条消息粘在一起,分不清边界。
  • 拆包 :一条大消息被切成两次收到,例如发一条 helloworld123456,第一次读到 hellow,第二次读到 orld123456。

|-------------------------------------------|
| 粘包、拆包不是 TCP 的 bug,而是 TCP 流式特性带来的正常现象。 |

三、粘包拆包的产生原因

1. 发送端 Nagle 算法(最常见的粘包原因)

Nagle 算法:小包不急着立刻发,攒够一定数据或收到 ACK 后再一起发,减少网络小包、提升效率。连续 send 很短的数据时,内核缓冲区攒起来一次性打包发出,就产生粘包。

可以通过 TCP_NODELAY 关闭 Nagle,但会增加小包、网络开销变大。

2. 接收端内核缓冲区

TCP 收到的数据先放到操作系统内核缓冲区,应用层调用 recv 才取走数据。缓冲区里有多条消息还没被取走时,一次 recv 会取多条(粘包);一条消息太大超过一次 recv 的上限,则分多次读取(拆包)。

3. 网络层 MSS 限制(拆包)

MSS 是 TCP 单次报文能承载的最大业务数据。send 的数据超过 MSS 时,TCP 底层会自动拆分成多个 TCP 段传输,产生拆包。

四、容易踩的误区

  • 误区 1:粘包是因为发得太快,加 sleep 延迟就解决了------只是临时缓解,网络波动时依旧会出现,无法根治。
  • 误区 2 :TCP 会自动切好一条一条消息------TCP 只保证字节有序、可靠,不帮你划分业务消息边界。

五、业务层怎么解决粘包拆包(四种主流方案)

|--------------|--------------------------------|-------------|
| 方案 | 做法 | 缺点 |
| 固定长度 | 每条消息固定大小,不足补占位符 | 浪费流量 |
| 分隔符 | 消息末尾加特殊分隔符(如 \n) | 消息内容不能包含分隔符 |
| 长度头(最常用) | 消息 = 4 字节长度 + 消息体,先读长度再按长度读 | 实现稍复杂 |
| 现成协议 | Protobuf、JSON + 长度头、HTTP(自带边界) | 依赖具体库 |

|--------------------------------------------------------------|
| 几乎所有 RPC、自定义 TCP 协议都用长度头方案(Netty、游戏协议等),下面的 PHP 示例用的就是它。 |

六、PHP 示例代码(长度头方案)

消息结构:4字节长度(大端uint32) + 消息内容。读取逻辑:先读满 4 字节拿到消息长度,再按长度读取对应内容。

|----------------------------------------------------------------|
| PHP stream 是流式读取,fread 不一定一次读完想要的字节,必须封装循环读函数 read_n,这是重点! |

公共工具函数(服务端、客户端共用)

php 复制代码
<?php
/**
 * 循环读取指定字节数,直到读够为止,解决 fread 读不全
 * @param resource $stream
 * @param int $needLen 需要读取的字节数
 * @return string|false
 */
function read_n($stream, int $needLen)
{
    $buf = '';
    while (strlen($buf) < $needLen) {
        $chunk = fread($stream, $needLen - strlen($buf));
        if ($chunk === false || $chunk === '') {
            return false; // 连接断开
        }
        $buf .= $chunk;
    }
    return $buf;
}

/**
 * 打包消息:加上 4 字节大端长度头
 * @param string $data 原始消息
 * @return string
 */
function pack_msg(string $data): string
{
    $len = strlen($data);
    // N = 大端无符号 32 位
    return pack('N', $len) . $data;
}

/**
 * 解包消息,从流中读取一条完整消息
 * @param resource $stream
 * @return string|false
 */
function unpack_msg($stream)
{
    // 第一步:先读 4 字节长度头
    $head = read_n($stream, 4);
    if ($head === false) return false;

    $lenArr = unpack('Nlen', $head);
    $bodyLen = $lenArr['len'];

    // 第二步:按长度读取消息体
    $body = read_n($stream, $bodyLen);
    return $body;
}

TCP 服务端 server.php

php 复制代码
<?php
require __DIR__ . '/common.php'; // 工具函数放到此文件,或直接粘贴到顶部

$ip = '0.0.0.0';
$port = 8888;
$socket = stream_socket_server("tcp://{$ip}:{$port}", $errno, $errstr);
if (!$socket) {
    die("创建服务失败:{$errno} {$errstr}");
}
echo "服务启动 tcp://{$ip}:{$port}\n";

while (true) {
    $conn = stream_socket_accept($socket);
    echo "有新连接\n";
    stream_set_blocking($conn, true); // 阻塞模式

    // 循环读取消息
    while (true) {
        $msg = unpack_msg($conn);
        if ($msg === false) {
            echo "客户端断开连接\n";
            fclose($conn);
            break;
        }
        echo "收到消息:{$msg}\n";

        // 回发一条消息给客户端
        $resp = "服务端收到:" . $msg;
        fwrite($conn, pack_msg($resp));
    }
}

TCP 客户端 client.php

php 复制代码
<?php
require __DIR__ . '/common.php';

$client = stream_socket_client('tcp://127.0.0.1:8888', $errno, $errstr, 3);
if (!$client) {
    die("连接失败 {$errno}:{$errstr}");
}
stream_set_blocking($client, true);

// 连续发送多条消息,故意快速发送,模拟容易粘包的场景
$sendList = [
    '第一条消息 hello',
    '第二条消息 world',
    '第三条消息 测试粘包拆包'
];

foreach ($sendList as $text) {
    $sendData = pack_msg($text);
    fwrite($client, $sendData);
}

// 读取服务端返回
for ($i = 0; $i < count($sendList); $i++) {
    $ret = unpack_msg($client);
    echo "客户端收到回复:{$ret}\n";
}

fclose($client);

使用说明

  1. 把公共工具函数复制到 common.php,或直接粘贴到两个文件最上方。
  2. 先运行服务端:php server.php。
  3. 新开终端运行客户端:php client.php。

七、TCP vs UDP 对比

|----------|---------|-----------|
| 对比项 | TCP | UDP |
| 数据形态 | 字节流、无边界 | 数据报、自带边界 |
| 粘包拆包 | 有粘包、拆包 | 不会粘包,只会丢包 |

**记忆口诀:**TCP 流式无边界,内核缓冲、Nagle、MSS 造就粘包拆包;TCP 不管业务消息,边界必须由上层自己做。

相关推荐
lazy H2 小时前
【计算机网络】第 13 篇:NAT 和端口映射是什么?为什么本地服务外网访问不了?
java·网络·网络协议·学习·计算机网络·408
Linux运维技术栈2 小时前
WAF部署在防火墙后面,获取不到真实IP怎么办?
服务器·网络·tcp/ip·安全·防火墙·waf
低慢小uav3 小时前
(已解决)Zynq芯片lwip协议栈,使用tcp作为服务器Service,接收大量数据时,长度接收正常,接收对数据校验,校验和异常分析
网络协议·tcp/ip·fpga开发
垆边人似月.3 小时前
RDMA项目踩坑(一):为什么最后一块数据必须使用实际长度?
网络·c/c++·rdma·高性能io·tcp与rdma
YH_Analytics3 小时前
2026 高压 E-House 市场深度分析报告市场格局
网络·数据库
zl_dfq3 小时前
Windows 下 UDP 套接字编程指南
网络·windows·udp
日常筹谋记3 小时前
数据中心逆变设备运维|通讯对接、动环接入与多系统联动方案科普
运维·网络
上海研博数据4 小时前
HY-K 太阳能控制器协议|水利物联太阳能供电系统接入指南
网络
zerwave4 小时前
从慢订阅者到发布者:LiteBus 如何用 Unix Socket 背压保护可靠 Topic
网络·机器人·unix
piaoyiren4 小时前
Python FTP 服务端项目详细功能文档
网络·数据库·python