【Netty】TCP粘包/拆包解决方案

 2023-01-19
原文作者:程序员小潘 原文地址:https://juejin.cn/post/6972541386885169160

202212302215277461.png TCP作为传输层(4层)协议,并不知道你的应用层(7层)数据报文的含义,因此使用TCP传输数据时,可能会出现「粘包/拆包」,需要引用层通过特定的协议去解决。

1. 什么是TCP粘包/拆包?

「粘包/拆包」在Socket编程中经常会出现,使用TCP协议传输数据时,如果对端连续发送多个小的数据包,TCP会将这些小的数据包打包,合并成一个TCP报文发送出去,这就是「粘包」。如果对端发送一个超大的数据包,TCP会根据缓冲区的情况,将这个超大数据包拆分成多个小的TCP报文发送出去,这就是「拆包」。

拿HTTP服务举例,如果一次HTTP请求的报文过大,TCP会进行拆包。因为TCP作为传输层协议,并不知道上层应用层HTTP的报文含义,它不知道单个报文的数据边界在哪里,而且一旦报文长度超过了双方约定的「可传输最大TCP报文长度」,就不得不拆包了。此时,服务端会先收到一个不完整的HTTP请求,如果服务端不做处理,那它要如何处理这个请求呢?服务端应该根据HTTP协议对请求报文和响应报文的规定,判断此次接收到的请求是否完整,如果是一个不完整的请求报文,就应该等待对端继续发送请求数据,等待读取到一个完整的请求时,再进行处理。

下图所示为粘包/拆包的场景:

202212302215285722.png

2. 为什么会导致粘包/拆包?

清楚了「粘包/拆包」的概念,顺带了解下可能会导致TCP「粘包/拆包」的原因。

2.1 Nagle算法

TCP是面向连接的、可靠的、基于字节流的传输层协议。应用层交给TCP的数据,并不会以应用层的报文消息为单位进行传输,这些消息可能会被组合成一个数据段发送给目标主机。

Nagle是一种通过减少数据包的方式来提高TCP传输效率的算法,因为网络带宽有限,如果频繁的发送小的数据包,对带宽的压力会比较大。Nagle算法会在本地缓冲区先缓冲待发送的数据,待数据总量达到最大数据段(MSS)时,再一次性批量发送。这种方式虽然可能会使消息的发送存在延迟,但是对带宽的压力小,降低了网络拥堵的可能性并减少了额外的开销。

现在的网络资源不像几十年前那样紧张了,Linux默认是关闭Nagle算法的,即SO_NODELAY=1。

2.2 TCP_CORK

TCP有一个选项TCP_CORK也可能会导致「粘包/拆包」。 如果开启TCP_CORK,当发送的数据小于最大数据段(MSS)时,TCP会延迟20ms发送,或者等待发送缓冲区的数据达到最大数据段(MSS)才真正发送。

2.1 MTU

最大传输单元(Maximum Transmission Unit,MTU)用来通知对方所能接受数据服务单元的最大尺寸,说明发送方能够接受的有效载荷大小。

指通信协议的最大传输单元,普遍使用的网卡MTU为1500,即最大只能传输1500字节的数据帧。可以通过ifconfig命令查看各网卡的数据帧:

202212302215295333.png

2.2 MSS

最大报文段长度(MSS)是TCP协议的一个选项,用于在TCP连接建立时,收发双方协商通信时每一个报文段所能承载的最大数据长度(不包括文段头)。

TCP双方建立连接后会约定可传输的最大报文长度,是TCP用来限制应用层可发送的最大字节数。如果应用层单次发送的报文长度超过了MSS,那么也会面临「拆包」。

3. 粘包/拆包的解决方案

常用的解决方案大致可分为三种:

  1. 数据报文定长,不足时自动填充。这种方式实现简单,但会浪费一定的带宽。
  2. 使用特定的分隔符(如换行符)将不同的报文进行分割。
  3. 在请求头中写入报文的长度。

针对这三种解决方案,Netty都提供了开箱即用的解码器,使用非常的方便。

202212302215309094.png DelimiterBasedFrameDecoder是一个可以自定义分隔符的解码器,Netty只有当读到指定的分隔符时才会认为是一个完整的数据报文。

LineBasedFrameDecoder是一个以「换行符」为分隔符的解码器。

FixedLengthFrameDecoder是一个定长帧的解码器,你需要指定定长的帧大小,Netty只有读到一个完整的桢时才会调用后续的ChannelRead()。

LengthFieldBasedFrameDecoder是一个将报文长度写入到请求头的解码器,Netty会根据长度字段的偏移量和长度字段占用的字节数,读取到本次报文的长度,当读到一个完整的帧时才调用后续的ChannelRead()。它的用法如下:

    /**
     * @param maxFrameLength 最大帧大小
     * @param lengthFieldOffset 长度字段的偏移量
     * @param lengthFieldLength 长度字段占用的字节数,一般用int,4字节
     */
    public LengthFieldBasedFrameDecoder(
            int maxFrameLength,
            int lengthFieldOffset, int lengthFieldLength) {
        this(maxFrameLength, lengthFieldOffset, lengthFieldLength, 0, 0);
    }

4. 案例实战

由于LengthFieldBasedFrameDecoder比较常用,这里只演示这一个,其他解码器大家自行探索。

因为只是单纯的测试,我这里图方便,只编写一个EmbeddedChannel来测试,没有启Netty服务,案例如下:

    // 读写半包 Demo
    public class HalfDemo {
    	public static void main(String[] args) {
    		EmbeddedChannel channel = new EmbeddedChannel();
    		// 最大帧1MB,0~4字节记录报文的长度
    		channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024 * 1024, 0, 4));
    		channel.pipeline().addLast(new ChannelInboundHandlerAdapter(){
    			@Override
    			public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    				ByteBuf buf = (ByteBuf) msg;
    				int length = buf.readInt();
    				System.out.println("报文长度:" + length);
    				System.out.println("收到数据:" + buf.toString(Charset.defaultCharset()));
    			}
    		});
    
    		ByteBuf buf = Unpooled.buffer();
    		// 写入报文的长度
    		buf.writeInt(5);
    		// 如果不写满5字节,控制台将不会有输出
    		buf.writeBytes("hello".getBytes());
    		channel.writeInbound(buf);
    	}
    }