网络通信时,如何解决粘包/半包、丢包或者包乱序的问题?
- 如果是 TCP 协议,面向连接(经历三次握手和四次挥手)、传输可靠((保证数据正确性,保证数据顺序)),在大多数场景下,是不存在丢包和包乱序问题的,因为 TCP 通信是可靠通信方式,TCP 协议栈通过
序列号和包重传应答确认机制
保证数据包的有序和一定被正确发到目的地; - 如果是 UDP 协议,面向非连接、传输不可靠(丢包[数据丢失])。如果不能接受少量丢包,那就要自己在 UDP 的基础上实现类似 TCP 这种有序和可靠传输机制了(例如 RTP协议、RUDP 协议)。
所以,对于 TCP 协议来说,我们需要关注的是如何粘包/半包问题。
说明一下,半包
:半包不是说只收到了全包的一半,而是说收到了全包的一部分,有时我们叫拆包。
应用A 通过网络发送数据向应用B 发送消息,大概会经过如下阶段:
- 阶段一:应用A 把流数据发送到 TCP发送缓冲区。
- 阶段二:TCP发送缓冲区把数据发送到达 B服务器 TCP接收缓冲区。
- 阶段三:应用B 从 TCP接收缓冲区读取流数据。
假设应用A 分别发送了两个数据包 D1 和 D2 给应用B,由于应用B一次读取到的字节数是不确定的,故可能存在以下 4 种情况。
(1)应用B 分两次读取到了两个独立的数据包,分别是 D1 和 D2,没有粘包和拆包;
(2)应用B 一次接收到了两个数据包,D1 和 D2 粘合在一起,被称为 TCP 粘包;
(3)应用B 分两次读取到了两个数据包,第一次读取到了完整的 D1 包和 D2 包的部分内容,第二次读取到了 D2 包的剩余内容,这被称为 TCP 拆包;
(4)应用B 分两次读取到了两个数据包,第一次读取到了 D1 包的部分内容 D1_1,第二次读取到了 D1 包的剩余内容 D1_2 和 D2 包的整包,这被称为 TCP 拆包;
由于 TCP 协议本身的机制,它会存在 TCP 粘包/半包问题。
TCP发送数据原由:
- 因为TCP本身传输的数据包大小就有限制,所以应用发出的消息包过大,TCP会把应用消息包拆分为多个TCP数据包发送出去。
- Negal算法的优化,当应用发送数据包太小,TCP为了减少网络请求次数的开销,它会等待多个消息包一起,打成一个TCP数据包一次发送出去。
TCP接收方的原由:
- 因为TCP缓冲区里的数据都是字符流的形式,没有明确的边界,因为数据没边界,所以应用从TCP缓冲区中读取数据时就没办法指定一个或几个消息一起读,而只能选择一次读取多大的数据流,而这个数据流中就可能包含着某个消息包的一部分数据。
粘包的主要原因:
- 发送方每次写入数据 < 套接字(Socket)缓冲区大小
- 接收方读取套接字(Socket)缓冲区数据不够及时
半包的主要原因:
- 发送方每次写入数据 > 套接字(Socket)缓冲区大小
- 发送的数据大于协议的 MTU (Maximum Transmission Unit,最大传输单元),因此必须拆包。
其实我们可以换个角度看待问题:
- 从收发的角度看,便是一个发送可能被多次接收,多个发送可能被一次接收。
- 从传输的角度看,便是一个发送可能占用多个传输包,多个发送可能共用一个传输包。
根本原因,其实是:
- TCP 是流式协议,消息无边界。 (PS : UDP 虽然也可以一次传输多个包或者多次传输一个包,但每个消息都是有边界的,因此不会有粘包和半包问题。)
就像上面说的,UDP 之所以不会产生粘包和半包问题,主要是因为消息有边界,因此,我们也可以采取类似的思路。
解决问题的根本手段:找出消息的边界。
2.1 改成短连接将 TCP 连接改成短连接,一个请求一个短连接。这样的话,建立连接到释放连接之间的消息即为传输的信息,消息也就产生了边界。
这样的方法就是十分简单,不需要在我们的应用中做过多修改。但缺点也就很明显了,效率低下,TCP 连接和断开都会涉及三次握手以及四次握手,每个消息都会涉及这些过程,十分浪费性能。
因此,并不推荐这种方式。
2.2 封装成帧封装成帧(Framing),也就是原本发送消息的单位是缓冲大小,现在换成了帧,这样我们就可以自定义边界了。
一般有4种方式:
2.2.1 固定长度这种方式下,消息边界也就是固定长度即可。
优点就是实现很简单,缺点就是空间有极大的浪费,如果传递的消息中大部分都比较短,这样就会有很多空间是浪费的。
因此,这种方式一般也是不推荐的。
2.2.2 分隔符这种方式下,消息边界也就是分隔符本身。
优点是空间不再浪费,实现也比较简单。缺点是当内容本身出现分割符时需要转义,所以无论是发送还是接受,都需要进行整个内容的扫描。
因此,这种方式效率也不是很高,但可以尝试使用。
2.2.3 专门的 length 字段这种方式,就有点类似 Http 请求中的 Content-Length,有一个专门的字段存储消息的长度。作为服务端,接受消息时,先解析固定长度的字段(length字段)获取消息总长度,然后读取后续内容。
优点是精确定位用户数据,内容也不用转义。缺点是长度理论上有限制,需要提前限制可能的最大长度从而定义长度占用字节数。
因此,十分推荐用这种方式。
2.2.4 其他方式其他方式就各不相同了,比如 JSON 可以看成是使用{}是否成对。这些优缺点就需要大家在各自的场景中进行衡量了。
二、Netty 中的实现 Netty 中解决粘包半包由于底层的 TCP 无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案。
Netty 支持上面所讲的封装成帧(Framing)中的前三种方式,简单介绍下:
FixedLengthFrameDecoder
采用的是定长协议:即把固定的长度的字节数当做一个完整的消息。
FixedLengthFrameDecodert提供了以下构造方法:
public FixedLengthFrameDecoder(int frameLength) {
ObjectUtil.checkPositive(frameLength, "frameLength");
this.frameLength = frameLength;
}
- frameLength参数:我们指定的消息长度。
注意:FixedLengthFrameDecoder并没有提供一个对应的编码器,因为接收方只需要根据字节数进行判断即可,发送方无需编码。
例如:我们规定每个报文的大小为固定长度 5个字节,表示一个有效报文,如果不够,空位补空格;
1)服务端
bootstrap.group(bossGroup, workerGroup)
...
.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline()
.addLast(new FixedLengthFrameDecoder(5)) //固定长度 5个字节
.addLast(new FixedLengthServerHandler());
}
});
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 获取客户端发送过来的消息
ByteBuf in = (ByteBuf) msg;
String request = in.toString(CharsetUtil.UTF_8);
System.out.println("Server Accept[" + request + "] and the counter is:" + counter.incrementAndGet());
ctx.writeAndFlush(Unpooled.copiedBuffer("Welcome to Netty!".getBytes()));
}
2)客户端
bootstrap.group(eventExecutors)/*将线程组传入*/
...
.handler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline()
.addLast(new FixedLengthFrameDecoder(5)) //固定长度 5个字节
.addLast(new FixedLengthClientHandler());
}
});
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
// 接收服务端发送过来的消息
System.out.println("client Accept[" + msg.toString(CharsetUtil.UTF_8) + "] and the counter is:" + counter.incrementAndGet());
}
/**
* 客户端被通知channel活跃后 channel活跃后,做业务处理
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 发送消息到服务端
ByteBuf in1 = Unpooled.buffer().writeBytes("CHARGE".getBytes());
ByteBuf in2 = Unpooled.buffer().writeBytes(" and ".getBytes());
ByteBuf in3 = Unpooled.buffer().writeBytes("ABCDEFGH".getBytes());
ctx.writeAndFlush(in1);
ctx.writeAndFlush(in2);
ctx.writeAndFlush(in3);
}
3)先启动服务端,再启动客户端,结果如下:
在包尾增加分割符,比如回车换行符进行分割,例如 FTP 协议;
2.1 回车换行符进行分割LineBasedFrameDecoder
采用的通信协议格式非常简单:使用换行符\n或者\r\n
作为依据,遇到\n或者\r\n都认为是一条完整的消息。
LineBasedFrameDecoder提供了2个构造方法,如下:
public LineBasedFrameDecoder(int maxLength) {
this(maxLength, true, false);
}
public LineBasedFrameDecoder(int maxLength, boolean stripDelimiter, boolean failFast) {
this.maxLength = maxLength;
this.failFast = failFast;
this.stripDelimiter = stripDelimiter;
}
其中:
- maxLength: 表示一行最大的长度,如果超过这个长度依然没有检测到\n或者\r\n,将会抛出TooLongFrameException
- failFast: 与maxLength联合使用,表示超过maxLength后,抛出TooLongFrameException的时机。如果为true,则超出maxLength后立即抛出TooLongFrameException,不继续进行解码;如果为false,则等到完整的消息被解码后,再抛出TooLongFrameException异常。
- stripDelimiter: 解码后的消息是否去除\n,\r\n分隔符。
1)服务端
bootstrap.group(bossGroup, workerGroup)/*将线程组传入*/
...
.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//回车换行符
socketChannel.pipeline()
.addLast(new LineBasedFrameDecoder(1024))
.addLast(new LineBaseServerHandler());
}
});
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 获取客户端发送过来的消息
ByteBuf in = (ByteBuf) msg;
String request = in.toString(CharsetUtil.UTF_8);
System.out.println("Server Accept[" + request + "] and the counter is:" + counter.incrementAndGet());
String resp = "Hello," + request + ". Welcome to Netty World!" + System.getProperty("line.separator");
ctx.writeAndFlush(Unpooled.copiedBuffer(resp.getBytes()));
}
2)客户端
bootstrap.group(eventExecutors)/*将线程组传入*/
...
.handler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//回车换行符
socketChannel.pipeline()
.addLast(new LineBasedFrameDecoder(1024))
.addLast(new LineBaseClientHandler());
}
});
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 发送消息到服务端
ByteBuf msg = null;
String request = "charge LineBasedFrameDecoder(1024),回车换行符" + System.getProperty("line.separator");
for (int i = 0; i
关注
打赏
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?