网络通信中的粘包问题
什么是粘包?
在网络传输层有TCP和UDP两种协议;
如果使用TCP进行通信,则在大多数场景下是不存在丢包和包乱序问题的,因为TCP通信是可靠的通信方式,TCP栈通过序列号和包重传确认机制保证数据包的有序和一定被正确发送到目的地;
如果使用UDP进行通信,且不允许少量丢包,就要自己在UDP的基础上实现类似TCP这种有序和可靠的传输机制(如:RTP、RUDP);
网络通信时,除了上面的丢包,乱序问题,还有一种粘包的问题;
粘包就是连续向对端发送两个或者两个以上的数据包,对端在一次收取中收到的数据包数量可能大于1个,当大于1个时,可能是几个(包括一个)包加上某个包的部分,或者干脆几个完整的包在一起;也可能存在收到的数据只是一个包的部分,这种情况一般也叫作半包;
如下图所示
下面以TCP为例子说明;
TCP中的粘包/拆包
TCP是一个流协议,所谓流就是没有界限的一串数据;TCP的底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为一个完整的包可能被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP的粘包和拆包问题;
粘包的例子
客户端
查看代码
public class NettyDumpSendClient {
private final int serverPort;
private final String serverIp;
Bootstrap b = new Bootstrap();
private final static Logger logger = LoggerFactory.getLogger(NettyDumpSendClient.class);
public NettyDumpSendClient(String ip, int port) {
this.serverPort = port;
this.serverIp = ip;
}
public void runClient() {
//创建reactor 线程组
EventLoopGroup workerLoopGroup = new NioEventLoopGroup();
try {
//1 设置reactor 线程组
b.group(workerLoopGroup);
//2 设置nio类型的channel
b.channel(NioSocketChannel.class);
//3 设置监听端口
b.remoteAddress(serverIp, serverPort);
//4 设置通道的参数
b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
b.option(ChannelOption.TCP_NODELAY, Boolean.TRUE);
//5 装配子通道流水线
b.handler(new ChannelInitializer<SocketChannel>() {
//有连接到达时会创建一个channel
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// pipeline管理子通道channel中的Handler
// 向子channel流水线添加一个handler处理器
ch.pipeline().addLast(NettyEchoClientHandler.INSTANCE);
}
});
ChannelFuture f = b.connect();
f.addListener((ChannelFuture futureListener) ->
{
if (futureListener.isSuccess()) {
logger.info("EchoClient客户端连接成功!");
} else {
logger.info("EchoClient客户端连接失败!");
}
});
// 阻塞,直到连接完成
f.sync();
Channel channel = f.channel();
//6发送大量的文字
byte[] bytes = "发送Echo Client.".getBytes(StandardCharsets.UTF_8);
for (int i = 0; i < 1000; i++) {
//发送ByteBuf
ByteBuf buffer = channel.alloc().buffer();
buffer.writeBytes(bytes);
channel.writeAndFlush(buffer);
}
// 7 等待通道关闭的异步任务结束
// 服务监听通道会一直等待通道关闭的异步任务结束
ChannelFuture closeFuture =channel.closeFuture();
closeFuture.sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 优雅关闭EventLoopGroup,
// 释放掉所有资源包括创建的线程
workerLoopGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws InterruptedException {
int port = 11111;
String ip = "127.0.0.1";
new NettyDumpSendClient(ip, port).runClient();
}
}
查看代码
@ChannelHandler.Sharable
public class NettyEchoClientHandler extends ChannelInboundHandlerAdapter {
private final static Logger logger = LoggerFactory.getLogger(NettyEchoClientHandler.class);
public static final NettyEchoClientHandler INSTANCE = new NettyEchoClientHandler();
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf in = (ByteBuf) msg;
int len = in.readableBytes();
byte[] arr = new byte[len];
in.getBytes(0, arr);
logger.info("client received: {}", new String(arr, StandardCharsets.UTF_8));
in.release();
}
}
服务端
查看代码
public class NettyEchoServer {
private final int serverPort;
ServerBootstrap b = new ServerBootstrap();
private final static Logger logger = LoggerFactory.getLogger(NettyEchoServer.class);
public NettyEchoServer(int port) {
this.serverPort = port;
}
public void runServer() {
//创建reactor 线程组
EventLoopGroup bossLoopGroup = new NioEventLoopGroup(1);
EventLoopGroup workerLoopGroup = new NioEventLoopGroup();
try {
//1 设置reactor 线程组
b.group(bossLoopGroup, workerLoopGroup);
//2 设置nio类型的channel
b.channel(NioServerSocketChannel.class);
//3 设置监听端口
b.localAddress(serverPort);
//4 设置通道的参数
b.option(ChannelOption.ALLOCATOR, UnpooledByteBufAllocator.DEFAULT);
b.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
b.childOption(ChannelOption.SO_KEEPALIVE, true);
//5 装配子通道流水线
b.childHandler(new ChannelInitializer<SocketChannel>() {
//有连接到达时会创建一个channel
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// pipeline管理子通道channel中的Handler
ch.pipeline().addLast(NettyEchoServerHandler.INSTANCE);
}
});
// 6 开始绑定server
// 通过调用sync同步方法阻塞直到绑定成功
ChannelFuture channelFuture = b.bind();
channelFuture.addListener((future)->{
if(future.isSuccess())
{
logger.info(" ========》反应器线程 回调 服务器启动成功,监听端口: " +
channelFuture.channel().localAddress());
}
});
// 7 等待通道关闭的异步任务结束
// 服务监听通道会一直等待通道关闭的异步任务结束
ChannelFuture closeFuture = channelFuture.channel().closeFuture();
closeFuture.sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 8 优雅关闭EventLoopGroup,
// 释放掉所有资源包括创建的线程
workerLoopGroup.shutdownGracefully();
bossLoopGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws InterruptedException {
int port = 11111;
new NettyEchoServer(port).runServer();
}
}
查看代码
public class NettyEchoServerHandler extends ChannelInboundHandlerAdapter {
public static final NettyEchoServerHandler INSTANCE = new NettyEchoServerHandler();
private final static Logger logger = LoggerFactory.getLogger(NettyEchoServerHandler.class);
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf in = (ByteBuf) msg;
logger.info("msg type: {}", (in.hasArray()?"堆内存":"直接内存"));
int len = in.readableBytes();
byte[] arr = new byte[len];
in.getBytes(0, arr);
logger.info("server received: {}", new String(arr, "UTF-8"));
//写回数据,异步任务
logger.info("写回前,msg.refCnt:{}", (in.refCnt()));
ChannelFuture f = ctx.writeAndFlush(msg);
f.addListener((ChannelFuture futureListener) -> {
logger.info("写回后,msg.refCnt:{}", in.refCnt());
});
}
}
对于服务端可能会出现三种类型的输出:
- 一个完整的客户端发送的ByteBuf数据;
- 读到多个客户端发送的ByteBuf数据,但是数据是粘在一起;
- 读到客户端发送的部分ByteBuf数据的内容,并且有乱码;
TCP粘包/拆包问题
假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下4种情况;
- 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;
- 服务端一次接收到了两个数据包,D1和D2粘合在一起,这种为TCP粘包;
- 服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这种为TCP拆包;
- 服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包剩余的内容D1_2和D2的整包;
对于上面示例的服务端输出,第一种情况,接收到完整的ByteBuf数据,这种为全包;第二种为粘包;第三种不是完整的数据包,这种为半包;
TCP粘包/拆包发生的原因
TCP报文封装封装
应用程序数据在发送到物理网络之前,将沿着协议栈从上往下依次传递,每层协议都将在上层数据的基础上加上自己的头部信息(有时还包括尾部信息),以实现该层的功能,这个过程称为封装;如下图;
经过TCP封装后的数据称为TCP报文段或TCP段,这部分数据的TCP头部信息和TCP内核缓冲区(发送缓冲区或接收缓存区)数据一起构成了TCP报文段;如下图;
当发送端应用程序使用send或write函数向一个TCP连接写入数据时,内核中TCP模块首先把这些数据复制到与该连接对应的TCP内核发送缓冲区中,然后TCP模块调用IP模块提供的服务,传递的参数包括TCP头部信息和TCP发送缓冲区中的数据,即TCP的报文段;
数据报的限制
经过IP封装后的数据称为IP数据报;IP数据报也包括头部信息和数据部分,其中数据部分就是一个TCP报文段,UDP数据报或ICMP报文;
经过数据链路层封装的数据称为帧,传输媒介不同,帧的类型也不同,如在以太网上传输的是以太网帧;帧的最大传输单元(Max Transmit Unit,MTU),即帧最多能携带多少上层协议数据(如:IP数据报),通常受到网络类型的限制;
以太网帧的MTU是1500字节,当IP数据报的长度超过帧的MTU时,它将被分片传输;
以太网帧的MTU是1500字节(可以通过ifconfig命令或netstat命令查看),因此他携带的IP数据报的数据部分最多是1480字节(IPv4头部占20字节);
TCP连接初始化时,通信双方使用该选项来协商最大报文段长度(Max Segement Size,MSS);TCP模块通常将MSS设置为(MTU - 40)字节(减掉的这40字节包括20字节的TCP头部和20字节的IPv4的头部,IPv4为例),这样携带TCP报文段的数据报长度就不会超过MTU(假设TCP头部和IP头部都不包括选项字段,这是一般情况),从而避免了发生了IP分片,对于以太网而言,MSS的值是1460(1500 - 40)字节;
参考:https://en.wikipedia.org/wiki/IPv4
https://en.wikipedia.org/wiki/IPv6
https://docs.oracle.com/cd/E19253-01/819-7058/ipv6-ref-2/index.html
https://en.wikipedia.org/wiki/Maximum_segment_size
假设用IP数据报封装一个长度为1481字节的ICMP报文(包括8字节的ICMP头部,所以其数据部分的长度为1473字节),则该数据报在使用以太网帧传输时必须被分片;如下图:
长度为1501字节的IP数据报被拆分成两个IP分片,第一个IP分片长度为1500字节,第二个IP分片的长度为21字节;每个IP分片都包含自己的IP头部(20字节),且第一个IP分片的IP头部设置了MF标志,而第二个IP分片的IP头部则没有设置改标志,因为它已经是最后一个分片了;原始IP数据报中的ICMP头部内容被完整地复制到第一个IP分片中,第二个IP分片不包含ICMP头部信息,因为IP模块重组该ICMP报文的时候只需要一份ICMP头部信息,重复传送这个信息没有任何益处;1473字节的ICMP报文数据的前1472字节被IP模块复制到第一个IP分片中,使其总长度为1500字节,从而满足MTU的要求,而多出的最后1字节则被复制到第二个IP分片中;
TCP粘包/拆包产生
TCP粘包/拆包产生的原因如下:
- 应用程序write写入的字节大小大于套接字发送缓冲区大小(SO_SNDBUF);
- 进行MSS大小的TCP分段;
- 以太网帧的payload(以太网数据链路层的有效数据)大于MTU,从而进行IP分片;
参考:https://man7.org/linux/man-pages/man7/socket.7.html
粘包问题的解决方式
- 固定包长的数据包,固定包长,即每个协议包的长度都是固定的;
假如用户规定每个协议包的大小都是64字节,每收满64字节,就取出来解析(如果不够,就先存起来),则这种通信协议的格式简单但灵活性差;如果包的内容长度小于指定的字节数,对剩余的空间就需要填充特殊的信息,例如"\0"(如果不填充特殊的内容,那么如何区分包里面的正常内容与填充信息呢);如果包的内容超过指定的字节数,又得分包分片,则需要增加额外的处理逻辑,在发送端进行分包分片,在接收端重新组装包片;
- 以指定的字符(串)为包的结束标志;
这种协议包比较常见,即在字节流中遇到特殊的符号值时就认为到一个包的末尾;例如 FTP或SMTP,在一个命令或者一段数据后面加上"\r\n"(即 CRLF)表示一个包的结束;对端收到数据后,每遇到一个"\r\n",就把之前的数据当作一个数据包;这种协议一般用于一些包含各种命令控制的应用中,其不足之处就是如果协议数据包的内容部分需要使用包结束标志字符,就需要对这些字符做转码或者转义操作,以免被接收方错误地当成包结束标志而误解析;
- 包头+包体格式;
这种格式的包一般分为两部分,即包头和包体,包头是固定大小的,且包头必须包含一个字段来说明接下来的包体有多大;