在了解Swoole下如何处理粘包问题之前,我们需要先了解什么是“粘包”。我们以下面这张图进行普及:
假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下几种情况。
(1)服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;
(2)服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP粘包;
(3)服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP拆包;
(4)服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余内容D1_2和D2包的整包。
(5)如果此时服务端TCP接收滑窗非常小,而数据包D1和D2比较大,很有可能会发生第五种可能,即服务端分多次才能将D1和D2包接收完全,期间发生多次拆包。
归根到底,会发生粘包或半包的问题,原因在于服务端不知道客户端发过来的数据一个包有多大。所以兵来将挡,我们只需要让服务端知道每个业务层包的边界,事情就迎刃而解了。这里就需要 2 个操作来解决:
- 分包:Server 收到了多个数据包,需要拆分数据包
- 合包:Server 收到的数据只是包的一部分,需要缓存数据,合并成完整的包
就因为这个问题,所以 TCP 网络通信时需要设定通信协议。常见的 TCP 通用网络通信协议有 HTTP、HTTPS、FTP、SMTP、POP3、IMAP、SSH、Redis、Memcache、MySQL 。
除了通用协议外还可以自定义协议。重点来了:Swoole 支持了 2 种类型的自定义网络通信协议: EOF结束符协议 和 固定包头+包体协议。
1、EOF结束符协议
1)解决分包与合包
$server = new Swoole\Server('0.0.0.0', 9501);
$server->set([
'max_wait_time'=>60,
'reload_async'=>true,
'worker_num'=>1,
'task_worker_num'=>1,
'task_max_request'=>100,
'open_eof_split' => true,
'package_eof' => "abc",
]);
看上面最后两个的配置:
open_eof_split:启用 EOF 自动分包
这个参数启用后,底层会从数据包中间查找 EOF,并拆分数据包。onReceive 每次仅收到一个以 EOF 字串结尾的数据包。
package_eof:设置 EOF 字符串。
这个配置需要与与 open_eof_check 或者 open_eof_split 配合使用,并且最大只允许传入 8 个字节的字符串。
根据以上的配置进行实验:
客户端发送两个包,分别是包1:123abc456a,包2:bc789abc
服务端则触发了三个onReceive事件,收到的data分别是:123abc / 456abc / 789abc
是不是很神奇,swoole底层根据package_eof配置设置的结束符帮我们把客户端发过来的数据包进行分包与合包,让我们在onRecieve业务层拿到的数据都是完整的业务数据包。但是,方便的同时带来的代价就是效率较低。open_eof_split这个配置让swoole需要遍历整个数据包的内容,查找 EOF,因此会消耗大量 CPU 资源。假设每个数据包为 2M,每秒 10000 个请求,这可能会产生 20G 条 CPU 字符匹配指令。
为了解决这个问题,我们一般让swoole帮我们解决分包问题,合包由我们在业务代码层面进行处理。接下来,请大家往下阅读。
2)只解决分包
$server = new Swoole\Server('0.0.0.0', 9501);
$server->set([
'max_wait_time'=>60,
'reload_async'=>true,
'worker_num'=>1,
'task_worker_num'=>1,
'task_max_request'=>100,
'open_eof_check' => true,
'package_eof' => "abc",
]);
这里的两个红包配置,与上面不同的是这里用的是open_eof_check,而不是open_eof_split。
open_eof_check:打开 EOF 检测【默认值:false】
此选项将检测客户端连接发来的数据,当数据包结尾是指定的字符串时才会投递给 Worker 进程。否则会一直拼接数据包,直到超过缓存区或者超时才会中止。当出错时底层会认为是恶意连接,丢弃数据并强制关闭连接。
注意:此配置仅对 STREAM(流式的) 类型的 Socket 有效,如 TCP 、Unix Socket Stream。EOF 检测不会从数据中间查找 eof 字符串,所以 Worker 进程可能会同时收到多个数据包。
根据以上的配置进行实验:
客户端发送两个包,分别是包1:123abc456a,包2:bc789abc
服务端则触发了一个onReceive事件,收到的data是:123abc456abc789abc
看到与open_eof_split的区别没?我们在onRecieve收到的是多个业务数据包合并在一样的数据,swoole保证提交到业务层是一个或多个完整的数据包,我们需要在业务层用explode()进行拆分。虽然需要我们额外的拆分动作,但这一切都是值得的,因为swoole效率高出了好多,因为swoole只检查数据包结尾是否是指定的eof字符串。
2、固定包头+包体协议
固定包头的方法非常通用,在服务器端程序中经常能看到。这种协议的特点是一个数据包总是由包头 + 包体 2 部分组成。包头由一个字段指定了包体或整个包的长度,长度一般是使用 2 字节 /4 字节整数来表示。服务器收到包头后,可以根据长度值来精确控制需要再接收多少数据就是完整的数据包。Swoole 的配置可以很好的支持这种协议,可以灵活地设置 4 项参数应对所有情况。
$server = new Swoole\Server("0.0.0.0", 9501);
$server->set(array(
'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:打开包长检测特性【默认值:false】
长度检测协议,只需要计算一次长度,数据处理仅进行指针偏移,性能非常高,推荐使用。
pack_max_length:设置最大数据包尺寸,单位为字节。【默认值:2M 即 2 * 1024 * 1024,最小值为 64K】(此参数不宜设置过大,否则会占用很大的内存)
--open_length_check:当发现包长度超过 package_max_length,将直接丢弃此数据,并关闭连接,不会占用任何内存;
--open_eof_check:因为无法事先得知数据包长度,所以收到的数据还是会保存到内存中,持续增长。当发现内存占用已超过 package_max_length 时,将直接丢弃此数据,并关闭连接;
--open_http_protocol:GET 请求最大允许 8K,而且无法修改配置。POST 请求会检测 Content-Length,如果 Content-Length 超过 package_max_length,将直接丢弃此数据,发送 http 400 错误,并关闭连接;
package_length_type:长度值的类型,接受一个字符参数,与 PHP 的 pack 函数一致。
目前 Swoole 支持 10 种类型:
不知道大小端与网络字节序是什么意思?请看文末的高级话题科普:)
package_length_offset:length 长度值在包头的第几个字节。
package_body_offset:从第几个字节开始计算长度,一般有 2 种情况:
-length 的值包含了整个包(包头 + 包体),package_body_offset 为 0
-包头长度为 N 字节,length 的值不包含包头,仅包含包体,package_body_offset 设置为 N
-----------------高级话题分割线------------------
大小端与网络字节序
“大端”和”小端”表示多字节值的哪一端存储在该值的起始地址处;
小端存储在起始地址处,即是小端字节序;大端存储在起始地址处,即是大端字节序;
具体的说:
①大端字节序(Big Endian):最高有效位存于最低内存地址处,最低有效位存于最高内存处;
②小端字节序(Little Endian):最高有效位存于最高内存地址,最低有效位存于最低内存处。
如下图:当以不同的存储方式,存储数据为0x12345678时:
UDP/TCP/IP协议规定:把接收到的第一个字节当作高位字节看待,这就要求发送端发送的第一个字节是高位字节;而在发送端发送数据时,发送的第一个字节是该数值在内存中的起始地址处对应的那个字节,也就是说,该数值在内存中的起始地址处对应的那个字节就是要发送的第一个高位字节(即:高位字节存放在低地址处);由此可见,多字节数值在发送之前,在内存中因该是以大端法存放的。
所以说,网络字节序是大端字节序(划重点,请做好笔记)