TCP作为传输层(4层)协议,并不知道你的应用层(7层)数据报文的含义,因此使用TCP传输数据时,可能会出现「粘包/拆包」,需要引用层通过特定的协议去解决。
1. 什么是TCP粘包/拆包?
「粘包/拆包」在Socket编程中经常会出现,使用TCP协议传输数据时,如果对端连续发送多个小的数据包,TCP会将这些小的数据包打包,合并成一个TCP报文发送出去,这就是「粘包」。如果对端发送一个超大的数据包,TCP会根据缓冲区的情况,将这个超大数据包拆分成多个小的TCP报文发送出去,这就是「拆包」。
拿HTTP服务举例,如果一次HTTP请求的报文过大,TCP会进行拆包。因为TCP作为传输层协议,并不知道上层应用层HTTP的报文含义,它不知道单个报文的数据边界在哪里,而且一旦报文长度超过了双方约定的「可传输最大TCP报文长度」,就不得不拆包了。此时,服务端会先收到一个不完整的HTTP请求,如果服务端不做处理,那它要如何处理这个请求呢?服务端应该根据HTTP协议对请求报文和响应报文的规定,判断此次接收到的请求是否完整,如果是一个不完整的请求报文,就应该等待对端继续发送请求数据,等待读取到一个完整的请求时,再进行处理。
下图所示为粘包/拆包的场景:
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
命令查看各网卡的数据帧:
2.2 MSS
最大报文段长度(MSS)是TCP协议的一个选项,用于在TCP连接建立时,收发双方协商通信时每一个报文段所能承载的最大数据长度(不包括文段头)。
TCP双方建立连接后会约定可传输的最大报文长度,是TCP用来限制应用层可发送的最大字节数。如果应用层单次发送的报文长度超过了MSS,那么也会面临「拆包」。
3. 粘包/拆包的解决方案
常用的解决方案大致可分为三种:
- 数据报文定长,不足时自动填充。这种方式实现简单,但会浪费一定的带宽。
- 使用特定的分隔符(如换行符)将不同的报文进行分割。
- 在请求头中写入报文的长度。
针对这三种解决方案,Netty都提供了开箱即用的解码器,使用非常的方便。
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);
}
}