Netty 黏包半包分析

发布时间 2023-10-04 16:59:58作者: 沈自在。

Netty 黏包半包分析

1 具体现象

粘包

  • 现象,发送 abc def,接收 abcdef
  • 原因
    • 应用层:接收方 ByteBuf 设置太大(Netty 默认 1024)
    • 滑动窗口:假设发送方 256 bytes 表示一个完整报文,但由于接收方处理不及时且窗口大小足够大,这 256 bytes 字节就会缓冲在接收方的滑动窗口中,当滑动窗口中缓冲了多个报文就会粘包
    • Nagle 算法:会造成粘包

半包

  • 现象,发送 abcdef,接收 abc def
  • 原因
    • 应用层:接收方 ByteBuf 小于实际发送数据量
    • 滑动窗口:假设接收方的窗口只剩了 128 bytes,发送方的报文大小是 256 bytes,这时放不下了,只能先发送前 128 bytes,等待 ack 后才能发送剩余部分,这就造成了半包
    • MSS 限制:当发送的数据超过 MSS 限制后,会将数据切分发送,就会造成半包

本质是因为 TCP 是流式协议,消息无边界

那么究竟什么是滑动窗口呢?

滑动窗口

  • TCP 以一个段(segment)为单位,每发送一个段就需要进行一次确认应答(ack)处理,但如果这么做,缺点是包的往返时间越长性能就越差

  • 为了解决此问题,引入了窗口概念,窗口大小即决定了无需等待应答而可以继续发送的数据最大值

  • 窗口实际就起到一个缓冲区的作用,同时也能起到流量控制的作用

    • 图中深色的部分即要发送的数据,高亮的部分即窗口
    • 窗口内的数据才允许被发送,当应答未到达前,窗口必须停止滑动
    • 如果 1001~2000 这个段的数据 ack 回来了,窗口就可以向前滑动
    • 接收方也会维护一个窗口,只有落在窗口内的数据才能允许接收

MSS 限制

  • 链路层对一次能够发送的最大数据有限制,这个限制称之为 MTU(maximum transmission unit),不同的链路设备的 MTU 值也有所不同,例如

  • 以太网的 MTU 是 1500

  • FDDI(光纤分布式数据接口)的 MTU 是 4352

  • 本地回环地址的 MTU 是 65535 - 本地测试不走网卡

  • MSS 是最大段长度(maximum segment size),它是 MTU 刨去 tcp 头和 ip 头后剩余能够作为数据传输的字节数

  • ipv4 tcp 头占用 20 bytes,ip 头占用 20 bytes,因此以太网 MSS 的值为 1500 - 40 = 1460

  • TCP 在传递大量数据时,会按照 MSS 大小将数据进行分割发送

  • MSS 的值在三次握手时通知对方自己 MSS 的值,然后在两者之间选择一个小值作为 MSS

Nagle 算法

  • 即使发送一个字节,也需要加入 tcp 头和 ip 头,也就是总字节数会使用 41 bytes,非常不经济。因此为了提高网络利用率,tcp 希望尽可能发送足够大的数据,这就是 Nagle 算法产生的缘由
  • 该算法是指发送端即使还有应该发送的数据,但如果这部分数据很少的话,则进行延迟发送
    • 如果 SO_SNDBUF 的数据达到 MSS,则需要发送
    • 如果 SO_SNDBUF 中含有 FIN(表示需要连接关闭)这时将剩余数据发送,再关闭
    • 如果 TCP_NODELAY = true,则需要发送
    • 已发送的数据都收到 ack 时,则需要发送
    • 上述条件不满足,但发生超时(一般为 200ms)则需要发送
    • 除上述情况,延迟发送

2 几种解决办法

1)短连接

  1. 短链接,发一个包建立一次连接,这样连接建立到连接断开之间就是消息的边界,缺点效率太低
  2. 每一条消息采用固定长度,缺点浪费空间
  3. 每一条消息采用分隔符,例如 \n,缺点需要转义
  4. 每一条消息分为 head 和 body,head 中包含 body 的长度

注意:滑动窗口与缓冲区的设置

// 系统服务器的接受缓冲区 ---> 滑动窗口
serverBootstrap.option(ChannelOption.SO_RCVBUF,10);
// netty 的接受缓冲区 ---> ByteByf
serverBootstrap.childOption(ChannelOption.RCVBUF_ALLOCATOR,new AdaptiveRecvByteBufAllocator(16,16,16))

半包用这种办法还是不好解决,因为接收方的缓冲区大小是有限的

2)定长解码器

简而言之,就是发送方的消息要使用统一长度,这样即使在发送方产生黏包现象,也可以在接受方通过定长解码器进行解析。

那么,究竟是神魔是定长解码器呢?

在使用上比较简单,其实就是直接加一个 handler 就可以,但是要注意入站顺序。

源码

非常统一的继承了 ByteToMessageDecoder,这应该是所有解码器都会继承的一个类。但是值得一提的是该类再次继承了ChannelInboundHandlerAdapter,想必这个类大家非常的熟悉吧。

当客户端通过Channel发送数据的时候,服务端会在ByteToMessageDecoder的channelRead方法接收到。我们以channelRead方法为入口看服务端是怎么进行消息解码的。

在channelRead方法中首先会判断消息msg是不是ByteBuf类型的:

①、如果msg不是ByteBuf类型的,直接调用ctx.fireChannelRead(msg)方法,fireChannelRead方法实际上是调用pipeline管道的下一个Handler去继续处理消息其他的逻辑去了,我们进入到AbstractChannelHandlerContext的fireChannelRead方法,发现只有一个方法invokeChannelRead,寻找下一个绑定的Handler并且调用ChannelRead方法。

    public ChannelHandlerContext fireChannelRead(Object msg) {
        invokeChannelRead(this.findContextInbound(32), msg);
        return this;
    }

接着进去看 invokeChannelRead 方法

    static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg) {
        final Object m = next.pipeline.touch(ObjectUtil.checkNotNull(msg, "msg"), next);
        EventExecutor executor = next.executor();
        if (executor.inEventLoop()) {
            next.invokeChannelRead(m);
        } else {
            executor.execute(new Runnable() {
                public void run() {
                    next.invokeChannelRead(m);
                }
            });
        }

    }

最终都会调用invokeChannelRead,而invokeChannelRead就是将Handler转换成ChannelInboundHandler类型去执行具体的channelRead方法,去进一步的读取消息并处理相关Handler逻辑。

    private void invokeChannelRead(Object msg) {
        if (this.invokeHandler()) {
            try {
                ((ChannelInboundHandler)this.handler()).channelRead(this, msg);
            } catch (Throwable var3) {
                this.invokeExceptionCaught(var3);
            }
        } else {
            this.fireChannelRead(msg);
        }

    }

②、如果msg是ByteBuf类型的,去调用解码逻辑callDecode方法去解析数据。

啊啊啊啊啊,这里看不懂了,有朝一日再来些吧。

3)行解码器

大白话:按分隔符解码

这样的解码器有俩种:

  • LineBasedFrameDecoder ---> 回车或者换行(按平台区分)
  • DelimiterBasedFrameDecoder ----> 自定义分隔符

缺点,处理字符数据比较合适,但如果内容本身包含了分隔符(字节数据常常会有此情况),那么就会解析错误

4)LTC解码器

基于长度字段的解码器 -------> LengthFieldBasedFrameDecoder

上面用到的自定义长度解码器LengthFieldBasedFrameDecoder构造器,涉及5个参数,都与长度域(数据包中的长度字段)相关,具体介绍如下:

(1) maxFrameLength - 发送的数据包最大长度;

(2) lengthFieldOffset - 长度域偏移量,指的是长度域位于整个数据包字节数组中的下标;

(3) lengthFieldLength - 长度域的自己的字节数长度。

(4) lengthAdjustment – 长度域的偏移量矫正。 如果长度域的值,除了包含有效数据域的长度外,还包含了其他域(如长度域自身)长度,那么,就需要进行矫正。矫正的值为:包长 - 长度域的值 – 长度域偏移 – 长度域长。

(5) initialBytesToStrip – 丢弃的起始字节数。丢弃处于有效数据前面的字节数量。比如前面有4个节点的长度域,则它的值为4。

第一、第二、第三个参数比较简单,不再啰嗦。

第四个参数长度域的矫正值为 -4,为什么呢? 它计算的方法是:包长(X+2)- 长度域的值(X) – 长度域偏移(2) – 长度域长(4)= -4 。

这里假定长度域的值为X,那么包长为X+2。因为在这个例子中,长度域的值,已经包括了长度域的长度值。长度域值与整个包长度相比,就少了前面的Header的2个字节。按照公式进行计算,最终的值为 2-2-4 = -4 。

第五个参数丢弃的起始字节数为6,为什么呢? 因为,最终的有效的应用层数据,需要去掉前面的6个字节。其中,包括2个字节的Header,4个字节的长度域长。