TCP 粘包

Posted HOsystem

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了TCP 粘包相关的知识,希望对你有一定的参考价值。


TCP粘包拆包问题

- TCP 全称是 Transmission Control Protocol(传输控制协议),它由 IETF 的 RFC 793 定义,是一种面向连接的点对点的传输层通信协议。
- 粘包拆包问题是处于⽹络⽐较底层的问题,在数据链路层、⽹络层以及传输层都有可能发⽣;
- TCP会发生粘包问题;TCP⽆消息保护边界,需要在接收端处理消息边界问题,也就是我们所说的粘包、拆包问题;
- UDP不会发生粘包问题;UDP具有保护消息边界,在每个UDP包中就有了消息头(UDP长度、源端口、目的端口、校验和)。

什么是粘包 - 拆包问题

  • 粘包问题
- 粘包问题指,当发送方发送了数据包 `消息1 - ABC` 和 `消息2 - DEF` 时,但接收方接收到的数据包却是 `消息 -  ABCDEF`,像这种一次性读取了两条数据包的数据粘连在一起的情况就叫做粘包(正常情况应该是一条一条读取的)。

  • 拆包问题
- 拆包问题是指,当发送方发送了数据包 ` 消息1 -  ABC ` 和 `消息2 - DEF` 时,接收方接收到数据包经拆分后获得了 `ABCD` 和 `EF` 两个数据包信息的情况,像这种情况有时候也叫做半包。

为什么存在粘包 - 拆包问题

- TCP 是面向连接的传输协议,TCP 传输的数据是以流的形式,而流数据是没有明确的开始结尾边界,所以 TCP 也没办法判断哪一段流属于一个消息;

- TCP 协议是流式协议;所谓流式协议,即协议的内容是像流水一样的字节流,内容与内容之间没有明确的分界标志,需要认为手动地去给这些协议划分边界。
  • 粘包主要原因
- 发送方每次写入数据 < 接收方套接字(Socket)缓冲区大小;
- 接收方读取套接字(Socket)缓冲区数据不够及时。
  • 拆包问题
- 发送方每次写入数据 > 接收方套接字(Socket)缓冲区大小;
- 发送的数据大于协议的 MTU (Maximum Transmission Unit,最大传输单元),既TCP报⽂⻓度-TCP头部⻓度>MSS时发生拆包问题。

粘包 - 拆包 演示

PasteServer.java: 服务端;

PasteClient.java: 客户端;

  • PasteServer.java
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * @author hosystem
 * @version 1.0
 */
public class PasteServer {
    // 字节数组的长度
    private static final int BYTE_LENGTH = 20;
    public static void main(String[] args) throws IOException {
        // 创建 Socket 服务器
        ServerSocket serverSocket = new ServerSocket(9999);
        // 获取客户端连接
        Socket clientSocket = serverSocket.accept();
        // 得到客户端发送的流对象
        try (InputStream inputStream = clientSocket.getInputStream()) {
            while (true) {
                // 循环获取客户端发送的信息
                byte[] bytes = new byte[BYTE_LENGTH];
                // 读取客户端发送的信息
                int count = inputStream.read(bytes, 0, BYTE_LENGTH);
                if (count > 0) {
                    // 成功接收到有效消息并打印
                    System.out.println("接收到客户端的信息是:" + new String(bytes));
                }
                count = 0;
            }
        }
    }
}
  • PasteClient.java
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;

/**
 * @author hosystem
 * @version 1.0
 */
public class PasteClient {

    public static void main(String[] args) throws IOException {
        // 创建 Socket 客户端并尝试连接服务器端
        Socket socket = new Socket("127.0.0.1", 9999);
        // 发送的消息内容
        final String message = "hello.java";
        // 使用输出流发送消息
        try (OutputStream outputStream = socket.getOutputStream()) {
            // 给服务器端发送 10 次消息
            for (int i = 0; i < 10; i++) {
                // 发送消息
                outputStream.write(message.getBytes());
            }
            outputStream.close();
        }finally {
            socket.close();
        }
    }
}

通过上述结果我们可以看出,服务器端发生了粘包和拆包问题,因为客户端发送了 10 次固定的“hello.java.”的消息;正常的结果应该是服务器端也接收到了 10 次固定的消息才对,但结果并非如此。

粘包 - 拆包 解决方案

# 解决方案
- 方案一: 设置定⻓消息,服务端每次读取既定⻓度的内容作为⼀条完整消息(固定缓冲区大小);

- 方式二: 使⽤⾃定义协议+编解码器(封装请求协议);

- 方案三: 设置消息边界,服务端从⽹络流中按消息编辑分离出消息内容(特殊字符结尾,按行读取)。

# 优缺点
- 方案一: 从以上代码可以看出,虽然这种方式可以解决粘包和拆包的问题,但这种固定缓冲区大小的方式增加了不必要的数据传输;当这种方式当发送的数据比较小时会使用空字符来弥补,所以这种方式就大大的增加了网络传输的负担,所以它也不是最佳的解决方案。

- 方案二: 实现较为复杂,更多情况下使用该种实现;Dubbo实现自定义的传输协议,使用Netty来实现可降低编码复杂程度,netty框架对于粘包有专门encoder和decoder接口来处理。

- 方案三: 特殊字符的方案其实是最不可取的;TCP是面向流的;所以应该认为TCP传输的是字节流,任何一个字节都可能被传输;在这种情况下,特殊字符也不特殊了,没法和正常数据区分。

方式一: 固定缓冲区大小

固定缓冲区大小的实现方案,只需要控制服务器端和客户端发送和接收字节的(数组)长度相同即可。

  • PasteServer.java
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * @author hosystem
 * @version 1.1
 */
public class PasteServer {

    // 字节数组的长度
    private static final int BYTE_LENGTH = 1024;
    public static void main(String[] args) throws IOException {
        // 创建 Socket 服务器
        ServerSocket serverSocket = new ServerSocket(9999);
        // 获取客户端连接
        Socket clientSocket = serverSocket.accept();
        // 得到客户端发送的流对象
        try (InputStream inputStream = clientSocket.getInputStream()) {
            while (true) {
                // 循环获取客户端发送的信息
                byte[] bytes = new byte[BYTE_LENGTH];
                // 读取客户端发送的信息
                int count = inputStream.read(bytes, 0, BYTE_LENGTH);
                if (count > 0) {
                    // 成功接收到有效消息并打印
                    System.out.println("接收到客户端的信息是:" + new String(bytes).trim());
                }
                count = 0;
            }
        }
    }
}
  • PasteClient.java
mport java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;

/**
 * @author hosystem
 * @version 1.1
 */
public class PasteClient {

    // 字节数组的长度
    private static final int BYTE_LENGTH = 1024;

    public static void main(String[] args) throws IOException {
        // 创建 Socket 客户端并尝试连接服务器端
        Socket socket = new Socket("127.0.0.1", 9999);
        // 发送的消息内容
        final String message = "hello.java";
        // 使用输出流发送消息
        OutputStream outputStream = socket.getOutputStream();
        try {

            //将数组装成定长字节数组
            byte[] bytes = new byte[BYTE_LENGTH];
            int index = 0;
            for (byte b : message.getBytes()) {
                bytes[index++] = b;
            }

            // 给服务器端发送 10 次消息
            for (int i = 0; i < 10; i++) {
                // 发送消息
                outputStream.write(bytes,0,BYTE_LENGTH);
            }
        }finally {
            socket.close();
            outputStream.close();
        }
    }
}

方式二: 封装请求协议

将请求的数据封装为两部分:数据头+数据正文,在数据头中存储数据正文的大小,当读取的数据小于数据头中的大小时,继续读取数据,直到读取的数据长度等于数据头中的长度时才停止。

实现起来较为复杂,这里不给出代码,可以使用netty完成方式二。

方式三: 特殊字符结尾 - 按行读取

使用 Java 中自带的 BufferedReader 和 BufferedWriter,也就是带缓冲区的输入字符流和输出字符流,通过写入的时候加上 \\n 来结尾,读取的时候使用readLine 按行来读取数据,通过遇到结束标志 \\n来结束行的读取。

  • PasteServer.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * @author hosystem
 * @version 1.3
 */
public class PasteServer {

    public static void main(String[] args) throws IOException {
        // 创建 Socket 服务器
        ServerSocket serverSocket = new ServerSocket(9999);
        // 获取客户端连接
        Socket clientSocket = serverSocket.accept();
        // 得到客户端发送的流对象
        try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()))) {
            while (true) {
                String msg = bufferedReader.readLine();
                if (msg != null) {
                    // 成功接收到客户端的消息并打印
                    System.out.println("接收到客户端的信息:" + msg);
                }
            }
        }
    }
}
  • PasteClient.java
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;

public class PasteClient {

    public static void main(String[] args) throws IOException {
        // 创建 Socket 客户端并尝试连接服务器端
        Socket socket = new Socket("127.0.0.1", 9999);
        // 发送的消息内容
        final String message = "hello.java";
        // 使用输出流发送消息

        try (BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))){
            // 给服务器端发送 10 次消息
            for (int i = 0; i < 10; i++) {
                // 注意:结尾的 \\n 不能省略,它表示按行写入
                bufferedWriter.write(message + "\\n");
                // 刷新缓冲区(此步骤不能省略)
                bufferedWriter.flush();
            }
        }finally {
            socket.close();
        }
    }
}

TCP粘包

TCP粘包分析与处理

TCP粘包现象

TCP粘包通俗来讲,就是发送方发送的多个数据包,到接收方后粘连在一起,导致数据包不能完整的体现发送的数据。

TCP粘包原因分析

导致TCP粘包的原因,可能是发送方的原因,也有可能是接受方的原因。

发送方

由于TCP需要尽可能高效和可靠,所以TCP协议默认采用Nagle算法,以合并相连的小数据包,再一次性发送,以达到提升网络传输效率的目的。但是接收方并不知晓发送方合并数据包,而且数据包的合并在TCP协议中是没有分界线的,所以这就会导致接收方不能还原其本来的数据包。

接收方

TCP是基于“流”的。网络传输数据的速度可能会快过接收方处理数据的速度,这时候就会导致,接收方在读取缓冲区时,缓冲区存在多个数据包。在TCP协议中接收方是一次读取缓冲区中的所有内容,所以不能反映原本的数据信息。

解决TCP粘包

分析了产生TCP粘包的原因之后,针对发生的原因,针对性的采取解决方法。

禁用Negle算法

因为TCP协议采用Negle算法,导致粘包。所以可以禁用Nagle算法。

const char chOpt = 1;
int nErr = setsockopt(m_socket, IPPROTO_TCP, TCP_NODELAY, &chOpt, sizeof(char));   
if(nErr == -1)
{
    TRACE( "setsockopt() error\\n",  WSAGetLastError());
    return ;
}

这种方法虽然能一定程度上解决TCP粘包,但是并不能完全解决问题。因为接收方也是可能造成粘包的原因,这种方法只是发送方有效。而且禁用Nagle算法,一定程度上使TCP传输效率降低了。所以,这并不是一种理想的方法。

PUSH标志

PUSH是TCP报头中的一个标志位,发送方在发送数据的时候可以设置这个标志位。该标志通知接收方将接收到的数据全部提交给接收进程。这里所说的数据包括与此PUSH包一起传输的数据以及之前就为该进程传输过来的数据。
当Server端收到这些数据后,它需要立刻将这些数据提交给应用层进程,而不再等待是否还有额外的数据到达。
设置PUSH标志也不能完全解决TCP粘包,只是降低了接收方粘包的可能性。实际上现在的TCP协议栈基本上都可以自行处理这个问题,而不是交给应用层处理。所以设置PUSH标志,也不是一种理想的方法。

自定协议

自定协议,将数据包分为了封包和解包两个过程。在发送方发送数据时,对发送的数据进行封包操作。在接收方接收到数据时对接收的数据包需要进行解包操作。
自定协议时,封包就是为发送的数据增加包头,包头包含数据的大小的信息,数据就跟随在包头之后。当然包头也可以有其他的信息,比如一些做校验的信息。这里主要讨论TCP粘包的问题,所以不考虑其他的。

发送方封包

PACKAGE_HEAD pPackageHead; //PACKAGE_HEAD 包头结构体
char PackageHead[1024];
int headLen = sizeof(PACKAGE_HEAD);
int packgeContextLen = strlen(packageContext); //packageContext 发送的数据
pPackageHead->nDataLen = packgeContextLen; //包的大小

char *packge = (char*)malloc(headLen + packgeContextLen); //包的内存分配
memset(packge, 0, headLen + packgeContextLen);
char *packgeCpy = (char*)memcpy(packge, (char*)&pPackageHead, headLen);//拷贝包头
packgeCpy += headLen;
packge = (char*)memcpy(packgeCpy, (char*)&packageContext, packgeContextLen);//拷贝包内容

int ret = 0;
ret = send(m_hSocket, packge, headLen + packgeContextLen, 0); //发送包
if (ret == SOCKET_ERROR || ret == 0)
{
    return ret;
}

接收方解包

char PackageHead[1024];
char PackageContext[1024*20];

int len;
PACKAGE_HEAD *pPackageHead; //PACKAGE_HEAD 包头结构体
while( m_bClose == false )
{
    memset(PackageHead, 0, sizeof(PACKAGE_HEAD));
    len = ReceiveSize(m_TcpSock, (char*)PackageHead, sizeof(PACKAGE_HEAD)); //接收包头
    if( len == SOCKET_ERROR )
    {
        break;
    }
    if(len == 0)
    {
        break;
    }
    pPackageHead = (PACKAGE_HEAD *)PackageHead;
    memset(PackageContext,0,sizeof(PackageContext));
    if(pPackageHead->nDataLen>0) //根据包头中的数据长度,接收数据
    {
        len = ReceiveSize(m_TcpSock, (char*)PackageContext,pPackageHead->nDataLen);
    }
}

接收指定长度的数据函数

//接收指定长度的数据
int ReceiveSize(SOCKET m_hSocket, char* strData, int gLen)
{
    if(strData == NULL)
        return ERR_BADPARAM;
    char *p = strData;
    int len = gLen;
    int ret = 0;
    int returnlen = 0;
    while( len > 0)
    {
        ret = recv( m_hSocket, p+(iLen-len), iLen-returnlen, 0);
        if (ret == SOCKET_ERROR || ret == 0)
        {
            return ret;
        }

        len -= ret;
        returnlen += ret;
    }

    return returnlen;
}

这样就可以达到解决TCP粘包的问题。在实际使用中包头还带有更多的信息,而且包尾可能还会带上分隔符,在redis、FTP中就是这样处理的。

UDP不存在粘包

由于UDP不是面向‘流’的,而且UDP是具有消息边界的。也就是说UDP的发送的每一个数据包都是独立的。所以UDP并不存在粘包的问题。

 

以上是关于TCP 粘包的主要内容,如果未能解决你的问题,请参考以下文章

12.netty中tcp粘包拆包问题及解决方法

12.netty中tcp粘包拆包问题及解决方法

详解啥是 TCP 粘包和拆包现象并演示 Netty 是如何解决的

Netty入门解决TCP粘包/分包的实例

socket tcp 粘包解决

TCP 粘包