详解TCP协议中的粘包和半包问题(tcp 粘包)
前言
TCP(Transmission Control Protocol)是一种面向连接的可靠传输协议,广泛应用于网络通信领域。在TCP协议中,数据被分割成一个一个的报文段进行传输。然而,由于网络传输的不可靠性,TCP协议会面临一些数据传输问题,如粘包和半包问题。在网络通信中,当发送方连续发送多个小数据包时,接收方可能会将它们合并成一个大的数据包,这就是粘包问题;而当发送方发送的数据包长度大于接收方的缓冲区长度时,接收方无法完整接收数据包,导致数据的接收不完整,这就是半包问题。本文将深入探讨TCP协议中的粘包和半包问题,分析他们出现的根本原因,并提供一些解决方案,以便更好地应对这些问题。
一、粘包半包问题以及产生的原因分析
粘包就是多个数据混淆在一起了,而且多个数据包之间没有明确的分隔,导致无法对这些数据包进行正确的读取。
半包就是一个大的数据包被拆分成了多个数据包发送,读取的时候没有把多个包合成一个原本的大包,导致读取的数据不完整。
这种问题产生的原因可能有多种因素,从应用层到链路层中都有可能引起这个问题。
我们先要搞懂几个概念:
- TCP协议中的滑动窗口机制
- TCP协议中的Nagle算法
- 传输层中的MSS限制和链路层的MTU机制
- 应用层的发送方缓冲区和接收方缓冲区
下面我们先逐个介绍这些概念,然后再分析这些机制在什么情况下会引起粘包或半包问题。
1.TCP协议中的滑动窗口
TCP协议是一种可靠性传输协议,所以在传输数据的时候必须要等到对方的应答之后才能发送下一条数据,这种显然效率不高。
TCP协议为了解决这个传输效率的问题,引入了滑动窗口。滑动窗口就是在发送方和接收方都有一个缓冲区,这个缓冲区就是"窗口",假设发送方的窗口大小是 0~100KB,那么发送数据的时候前100KB的数据不需要等到对方ACK应答即可全部发送。
如果发送的过程中收到了对方返回某个数据包的ACK,那么这个窗口会对应的向后滑动。比如刚开始的窗口大小是0~100KB,收到前20KB数据包的ACK之后,这个窗口就会滑动到20~120KB的位置,以此类推。这里还有一个小问题,如果发送方一直未接收到前20KB的ACK消息,那么在发送完0~100KB的数据之后,窗口就会卡在那里,这就是经典的队头阻塞问题,后续会讲解,本文重点不是这个,先有个印象。
接收方那里也有这么一个窗口,只会读取窗口内的数据并返回ACK,返回ACK后,接收窗口往后滑动。
对于TCP的滑动窗口,发送方的窗口起到了优化传输效率的作用,接收方的窗口起到了流量控制的作用。
2.传输层的MSS与链路层的MTU
MSS是传输层的最大报文长度限制,而MTU则是链路层的最大数据包大小限制,一般MTU会限制MSS,比如MTU=1500,那么MSS每次传输的数据包大小只能是MTU-40=1460(TCP报文头大小为40)。
有这个限制的原因是为了避免出现网络堵塞。因为网卡会有带宽限制,如果一次发送一个1GB大小的数据包,如果没有限制直接发送,就会导致网络堵塞,并且超出网络硬件设备单次传输数据的最大限制。
每次传输的数据包大小超过MSS大小时,就会自动切割这个数据包,将大的数据包拆分成多个小包。
3.TCP协议中的Nagle算法
有这么一种情况,每次发送的数据包都非常小,比如只有1个字节,但是TCP的报文头默认有40个字节,数据+报文头一共是41字节。如果这种较小的数据包经常出现,会造成过多的网络资源浪费。比如有1W个这样的数据包,那么总数据量中有400MB都是报文头,只有10MB是真正的数据。
所以TCP中引入了一种叫做Nagle的算法,如若连续几次发送的数据都很小,TCP会根据这个算法把多个数据合并成一个包发出,从而优化传输效率,避免网络资源浪费。
4.应用层的接收缓冲区和发送缓冲区
对于操作系统的IO函数而言,网络数据不管是发送或者接收,都不会去逐个读取,而是会先把接收/发送的数据放入到一个缓冲区中,然后批量进行操作。当然,发送和接收各自会对应有一个缓冲区。
假设现在要发送我叫王大锤,我在总结粘包和半包问题这组数据,操作系统的IO函数会挨个将他们写入到发送缓冲区。接收方也是这样,会将他们挨个从接收缓冲区中读取出来。
5.产生原因分析
搞清楚上面几个概念之后,我们再来分析一下为什么会产生粘包或者半包的问题
粘包:发送ABCD、EFGHIJK两个数据包,被接收成ABCDEFGHIJK一个数据包,多个包粘在一起。
- 应用层:接收方的接收缓冲区太大,导致读取多个数据包一起输出。
- TCP滑动窗口:接收方窗口较大,导致发送方发出的多个数据包处理不及时造成粘包
- Nagle算法:由于发送方的单个数据包体积太小,导致多个包合并成一个包发送
半包:发送ABCDEFG一个数据包,被接收成ABC、DEFG两个数据包,一个包被拆成了多个。
- 应用层:接收方缓冲区太小,无法存放发送发的单个数据包,因此拆开读取。
- 滑动窗口:接收方的窗口太小,无法一次性放下完整的数据包,只能读取其中的一部分。
- MSS限制:发送方的单个包大小超出了MSS限制,被拆分成了多个包
以上就是出现粘包和半包的根本原因,大部分都是TCP协议中的优化手段导致的,但是想要解决这个问题难道要重写TCP协议吗?这显然是不现实的。那么我们只能从应用层下手了,其实粘包半包问题都是由于数据包之间没有边界导致的,想要解决这个问题,我们只需要在每个数据包后面加上边界,然后接收方按照约定读取相应的边界符号进行读取即可。
二、解决方案
半包粘包的问题,我们借鉴的是Netty中的处理方式。Netty官方提供了4种适用于不同场景的解决方式。第一种方式是我们利用短连接解决了粘包问题(不是官方提供的)。
2.1 短连接解决粘包
所谓短连接就是一次性把数据发完,然后就断开连接。客户端断开连接之后,服务端会接收到一个-1的状态码,可以以这个-1作为每个数据包的边界。
Talk is cheap,show me the code,咱们直接上代码
// 演示粘包、半包问题的初始化器
public class ServerInitializer extends ChannelInitializer {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
socketChannel.pipeline().addLast(new ChannelInboundHandlerAdapter() {
// 数据就绪事件:当收到客户端数据时会读取通道内的数据
@Override
public void channelReadComplete(ChannelHandlerContext ctx)
throws Exception {
// 在这里直接输出通道内的数据信息
System.out.println(ctx.channel());
super.channelReadComplete(ctx);
}
});
}
}
/**
* @author ZhangZhe
* @version V1.0.0
* @date 2023年04月06日 下午2:59
* 通过短连接解决粘包问题的服务端
*/
public class AdhesivePackageServer {
public static void main(String[] args) {
NioEventLoopGroup group = new NioEventLoopGroup();
ServerBootstrap server = new ServerBootstrap();
server.group(group);
server.channel(NioServerSocketChannel.class);
server.childHandler(new ServerInitializer());
server.bind("127.0.0.1", 8888);
System.out.println("服务端启动成功....");
}
}
//客户端每次发送完数据之后就断开本次连接
class Client {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
sendData();
}
}
private static void sendData() {
EventLoopGroup worker = new NioEventLoopGroup();
Bootstrap client = new Bootstrap();
try {
client.group(worker);
client.channel(NioSocketChannel.class);
client.handler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
//连接到服务端之后触发
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
//向服务端发送一个20字节的数据包,然后就断开连接
ByteBuf buffer = ctx.alloc().buffer(1);
buffer.writeBytes(new byte[]
{'0', '1', '2', '3', '4',
'5', '6', '7', '8', '9',
'A', 'B', 'C', 'D', 'E',
'M', 'N', 'X', 'Y', 'Z'});
ctx.writeAndFlush(buffer);
//发送完数据,就断开连接
ctx.channel().close();
}
});
}
});
client.connect("127.0.0.1", 8888).sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
worker.shutdownGracefully();
}
}
}
运行服务端,并使用客户端发送数据,每次发送完数据之后就断开本次与服务端的连接。
结果如下,可以看到每次发送的数据没有出现粘包问题。
虽然说短连接可以解决粘包问题,但是还是有可能出现半包问题的,如果单次发送的数据包大小超过MSS限制,数据包就会被切割,还是会有半包的问题。
这种方式只能解决粘包问题,所以只适用于一些特定的场景。
2.2 定长帧解码器
这个是Netty官方提供的一种处理方式,定长帧其实就是固定每次数据包的大小,比如固定每个包的大小为8个字节,那么发送方每次最多发送8个字节的数据,不够的自动补齐,然后接收方在接收的时候,每次也只接收8个字节大小的数据,这样就可以有效避免粘包半包问题。
Talk is cheap, show me the code!
/**
* @author ZhangZhe
* @version V1.0.0
* @date 2023年04月06日 下午3:26
* 通过Netty官方提供的定长帧解码器解决粘包半包问题
*/
public class FixedLengthFrameDecoderDemo {
public static void main(String[] args) {
EmbeddedChannel channel = new EmbeddedChannel(
//定长帧解码器
new FixedLengthFrameDecoder(8),
new LoggingHandler(LogLevel.DEBUG)
);
//调用三次发送数据的方法
sendData(channel,"ABC",8);
sendData(channel,"123456",8);
sendData(channel,"ABC123",8);
}
/**
* @param channel 数据通道
* @param data 要发送的数据
* @param len 数据长度
*/
private static void sendData(EmbeddedChannel channel, String data, int len) {
//获取要发送数据的字节长度
byte[] bytes = data.getBytes();
int dataLength = bytes.length;
//根据固定长度补齐要发送的数据
String alignString = "";
if (dataLength < len) {
int alignLength = len - bytes.length;
for (int i = 1; i <= alignLength; i++) {
alignString += "*";
}
}
//拼接上对齐数据
String msg = data + alignString;
byte[] msgBytes = msg.getBytes();
//构建缓冲区,通过channel发送数据
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
buffer.writeBytes(msgBytes);
channel.writeInbound(buffer);
}
}
运行之后可以看到结果如下
可以看到,不足8字节的数据会自动补齐到8字节大小。这种方式虽然可以解决粘包半包问题,但是也存在一些很明显的问题:
- 适用场景有限,只能传输固定大小的数据,而且不够的还要在客户端补齐,补齐的数据相当于无效数据,占用网络资源。
- 如果发送的数据包大小超过了固定长度,还是会有半包问题。
2.3 行帧解码器
之前的定长帧解码器只适用于固定长度的数据,如果每次传输的数据包大小都是不确定的,那么就不适用了,为此需要一个应对可变数据长度的解码器,Netty官方提供了行帧解码器,看名字其实我们就可以推断出,这种解码器是以行标识符作为每个数据包的边界的。
Talk is cheap, show me the code!
/**
* @author ZhangZhe
* @version V1.0.0
* @date 2023年04月06日 下午3:44
* 通过Netty官方提供的行帧解码器解决粘包半包问题
*/
public class LineFrameDecoderDemo {
public static void main(String[] args) {
// 通过Netty提供的测试通道来代替服务端、客户端
EmbeddedChannel channel = new EmbeddedChannel(
// 添加一个行帧解码器(在超出1024后还未检测到换行符,就会停止读取)
new LineBasedFrameDecoder(1024),
//加一个字符串处理器,可以处理中文,要不然中文数据显示不出来
new StringDecoder(),
new LoggingHandler(LogLevel.DEBUG)
);
// 调用三次发送数据的方法
sendData(channel,"我叫王大锤");
sendData(channel,"我在总结梳理粘包半包问题");
sendData(channel,"知道的越多,不知道的就越多");
}
private static void sendData(EmbeddedChannel channel, String data) {
// 在要发送的数据结尾,拼接上一个\n换行符(\r\n也可以)
String msg = data + "\n";
// 获取发送数据的字节长度
byte[] msgBytes = msg.getBytes();
// 构建缓冲区,通过channel发送数据
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
buffer.writeBytes(msgBytes);
channel.writeInbound(buffer);
}
}
运行结果如下,可以看到,没有了之前的那种对齐数据了,节省了网络资源。
看起来是比那个定长帧的好了点,但是这个还是有可能出现半包问题的,因为开头我们在添加这个解码器的时候,需要指定最大数据长度,也就是说如果我们单个数据包超过这个大小了,还是会被切割成多个数据包。
2.4 分隔符帧解码器
如果我们不想以换行符作为每个数据包的边界,想要自己定义边界符号也是可以的,Netty官方为我们提供了分隔符帧解码器,让我们可以自定义边界符号。
Talk is cheap, show me the code!
/**
* @author ZhangZhe
* @version V1.0.0
* @date 2023年04月06日 下午3:53
* 通过Netty官方提供的分隔符帧解码器解决粘包半包问题
*/
public class DelimiterFrameDecoderDemo {
public static void main(String[] args) {
// 自定义一个分隔符(记得要用ByteBuf对象来包装)
ByteBuf delimiter = ByteBufAllocator.DEFAULT.buffer(1);
delimiter.writeByte('|');
// 通过Netty提供的测试通道来代替服务端、客户端
EmbeddedChannel channel = new EmbeddedChannel(
// 添加一个分隔符帧解码器(传入自定义的分隔符)
new DelimiterBasedFrameDecoder(1024,delimiter),
new LoggingHandler(LogLevel.DEBUG)
);
sendData(channel,"123");
sendData(channel,"979799");
sendData(channel,"123o12p3i12po3iop21i3");
}
private static void sendData(EmbeddedChannel channel, String data){
// 在要发送的数据结尾,拼接上一个*号(因为前面自定义的分隔符为*号)
String msg = data + "|";
// 获取发送数据的字节长度
byte[] msgBytes = msg.getBytes();
// 构建缓冲区,通过channel发送数据
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
buffer.writeBytes(msgBytes);
channel.writeInbound(buffer);
}
}
这个处理方式其实和行帧解码器一样,只是换了个边界符号而已,存在和行帧解码器一样的问题,也可能出现半包。
2.5 LTC解码器
之前的解码器多多少少都有点小问题,那有没有一种可以使用任何使用场景的解码器呢?当然也是有的,Netty官方提供了目前最完善的解码器→ LTC解码器。
这个解码器一般可以用来做自定义协议的时候使用,可以灵活的定义每个数据包中包含的各种信息。我们先来看一下这个解码器的构造方法。要先理解这些参数表示的含义,才可以明白这个解码器是怎么用的。
public LengthFieldBasedFrameDecoder(
int maxFrameLength,
int lengthFieldOffset,
int lengthFieldLength,
int lengthAdjustment,
int initialBytesToStrip) {
this(
maxFrameLength,
lengthFieldOffset, lengthFieldLength, lengthAdjustment,
initialBytesToStrip, true);
}
maxFrameLength: 数据最大长度,超出了会被分包。
lengthFieldOffset: 长度字段偏移量,表示描述数据长度的信息从第几个字节开始
lengthFieldLength: 长度字段占用字节数,描述数据正文长度用了几个字节
lengthAdjustment:长度调整数,表示在长度字段的第N个字节之后才是正文数据的开始
initialBytesToStrip:跳过几个字节开始读取数据,可以用来跳过一些头部信息,直接读取正文数据
Talk is cheap, show me the code!
/**
* @author ZhangZhe
* @version V1.0.0
* @date 2023年04月06日 下午12:59
*/
public class LTCDecoderDemo {
public static void main(String[] args) {
//通过Netty提供的测试通道来代替客户端和服务端
EmbeddedChannel channel = new EmbeddedChannel(
//添加一个LTC解码器(超出1024后还是会进行分包操作)
new LengthFieldBasedFrameDecoder(
1024,//单个包最大传1024个字节,超出了分包
0,//长度字段偏移量,表示描述数据长度的信息从第几个字节开始
4,//长度字段占用字节数
0,//长度调整数,表示在长度字段的第N个字节之后才是正文数据的开始
4//跳过几个字节开始读取数据,可以用来跳过一些头部信息,直接读取正文数据
),
new StringDecoder(),
new LoggingHandler(LogLevel.DEBUG)
);
//调用发送数据的方法
sendData(channel,"长度字段偏移量,表示描述数据长度的信息从第几个字节开始");
sendData(channel,"长度字段占用字节数");
sendData(channel,"长度调整数,表示在长度字段的第N个字节之后才是正文数据的开始");
sendData(channel,"跳过几个字节开始读取数据,可以用来跳过一些头部信息,直接读取正文数据");
}
public static void sendData(EmbeddedChannel channel, String data) {
//获取要发啊送数据的字节以及长度
byte[] dataBytes = data.getBytes();
int dataLength = dataBytes.length;
//将数据长度写入到缓冲区,再将正文数据写入到缓冲区
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
//写入数据正文的长度(int占用4个字节)
buffer.writeInt(dataLength);
//写入数据
buffer.writeBytes(dataBytes);
//发送最终组装好的数据
channel.writeInbound(buffer);
}
}
可以看到运行结果,没有粘包半包的问题,不用逐个字节去判断是否为边界符号,这种方式比之前的解码器效率上显然会提升不少。
三、总结
我们介绍了解决粘包半包的五种方案,其中应用最广泛的方式是LTC解码器的处理方式,不管是我们使用Netty官方提供的,还是基于原生的JavaNIO进行开发(现在估计没人会这么做了吧),LTC的处理方式我们都可以借鉴。
虽然说相比较来说LTC的处理方式是最优的,但是我们在实际开发中还是要考虑业务场景的,没必要就只认定LTC解码器的这种处理方式,毕竟技术是要为业务服务的嘛。