深入了解Netty【八】TCP拆包、粘包和解决方案


1、TCP协议传输过程

TCP协议是面向流的协议,是流式的,没有业务上的分段,只会根据当前套接字缓冲区的情况进行拆包或者粘包:


发送端的字节流都会先传入缓冲区,再通过网络传入到接收端的缓冲区中,最终由接收端获取。

2、TCP粘包和拆包概念

因为TCP会根据缓冲区的实际情况进行包的划分,在业务上认为,有的包被拆分成多个包进行发送,也可能多个晓小的包封装成一个大的包发送,这就是TCP的粘包或者拆包。

3、TCP粘包和拆包图解

假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下几种情况:

  1. 服务端分两次读取到两个独立的数据包,分别是D1和D2,没有粘包和拆包。
  2. 服务端一次接收到了两个数据包,D1和D2粘在一起,发生粘包。
  3. 服务端分两次读取到数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,发生拆包。
  4. 服务端分两次读取到数据包,第一次读取到部分D1包,第二次读取到剩余的D1包和全部的D2包。

当TCP缓存再小一点的话,会把D1和D2分别拆成多个包发送。

4、TCP粘包和拆包解决策略

因为TCP只负责数据发送,并不处理业务上的数据,所以只能在上层应用协议栈解决,目前的解决方案归纳:

  1. 消息定长,每个报文的大小固定,如果数据不够,空位补空格。
  2. 在包的尾部加回车换行符标识。
  3. 将消息分为消息头与消息体,消息头中包含消息总长度。
  4. 设计更复杂的协议。

5、Netty中的解决办法

Netty提供了多种默认的编码器解决粘包和拆包:


5.1、LineBasedFrameDecoder

基于回车换行符的解码器,当遇到"\n"或者 "\r\n"结束符时,分为一组。支持携带结束符或者不带结束符两种编码方式,也支持配置单行的最大长度。
LineBasedFrameDecoder与StringDecoder搭配时,相当于按行切换的文本解析器,用来支持TCP的粘包和拆包。
使用例子:

private void start() throws Exception {
        //创建 EventLoopGroup
        NioEventLoopGroup group = new NioEventLoopGroup();
        NioEventLoopGroup work = new NioEventLoopGroup();
        try {
            //创建 ServerBootstrap
            ServerBootstrap b = new ServerBootstrap();
            b.group(group, work)
                    //指定使用 NIO 的传输 Channel
                    .channel(NioServerSocketChannel.class)
                    //设置 socket 地址使用所选的端口
                    .localAddress(new InetSocketAddress(port))
                    //添加 EchoServerHandler 到 Channel 的 ChannelPipeline
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) {
                            ChannelPipeline p = ch.pipeline();
                            p.addLast(new LineBasedFrameDecoder(1024));
                            p.addLast(new StringDecoder());
                            p.addLast(new StringEncoder());
                            p.addLast(new EchoServerHandler());
                        }
                    });
            //绑定的服务器;sync 等待服务器关闭
            ChannelFuture f = b.bind().sync();
            System.out.println(EchoServer.class.getName() + " started and listen on " + f.channel().localAddress());
            //关闭 channel 和 块,直到它被关闭
            f.channel().closeFuture().sync();
        } finally {
            //关机的 EventLoopGroup,释放所有资源。
            group.shutdownGracefully().sync();
        }
    }

注意ChannelPipeline 中ChannelHandler的顺序,

5.2、DelimiterBasedFrameDecoder

分隔符解码器,可以指定消息结束的分隔符,它可以自动完成以分隔符作为码流结束标识的消息的解码。回车换行解码器实际上是一种特殊的DelimiterBasedFrameDecoder解码器。
使用例子(后面的代码只贴ChannelPipeline部分):

ChannelPipeline p = ch.pipeline();
p.addLast(new DelimiterBasedFrameDecoder(1024, Unpooled.copiedBuffer("制定的分隔符".getBytes())));
p.addLast(new StringDecoder());
p.addLast(new StringEncoder());
p.addLast(new EchoServerHandler());

5.3、FixedLengthFrameDecoder

固定长度解码器,它能够按照指定的长度对消息进行自动解码,当制定的长度过大,消息过短时会有资源浪费,但是使用起来简单。

 ChannelPipeline p = ch.pipeline();
p.addLast(new FixedLengthFrameDecoder(1 << 5));
p.addLast(new StringDecoder());
p.addLast(new StringEncoder());
p.addLast(new EchoServerHandler());

5.4、LengthFieldBasedFrameDecoder

通用解码器,一般协议头中带有长度字段,通过使用LengthFieldBasedFrameDecoder传入特定的参数,来解决拆包粘包。
io.netty.handler.codec.LengthFieldBasedFrameDecoder的实例化:

    /**
     * Creates a new instance.
     *
     * @param maxFrameLength      最大帧长度。也就是可以接收的数据的最大长度。如果超过,此次数据会被丢弃。
     * @param lengthFieldOffset   长度域偏移。就是说数据开始的几个字节可能不是表示数据长度,需要后移几个字节才是长度域。
     * @param lengthFieldLength   长度域字节数。用几个字节来表示数据长度。
     * @param lengthAdjustment    数据长度修正。因为长度域指定的长度可以是header+body的整个长度,也可以只是body的长度。如果表示header+body的整个长度,那么我们需要修正数据长度。
     * @param initialBytesToStrip 跳过的字节数。如果你需要接收header+body的所有数据,此值就是0,如果你只想接收body数据,那么需要跳过header所占用的字节数。
     * @param failFast            如果为true,则在解码器注意到帧的长度将超过maxFrameLength时立即抛出TooLongFrameException,而不管是否已读取整个帧。
     *                            如果为false,则在读取了超过maxFrameLength的整个帧之后引发TooLongFrameException。
     */
    public LengthFieldBasedFrameDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength,
                                        int lengthAdjustment, int initialBytesToStrip, boolean failFast) {
        //略
    }
  • maxFrameLength
    最大帧长度。也就是可以接收的数据的最大长度。如果超过,此次数据会被丢弃。
  • lengthFieldOffset
    长度域偏移。就是说数据开始的几个字节可能不是表示数据长度,需要后移几个字节才是长度域。
  • lengthFieldLength
    长度域字节数。用几个字节来表示数据长度。
  • lengthAdjustment
    数据长度修正。因为长度域指定的长度可以是header+body的整个长度,也可以只是body的长度。如果表示header+body的整个长度,那么我们需要修正数据长度。
  • initialBytesToStrip
    跳过的字节数。如果你需要接收header+body的所有数据,此值就是0,如果你只想接收body数据,那么需要跳过header所占用的字节数。
  • failFast
    如果为true,则在解码器注意到帧的长度将超过maxFrameLength时立即抛出TooLongFrameException,而不管是否已读取整个帧。
    如果为false,则在读取了超过maxFrameLength的整个帧之后引发TooLongFrameException。

下面通过Netty源码中LengthFieldBasedFrameDecoder的注释几个例子看一下参数的使用:

5.4.1、2 bytes length field at offset 0, do not strip header

本例中的length字段的值是12 (0x0C),它表示“HELLO, WORLD”的长度。默认情况下,解码器假定长度字段表示长度字段后面的字节数。

  • lengthFieldOffset = 0: 开始的2个字节就是长度域,所以不需要长度域偏移。
  • lengthFieldLength = 2: 长度域2个字节。
  • lengthAdjustment = 0: 数据长度修正为0,因为长度域只包含数据的长度,所以不需要修正。
  • initialBytesToStrip = 0: 发送和接收的数据完全一致,所以不需要跳过任何字节。
5.4.2、2 bytes length field at offset 0, strip header

因为我们可以通过调用readableBytes()来获得内容的长度,所以可能希望通过指定initialbystrip来删除长度字段。在本例中,我们指定2(与length字段的长度相同)来去掉前两个字节。

  • lengthFieldOffset = 0: 开始的2个字节就是长度域,所以不需要长度域偏移。
  • lengthFieldLength = 2 :长度域2个字节。
  • lengthAdjustment = 0: 数据长度修正为0,因为长度域只包含数据的长度,所以不需要修正。
  • initialBytesToStrip = 2 :我们发现接收的数据没有长度域的数据,所以要跳过长度域的2个字节。
5.4.3、2 bytes length field at offset 0, do not strip header, the length field represents the length of the whole message

在大多数情况下,length字段仅表示消息体的长度,如前面的示例所示。但是,在一些协议中,长度字段表示整个消息的长度,包括消息头。在这种情况下,我们指定一个非零长度调整。因为这个示例消息中的长度值总是比主体长度大2,所以我们指定-2作为补偿的长度调整。

  • lengthFieldOffset = 0: 开始的2个字节就是长度域,所以不需要长度域偏移。
  • lengthFieldLength = 2: 长度域2个字节。
  • lengthAdjustment = -2 :因为长度域为总长度,所以我们需要修正数据长度,也就是减去2。
  • initialBytesToStrip = 0 :发送和接收的数据完全一致,所以不需要跳过任何字节。
5.4.4、3 bytes length field at the end of 5 bytes header, do not strip header

下面的消息是第一个示例的简单变体。一个额外的头值被预先写入消息中。长度调整再次为零,因为译码器在计算帧长时总是考虑到预写数据的长度。

  • lengthFieldOffset = 2 :(= the length of Header 1)跳过2字节之后才是长度域
  • lengthFieldLength = 3:长度域3个字节。
  • lengthAdjustment = 0:数据长度修正为0,因为长度域只包含数据的长度,所以不需要修正。
  • initialBytesToStrip = 0:发送和接收的数据完全一致,所以不需要跳过任何字节。
5.4.5、3 bytes length field at the beginning of 5 bytes header, do not strip header

这是一个高级示例,展示了在长度字段和消息正文之间有一个额外头的情况。您必须指定一个正的长度调整,以便解码器将额外的标头计数到帧长度计算中。

  • lengthFieldOffset = 0:开始的就是长度域,所以不需要长度域偏移。
  • lengthFieldLength = 3:长度域3个字节。
  • lengthAdjustment = 2 :(= the length of Header 1) 长度修正2个字节,加2
  • initialBytesToStrip = 0:发送和接收的数据完全一致,所以不需要跳过任何字节。
5.4.6、2 bytes length field at offset 1 in the middle of 4 bytes header, strip the first header field and the length field

这是上述所有示例的组合。在长度字段之前有预写的header,在长度字段之后有额外的header。预先设置的header会影响lengthFieldOffset,而额外的leader会影响lengthAdjustment。我们还指定了一个非零initialBytesToStrip来从帧中去除长度字段和预定的header。如果不想去掉预写的header,可以为initialBytesToSkip指定0。

  • lengthFieldOffset = 1 :(= the length of HDR1) ,跳过1个字节之后才是长度域
  • lengthFieldLength = 2:长度域2个字节
  • lengthAdjustment = 1: (= the length of HDR2)
  • initialBytesToStrip = 3 :(= the length of HDR1 + LEN)
5.4.7、2 bytes length field at offset 1 in the middle of 4 bytes header, strip the first header field and the length field, the length field represents the length of the whole message

让我们对前面的示例进行另一个修改。与前一个示例的惟一区别是,length字段表示整个消息的长度,而不是消息正文的长度,就像第三个示例一样。我们必须把HDR1的长度和长度计算进长度调整里。请注意,我们不需要考虑HDR2的长度,因为length字段已经包含了整个头的长度。

  • lengthFieldOffset = 1:长度域偏移1个字节,之后才是长度域。
  • lengthFieldLength = 2:长度域2个字节。
  • lengthAdjustment = -3: (= the length of HDR1 + LEN, negative)数据长度修正-3个字节。
  • initialBytesToStrip = 3:因为接受的数据比发送的数据少3个字节,所以跳过3个字节。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容

  • 简介 用简单的话来定义tcpdump,就是:dump the traffic on a network,根据使用者...
    JasonShi6306421阅读 1,221评论 0 1
  • 简介 用简单的话来定义tcpdump,就是:dump the traffic on a network,根据使用者...
    保川阅读 5,939评论 1 13
  • 1,网络传输三大问题:packet reordering, packet duplication, and pac...
    码农崛起阅读 464评论 0 0
  • 拆包的原理 关于拆包原理的上一篇博文 netty源码分析之拆包器的奥秘 中已详细阐述,这里简单总结下:netty的...
    简书闪电侠阅读 44,800评论 28 71
  • 血液透析肾友常规使用的药物 对于血液透析病人来说,维系生命除了要规律透析,药物也是维系生命必不可少的,那么血液透析...
    jw8868阅读 313评论 1 1