前言
什么是粘包拆包?
拆包和粘包是在socket编程中经常出现的情况,在socket通讯过程中,如果通讯的一端一次性连续发送多条数据包,tcp协议会将多个数据包打包成一个tcp报文发送出去,这就是所谓的粘包。而如果通讯的一端发送的数据包超过一次tcp报文所能传输的最大值时,就会将一个数据包拆成多个最大tcp长度的tcp报文分开传输,这就叫做拆包。
我们看一下下面这张图就知道了:
粘包拆包产生的原因
数据流在TCP协议下传播,因为协议本身对于流有一些规则的限制,这些规则会导致当前对端接收到的数据包不完整,归结原因有下面三种情况:
- Socket 缓冲区与滑动窗口
- MSS/MTU限制
- Nagle算法
1. Socket缓冲区与滑动窗口
对于 TCP 协议而言,它传输数据是基于字节流传输的。应用层在传输数据时,实际上会先将数据写入到 TCP 套接字的缓冲区,当缓冲区被写满后,数据才会被写出去。每个TCP Socket 在内核中都有一个发送缓冲区(SO_SNDBUF )和一个接收缓冲区(SO_RCVBUF),TCP 的全双工的工作模式以及 TCP 的滑动窗口便是依赖于这两个独立的 buffer 以及此 buffer 的填充状态。
SO_SNDBUF:
进程发送的数据的时候假设调用了一个 send 方法,将数据拷贝进入 Socket 的内核发送缓冲区之中,然后 send 便会在上层返回。换句话说,send 返回之时,数据不一定会发送到对端去(和write写文件有点类似),send 仅仅是把应用层 buffer 的数据拷贝进 Socket 的内核发送 buffer 中。
SO_RCVBUF:
把接收到的数据缓存入内核,应用进程一直没有调用 read 进行读取的话,此数据会一直缓存在相应 Socket 的接收缓冲区内。不管进程是否读取 Socket,对端发来的数据都会经由内核接收并且缓存到 Socket 的内核接收缓冲区之中。read 所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的 buffer 里面,仅此而已。
接收缓冲区保存收到的数据一直到应用进程读走为止。对于 TCP,如果应用进程一直没有读取,buffer 满了之后发生的动作是:通知对端 TCP 协议中的窗口关闭。这个便是滑动窗口的实现。保证 TCP 套接口接收缓冲区不会溢出,从而保证了 TCP 是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。 这就是 TCP 的流量控制,如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方 TCP 将丢弃它。
滑动窗口:
TCP连接在三次握手的时候,会将自己的窗口大小(window size)发送给对方,其实就是 SO_RCVBUF 指定的值。之后在发送数据的时,发送方必须要先确认接收方的窗口没有被填充满,如果没有填满,则可以发送。
每次发送数据后,发送方将自己维护的对方的 window size 减小,表示对方的 SO_RCVBUF 可用空间变小。
当接收方处理开始处理 SO_RCVBUF 中的数据时,会将数据从 Socket 在内核中的接受缓冲区读出,此时接收方的 SO_RCVBUF 可用空间变大,即 window size 变大,接受方会以 ack 消息的方式将自己最新的 window size 返回给发送方,此时发送方将自己的维护的接受的方的 window size 设置为ack消息返回的 window size。
此外,发送方可以连续的给接受方发送消息,只要保证对方的 SO_RCVBUF 空间可以缓存数据即可,即 window size>0。当接收方的 SO_RCVBUF 被填充满时,此时 window size=0,发送方不能再继续发送数据,要等待接收方 ack 消息,以获得最新可用的 window size。
由于发送端或者接收端的Socket大小动态变化,所以就有滑动窗口这个概念,进而报文并不会按照原先的分包来发送
2. MSS/MTU分片
MTU (Maxitum Transmission Unit,最大传输单元)是链路层对一次可以发送的最大数据的限制。
MSS(Maxitum Segment Size,最大分段大小)是 TCP 报文中 data 部分的最大长度,是传输层对一次可以发送的最大数据的限制。
数据在传输过程中,每经过一层,都会加上一些额外的信息:
- 应用层:只关心发送的数据 data,将数据写入 Socket 在内核中的缓冲区 SO_SNDBUF 即返回,操作系统会将 SO_SNDBUF 中的数据取出来进行发送;
- 传输层:会在 data 前面加上 TCP Header(20字节);
- 网络层:会在 TCP 报文的基础上再添加一个 IP Header,也就是将自己的网络地址加入到报文中。IPv4 中 IP Header 长度是 20 字节,IPV6 中 IP Header 长度是 40 字节;
- 链路层:加上 Datalink Header 和 CRC。会将 SMAC(Source Machine,数据发送方的MAC地址),DMAC(Destination Machine,数据接受方的MAC地址 )和 Type 域加入。SMAC+DMAC+Type+CRC 总长度为 18 字节;
- 物理层:进行传输。
在回顾这个基本内容之后,再来看 MTU 和 MSS。MTU 是以太网传输数据方面的限制,每个以太网帧最大不能超过 1518bytes。刨去以太网帧的帧头(DMAC+SMAC+Type域) 14Bytes 和帧尾 (CRC校验 ) 4 Bytes,那么剩下承载上层协议的地方也就是 data 域最大就只能有 1500 Bytes 这个值 我们就把它称之为 MTU。
MSS 是在 MTU 的基础上减去网络层的 IP Header 和传输层的 TCP Header 的部分,这就是 TCP 协议一次可以发送的实际应用数据的最大大小。
CopyMSS = MTU(1500) -IP Header(20 or 40)-TCP Header(20)
由于 IPV4 和 IPV6 的长度不同,在 IPV4 中,以太网 MSS 可以达到 1460byte。在 IPV6 中,以太网 MSS 可以达到 1440byte。
也就是说在发送TCP请求时,在每一层都会封装一些首部和尾部字段,如果按照一个请求只封装在一个包的话,那么就算一个字节的传输到最后也会变成 41 或者 61 个字节,浪费了网络带宽。所以一般会多个一起发。
3.Nagle 算法
由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下。
Nagle 算法的规则:
- 如果 SO_SNDBUF 中的数据长度达到 MSS,则允许发送;
- 如果该 SO_SNDBUF 中含有 FIN,表示请求关闭连接,则先将 SO_SNDBUF 中的剩余数据发送,再关闭;
- 设置了
TCP_NODELAY=true
选项,则允许发送。TCP_NODELAY 是取消 TCP 的确认延迟机制,相当于禁用了 Negale 算法。正常情况下,当 Server 端收到数据之后,它并不会马上向 client 端发送 ACK,而是会将 ACK 的发送延迟一段时间(一般是 40ms),它希望在 t 时间内 server 端会向 client 端发送应答数据,这样 ACK 就能够和应答数据一起发送,就像是应答数据捎带着 ACK 过去。当然,TCP 确认延迟 40ms 并不是一直不变的, TCP 连接的延迟确认时间一般初始化为最小值 40ms,随后根据连接的重传超时时间(RTO)、上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整。另外可以通过设置 TCP_QUICKACK 选项来取消确认延迟。 - 未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送。(总结就是是否要等更多数据再一起发)
- 上述条件都未满足,但发生了超时(一般为200ms),则立即发送。
Netty 对于粘包拆包的解决方案
首先我们先来了解一下TCP请求中是怎么向上交付的
首先我们客户端会先将要发送的数据进行编码,把要发送的数据变成网络中能传数的格式,然后传到服务器中,由服务器进行解码,变成我们能看得懂的数据,然后再由业务逻辑来处理。
举个例子:
比如我们用户 1 发送一个你好消息给 2 :
{
from:1,
to:2,
msg:你好
}
经过编码会变成一堆二进制的01,然后发送给服务端,那么问题来了,服务端怎么知道要怎么将数据进行区分呢?
答案:解码
Netty 的解决方案
为了解决TCP粘包/拆包导致的半包读写问题,Netty默认提供了多种编解码器用于处理半包,使其解决TCP粘包问题变得非常容易,主要有:
-
FixedLengthFrameDecoder(使用定长的报文来分包)
-
LineBasedFrameDecoder
-
DelimiterBasedFrameDecoder(添加特殊分隔符报文来分包)
-
LengthFieldBasedFrameDecoder
FixedLengthFrameDecoder
定长来区分,它能够按照指定的长度对消息进行自动解码,开发者不需要考虑TCP的粘包/拆包等问题,非常实用。
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 100)
.handler(new LoggingHandler(LogLevel.INFO))//配置日志输出
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch)
throws Exception {
ch.pipeline().addLast(new FixedLengthFrameDecoder(1<<5));
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());
ch.pipeline().addLast(new ServerHandler());
}
});
利用 FixedLengthFrameDecoder 解码器,无论一次接收到多少数据报,它都会按照构造函数中设置的固定长度进行解码,如果是半包消息,FixedLengthFrameDecoder 会缓存半包消息并等待下个包到达后进行拼包,直到读取到一个完整的包。但是大部分数据并不是定长的。
LineBasedFrameDecoder
LineBasedFrameDecoder的工作原理是它依次遍历ByteBuf中的可读字节,判断是否有
“\n”或“\r\n”
,如果有,就以此位置为结束位置,从可读索引到结束位置区间的字节就组成了一行。它是以换行符为结束标志的解码器,支持携带结束符或不携带结束符两种解码方式,同时支持配置单行的最大长度。如果连接读取到最大长度后仍然没有发现换行符,就会抛出异常,同时忽略掉之前读到的异常码流。防止由于数据报没有携带换行符导致接收到 ByteBuf 无限制积压,引起系统内存溢出。通常LineBasedFrameDecoder会和StringDecoder搭配使用。StringDecoder的功能非常简单,就是将接收到的对象转换成字符串,然后继续调用后面的Handler。LineBasedFrameDecoder+StringDecoder组合就是按行切换的文本解码器,它本设计用来支持TCP的粘包和拆包。对于文本类协议的解析,文本换行解码器非常实用,例如对 HTTP 消息头的解析、FTP 协议消息的解析等。
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new LineBasedFrameDecoder(1024));
p.addLast(new StringDecoder());
p.addLast(new StringEncoder());
p.addLast(new LineServerHandler());
}
});
DelimiterBasedFrameDecoder
DelimiterBasedFrameDecoder是分隔符解码器,用户可以指定消息结束的分隔符,它可以自动完成以分隔符作为码流结束标识的消息的解码。回车换行解码器实际上是一种特殊的DelimiterBasedFrameDecoder解码器。和LineBasedFrameDecoder差不多
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new DelimiterBasedFrameDecoder(1024, Unpooled.copiedBuffer(Constants.DELIMITER.getBytes())));
p.addLast(new StringDecoder());
p.addLast(new StringEncoder());
p.addLast(new DelimiterServerHandler());
}
});
DelimiterBasedFrameDecoder 原理分析:解码时,判断当前已经读取的 ByteBuf 中是否包含分隔符 ByteBuf,如果包含,则截取对应的 ByteBuf 返回,源码如下:
LengthFieldBasedFrameDecoder
LengthFieldBasedFrameDecoder
相对就高端一点。前面我们使用到的拆包都是基于一些约定来做的,比如固定长度,特殊分隔符,这些方案总是有一定的弊端。最好的方案就是:发送方告诉我当前消息总长度,接收方如果没有收到该长度大小的数据就认为是没有收完继续等待。
里面有四个参数:
比如:
使用以下参数组合进行解码:
- lengthFieldOffset = 0;从第几开始算起
- lengthFieldLength = 2;几个字节代表长度
- lengthAdjustment = 0;多长是要拒绝的
总长度**,接收方如果没有收到该长度大小的数据就认为是没有收完继续等待。