当Netty涉及网络IO数据传输时,可能会涉及到下面这些面试题:
- 什么是TCP粘包和拆包?为什么UDP不会出现这个问题?
- 发生粘包和拆包的原因是什么?
- Netty是如何解决TCP粘包和拆包的?
一、详解TCP粘包拆包问题
1. 问题复现
在正式讲解问题之前,我们先来看一段示例,查看TCP粘包和拆包问题是如何发生的,下面这两段代码分别是服务端配置和业务处理器,它会在与客户端建立连接之后,不断输出客户端发送的数据:
服务端业务处理器核心代码,逻辑也非常简单,收到消息后直接打印输出:
我们再来看看客户端的业务处理器和配置类,业务处理器的代码非常简单,在建立连接后连续发送1000条数据,数据内容为:hello Netty Server!:
而配置类也是固定模板:
将服务端和客户端启动后,我们可以看到下面这段输出,可以看到大量的hello Netty Server!数据粘在一起构成一个个粘包。
2. 原因剖析
在TCP编程中,在服务端与客户端通信时消息都会有固定的消息格式,这种格式我们通常称之为protocol即协议,例如我们常见的应用层协议:HTTP、FTP等。
而上述例子出现粘包的原因本质就是我们服务端与客户端进行通信时,没有确认协议的规范,因为TCP是面向连接、面向流的协议,它会因为各种原因导致完整的数据包被拆封无数个小的数据包进行发送,进而导致接收方收到数据后无法正确的处理数据,出现粘包和拆包:
而出现TCP数据包被拆分的原因大致有3个:
- socket缓冲区与滑动窗口
- nagle算法
- mss
先来说说socket缓冲区和滑动窗口的共同作用,我们都知道TCP是全双工、面向流的协议。这意味发送时必须要保证收发正常,所以TCP就提出了一个滑动窗口机制,即以滑动窗口的大小为单位,让双方基于这个窗口的大小进行数据收发,发送方只有在滑动窗口以内的数据才能被发送,接收方也只有在窗口以内的数据被接收和处理,只有接收方的滑动窗口收到发送方的数据,且处理完成并发送确认信号ACK之后,发送方的窗口才能继续向后移动:
由于TCP是面向流的协议,在此期间双方收发的数据也都会会存放到socket缓冲区中。这意味这连个缓冲区是无法知晓这些数据是否属于同一个数据包的。 同理socket缓冲区也分为发送缓冲区(SO_SNDBUF )和接收缓冲区(SO_RCVBUF),所有socket需要发送的数据也都是存放到socket的缓冲区中然后通过内核函数传到内核协议栈进行数据发送,socket接收缓冲区也是通过操作系统的内核函数将数据拷贝至socket缓冲区。
所以。socket缓冲区和滑动窗口机制共同作用下就会出现以下两种异常情况:
(1) 发送方发送的数据达到了滑动窗口的限制,停止发送,接收方的socket缓冲区拿到这些数据后,直接向应用层传输,因为包不是完整的,从接收方的角度来看,出现了拆包。
(2) 发送方发送多个数据包到接收方缓冲区,因为接收方socket缓冲区无法及时处理,导致真正开始处理时无法知晓数据包的边界,只能一次性将数据包向上传递,导致粘包。
再来说说Nagle算法,考虑到每次发送数据包时都需要为数据加上TCP Header20字节和IP header 20字节,以及还得等待发送方的ACK确认包,这就很可能出现下面这种非常浪费网络资源的情况:
为了1个字节的有用信息去组装10字节的头部信息!
对此,操作系统为了尽可能的利用网络带宽,就提出了Nagle算法,该算法要求所有已发送出去的小数据包(长度小于MSS)必须等到接收方的都回复ack信号之后,然后再将这些小数据段一并打包成一个打包发送,从而尽可能利用带宽及尽可能避免因为大量小的网络包的传输造成网络拥塞。
很明显如果将多个小的数据包合并发送,接收方也很可能因为无法确认数据包的边界而出现粘包或拆包问题:
最后就是mss,也就是Maximum Segement Size的缩写,代表传输一次性可以发送的数据最大长度,如果数据超过MSS的最大值,那么网络数据包就会被拆成多个小包发送,这种情况下也很可能因为零零散散的数据包发送而会出现粘包和拆包问题。
对此我们不妨通过WireShark进行抓包分析,基于服务端端口键入如下指令进行过滤:
启动客户端和服务端之后,发现双方交换得出的MSS远大于每次发送的数据大小,所以首先排除分包问题:
查看每次服务端发送的数据,无论大小还是内容都没有缺失,内核缓冲区空间也是充足的,所以原因很明显,因为TCP协议是面向流传输,接收方从内核缓冲区读取时,拿到了过多或者过少的数据导致粘包或拆包。
二、半包粘包的解决对策
1. 几种解决对策简介
其实上述的问题的原因都是因为TCP是面向流的协议,导致了数据包无法被正常切割成一个个正常数据包的流。就以上面的数据包为例,发送的数据为hello Netty Server!,其实我们做到下面这几种分割方式:
- 如果发送的数据都是以"!"结尾,那我们的分割时就判断收到的流是否包含"!",只有包含时再将数据装成数据包发送。
- 上述发送的数据长度为19,我们也可以规定发送的数据长度为19字节,一旦收到的数据达到19个字节之后,就组装成一个数据包。
- 自定义一个协议,要求发送方根据协议要求组装数据包发送,例如要求数据包包含长度length和data两个字段,其中length记录数据包长度,以上述数据为例,这个字段的值为19,而data包含的就是数据内容。
2. 基于分隔符的解码器DelimiterBasedFrameDecoder
先来看看基于分隔符的,可以看到每一个数据末尾都有一个感叹号,所以我们可以通过判断特殊符号完成数据拆包。
代码如下,我们基于DelimiterBasedFrameDecoder完成基于特殊分隔符进行拆包,每个参数对应含义为:
- 数据包最大长度。
- 解码时是否去掉分隔符。
- 分隔符。
启动之后可以看到问题也得以解决:
3. 基于数据长度的解码器FixedLengthFrameDecoder
同理,我们也可以基于数据长度,对数据包进行分割:
由上文可知,我们发送的数据长度都是19,所以第一种方案是在服务端的pipeline配置一个基于长度拆包的解码器,确保在每19个字节截取一次以确保数据包可以正确读取和解析。 所以我们在pipeline添加一个FixedLengthFrameDecoder,长度设置为19。
4. 基于协议长度字段的解码器LengthFieldBasedFrameDecoder
最后一种,也是笔者比较推荐的一种长度,即自定义协议,我们在传输过程中,可能数据的长度或者分隔符都无法保证,所以我们可以和客户端协商一下,在传输的数据头部添加一个数据包长度,例如用4字节表示数据包长度。
所以客户端建立连接后写数据的代码就改为:
最终的数据包结构如下图所示:
图片
而服务端的处理器则改为使用LengthFieldBasedFrameDecoder,构造方法如下:
按照对应参数含义为:
- maxFrameLength:数据包最大长度,这里我们设置为Integer.MAX_VALUE,等同于不限制。
- lengthFieldOffset:该数值代表获取描述数据包长度的字段的位置偏移量,以我们的数据包为例,就是0,即从最初始的位置读取长度。
- lengthFieldLength:描述数据包长度的字段的字节数,以我们的数据包为例就是4字节。
- lengthAdjustment:要添加到长度字段值的补偿值,这个字段比较有意思,我们还是举个例子说明,以下面这个数据包为例,假如我们需要得到data的数据,而长度记录的值为12字节(head+length+data),为了达到我们的预期即只取10字节的数据,我们就可以基于将这个字段的值设置为-2,将12减去10以得到实际的data数据长度。
对应的我们本次数据包长度记录的值没有错,这里直接直接设置为0,无需调整。
- initialBytesToStrip:读取时需要跳过数据包几个字节,以我们的数据包为例就说4,代表我们要跳过4字节的length字段,只要data的数据,对应的我们也给出下面这个构造方法:
于是我们就有了下面这样一个构造的解码器,再次进行压测后数据都是可以正常解析处理的:
5. 更多关于Netty内置解码器
设计者也在注释上为我们提供更多的使用案例,先来看看第一个示例,该数据包长度字段2字节,偏移量为0。假如我们希望读整个数据包,那么参数设置方式为:
- lengthFieldOffset即偏移量设置为0,即长度字段无需偏移就在数据包高位。
- lengthFieldLength为2,即读取2字节的数据,即可获得数据包长度。
- lengthAdjustment 为0,代表长度字段描述的数据就是后续数据的长度,无需调整。
- initialBytesToStrip 为0,即读取时从数据包最开始位置读取并加上长度字段里描述的长度的数据,无需跳过。
再来看看示例2,数据包和上文相同,只不过希望读取的数据不包含length字段,所以参数设置为:
- lengthFieldOffset即偏移量设置为0,即长度字段无需偏移就在数据包高位。
- lengthFieldLength为2,即读取2字节的数据,即可获得数据包长度。
- lengthAdjustment 为0,代表长度字段描述的数据就是后续数据的长度,无需调整。
- initialBytesToStrip 为2,即读取时从数据包起始位置开始,跳过2字节数据,即跳过length字段。
再来看看情况3,2字节长度描述长度,只不过该长度包含了描述长度的字段长度,即length的值为length字段长度2+后续HELLO, WORLD字符串长度为14。如果我们希望获取一个完整的数据包,那么参数就需要设置为:
- lengthFieldOffset即偏移量设置为0,即长度字段无需偏移就在数据包高位。
- lengthFieldLength为2,即读取2字节的数据,即可获得数据包长度。
- lengthAdjustment 为-2,代表长度字段描述的是整个包的长度,需要减去length字段的长度。
- initialBytesToStrip 为0,即读取时从数据包最开始位置读取并加上长度字段里描述的长度的数据,无需跳过。
示例4需要跳过header字段读取到长度字段,最后需要得到一个包含所有部分的数据包,所以参数如下:
- lengthFieldOffset即偏移量设置为2,即跳过Header 。
- lengthFieldLength为3,即读取3字节的数据,即可获得数据包长度。
- lengthAdjustment 为0,代表长度字段描述的是就是后续数据的长度,无需调整。
- initialBytesToStrip 为0,即读取时从数据包最开始位置读取并加上长度字段里描述的长度的数据,无需跳过。
示例5情况比较特殊,length描述后文数据的长度,却不包含后文header的长度,若我们希望获取到所有部分的数据包,则参数需要设置为:
- lengthFieldOffset即偏移量设置为0,即无需偏移,长度就在数据包高位。
- lengthFieldLength为3,即读取3字节的数据,即可获得数据包长度。
- lengthAdjustment 为2,即代表length字段仅仅记录的Actual Content的长度,length字段后面还有一个header的长度需要计算,故设置为2,意味实际长度要+2。
- initialBytesToStrip 为0,即读取时从数据包最开始位置读取并加上长度字段里描述的长度的数据,无需跳过。
示例6,长度在hdr1后面,并且最终读取的数据是hdr2和Actual Content。参数设置为:
- lengthFieldOffset即偏移量设置为1,即跳过HDR1。
- lengthFieldLength为2,即读取2字节的数据,即可获得数据包长度。
- lengthAdjustment 为1,即代表length字段仅仅记录的Actual Content的长度,length字段后面还有一个HDR2 的长度需要计算,故设置为1,意味实际长度要+1。
- initialBytesToStrip 为3,即跳过HDR1和length开始读取。
示例7即可Length记录的是整个包的长度,为了拿到HDR2和Actual Content的数据,对应参数设置如下:
- lengthFieldOffset即偏移量设置为1,即跳过HDR1。
- lengthFieldLength为2,即读取2字节的数据,即可获得数据包长度。
- lengthAdjustment 为-3,即代表减去HDR1和 LEN的字段长度。
- initialBytesToStrip 为3,即跳过HDR1和length开始读取。
三、小结
以上便是笔者对于Netty如何解决半包与粘包问题的源码解析与实践的全部内容,希望对你有帮助。