写点什么

【Netty】「优化进阶」(一)粘包半包问题及解决方案

作者:sidiot
  • 2023-06-20
    浙江
  • 本文字数:4431 字

    阅读完需:约 15 分钟

前言


本篇博文是《从 0 到 1 学习 Netty》中进阶系列的第一篇博文,主要内容是介绍粘包半包出现的现象和原因,并结合应用案例来深入讲解多种解决方案,往期系列文章请访问博主的 Netty 专栏,博文中的所有代码全部收集在博主的 GitHub 仓库中;


粘包现象


粘包是指多个独立的数据包被粘合在一起发送,接收端无法区分每个数据包的边界。例如,发送端要发送三个数据包 A、B 和 C,但它们被粘合在一起发送了,接收端收到的数据可能是 AB 或 ABC 等,需要额外的处理才能区分出每个数据包。


比如,通过下述代码客户端将向服务端发送十次消息:


for (int i = 0; i < 10; i++) {      ByteBuf buffer = ctx.alloc().buffer();      buffer.writeBytes("sidiot.".getBytes());      ctx.writeAndFlush(buffer);  }  
复制代码


但是服务端最终收到的结果却是如下所示:



按照理论来说,客户端发送了十次数据也就是十个包,服务端理应也收到十个包,但是,实际情况却是服务端只收到了一个包,并且十次数据都包含在其中。


半包现象


半包是指一个完整的数据包被拆分成了多个数据包进行发送,接收端只收到了部分数据包,无法还原完整的数据包。例如,发送端要发送一个数据包 D,但它被拆分成了两个数据包 D1 和 D2 进行发送,接收端只收到了 D1 或 D2,无法还原完整的数据包。


比如,通过下述代码客户端将向服务端一次性发送 70 字节的数据:


ByteBuf buffer = ctx.alloc().buffer();  for (int i = 0; i < 10; i++) {      buffer.writeBytes("sidiot.".getBytes());  }  ctx.writeAndFlush(buffer);
复制代码


服务端设置一个名为 SO_RCVBUF 的 TCP/IP 协议选项,SO_RCVBUF 是一个用于指定接收缓冲区大小的选项。接收缓冲区是操作系统内核用来存储接收到的数据的内存区域。当应用程序接收数据时,数据首先被写入到接收缓冲区中,然后应用程序再从缓冲区中读取数据进行处理。:


serverBootstrap.option(ChannelOption.SO_RCVBUF, 7);
复制代码


在这行代码中,选项的值被设置为 7,这意味着接收缓冲区的大小被限制为 7 字节,但这仅仅决定了 Netty 读取的最小单位,Netty 实际每次读取的字节大小一般是它的整数倍。并且这个值非常小,在实际情况下并不会使用这个选项设置这么小的缓冲区大小。如果接收缓冲区太小,那么可能会导致网络拥塞、丢包等问题。


运行结果:



从上述结果中,我们可以获知原先的 70 字节的数据包被拆分成了两个数据包,其大小分别为 14 字节和 56 字节,也都恰好是 7 的倍数。


分析原因


粘包半包是因为数据在网络传输过程中被拆分成多个数据块进行传输,但是接收端无法确定每个数据块的大小和边界,从而导致的问题。


具体来说,粘包现象发生是因为发送方将两个或多个数据包连续地发送到网络中,而接收方一次性读取了多个数据包,从而把它们看作一个数据包处理,造成了粘包的现象。


举个例子,假设发送方一个完整报文的大小为 52 字节,接收方的滑动窗口大小为 256 字节,由于接收方处理不及时且滑动窗口空闲大小足够大,这 52 字节的报文就会缓冲在接收方的滑动窗口中,当滑动窗口中缓冲了多个报文,就会发生粘包现象。


同时,如果在 Netty 中接收方将 ByteBuf 设置过大(默认为 1024)以及 Nagle 算法都会造成粘包现象。


半包现象则是指发送方将一个数据包分割成多个数据块进行传输,在接收方接收到部分数据块时就开始处理数据,从而只处理了部分数据信息,无法还原完整的数据包。


举个例子,假设接收方的滑动窗口大小只剩下 128 字节,而发送方一个完整报文的大小为 256 字节,这时,接收方的滑动窗口无法容纳发送方的全部报文,发送方只能先发送前 128 字节,等待 ACK 确认应答后才能发送剩余部分,这就造成了半包现象。


同时,如果在 Netty 中接收方将 ByteBuf 设置过小以及当发送的数据超过 MSS 的大小限制后,系统都会将数据切分发送,这就会造成半包现象。


这些问题通常由底层协议不正确或者网络拥塞等原因引起。为了解决这些问题,可以采用各种方法,如使用固定长度的数据包、在数据包中添加长度头等方式进行控制。


发生粘包与半包现象的本质是 TCP 是流式协议,消息无边界,想要进一步了解,可以阅读博主往期的博文:



解决方案

短链接


短链接是指客户端和服务器之间只建立一次连接,发送完数据后立即断开连接的通信方式。


在短链接通信中,每次发送的数据都与一个完整的消息边界对应,不需要使用滑动窗口等技术来缓冲数据,因此不会出现粘包现象。


然而,如果一次性发送的数据过多,接收方无法一次性容纳全部数据,仍然可能会出现半包现象。因此,短链接并不能完全解决半包问题


修改客户端的代码:


// 短链接:每次发送完毕就断开连接@Override  public void channelActive(ChannelHandlerContext ctx) throws Exception {      log.debug("sending...");      ByteBuf buffer = ctx.alloc().buffer();      buffer.writeBytes("sidiot.".getBytes());      ctx.writeAndFlush(buffer);      ctx.channel().close();  }
复制代码


然后将全部代码封装成一个方法,这里取名为 send(),并运行十次:


public static void main(String[] args) {      for (int i = 0; i < 10; i++) {          send();      }  }    private static void send() { ... }
复制代码


运行结果:



定长解码器


定长解码器指的是客户端和服务器之间进行数据传输时,双方事先约定一个最大长度。


客户端在发送数据时必须保证每次发送的数据长度都不会超过该最大长度,如果发送的数据长度不足,则需要进行补齐。


当服务器接收到数据时,会按照约定的最大长度进行拆分,即使在传输过程中出现了粘包的情况,也可以通过定长解码器将数据正确地拆分开来。这样可以保证数据在传输过程中的完整性和准确性,防止数据因为长度问题而被截断或拼接错误。


服务端需要用到 FixedLengthFrameDecoder 函数对数据进行定长解码,代码如下:


ch.pipeline().addLast(new FixedLengthFrameDecoder(10));
复制代码


FixedLengthFrameDecoder 是一个解码器,它的作用是将接收到的 ByteBuf 按照固定长度进行拆分,并将每个拆分出来的数据封装成一个新的 ByteBuf 对象。这样,无论原始数据包的长度如何,都可以保证每个新的数据包的长度是一致的。


举个例子,假设我们想要接收长度为 10 的固定长度数据包。那么当接收到一个长度为 20 的数据包时,FixedLengthFrameDecoder 会将其拆分成两个长度为 10 的数据包。而当接收到一个长度为 5 的数据包时,FixedLengthFrameDecoder 会暂存这个数据包,直到接收到下一个数据包,然后将这两个数据包拼接在一起再进行拆分。总之,只要接收到的数据包长度不足固定长度,FixedLengthFrameDecoder 就会等待更多数据的到来,直到达到固定长度为止。


根据上述例子,我们先测试拆分数据包,修改客户端代码如下:


@Override  public void channelActive(ChannelHandlerContext ctx) throws Exception {      for (int i = 0; i < 5; i++) {          ByteBuf buffer = ctx.alloc().buffer();          buffer.writeBytes("sidiot.".getBytes());          ctx.writeAndFlush(buffer);      }  }
复制代码


运行结果:



根据上述例子,我们再测试合并数据包,修改客户端代码如下:


@Override  public void channelActive(ChannelHandlerContext ctx) throws Exception {      for (int i = 0; i < 5; i++) {          ByteBuf buffer = ctx.alloc().buffer();          buffer.writeBytes("sidiot.".getBytes());          ctx.writeAndFlush(buffer);      }      Thread.sleep(1000);      ByteBuf buffer = ctx.alloc().buffer();      buffer.writeBytes("Next......".getBytes());      ctx.writeAndFlush(buffer);  }
复制代码


运行结果:



行解码器


行解码器指的是通过分隔符对数据进行拆分。


客户端在每个数据包的末尾添加一个特定的分隔符,比如回车换行符 \r\n,表示该数据包已经结束;而服务端则根据分隔符将接收到的数据进行拆分,以此恢复原始的数据包。


需要注意的是,分隔符的选取应当与数据本身的格式相适应,并且需要考虑到特殊字符的转义等问题,以避免出现误解析的情况。同时,行解码器只适用于传输文本数据,对于二进制数据需要采用其他的解决方案。


服务端通过 LineBasedFrameDecoder 函数来拆分以换行符 \n 为分隔符的数据,代码如下:


ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
复制代码


LineBasedFrameDecoder 是一个解码器,它的作用是将基于行的文本协议中的数据流分成一系列的帧。它会扫描缓冲区中的字节,直到找到行结束符号(例如 \n\r\n),然后将这一段数据作为一个完整的帧返回。使用 LineBasedFrameDecoder 解码器时,每个帧都被视为一个字符串对象,其中包含了行结束符以前的所有数据。


客户端代码如下:


public static StringBuilder makeString(char ch, int len) {      StringBuilder sb = new StringBuilder(len + 2);      for (int i = 0; i < len; i++) {          sb.append(ch);      }      sb.append("\n");      return sb;  }
@Override public void channelActive(ChannelHandlerContext ctx) { ByteBuf buffer = ctx.alloc().buffer(); char ch = 's'; Random r = new Random(); for (int i = 0; i < 5; i++) { StringBuilder sb = makeString(ch, r.nextInt(52)+1); ch++; buffer.writeBytes(sb.toString().getBytes()); } ctx.writeAndFlush(buffer); }
复制代码


客户端运行结果:



服务端运行结果:



当然,我们也可以使用函数 DelimiterBasedFrameDecoder 来自定义分割符,代码如下所示:


ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, false, Unpooled.wrappedBuffer("\\s".getBytes())));
复制代码


客户端运行结果:



服务端运行结果:



如果想要在结果中显示分割符,将 true 改为 false 就可以了,结果如下:



长度字段解码器



详见博主的另一篇博文 浅谈 LengthFieldBasedFrameDecoder:如何实现可靠的消息分割?

后记


虽然粘包半包问题是一个非常普遍的现象,但是我们可以通过多种方式来解决这个问题。其中最常见的方法是使用消息长度分隔符来标记每个消息的边界,以确保每个消息都可以独立处理。此外,我们还可以使用固定长度的消息来防止粘包和半包问题的发生。最后,我们还可以通过自定义协议来解决粘包半包问题,例如使用特殊字符来分隔不同的消息。


需要注意的是,不同的应用场景可能需要不同的解决方案。因此,在实际应用中,我们应该根据具体情况选择最适合的解决方案,以确保网络通信的稳定和可靠。


以上就是 粘包半包问题及解决方案 的所有内容了,希望本篇博文对大家有所帮助!


参考:


发布于: 刚刚阅读数: 3
用户头像

sidiot

关注

还未添加个人签名 2023-06-04 加入

还未添加个人简介

评论

发布
暂无评论
【Netty】「优化进阶」(一)粘包半包问题及解决方案_Java_sidiot_InfoQ写作社区