深入理解拆包/粘包

举报
大明哥 发表于 2024/11/19 22:52:24 2024/11/19
【摘要】 编解码技术是实现网络通信的根本,从这篇文章开始,大明哥将用三篇文章来彻底说清楚 Netty 的编解码技术。其实在 死磕 Java NIO 一文 【死磕 Java NIO】— 消息边界的处理 中大明哥就已深入分析了在 Java NIO 消息边界的问题以及相对应的解决方案,下面我们来看 Netty 的吧。 现象演示首先我们需要知道什么是拆包/粘包现象。假设客户端向服务端发送两个数据包,分别为 ...

编解码技术是实现网络通信的根本,从这篇文章开始,大明哥将用三篇文章来彻底说清楚 Netty 的编解码技术。其实在 死磕 Java NIO 一文 【死磕 Java NIO】— 消息边界的处理 中大明哥就已深入分析了在 Java NIO 消息边界的问题以及相对应的解决方案,下面我们来看 Netty 的吧。

现象演示

首先我们需要知道什么是拆包/粘包现象。

假设客户端向服务端发送两个数据包,分别为 package1 和 package2,这个时候服务端接收到客户端的数据有可能有如下四种情况。

  • 情况 1 :服务端正常接收 package1 和 package 2,这种属于正常情况。
  • 情况 2:服务端只接收到了一个 package,由于 TCP 保证送达的特性,所以这个 package 包含了客户端发送的两个 package ,这种现象属于粘包现象。如果客户端和服务端没有对应的协议来明确 package1 和 package2 的界限,那么服务端是无法区分 package1 和 package2 的。
  • 情况 3 :服务端可能会接收到 3 个 package,package1 可能会被拆分为 package1.1 和 package1.2 ,这种现象属于拆包现象。
  • 情况 4 :服务端接收到 2 个 package,但是这两个 package 都不为完整的,比如 package1 拆分成了 package1.1 和 package1.2 ,但是服务端接收的两个包为 package1.1 和 package1.2 + package2,这种情况是拆包和粘包的综合体。

下面大明哥通过两个例子来分别阐述 Netty 中的拆包/粘包现象。

粘包现象

从上面图中可以看出,拆包其实就是多个数据包合并成一个。所以我们只需要在客户端发送多个消息给服务端,看服务端是否是接收多次就可以了。

  • 服务端代码
public class StickyServer {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup(),new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                    }
                })
                .bind(8081);
    }
}

服务端没有多余的代码,只有一个 LoggingHandler 的 ChannelHandler,该 Handler 主要是用于打印服务端的日志情况。

  • 客户端代码
public class StickyClient {
    public static void main(String[] args) {
        new Bootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                            @Override
                            public void channelActive(ChannelHandlerContext ctx) throws Exception {
                                log.info("客户端连接成功,开始发送数据");
                                for (int i = 0 ; i < 10 ; i++) {
                                    ByteBuf byteBuf = ctx.alloc().buffer(16);
                                    byteBuf.writeBytes(new byte[]{0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15});
                                    ctx.channel().writeAndFlush(byteBuf);
                                }
                                log.info("数据已发送完成");
                            }
                        });
                    }
                }).connect("127.0.0.1",8081);

    }
}

客户端与服务端建立连接后,就向服务端发送 10 次消息,每次 16 byte。

  • 服务端日志

从服务端的运行日志中我们可以看出,客户端虽然发了 10 次,但是服务端只接收了一次,一次 160 byte,充分展示了粘包情况。

拆包现象

拆包就和粘包相反,它是将一个数据包拆分为多个数据包。

  • 服务端
public class UnpackServer {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup(),new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new StringDecoder());
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){

                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                System.out.println("服务端接收的内容:" + msg.toString());
                            }
                        });
                    }
                })
                .bind(8081);
    }
}

服务端就单纯地将客户端发送过来的消息打印即可。

  • 客户端
public class UnpackClient {
    public static void main(String[] args) {
        new Bootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new StringEncoder());
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                            @Override
                            public void channelActive(ChannelHandlerContext ctx) throws Exception {
                                for (int i = 0 ; i < 500 ; i++) {
                                    ctx.channel().writeAndFlush("大家好,我是大明哥,一个专注[死磕 Java] 的男人!!!\r\n");
                                }
                            }
                        });
                    }
                }).connect("127.0.0.1",8081);

    }
}

客户端建立连接后,向服务端发送 500 次消息“大家好,我是大明哥,一个专注[死磕 Java] 的男人!!!\r\n”

  • 运行结果

运行结果中有一段是乱码,消息也不是完整的,所以这里一定是不完整的,发生了拆包现象。

为什么会有拆包/粘包

客户端消息明明是一条一条地发,为什么会有拆包/粘包情况呢?TCP 是传输层协议,它并不了解我们应用层业务数据的含义,它会根据实际情况对数据包进行划分。所以在业务上我们认为是一个完整的数据包,可能会被 TCP 拆分为多个数据库报进行发送,也有可能会将多个数据包合并成一个数据包发送,这就会出现拆包/粘包的问题。

在网络通信的过程中,影响可以发送的数据包大小受很多因素限制,比如 MTU 传输单元大小、MSS 最大报文长度、滑动窗口。同时 TCP 也采用了 Nagle 算法对网络数据包进行了优化,所以要了解 TCP 为什么会有拆包/粘包问题,就需要了解这些概念。

MTU 最大传输单元

MTU(Maximum Transmission Unit),最大传输单元,是指网络能够传输的最大数据包大小,它决定了发送端一次能够发送报文的最大字节数。它是链路层协议,其最大值默认为 1500 byte。

MTU 是数据链路层对网络层的限制,最小为 46 byte,最大为 1500 byte,意思就是说网络层必须将发给网卡 API 的数据包大小控制在 1500 byte 以下,否则失败。

那为什么要有一个这样的限制呢?我们都知道网络中通常是以数据包为单位进行信息传输的,那么一次传输多大的数据包就决定了整个传输过程中的效率了,理论上数据包的大小设置为尽可能大,因为着有效的数据量就越大,传输的效率也就越高,但是传输一个数据包的延迟就会很大,而且数据包中 bite 位发送错误的概率也就越大,如果这个数据包丢失了,那么重传的代价就会很大。但是如果我们将数据包大小设置较小,那么我们传输的有效数据就会很小,传输效率就会比较低。所以我们就需要 MTU 来控制网络上传输数据包的大小,如果数据包大,我们就将其拆分,如果小,我们就把几个数据包进行合并,从而提供传输效率。

MSS 最大报文长度

MSS(Maximum Segment Size),最大报文长度,它表示 TCP payload 的最大值,它是 TCP 用来限制应用层发送的最大字节数。

我们知道了 MTU 限定了网络层往数据链路层发送数据包的大小,如果网络层发现一个数据包大于 MTU,那么它需要将其进行分片,切割成小于 MTU 的数据包,再将其发送到数据链路层。

一台主机上的所有应用都将数据包发往网络层,如果这些数据包太大了,则需要对其进行分片,但是这么多数据包都交给网络层来分片,是不是降低了效率?作为网络层,它的理想状态是,让 TCP 来的每一个数据包,大小都小于 MTU,这样它就不需要分片了。

MSS 是 TCP 协议定义的一个选项,是 TCP 用来限定应用层最大的发送字节数。它是在 TCP 连接建立时,双方进行约定的。当一个 TCP 连接建立时,连接的双方都需要通告各自的 MSS,以避免分片。

TCP 建立连接时,双方都需要根据 MTU 来计算各自的 MSS,计算规则如下:

MTU = IP Header(20) + TCP Header(20) + Data,MTU 默认最大值为 1500,所以 TCP 的有效数据 Data 的最大值为 1500 - 20 - 20 = 1460 ,这个值就是 MSS 的值。

MSS 的值是通过三次握手的方式告知对方的,互相确认对方的 MSS 值大小,取较小的那个作为 MSS。

  1. 客户端在发送的 SYN 报文中携带自己的 MSS(1300)。
  2. 服务端接收该报文后,取客户端的 MSS(1300) 和自己本地的 MSS (1200)中较小的那个值作为自己的 MSS(1200)。在回复的 SYN-ACK 中也携带自己的 MSS(1200)。
  3. 客户端收到该 SYN-ACK 后,取服务端的 MSS(1200)和自己本地的 MSS(1200)中较小的那个值作为客户端的 MSS(1200)。

Nagle 算法

Nagle 算法于 1984 年被福特航空和通信公司定义为 TCP/IP 拥塞控制方法。它主要用于解决频繁发送小数据包而带来的网络拥塞问题。

为了尽可能地利用网络带宽,TCP 总是希望能够发送足够大的数据包,由于有 MSS 的控制,所以它总是希望每次都能够以 MSS 的尺寸来发送数据。但是我们需要发送的数据并不会每次都有那么多字节,怎么办?攒着。Nagle 算法会在数据为得到确认之前会先将其写入到缓冲区中,等待数据确认或者缓冲区积攒到一定大小再把数据包发送出去。

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

Nagle 能够有效地降低网络开销,但是它会有一定的延时性,如果我们的业务系统对时延要求比较高的话,希望发出去的消息都能够尽快地响应,这个时候我们就需要关闭 Nagle 算法了。Netty 为了使数据传输延迟最小化,所以就默认禁用了 Nagle 算法。

在 Netty 中可以通过参数 ChannelOption.TCP_NODELAY 来开启和关闭 Nagle 算法。

总结

本篇大明哥详细分析了拆包/粘包问题,并分析了产生拆包/粘包的核心原因,我相信小伙伴一定有所收获。当然对于一些计算机网络方面的知识点,大明哥都是浅尝遏止,并没有深入,因为本系列的重点并不是计算机网络,而是 Netty,所以小伙伴如果对 MTU、MSS以及 Nagle 算法有兴趣的话,可以更加深入下,下面是大明哥在网上找了一些不错的文章,就分析给各位小伙伴了。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。