swoole 粘包 EOF 包头+包尾

今天在跟硬件开发的小伙伴测试的时候发现了一个非常有趣的问题,即Client 每次请求,Server 端我返回相同信息,但是有时候会出现两次或多次返回的数据堆叠到一起的现象。开始我还以为我代码写的有问题,仔细看了一下代码没问题啊,终究还是要相信科学。

tcp 粘包
经我多方查证,原来我们的问题是 TCP 粘包问题,下面科普一下。

TCP 粘包

长连接和短连接

在讨论 TCP 粘包的问题前,我们先理解一下长连接和短连接。
长连接

Client 与 Server 先建立通讯连接,连接建立后不断开, 然后再进行数据发送和接收。

短连接

Client 与 Server 每进行一次数据收发交易时才进行通讯连接,交易完毕后立即断开连接。此种方式常用于一点对多点通讯,比如多个Client连接一个Server。

什么是 TCP 粘包

TCP 粘包是指发送方发送的若干包数据接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾

TCP 出现粘包的原因

  • 发送方:发送方需要等缓冲区满才发送出去,造成粘包
  • 接收方:接收方不及时接收缓冲区的包,造成多个包接收

什么时候需要处理粘包

不处理:

  • 短连接,每次发送数据,就与对方建立连接,然后双方发送完一段数据后,就关闭连接,这样就不会出现粘包问题。
  • 如果发送数据无结构,如文件传输被分成多个分组发送,也不用考虑粘包。

需处理:

双方建立连接,需要在连接后一段时间内发送不同结构数据,如连接后,有好几种结构:good morninggood evening,如果发送方连续发了两个包出去,接收方可能就回收到`good morninggood evening很明显不是我们想要,需要处理。

Swoole 处理粘包

既然了解了为什么会出现粘包现象和什么时候需要处理粘包,那下边就是想办法解决了。很显然我的需求得处理,很幸运 Swoole 已经提供了怎么解决粘包问题的方案。

EOF 结束协议

之前我在翻阅 Swoole 文档过程中,在看到 Server 配置选项的时候,看到过open_eof_checkopen_eof_splitpackage_eof…配置的时候,搂了两眼,感觉很深奥的样子,就一眼带过了。现在才知道它们是用来干嘛的。
EOF,即指每一个数据包的结尾加一个EOF标记,表示数据包的结束,但是如果你的数据本身含有EOF标记,那就会造成收到的数据包不完整,所以开启EOF支持后,应避免数据中含有EOF标记。
Server 的 open_eof_check设置为true时,打开EOF检测,此选项将检测客户端连接发来的数据,当数据包结尾是指定的EOF字符串时才会投递给Worker进程。否则会一直拼接数据包,直到超过缓存区或者超时才会中止。当出错时swoole底层会认为是恶意连接,丢弃数据并强制关闭连接。

1
2
3
4
$serv->set([
'open_eof_check' => true, //打开EOF检测
'package_eof' => "\r\n", //设置EOF
]);

常见的Memcache/SMTP/POP等协议都是以\r\n结束的,就可以使用以上配置。开启后可以保证Worker进程一次性总是收到一个或者多个完整的数据包。
虽然Swoole 已经帮我在服务端做了 EOF 结束协议,但是你不能保证客户端会一次性发过来几条数据,这样会出现一次性接受多个数据包的问题,因为EOF检测不会从数据中间查找eof字符串,所以Worker进程可能会同时收到多个数据包,需要在应用层代码中自行explode(“\r\n”, $data) 来拆分数据包。

1
2
3
4
5
6
7
8
9
10
11
public function onReceive($serv, $fd, $fromId, $data)
{
$datas = explode("\r\n", $data);
foreach ($datas as $data)
{
if(!$data){
continue;
}
echo "Server received data: {$data}" . PHP_EOL;
}
}

虽然我们可以自己拆分数据,但是Swoole1.7.15版本增加了open_eof_split,支持从数据中查找EOF,并切分数据,那我们何乐而不为呐?

1
2
3
4
5
$serv->set([
'open_eof_check' => true, //打开EOF检测
'package_eof' => "\r\n", //设置EOF
'open_eof_split' => true,
]);

配置成上面这样,我们就不用自己在应用层代码中自行 explode()了。

发送长度 固定包头和包尾

发送长度:发送每条数据的时候,将数据的长度一并发送,比如可以选择每条数据的前4位是数据的长度,应用层处理时可以根据长度来判断每条数据的开始和结束。
在这种协议下,我们的数据包的组成就是包头+包体。其中包头就是包体长度的二进制形式。比如我们本来想向服务端发送一段数据 “Just a test.” 共12个字符,现在我们要发送的数据就应该是这样的

1
pack('N', strlen("Just a test.")) . "Just a test."

pack()函数是将数据打包成二进制字符串,也就是我们的包头部分。
这样的话 Server 收到一个数据包(可能是多个完整的数据包)之后,会先解出包头指定的数据长度,然后按照这个长度取出后面的数据,如果一次性收到多个数据包,依次循环,如此就能保证Worker进程可以一次性收到一个完整的数据包。
Swoole 为我们提供一下几个配置选项:

  • open_length_check:打开包长检测特性
  • package_length_type:长度字段的类型,固定包头中用一个4字节或2字节表示包体长度,文章最后给出详细长度值的类型
  • package_length_offset:从第几个字节开始是长度,比如包头长度为120字节,第10个字节为长度值,这里填入9(从0开始计数)
  • package_body_offset:从第几个字节开始计算长度,比如包头为长度为120字节,第10个字节为长度值,包体长度为1000。如果长度包含包头,这里填入0,如果不包含包头,这里填入120
  • package_max_length:最大允许的包长度。因为在一个请求包完整接收前,需要将所有数据保存在内存中,所以需要做保护。避免内存占用过大。

具体配置就写这样:

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

数据处理部分为这样:

1
2
3
4
5
6
7
public function onReceive($serv, $fd, $fromId, $data)
{
$info = unpack('N', $data);
$len = $info[1];
$body = substr($data, - $len);
echo "server received data: {$body}\n";
}

完成这些,这种方案就算完成了。

package_length_type 长度值的类型

长度值的类型,接受一个字符参数,与php的pack函数一致。目前swoole支持10种类型:

  • c:有符号、1字节
  • C:无符号、1字节
  • s :有符号、主机字节序、2字节
  • S:无符号、主机字节序、2字节
  • n:无符号、网络字节序、2字节 (常用)
  • N:无符号、网络字节序、4字节 (常用)
  • l:有符号、主机字节序、4字节(小写L)
  • L:无符号、主机字节序、4字节(大写L)
  • v:无符号、小端字节序、2字节
  • V:无符号、小端字节序、4字节
坚持原创技术分享,您的支持将鼓励我继续创作!