今天在跟硬件开发的小伙伴测试的时候发现了一个非常有趣的问题,即Client 每次请求,Server 端我返回相同信息,但是有时候会出现两次或多次返回的数据堆叠到一起的现象。开始我还以为我代码写的有问题,仔细看了一下代码没问题啊,终究还是要相信科学。
经我多方查证,原来我们的问题是 TCP 粘包问题,下面科普一下。
TCP 粘包
长连接和短连接
在讨论 TCP 粘包的问题前,我们先理解一下长连接和短连接。长连接
Client 与 Server 先建立通讯连接,连接建立后不断开, 然后再进行数据发送和接收。
短连接
Client 与 Server 每进行一次数据收发交易时才进行通讯连接,交易完毕后立即断开连接。此种方式常用于一点对多点通讯,比如多个Client连接一个Server。
什么是 TCP 粘包
TCP 粘包是指发送方发送的若干包数据 到 接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。
TCP 出现粘包的原因
- 发送方:发送方需要等缓冲区满才发送出去,造成粘包
- 接收方:接收方不及时接收缓冲区的包,造成多个包接收
什么时候需要处理粘包
不处理:
- 短连接,每次发送数据,就与对方建立连接,然后双方发送完一段数据后,就关闭连接,这样就不会出现粘包问题。
- 如果发送数据无结构,如文件传输被分成多个分组发送,也不用考虑粘包。
需处理:
双方建立连接,需要在连接后一段时间内发送不同结构数据,如连接后,有好几种结构:
good morning
、good evening
,如果发送方连续发了两个包出去,接收方可能就回收到`good morninggood evening
很明显不是我们想要,需要处理。
Swoole 处理粘包
既然了解了为什么会出现粘包现象和什么时候需要处理粘包,那下边就是想办法解决了。很显然我的需求得处理,很幸运 Swoole 已经提供了怎么解决粘包问题的方案。
EOF 结束协议
之前我在翻阅 Swoole 文档过程中,在看到 Server 配置选项的时候,看到过open_eof_check
、open_eof_split
、package_eof
…配置的时候,搂了两眼,感觉很深奥的样子,就一眼带过了。现在才知道它们是用来干嘛的。
EOF,即指每一个数据包的结尾加一个EOF标记,表示数据包的结束,但是如果你的数据本身含有EOF标记,那就会造成收到的数据包不完整,所以开启EOF支持后,应避免数据中含有EOF标记。
Server 的 open_eof_check
设置为true
时,打开EOF检测,此选项将检测客户端连接发来的数据,当数据包结尾是指定的EOF字符串时才会投递给Worker进程。否则会一直拼接数据包,直到超过缓存区或者超时才会中止。当出错时swoole底层会认为是恶意连接,丢弃数据并强制关闭连接。
常见的Memcache/SMTP/POP等协议都是以\r\n
结束的,就可以使用以上配置。开启后可以保证Worker进程一次性总是收到一个或者多个完整的数据包。
虽然Swoole 已经帮我在服务端做了 EOF 结束协议,但是你不能保证客户端会一次性发过来几条数据,这样会出现一次性接受多个数据包的问题,因为EOF检测不会从数据中间查找eof字符串,所以Worker进程可能会同时收到多个数据包,需要在应用层代码中自行explode(“\r\n”, $data) 来拆分数据包。
虽然我们可以自己拆分数据,但是Swoole1.7.15版本增加了open_eof_split
,支持从数据中查找EOF,并切分数据,那我们何乐而不为呐?
配置成上面这样,我们就不用自己在应用层代码中自行 explode()
了。
发送长度 固定包头和包尾
发送长度:发送每条数据的时候,将数据的长度一并发送,比如可以选择每条数据的前4位是数据的长度,应用层处理时可以根据长度来判断每条数据的开始和结束。
在这种协议下,我们的数据包的组成就是包头+包体。其中包头就是包体长度的二进制形式。比如我们本来想向服务端发送一段数据 “Just a test.” 共12个字符,现在我们要发送的数据就应该是这样的
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,如果不包含包头,这里填入120package_max_length
:最大允许的包长度。因为在一个请求包完整接收前,需要将所有数据保存在内存中,所以需要做保护。避免内存占用过大。
具体配置就写这样:
数据处理部分为这样:
完成这些,这种方案就算完成了。
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字节