先记住重点一句话:**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);
使用说明
- 把公共工具函数复制到 common.php,或直接粘贴到两个文件最上方。
- 先运行服务端:php server.php。
- 新开终端运行客户端:php client.php。
七、TCP vs UDP 对比
|----------|---------|-----------|
| 对比项 | TCP | UDP |
| 数据形态 | 字节流、无边界 | 数据报、自带边界 |
| 粘包拆包 | 有粘包、拆包 | 不会粘包,只会丢包 |
**记忆口诀:**TCP 流式无边界,内核缓冲、Nagle、MSS 造就粘包拆包;TCP 不管业务消息,边界必须由上层自己做。