当前位置: 首页 > 科技观察

Socket粘包问题的3种解决方案,哪个更好!

时间:2023-03-14 10:55:50 科技观察

本文转载自微信公众号“Java中文社区”,作者雷哥。转载本文请联系Java中文社区公众号。在Java语言中,传统的Socket编程分为两种实现,也分别对应两种不同的传输层协议:TCP协议和UDP协议,但是TCP作为互联网最常用的传输层协议,在使用的时候,它会导致粘连和半包的问题,??所以为了彻底解决这个问题,这篇文章诞生了。什么是TCP协议?TCP的全称是TransmissionControlProtocol(传输控制协议),由IETF的RFC793定义,是一种面向连接的点对点传输层通信协议。TCP通过使用序列号和确认消息从发送节点提供有关将数据包传送到目标节点的信息。TCP确保数据可靠性、端到端交付、重新排序和重新传输,直到达到超时条件或收到数据包的确认。TCP是互联网上最常用的协议,也是HTTP(HTTP1.0/HTTP2.0)通信的基础。当我们在浏览器中请求一个网页时,计算机会发送一个TCP数据包到Web服务器的地址,要求它把网页返回给我们,Web服务器通过发送一个TCP数据包流来响应,而浏览器将这些数据包拼接在一起形成网页。TCP的全部要点在于它是可靠的,它通过对数据包进行编号来排序数据包,并通过让服务器将响应发送回浏览器说“已收到”来进行错误检查,因此在传输过程中不会丢失或破坏任何数据。目前市面上主流的HTTP协议版本为HTTP/1.1,如下图:什么是粘包和半包问题?粘包问题指的是当发送两条消息时,比如发送了ABC和DEF,但是另外一端收到的是ABCD。一次读取两条数据的情况称为粘包(通常应该一条一条读取)。半包问题是指当发送的消息是ABC时,另一端收到AB和C两条消息,这种情况称为半包。为什么会出现粘包和半包问题?这是因为TCP是面向连接的传输协议。TCP传输的数据是流的形式,流数据没有明确的起止边界,所以TCP没有办法判断A流属于报文的哪个段。粘包的主要原因:发送方每次写入数据<套接字(Socket)缓冲区大小;接收方读取套接字(Socket)缓冲区数据不够及时。半包的主要原因:发送方每次写入数据>套接字(Socket)缓冲区大小;发送的数据大于协议的MTU(MaximumTransmissionUnit),所以必须解包。小知识点:什么是缓冲区?缓冲区也称为高速缓存,是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间称为缓冲区。缓冲区的优点以文件流的写入为例。如果我们不使用buffer,每次写操作时CPU都会和低速存储设备,也就是磁盘进行交互,整个文件的写入速度都会受到低速存储设备的限制(磁盘)。但如果使用缓冲区,每次写操作都会先将数据保存在高速缓冲存储器中,当缓冲区中的数据达到一定阈值时,一次性将文件写入磁盘。因为内存的写入速度比磁盘的写入速度要快很多,所以当有缓冲区的时候,文件的写入速度会大大提高。粘包和半包问题的演示接下来,我们将用代码来演示粘包和半包问题。为了演示的直观性,我将设置两个角色:服务器用于接收消息;客户端用于发送固定信息。然后通过打印服务器收到的信息观察粘包和半包问题。服务器端代码如下:/***服务器端(只负责接收消息)*/classServSocket{//字节数组的长度privatestaticfinalintBYTE_LENGTH=20;publicstaticvoidmain(String[]args)throwsIOException{//创建一个SocketserverServerSocketserverSocket=newServerSocket(9999);//获取客户端连接SocketclientSocket=serverSocket.accept();//获取客户端发送的流对象try(InputStreaminputStream=clientSocket.getInputStream()){while(true){//循环获取客户端发送的流对象信息byte[]bytes=newbyte[BYTE_LENGTH];//读取客户端发送的信息intcount=inputStream.read(bytes,0,BYTE_LENGTH);if(count>0){//成功接收到有效消息并打印System.out.println("从客户端接收到的信息为:"+newString(bytes));}count=0;}}}}客户端代码如下:/***client(只负责发送消息)*/staticclassClientSocket{publicstaticvoidmain(String[]args)throwsIOException{//创建一个Socket客户端并尝试连接serverSocketsocket=newSocket("127.0.0.1",9999);//发送的消息内容finalStringmessage="Hi,Java.";//使用输出流来sendamessagetry(OutputStreamoutputStream=socket.getOutputStream()){//向服务器发送10条消息for(inti=0;i<10;i++){//发送一条消息outputStream.write(message.getBytes());}}}}以上程序的通信结果如下图所示:从上面的结果可以看出,服务端发生了粘包和半包问题,因为客户端发送了10个固定的“Hi,Java”。消息,正常的结果应该是服务端也收到了10条固定消息,但是实际结果并没有那么粘和半包粘包和半包的三种解决方案:发送方和接收方指定一个固定大小的缓冲区,即发送和接收都使用一个固定大小的byte[]数组长度,当字符长度不够时使用空字符进行弥补;在TCP协议的基础上封装一层数据请求协议,将数据包封装成数据头(存储数据文本大小)+数据文本的形式,让服务器知道每个数据包的具体内容长度,知道发送数据的具体边界后,就可以解决半包和粘包的问题;以特殊字符结束,比如“\n”,这样我们就知道结束字符,从而避免半包和粘包问题(推荐方案)。下面我们来演示一下上述方案的具体代码实现。方案一:固定缓冲区大小固定缓冲区大小的实现只需要控制服务器和客户端发送和接收的字节(数组)长度相同即可。服务器端实现代码如下:/***服务器端,改进版1(只负责接收消息)*/staticclassServSocketV1{privatestaticfinalintBYTE_LENGTH=1024;//字节数组长度(用于接收消息)publicstaticvoidmain(String[]args)throwsIOException{ServerSocketserverSocket=newServerSocket(9091);//获取连接SocketclientSocket=serverSocket.accept();try(InputStreaminputStream=clientSocket.getInputStream()){while(true){byte[]bytes=newbyte[BYTE_LENGTH];//读取客户端发送的信息intcount=inputStream.read(bytes,0,BYTE_LENGTH);if(count>0){//接收消息打印System.out.println("从客户端接收到的信息为:"+newString(bytes).trim());}count=0;}}}}客户端实现代码如下:/***客户端,改进版1(只负责接收消息)*/staticclassClientSocketV1{privatestaticfinalintBYTE_LENGTH=1024;//字节长度publicstaticvoidmain(String[]args)throwsIOException{Socketsocket=newSocket("127.0.0.1",9091);finalStringmessage="Hi,Java.";//发送消息try(OutputStreamoutputStream=socket.getOutputStream()){//将数据组装成定长字节数组byte[]bytes=newbyte[BYTE_LENGTH];intidx=0;for(byteb:message.getBytes()){bytes[idx]=b;idx++;}//向服务器发送10个子消息for(inti=0;i<10;i++){outputStream.write(bytes,0,BYTE_LENGTH);}}}}上面代码的执行结果如下图所示:优缺点分析从中可以看出上面的代码,虽然这种方法可以解决粘包和半包的问题,??但是这种固定缓冲区大小的方法增加了不必要的数据传输,因为这种方法会使用空字符来弥补发送的少量数据,所以这种方法大大减少了。增加了网络传输的负担,所以不是最好的方案解决方案二:封装请求协议该方案的实现思路是将请求的数据封装成两部分:数据头+数据文本,在数据头时读取的数据小于数据头中的大小,继续读取数据,直到读取数据的长度等于数据头中的长度。因为这种方式可以获取到数据的边界,所以不会造成粘包和半包的问题。但是这种实现方式编码成本大,不够优雅,不是最好的实现方案,所以这里略过,直接看最终方案。解决方案3:以特殊字符结尾。您可以通过阅读以特殊字符结尾的行来了解流的边界。因此也可以用来解决粘包和半包的问题。这个实现是我们推荐的最终解决方案。该方案的核心是利用Java自带的BufferedReader和BufferedWriter,即输入字符流和输出字符流带缓冲区,写入时在末尾加上\n,读取时使用readLine读取数据byline,让你知道流量的边界,从而解决粘包和半包的问题。服务端实现代码如下:/***服务端,改进版3(只负责接收消息)*/staticclassServSocketV3{publicstaticvoidmain(String[]args)throwsIOException{//创建Socket服务端ServerSocketserverSocket=newServerSocket(9092);//获取客户端连接SocketclientSocket=serverSocket.accept();//使用线程池处理更多客户端ThreadPoolExecutorthreadPool=newThreadPoolExecutor(100,150,100,TimeUnit.SECONDS,newLinkedBlockingQueue<>(1000));threadPool.submit(()->{//消息处理processMessage(clientSocket);});}/***消息处理*@paramclientSocket*/privatestaticvoidprocessMessage(SocketclientSocket){//获取客户端发送的消息流对象try(BufferedReaderbufferedReader=newBufferedReader(newInputStreamReader(clientSocket.getInputStream()))){while(true){//通过line读取客户端发送的消息Stringmsg=bufferedReader.readLine();if(msg!=null){//成功接收消息来自客户和pri系统。out.println("Receivedclientinformation:"+msg);}}}catch(IOExceptionioException){ioException.printStackTrace();}}}PS:以上代码使用线程池解决多个客户端同时访问服务端time结束问题,从而实现一对多的服务器响应。客户端的实现代码如下:/***Client,改进版3(只负责发送消息)*/staticclassClientSocketV3{publicstaticvoidmain(String[]args)throwsIOException{//启动Socket并尝试连接serverSocketsocket=newSocket("127.0.0.1",9092);finalStringmessage="Hi,Java.";//发送一条消息try(BufferedWriterbufferedWriter=newBufferedWriter(newOutputStreamWriter(socket.getOutputStream()))){//发送10条消息totheserverfor(inti=0;i<10;i++){//注意:末尾的\n不能省略,表示写bufferedWriter.write(message+"\n");//刷新buffer(这一步不能省略)bufferedWriter.flush();}}}}上面代码的执行结果如下图所示:图片摘要读取,而半包问题是指读取了一半的信息。造成粘包和半包的原因是TCP传输是以流的形式进行的,而流数据没有明确的起止标志,导致出现这个问题。在本文中,我们提供了sticky和half-packet三种解决方案,其中最推荐的是使用BufferedReader和BufferedWriter按行读写和区分消息,也就是本文的第三种方案。