标题:【Swoole系列5.2】TCP数据边界(粘包)
TCP数据边界(粘包)
说到 TCP 数据边界问题,可能很多人还反应不过来,这是个啥东西?不过它还有另外一个更出名的代名词,叫做 粘包 。如果以正式的说法来说的话,其实并不存在所谓的 粘包 问题 ,或者说,这个东西本身不能算做是一个问题。这个我们最后再说,先来看看到底啥是数据边界。
什么是数据边界问题
一般来说,我们在 Swoole 中运行之前写过的 TCP 相关的测试代码,都没有什么问题。那是因为我们只是自己单机测试,没什么并发流量,如果一旦踫到高并发,那么就有可能会出现 TCP 数据包的边界问题。
在学习网络相关的知识时,我们知道 TCP 是解决了 UDP 的顺序和丢包这两个重要的问题,它是会相互建立连接的,但是连接建立后,TCP 会以流式的方式进行数据传输。还记得我们使用 Socket 建立连接时使用的那个区分 TCP 和 UDP 的常量吗?TCP 使用的就是 SOCK_STREAM ,是不是从名字就看出来了,STREAM 代表的就是流。
然而,这种流式数据包的传输是没有边界的。怎么说呢?直接用例子来说话。
// 服务端
Swoole\Coroutine\run (function () {
$server = new Swoole\Coroutine\Server('0.0.0.0', 9501, false);
$server->handle(function(Swoole\Coroutine\Server\Connection $conn){
while($data = $conn->recv()){
echo $data," EOF ====== ", PHP_EOL;
}
});
$server->start();
});
服务端就是我们之前经常用的那个 TCP 服务端,在这里我们直接循环挂起接收 recv() 方法获得的数据,然后在里面打印出来,每次打印后面都加上一段分隔描述的字符串。
<?php
\Swoole\Coroutine\run(function () {
$client = new \Swoole\Coroutine\Client(SWOOLE_SOCK_TCP);
if (!$client->connect('127.0.0.1', 9501, 0.5)) {
echo "connect failed. Error: {$client->errCode}\n";
}
for($i = 1;$i<=10;$i++){
$client->send("hello world {$i},");
}
for($i = 11;$i<=20;$i++){
$client->send("hello world {$i},");
}
co::sleep(1);
for($i = 21;$i<=30;$i++){
$client->send("hello world {$i},");
$client->send("\r\n");
}
});
客户端也很简单,三个循环,分别使用 send() 发送数据,然后第二个个循环之后 sleep() 一下。
到这里,都没什么问题吧?猜猜我们运行客户端之后,服务端会输出什么?
hello world 1,hello world 2,hello world 3,hello world 4,hello world 5,hello world 6,hello world 7,hello world 8,hello world 9,hello world 10,hello world 11,hello world 12,hello world 13,hello world 14,hello world 15,hello world 16,hello world 17,hello world 18,hello world 19,hello world 20,
EOF ======
hello world 21,
hello world 22,
hello world 23,
hello world 24,
hello world 25,
hello world 26,
hello world 27,
hello world 28,
hello world 29,
hello world 30,
EOF ======
注意,我们是三个循环,每个循环里至少有一个 send() ,但是注意服务端,只输出了两个带 EOF+等号 的 recv() 信息。
看明白了吗?send() 和 recv() 并不是匹配的一对,并不是说客户端发一次 send() ,服务端就要接收一次 recv() 。真实的情况是,如果没有休息的那一秒的话,我们在这边一直 send() 到最后,服务端才会输出一条接收到的数据。因为,send() 发送的是数据流,在未超时中断的情况下,它会持续发送,就像高并发的时候,数据不断传送,服务端最后接到的数据可能就是有问题的数据。
这就是最直观的一种数据包边界问题的展示。如果是这种情况,你会想到什么解决方案呢?我们可以自己定义一个分隔字符,然后在服务端截开不同的数据,就像 HTTP 的请求一样,HTTP 的描述、头、Body信息就是以换行符分隔的。HTTP 本身就是一种 TCP 的高层服务,它当然也会有这个问题,只不过服务器程序和浏览器已经帮我们解决了。
上面这个问题可以再细分成一个操作,那就是 分包 操作。另外还有一种情况,比如说一些大文件的传输,有可能一次传输只是传来了一部分,这时候我们需要将之前先接收到的数据缓存起来,然后等待后续的数据继续发送过来之后合并在一起成为一个完整的数据包。这种情况叫做 合包 。
接下来,我们看看 Swoole 中处理这种数据边界问题的方式。
解决边界问题
在 Swoole 中,有两种解决数据边界问题的试试,一个是通过 EOF 结束符协议,另一个是通过固定包头+包体协议的试。我们今天就用 EOF 结束符协议来演示一下。
这个 EOF 结束符,其实就是我们在上面讲到的方法,定义一个结束符号,然后让框架自动帮我们完成 分/合包 操作。
// 服务端
$server->set(array(
// 'open_eof_split' => true,
'open_eof_check'=>true,
'package_eof' => "\r\n",
));
// 客户端
$client->set(array(
// 'open_eof_split' => true,
'open_eof_check'=>true,
'package_eof' => "\r\n",
));
for($i = 1;$i<=10;$i++){
$client->send("hello world {$i},");
}
co::sleep(1);
for($i = 11;$i<=20;$i++){
$client->send("hello world {$i},");
}
$client->send("\r\n");
co::sleep(1);
for($i = 21;$i<=30;$i++){
$client->send("hello world {$i},");
$client->send("\r\n");
}
在客户端和服务端,我们都增加了一个 set() ,然后设置了一些属性,open_eof_check 表示开启 EOF 结束符检查,package_eof 表示指定的结束符号,这里我们使用的是大部分情况下通用的 换行 标记,你可以试着换成其它符号。
然后,客户端还有一些小变化。我们在第一和第二个循环之间加了一个 sleep() ,如果是之前的情况,1-10 和 11-20 是会因为时间问题分成两个 recv() 的,但现在,我们在加了 sleep() 之后,在第二个循环结束的时候才再发送了 换行 标记。可以先想想,这里输出的结果会是什么样子的。
第三个循环还是老样子,但是在循环中,有两个 send() ,第二个 send() 每次循环都会发送一个 换行 标记。
试试运行的结果吧!
hello world 1,hello world 2,hello world 3,hello world 4,hello world 5,hello world 6,hello world 7,hello world 8,hello world 9,hello world 10,hello world 11,hello world 12,hello world 13,hello world 14,hello world 15,hello world 16,hello world 17,hello world 18,hello world 19,hello world 20,
EOF ======
hello world 21,
EOF ======
hello world 22,
EOF ======
hello world 23,
EOF ======
hello world 24,
EOF ======
hello world 25,
EOF ======
hello world 26,
EOF ======
hello world 27,
EOF ======
hello world 28,
EOF ======
hello world 29,
EOF ======
hello world 30,
EOF ======
有意思吗?1-20 还是在一起输出了,看着和最上面的测试代码效果一样呀。不不不,在这段测试代码中,我们中间 sleep() 了,然后服务端在第一个循环数据接收完之后,没有看到 换行 标记,于是等待了 1 秒,接着继续接收 11-20 的数据之后,看到了 换行 标记,才结束了这次请求接收。这是一个合包的过程。
第三段循环中,每次循环都有一个结束标记,于是,每一段循环都会在服务端获得一个 recv() 数据,就像我们把字符串分割开了一样。很明显,这是一个 分包 过程。
EOF 这种情况比较好理解,另外一种处理 固定包头 的方式则是另外一种概念。它的特点是一个数据包总是由包头 + 包体 2 部分组成。包头由一个字段指定了包体或整个包的长度,长度一般是使用 2 字节 /4 字节整数来表示。服务器收到包头后,可以根据长度值来精确控制需要再接收多少数据就是完整的数据包。
关于固定包头的处理方式的演示我就不展示了,因为我也没怎么接触过,或者说并没有太深入的学习过这一方面的内容,大家有兴趣的可以自行查阅相关资料。
真的有“粘包问题”吗?
其实,粘包,或者说数据边界,本身就是 TCP 协议在设计时的特点。它本身并不构成问题,只是我们在使用的时候,需要知道多少数据应该是一个完整的数据包,因此,这是因为我们的需求而出现的问题。说得有点绕,换个角度说,它不是一个技术问题,而是一个需求问题。
好吧,如果还不理解的话,下面参考文档中第二条链接中,知乎的各路大神们早就对这个问题展开了各种探讨,大家可以移步前去观战。
总结
要真是细说起来,这一块的知识其实是网络编程相关的内容,但是,如果你要是在简历中提到了 Swoole 的话,那么这个问题的面试出题率还是相当高的,毕竟太出名了。希望在今天的学习之后,大家能够更加重视编程四大件的学习,还记得是哪四个吗?操作系统、算法与数据结构、计算机网络、设计模式。如果还有一个的话,可以再把数据库原理加上。要知道,我们之前学习的进程、线程、协程全是操作系统这个学科中的知识,而TCP、UDP、SOCKET这些又全是计算机网络中的知识。学海无涯呀,各位大佬们好好加油吧。
测试代码:
参考文档:
https://wiki.swoole.com/#/learn?id=tcp数据包边界问题
https://www.zhihu.com/question/20210025
视频链接
微信文章地址:https://mp.weixin.qq.com/s/stmHXkl5zSl44f5P8RNUrA