十三、Swoole 基础学习笔记 - Swoole TCP 粘包问题

作者: 温新

分类: 【Swoole 系列】

阅读: 1022

时间: 2023-03-13 11:13:17

hi,我是温新,一名PHPer

文章基于 Swoole 5.0.1 版本编写。

**学习目标:学会处理 TCP 粘包问题 **

说明:本篇文章结合官方文档编写及参考网络资料编写,虽非全部原创,但也是结合了自己的理解,若转载请附带本文 URL,编写不易,持续编写更不易,谢谢!

本篇文章可能有点难以理解,因为涉及到了 TCP 数据传输的问题,这一块我不懂。虽然不懂,但学习还是要继续的,Swoole 已经为我们处理了很多,并且给出了解决的方法,一起来啃吧。

TCP 通信特点

  • 1、TCP 以流式协议通信,数据没有边界。TCP 服务端接收到客户端的 1 个大数据包时,可能会被拆分成多个数据包进程发送;发送的数据少,又可能会被一次全部接收到;
  • 2、基于流式,面向连接的、可靠的通信方式;

以下是理解点:

消息没有边界:举个例子,“人不可能两次踏进同一条河流”。在一条流动的小河中,我们不知道哪一段才是我们所需要的水(也就是数据);

收发数据有缓冲区:举个水桶接水的例子,打开水龙头接水,我们是把水接满后关闭水龙头,然后把装满水的桶拿走。

在这个例子的基础我们再来理解:

  • 1、我们不会接一点水,就处理一点水,等桶装满后再统一处理;
  • 2、接水 相当于 就是在接收数据;
  • 3、桶 相当于是 缓冲区;

为什么要等缓冲区满了才发送?为了提高性能。

什么是 TCP 粘贴

TCP 粘包是指**发送方(客户端)发送的若干数据到接收方(服务端)**接收时粘成一包,从接收缓冲区看,后一包数据的头紧贴着前一包数据的结尾。

举个例子:生活中我们经常会看到到时时,如 3、2、1 开始,这个数字每喊一个都是一句话,假设不小心被喊成了 321,由三句话变成了一句话,而最后的 1,由开头的信息变成了贴在 2 后的信息,同理,2 也在 贴在 3 的后面。

出现 TCP 粘包的原因

出现粘包有可能是客户端导致的,也有可能是服务端导致的。

发送方(客户端):发送方需要等待缓冲区装满才会发送数据数据,造成粘包;

接收方(服务端):接收方不及时接收缓冲区的包,造成多个包被接收。

TCP 数据包边界问题

在高并发的情况下,会出现 TCP 数据包边界问题,因为 TCP 通信是流式的,在接收 1 个大数据包时,可能会被拆分成多个数据包发送。多次 Send 底层也可能会合并成一次进行发送。这里就需要 2 个操作来解决:

  • 分包:Server 收到了多个数据包,需要拆分数据包;
  • 合包:Server 收到的数据只是包的一部分,需要缓存数据,合并成完整的包。

Swoole 中,可以使用 EOF 结束符协议固定包头 + 包体协议 两种方式解决 TCP 粘包问题。

粘包案例

上述说了那么理论,现在通过实例来演示粘贴问题:

案例一:连续向客户端发送小数据

服务端代码

<?php
// 10-swoole-tcp-pack-server.php
$server = new Swoole\Server('0.0.0.0', 9501, SWOOLE_PROCESS);

$server->on('Receive', function ($server, $fd, $fromId, $data) {
	echo 'Server:' . $data . PHP_EOL;
});

$server->start();

客户端代码

<?php
// 10-swoole-tcp-pack-client.ph
$client = new Swoole\Client(SWOOLE_SOCK_TCP);

if (!$client->connect('127.0.0.1', 9501, -1)) {
	exit('连接服务器失败' . $client->errCode . PHP_EOL);
}

// 发送 5 次请求
// 连续向服务端发送小数据
for ($i = 0; $i < 5; $i++) {
	$client->send('i am client ' . $i);
}
 
$client->close();

根据上面的代码来输出的结果是什么?会是心中的结果吗?

很遗憾,输出结果是一行信息:

# 心中所想的结果
Server:i am client 0
Server:i am client 1
Server:i am client 2
Server:i am client 3
Server:i am client 4

# 最终输出的结果
$php 10-swoole-tcp-pack-server.php 
Server:i am client 0i am client 1i am client 2i am client 3i am client 4

这个案例中,客户端向服务端发起了 5 次请求,按理说应该会输出 5 行信息,但实际情况是输出了 1 行信息。要理解这个问题就要结合上面的理论知识了。

TCP 发送数据时,有 socket 缓冲区的概念,每一个 TCP socket 在内核中都有一个发送缓冲区和一个接收缓冲区。客户端 send 时仅仅只是把数据拷贝到 buffer 中,也就是说 send 操作完成,但是并不能表明数据已经发送到了服务端且被服务端接收。发送到缓冲区由 TCP 协议从 buffer 中把数据发送到服务端,此时的服务端才从 TCP 中接收到缓冲区的数据,最后 server 才从 buffer 中读取数据。

因此,在 onReceive 中接收到的数据无法保证数据包的完整性,Server 可能同时接收到多个请求,也可能收到一个请求的部分数据。

案例二:客户端一次向服务端发送一个大数据包

知道了多次发送小数据出现的粘包问题,那么大数据又是什么情况?一起来看看吧。

<?php
// 10-swoole-tcp-pack-client-2.php
$client = new Swoole\Client(SWOOLE_SOCK_TCP);

if (!$client->connect('127.0.0.1', 9501, -1)) {
	exit('连接服务器失败' . $client->errCode . PHP_EOL);
}

$client->send(str_repeat('www.ziruchu.com', 1024 * 1024 *1));

$client->close();

这一次会是什么结果?由于数据太大,这里只展示部分,但足以说明问题了。

$php 10-swoole-tcp-pack-server.php
# 第一段结果
.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww
# 第二段结果
Server:.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.comwww.ziruchu.com

从这里可以看出,一个大数据包被分成了很多端,每一段都不一样,而且还存在着数据被截断发送的情况。这也就解释了 分包合包 的意思,再用大白话来说明一下:

分包:将一个大的数据拆分成多个数据包发送;

合包:将多个数据装满 buffer 再发送。

Swoole 处理 TCP 粘包

方式一:EOF 结束协议

**通过约定结束符来确定数据包是否发送完毕。**举个例子,mysql 中,sql 语句结束符号是 ;,对于这个符号可以更改,如更改为 \\,那么最后必须输入新的结束符,这样 mysql 才能知道一条语句结束了。同理, EOF 结束符也是如此。

Swoole 中,通过配置 open_eof_check=true 并用 package_of 来设置一个完整数据结束符号,同时设置 open_eof_split 自动拆分数据配置。

为什么要配置 open_eof_split?先把粘包问题解决了,然后我们再来看看为什么要加这个配置。

服务端代码:

<?php
// 10-swoole-tcp-pack-server-2.php
$server = new Swoole\Server('0.0.0.0', 9501, SWOOLE_PROCESS);

$server->set([
	'worker_num'     => 3,
	// 设置换行为结束符
	'package_eof'    => "\r\n",
	// 开启 eof 检测
	'open_eof_check' => true,
	// 开启自动拆分
	'open_eof_split' => true,
]);

$server->on('Receive', function ($server, $fd, $fromId, $data) {
	echo 'Server:' . $data . PHP_EOL;
});

$server->start();

客户端代码:

<?php
// 10-swoole-tcp-pack-client-3.php
$client = new Swoole\Client(SWOOLE_SOCK_TCP);

if (!$client->connect('127.0.0.1', 9501, -1)) {
	exit('连接服务器失败' . $client->errCode . PHP_EOL);
}

for ($i = 0; $i < 5; $i++) {
	$client->send($i . " i am client \r\n");
}
 
$client->close();

输出结果:

$php 10-swoole-tcp-pack-server-2.php 
Server:0 i am client 

Server:1 i am client 

Server:2 i am client 

Server:3 i am client 

Server:4 i am client

这就是我们所想要的结果。既然结果是正确了,我们在来看看它存在的问题,为什么要配置 open_eof_split

存在的问题

现在我们重新修改一下服务端的代码,然后再来看看出现的问题,关于代码的使用,还是使用上面代码的案例,只不过进行了修改。

<?php
// 10-swoole-tcp-pack-server-2.php
$server = new Swoole\Server('0.0.0.0', 9501, SWOOLE_PROCESS);

$server->set([
	'worker_num'     => 3,
	// 配置结束符号
	'package_eof'    => "\r\n",
	// 开启 eof 检测
	'open_eof_check' => true,
    // 开启自动拆分
	//'open_eof_split' => true,
]);

$server->on('Receive', function ($server, $fd, $fromId, $data) {
	echo 'Server:' . $data . PHP_EOL;
});

$server->start();

现在再来重新跑一下看看。

$php 10-swoole-tcp-pack-server-2.php 
Server:0 i am client 
1 i am client 
2 i am client 
3 i am client 
4 i am client 
# 此处是输出的换行

仅仅只是少了一个参数,但结果却是天壤之别。使用 package_eofopen_eof_check 只能确保 Server 接收到一个或多个完整的数据包。如果不使用 open_eof_split,那么就只能进行手动处理,下面我们看看手动处理的案例。

$server->on('Receive', function ($server, $fd, $fromId, $data) {
	$datas = explode("\r\n", $data);
	foreach ($datas as $data) {
		if (!$data) {
			continue;
		}
		echo 'Server:' . $data . PHP_EOL;
	}
});

输出结果如下:

$php 10-swoole-tcp-pack-server-2.php 
Server:0 i am client 
Server:1 i am client 
Server:2 i am client 
Server:3 i am client 
Server:4 i am client

方式二:固定包头 + 包体协议(推荐)

其原理是通过约定数据流的前几个字节来表示一个完整的数据有多长,从第一个数据到达后,,先通过读取固定的几个字节,解出数据包的长度,然后按照这个长度明治维新取出后续的数据,依次循环。

参考配置:

<?php
$server->set([
    'open_length_check'     => true,
	'package_max_length'    => 81920,
	'package_length_type'   => 'N', //see php pack()
	'package_length_offset' => 0,
	'package_body_offset'   => 2,
]);

配置含义如下:

  • open_length_check:打开包长检测特性;
  • package_length_type:长度字段的类型,固定包头中用一个 4 字节或 2 这字节表示包体长度;
  • package_length_type:从第几个字节开始是长度,如包头长度为 120 字节,第 10 个字节为长度值,配置中就填 9;
  • package_body_offset:从第几个字节开始是长度,如包头长度为 120 字节,第 10 个字节为长度值,包体长度为 1000。若长度包含包头,配置中填写 0,若不包含包头,则填写 120;
  • package_max_length:最大允许的包长度。若一个请求包被完整接收之前,需要将所有数据保存在内存中,因此需要做保护,避免内存占用过大。

package_length_type 长度值类型如下:

  • c:有符号,1 字节;
  • C:无符号,1 字节;
  • s:有符号,主机字节序,2 字节;
  • S:无符号,主机字节序,2 字节;
  • n:无符号,网络字节序,2 字节(常用);
  • N:无符号,网络字节序,4 字节(常用);
  • l:有符号,主机字节序,4 字节(小写 L);
  • L:无符号,主机字节序,4 字节;
  • v:无符号,小端字节序,2字节;
  • V:无符号,小端字节序,4 字节。

先看案例,再来说明。

服务端代码:

<?php
// 10-swoole-tcp-pack-server-3.php
$server = new Swoole\Server('0.0.0.0', 9501, SWOOLE_PROCESS);

$server->set([
	'worker_num'            => 3,
	// 开启协议解析
	'open_length_check'     => true,
	// 协议最大长度
	'package_max_length'    => 81920,
	// 长度字段类型
	'package_length_type'   => 'N', 
	// 第几个字节是包的长度值
	'package_length_offset' => 0,
	// 第几个字节开始计算长度
	'package_body_offset'   => 2,
]);

$server->on('Receive', function ($server, $fd, $fromId, $data) {
	$info = unpack('N', $data);
	$len  = $info[1];
	$body = substr($data, - $len);
	echo 'server: ' . $body .PHP_EOL;
});

$server->start();

客户端代码

<?php
// 10-swoole-tcp-pack-client-4.php
$client = new Swoole\Client(SWOOLE_SOCK_TCP);

if (!$client->connect('127.0.0.1', 9501, -1)) {
	exit('连接服务器失败' . $client->errCode . PHP_EOL);
}

for ($i = 0; $i < 5; $i++) {
	$data = 'i am client. ';
    // 将数据打包成二进制数据
	$data = pack('N', strlen($data)) . $data;
	$client->send($data);
}
 
$client->close();

运行结果:

$php 10-swoole-tcp-pack-server-3.php 
server: i am client. 
server: i am client. 
server: i am client. 
server: i am client. 
server: i am client. 

输出的结果就是我们所期望的结果。但这是什么呢?

  • 1、server 中,开启了 open_legth_check,表示启用固定包头协议解析;

  • 2、package_length_type 设置包头长度类型;

  • 3、客户端使用 pack 将数据打包成二进制。如 pack('n', strlen($data)) . $data 意思是,包头是 pack 函数打包的二进制数据,内容是真实数据的长度 strlen($data)。

    在实际内存中,整数一般占用 4 字节,因此在该段数据中 0~4 字节表示是包头,其余的是真实数据内容。此时 server 还知道情况。因此 package_length_offsetpackage_body_offset 就是告诉 server 第第几个字节开始是长度和第几个字节开始计算长度。

本篇文章到此结束,下篇文章见。

请登录后再评论