TCP粘包问题:如何正确处理TCP消息边界

322 阅读6分钟

本篇文章主要介绍了TCP协议中的粘包问题,以及如何正确处理TCP消息边界。

TCP协议的一些细节

  • TCP是面向字节流的协议

  • 网络传输过程中,多个TCP数据包可能被合并成一个数据包进行传输。

  • TCP拥塞控制算法**Nagle**

    • 如果包长度达到 MSS,则允许发送;
    • 如果该包含有 FIN,则允许发送;
    • 设置了 TCP_NODELAY 选项,则允许发送;
    • 未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送;
    • 上述条件都未满足,但发生了超时(一般为 200ms),则立即发送。
  • 发送方会将多个数据包放入一个TCP数据包中发送

  • 接收方会先把收到的数据放在系统接收缓冲区,用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据。

粘包问题的实际含义

从TCP的这些细节我们可以看出,由于TCP是面向字节流的协议,不论是在发送端,传输链路,还是在接收端,多个TCP数据包,都有可能被合并成一个。这种特点会给我们造成一些困扰,比如我们发送了两条消息“hello”和“world”,预期应该是这样的

hello和world.png

但是有可能会变成这样子

helloworld.png

到这里我们可以看出,所谓的粘包问题,实际上问的是:TCP的上层应用如何正确处理消息边界。

具体来讲,不是所有的粘包现象都需要处理,若传输的数据为不带结构的连续流数据(如文件传输),则不必把粘连的包分开(简称分包)。但在实际工程应用中,传输的数据一般为带结构的数据,这时就需要做分包处理。

如何正确处理TCP消息边界

为了避免TCP粘包,可以采用以下方法:

  1. 使用消息头和消息体:发送方在每个数据包的头部添加一个消息头,消息头中包含了该数据包的长度信息,接收方在接收数据时,先读取消息头,然后根据消息头中的长度信息来读取消息体,从而避免了粘包的问题。
  2. 使用特殊分隔符:发送方在每个数据包的末尾添加一个特殊的分隔符,接收方在接收数据时,通过该分隔符来区分每个数据包的边界。
  3. 消息定长:发送方在发送数据时,按照固定的长度进行分割,接收方按照固定的长度进行接收和解析,从而避免了粘包的问题。

使用消息头和消息体

好的,以下是一个使用Java代码实现基于消息头和消息体的TCP粘包示例:

发送方代码

import java.ioimport java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.ByteBuffer;

public class Client {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 8888);
        System.out.println("Connected to server.");

        OutputStream outputStream = socket.getOutputStream();

        // 发送第一个消息
        String message1 = "Hello";
        byte[] message1Bytes = message1.getBytes();
        int length1 = message1Bytes.length;
        byte[] header1 = ByteBuffer.allocate(4).putInt(length1).array();
        byte[] packet1 = new byte[header1.length + message1Bytes.length];
        System.arraycopy(header1, 0, packet1, 0, header1.length);
        System.arraycopy(message1Bytes, 0, packet1, header1.length, message1Bytes.length);
        outputStream.write(packet1);

        // 发送第二个消息
        String message2 = "World";
        byte[] message2Bytes = message2.getBytes();
        int length2 = message2Bytes.length;
        byte[] header2 = ByteBuffer.allocate(4).putInt(length2).array();
        byte[] packet2 = new byte[header2.length + message2Bytes.length];
        System.arraycopy(header2, 0, packet2, 0, header2.length);
        System.arraycopy(message2Bytes, 0, packet2, header2.length, message2Bytes.length);

        outputStream.write(packet2);

        socket.close();
        System.out.println("Connection closed.");
    }
}

接收方代码

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.ByteBuffer;

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("Server started at port 8888.");

        while (true) {
            Socket socket = serverSocket.accept();
            System.out.println("Accepted a new client.");

            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();

                    while (true) {
                        // 读取消息头
                        byte[] header = new byte[4];
                        inputStream.read(header);

                        // 解析消息长度
                        int length = ByteBuffer.wrap(header).getInt();

                        // 读取消息体
                        byte[] message = new byte[length];
                        inputStream.read(message);

                        // 将字节数组转换为字符串,并打印
                        String str = new String(message);
                        System.out.println("Received message: " + str);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

在上面的示例中,发送数据时,也先将消息转换为字节数组,并计算出消息的长度信息,然后在每个数据包的头部添加一个四字节的消息头,将消息头和消息体合并为一个数据包,然后发送给服务端。

在接收数据时,先读取四个字节的消息头,然后根据消息头中的长度信息来读取相应长度的消息体。

使用特殊分隔符

发送方代码

public class Client {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 8888);
        System.out.println("Connected to server.");

        OutputStream outputStream = socket.getOutputStream();

        // 发送第一个消息
        String message1 = "Hello#";
        byte[] message1Bytes = message1.getBytes("UTF-8");
        outputStream.write(message1Bytes);

        // 发送第二个消息
        String message2 = "World#";
        byte[] message2Bytes = message2.getBytes("UTF-8");
        outputStream.write(message2Bytes);

        socket.close();
        System.out.println("Connection closed.");
    }
}

接收方代码

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

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("Server started at port 8888.");

        while (true) {
            Socket socket = serverSocket.accept();
            System.out.println("Accepted a new client.");

            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    byte[] buffer = new byte[1024];
                    int len;
                    StringBuilder sb = new StringBuilder();

                    while ((len = inputStream.read(buffer)) != -1) {
                        // 将字节数据转换为字符串
                        String data = new String(buffer, 0, len, "UTF-8");

                        // 拼接字符串
                        sb.append(data);

                        // 判断是否收到了一个完整的消息
                        String message = sb.toString();
                        int index;
                        while ((index = message.indexOf("#")) != -1) {
                            String packet = message.substring(0, index);
                            System.out.println("Received message: " + packet);
                            sb.delete(0, index + 1);
                            message = message.substring(index + 1);
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

服务端在接收数据时,先将字节数据转换为字符串,然后将字符串拼接到一个可变字符串中,然后使用特殊分隔符“#”来判断每个数据包的边界,如果收到一个完整的数据包,则将该数据包解析为一个消息并打印出来。如果收到的数据包是不完整的,则保留到下一次读取时继续拼接。

如果特殊分隔符不能与消息中的数据重复,否则就会出现解析错误的问题,可以通过以下两种方式解决

  • 转义特殊字符:在消息中出现特殊分隔符时,使用转义字符对其进行转义,如使用“\”对“#”进行转义,这样就可以避免特殊分隔符与消息中的数据重复的问题。
  • 使用多个特殊分隔符:在消息中使用两个或多个特殊分隔符来表示一个分隔符,例如使用“##”来表示一个“#”,这样就可以避免特殊分隔符与消息中的数据重复的问题。

使用“转义特殊字符”处理分隔符与消息中数据重复的问题

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

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("Server started at port 8888.");

        while (true) {
            Socket socket = serverSocket.accept();
            System.out.println("Accepted a new client.");

            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    byte[] buffer = new byte[1024];
                    int len;
                    StringBuilder sb = new StringBuilder();

                    while ((len = inputStream.read(buffer)) != -1) {
                        // 将字节数据转换为字符串
                        String data = new String(buffer, 0, len, "UTF-8");

                        // 拼接字符串
                        sb.append(data);

                        // 判断是否收到了一个完整的消息
                        String message = sb.toString();
                        int index;
                            while ((index = message.indexOf("#")) != -1) {
                                // 判断特殊分隔符是否被转义
                                boolean isEscaped = false;
                                if (index > 0 && message.charAt(index - 1) == '\\') {
                                    isEscaped = true;
                                }

                                if (isEscaped) {
                                    sb.delete(index - 1, index + 1);
                                    message = message.substring(index + 1);
                                } else {
                                    String packet = message.substring(0, index);
                                    System.out.println("Received message: " + packet);
                                    sb.delete(0, index + 1);
                                    message = message.substring(index + 1);
                                }
                            }
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }).start();
            }
        }
    }
}

固定消息长度

发送方

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;

public class Client {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 8888);
        System.out.println("Connected to server.");

        OutputStream outputStream = socket.getOutputStream();

        // 发送第一个消息
        String message1 = "Hello";
        byte[] message1Bytes = message1.getBytes("UTF-8");
        if (message1Bytes.length < 1024) {
            byte[] padding = new byte[1024 - message1Bytes.length];
            for (int i = 0; i < padding.length; i++) {
                padding[i] = '#'; // 使用特殊字符“#”来填充消息
            }
            outputStream.write(message1Bytes);
            outputStream.write(padding);
        }

        // 发送第二个消息
        String message2 = "World";
        byte[] message2Bytes = message2.getBytes("UTF-8");
        if (message2Bytes.length < 1024) {
            byte[] padding = new byte[1024 - message2Bytes.length];
            for (int i =0; i < padding.length; i++) {
                padding[i] = '#'; // 使用特殊字符“#”来填充消息
            }
            outputStream.write(message2Bytes);
            outputStream.write(padding);
        }

        socket.close();
        System.out.println("Connection closed.");
    }
}

接受方

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

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("Server started at port 8888.");

        while (true) {
            Socket socket = serverSocket.accept();
            System.out.println("Accepted a new client.");

            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    byte[] buffer = new byte[1024];
                    int len;

                    while ((len = inputStream.read(buffer)) != -1) {
                        // 解析固定长度的数据包
                        String packet = new String(buffer, 0, len, "UTF-8");
                        System.out.println("Received message: " + packet);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}