Netty——TCP粘包、拆包

一、TCP 粘包和拆包基本介绍

TCP是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发给接收端的包,更有效的发给对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样做虽然提高了效率,但是接收端就难于分辨出完整的数据包了,因为面向流的通信是无消息保护边界的。

由于TCP无消息保护边界, 需要在接收端处理消息边界问题,也就是我们所说的粘包、拆包问题。

TCP粘包、拆包图解


假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下四种情况:

  • 1、服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包。
  • 2、服务端一次接受到了两个数据包,D1和D2粘合在一起,称之为TCP粘包。
  • 3、服务端分两次读取到了数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这称之为TCP拆包。
  • 4、服务端分两次读取到了数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余部分内容D1_2和完整的D2包。

特别要注意的是,如果TCP的接受滑窗非常小,而数据包D1和D2比较大,很有可能会发生第五种情况,即服务端分多次才能将D1和D2包完全接受,期间发生多次拆包。

二、 粘包、拆包发生原因

产生原因主要有这3种:滑动窗口、MSS/MTU限制、Nagle算法

1、滑动窗口

TCP流量控制主要使用滑动窗口协议,滑动窗口是接受数据端使用的窗口大小,用来告诉发送端接收端的缓存大小,以此可以控制发送端发送数据的大小,从而达到流量控制的目的。这个窗口大小就是我们一次传输几个数据。对所有数据帧按顺序赋予编号,发送方在发送过程中始终保持着一个发送窗口,只有落在发送窗口内的帧才允许被发送;同时接收方也维持着一个接收窗口,只有落在接收窗口内的帧才允许接收。这样通过调整发送方窗口和接收方窗口的大小可以实现流量控制。

现在来看一下滑动窗口是如何造成粘包、拆包的?

  • 粘包:假设发送方的每256 bytes表示一个完整的报文,接收方由于数据处理不及时,这256个字节的数据都会被缓存到SO_RCVBUF(接收缓存区)中。如果接收方的SO_RCVBUF中缓存了多个报文,那么对于接收方而言,这就是粘包。

  • 拆包:考虑另外一种情况,假设接收方的窗口只剩了128,意味着发送方最多还可以发送128字节,而由于发送方的数据大小是256字节,因此只能发送前128字节,等到接收方ack后,才能发送剩余字节。这就造成了拆包。

2、MSS和MTU分片

MSS:是Maximum Segement Size缩写,表示TCP报文中data部分的最大长度,是TCP协议在OSI五层网络模型中传输层对一次可以发送的最大数据的限制。

MTU:最大传输单元是Maxitum Transmission Unit的简写,是OSI五层网络模型中链路层(datalink layer)对一次可以发送的最大数据的限制。

当需要传输的数据大于MSS或者MTU时,数据会被拆分成多个包进行传输。由于MSS是根据MTU计算出来的,因此当发送的数据满足MSS时,必然满足MTU。

为了更好的理解,我们先介绍一下在5层网络模型中应用通过TCP发送数据的流程:


  • 对于应用层来说,只关心发送的数据DATA,将数据写入socket在内核中的发送缓冲区SO_SNDBUF即返回,操作系统会将SO_SNDBUF中的数据取出来进行发送。

  • 传输层会在DATA前面加上TCP Header,构成一个完整的TCP报文。

  • 当数据到达网络层(network layer)时,网络层会在TCP报文的基础上再添加一个IP Header,也就是将自己的网络地址加入到报文中。

  • 到数据链路层时,还会加上Datalink Header和CRC。

  • 当到达物理层时,会将SMAC(Source Machine,数据发送方的MAC地址),DMAC(Destination Machine,数据接受方的MAC地址 )和Type域加入。

可以发现数据在发送前,每一层都会在上一层的基础上增加一些内容,下图演示了MSS、MTU在这个过程中的作用。


MTU是以太网传输数据方面的限制,每个以太网帧都有最小的大小64bytes最大不能超过1518bytes。刨去以太网帧的帧头 (DMAC目的MAC地址48bit=6Bytes+SMAC源MAC地址48bit=6Bytes+Type域2bytes)14Bytes和帧尾 CRC校验部分4Bytes(这个部分有时候大家也把它叫做FCS),那么剩下承载上层协议的地方也就是Data域最大就只能有1500Bytes这个值 我们就把它称之为MTU。

由于MTU限制了一次最多可以发送1500个字节,而TCP协议在发送DATA时,还会加上额外的TCP Header和Ip Header,因此刨去这两个部分,就是TCP协议一次可以发送的实际应用数据的最大大小,也就是MSS。

MSS长度=MTU长度-IP Header-TCP Header

TCP Header的长度是20字节,IPv4中IP Header长度是20字节,IPV6中IP Header长度是40字节,因此:在IPV4中,以太网MSS可以达到1460byte;在IPV6中,以太网MSS可以达到1440byte。

需要注意的是MSS表示的一次可以发送的DATA的最大长度,而不是DATA的真实长度。发送方发送数据时,当SO_SNDBUF中的数据量大于MSS时,操作系统会将数据进行拆分,使得每一部分都小于MSS,这就是拆包,然后每一部分都加上TCP Header,构成多个完整的TCP报文进行发送,当然经过网络层和数据链路层的时候,还会分别加上相应的内容。

需要注意: 默认情况下,与外部通信的网卡的MTU大小是1500个字节。而本地回环地址的MTU大小为65535,这是因为本地测试时数据不需要走网卡,所以不受到1500的限制。

3、Nagle算法

TCP/IP协议中,无论发送多少数据,总是要在数据(DATA)前面加上协议头(TCP Header+IP Header),同时,对方接收到数据,也需要发送ACK表示确认。

即使从键盘输入的一个字符,占用一个字节,可能在传输上造成41字节的包,其中包括1字节的有用信息和40字节的首部数据。这种情况转变成了4000%的消耗,这样的情况对于重负载的网络来是无法接受的。

为了尽可能的利用网络带宽,TCP总是希望尽可能的发送足够大的数据。(一个连接会设置MSS参数,因此,TCP/IP希望每次都能够以MSS尺寸的数据块来发送数据)。

Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。

Nagle算法的基本定义是任意时刻,最多只能有一个未被确认的小段。 所谓“小段”,指的是小于MSS尺寸的数据块,所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的ACK确认该数据已收到。

Nagle算法的规则:

  • 1、如果SO_SNDBUF(发送缓冲区)中的数据长度达到MSS,则允许发送;
  • 2、如果该SO_SNDBUF中含有FIN,表示请求关闭连接,则先将SO_SNDBUF中的剩余数据发送,再关闭;
  • 3、设置了TCP_NODELAY=true选项,则允许发送。TCP_NODELAY是取消TCP的确认延迟机制,相当于禁用了Nagle 算法。
  • 4、未设置TCP_CORK选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送;
  • 5、上述条件都未满足,但发生了超时(一般为200ms),则立即发送。

三、通信协议

在了解了粘包、拆包产生的原因之后,现在来分析接收方如何对此进行区分。道理很简单,如果存在不完整的数据(拆包),则需要继续等待数据,直至可以构成一条完整的请求或者响应。

通过定义通信协议(protocol),可以解决粘包、拆包问题。协议的作用就定义传输数据的格式。这样在接受到的数据的时候:

  • 如果粘包了,就可以根据这个格式来区分不同的包。
  • 如果拆包了,就等待数据可以构成一个完整的消息来处理。

3.1、定长协议

定长协议:顾名思义,就是指定一个报文的必须具有固定的长度。例如,我们规定每3个字节,表示一个有效报文,如果我们分4次总共发送以下9个字节:

+---+----+------+----+ 
| A | BC | DEFG | HI | 
+---+----+------+----+ 

那么根据协议,我们可以判断出来,这里包含了3个有效的请求报文,如下:

+-----+-----+-----+ 
| ABC | DEF | GHI | 
+-----+-----+-----+ 

在定长协议中:

  • 发送方,必须保证发送报文长度是固定的。如果报文字节长度不能满足条件,如规定长度是1024字节,但是实际需要发送的内容只有900个字节,那么不足的部分可以补充0。因此定长协议可能会浪费带宽。
  • 接收方,每读取到固定长度的内容时,则认为读取到了一个完整的报文。

提示:Netty中提供了FixedLengthFrameDecoder,支持把固定的长度的字节数当做一个完整的消息进行解码。

3.2、特殊字符分隔符协议

在包尾部增加回车或者空格符等特殊字符进行分割 。例如,按行解析,遇到字符\n、\r\n的时候,就认为是一个完整的数据包。对于以下二进制字节流:

+--------------+ 
| ABC\nDEF\r\n | 
+--------------+ 

那么根据协议,我们可以判断出来,这里包含了2个有效的请求报文

+-----+-----+ 
| ABC | DEF | 
+-----+-----+ 

在特殊字符分隔符协议中:

  • 发送方,需要在发送一个报文时,需要在报文尾部添加特殊分割符号。
  • 接收方,在接收到报文时,需要对特殊分隔符进行检测,直到检测到一个完整的报文时,才能进行处理。

在使用特殊字符分隔符协议的时候,需要注意的是,我们选择的特殊字符,一定不能在消息体中出现,否则可能会出现错误的拆包。例如,发送方希望把”12\r\n34”,当成一个完整的报文,如果是按行拆分,那么就会错误的拆分为2个报文。一种解决策略是,发送方对需要发送的内容预先进行base64编码,由于base64编码只包含64个字符:0-9、a-z、A-Z、+、/,我们可以选择这64个字符之外的特殊字符作为分隔符。

提示:netty中提供了DelimiterBasedFrameDecoder根据特殊字符进行解码。事实上,我们熟悉的的缓存服务器redis,也是通过换行符来区分一个完整的报文。

3.3、变长协议

将消息区分为消息头和消息体,在消息头中,我们使用一个整形数字,例如一个int,来表示消息体的长度。而消息体实际实际要发送的二进制数据字节。以下是一个基本格式:

header    body 
+--------+----------+ 
| Length |  Content | 
+--------+----------+ 

在变长协议中:

  • 发送方,发送数据之前,需要先获取需要发送内容的二进制字节大小,然后在需要发送的内容前面添加一个整数,表示消息体二进制字节的长度。
  • 接收方,在解析时,先读取内容长度Length,其值为实际消息体内容(Content)占用的字节数,之后必须读取到这么多字节的内容,才认为是一个完整的数据报文。

提示:Netty中提供了LengthFieldPrepender给实际内容Content进行编码添加Length字段,接受方使用LengthFieldBasedFrameDecoder解码。

3.4、序列化

序列化本质上已经不是为了解决粘包和拆包问题,而是为了在网络开发中可以更加的便捷。在变长协议中,我们看到可以在实际要发送的数据之前加上一个length字段,表示实际要发送的数据的长度。这实际上给我们了一个很好的思路,我们完全可以将一个对象转换成二进制字节,来进行通信,例如使用一个Request对象表示请求,使用一个Response对象表示响应。

序列化框架有很多种,我们在选择时,主要考虑序列化/反序列化的速度,序列化占用的体积,多语言支持等。下面列出了业界流行的序列化框架:


提示:xml、json也属于序列化框架的范畴,上面的表格中并没有列出。

一些网络通信的RPC框架通常会支持多种序列化方式,例如dubbo支持hessian、json、kyro、fst等。在支持多种序列化框架的情况下,在协议中通常需要有一个字段来表示序列化的类型,例如,我们可以将上述变长协议的格式改造为:

+--------+-------------+------------+ 
| Length |  serializer |   Content  | 
+--------+-------------+------------+ 

这里使用1个字节表示Serializer的值,使用不同的值代表不同的框架。

发送方,选择好序列化框架后编码后,需要指定Serializer字段的值。

接收方,在解码时,根据Serializer的值选择对应的框架进行反序列化。

3.5、压缩

通常,为了节省网络开销,在网络通信时,可以考虑对数据进行压缩。常见的压缩算法有lz4、snappy、gzip等。在选择压缩算法时,我们主要考虑压缩比以及解压缩的效率。

我们可以在网络通信协议中,添加一个compress字段,表示采用的压缩算法:

+--------+-----------+------------+------------+ 
| Length | serializer|  compress  |   Content  | 
+--------+-----------+------------+------------+ 

通常,我们没有必要使用一个字节,来表示采用的压缩算法,1个字节可以标识256种可能情况,而常用压缩算法也就那么几种,因此通常只需要使用2~3个bit来表示采用的压缩算法即可。

另外,由于数据量比较小的时候,压缩比并不会太高,没有必要对所有发送的数据都进行压缩,只有再超过一定大小的情况下,才考虑进行压缩。如rocketmq,producer在发送消息时,默认消息大小超过4k,才会进行压缩。因此,compress字段,应该有一个值,表示没有使用任何压缩算法,例如使用0。

3.6、查错校验码

一些通信协议传输的数据中,还包含了查错校验码。典型的算法如CRC32、Adler32等。java对这两种校验方式都提供了支持,java.util.zip.Adler32、java.util.zip.CRC32。

+--------+-----------+------------+------------+---------+ 
| Length | serializer|  compress  |   Content  |  CRC32  | 
+--------+-----------+------------+------------+---------+ 

这里并不对CRC32、Adler32进行详细说明,主要是考虑,为什么需要进行校验?

有人说是因为考虑到安全,这个理由似乎并不充分,因为我们已经有了TLS层的加密,CRC32、Adler32的作用不应该是为了考虑安全。

一位同事的观点,我非常赞同:二进制数据在传输的过程中,可能因为电磁干扰,导致一个高电平变成低电平,或者低电平变成高电平。这种情况下,数据相当于受到了污染,此时通过CRC32等校验值,则可以验证数据的正确性。

另外,通常校验机制在通信协议中,是可选的配置的,并不需要强制开启,其虽然可以保证数据的正确,但是计算校验值也会带来一些额外的性能损失。如Mysql主从同步,虽然高版本默认开启CRC32校验,但是也可以通过配置禁用。

小结

本节通过一些基本的案例,讲解了在TCP编程中,如何通过协议来解决粘包、拆包问题。在实际开发中,通常我们的协议会更加复杂。例如,一些RPC框架,会在协议中添加唯一标识一个请求的ID,一些支持双向通信的RPC框架,如sofa-bolt,还会添加一个方向信息等。当然,所谓复杂,无非是在协议中添加了某个字段用于某个用途,只要弄清楚这些字段的含义,也就不复杂了。

参考:
https://www.cnblogs.com/Leo_wl/p/10297113.html

https://www.cnblogs.com/sidesky/p/6913109.html

https://blog.csdn.net/u022812849/article/details/107254239

https://www.jianshu.com/p/c90ec659397c

https://network.51cto.com/art/201910/604438.htm

你可能感兴趣的:(Netty——TCP粘包、拆包)