Bootstrap

什么是TCP粘包和半包问题?如何解决?

什么是TCP粘包问题?如何解决?

TCP粘包和半包是数据传输中比较常见的问题。所谓的粘包问题就是指在数据传输的时候,在一条消息中读取到了另一条消息的部分数据,如下图:

半包是指接收端只收到了部分的数据,而非完整的数据的情况,如下图:

 大部分情况下我们都把粘包问题和半包问题看成同一个问题,所以下文就用粘包问题来替代粘包和半包问题

为什么会有粘包问题

粘包问题发生在TCP/IP协议中,因为TCP是面向连接的传输协议,它是以流的形式传输数据的,而流数据是没有明确开始和结尾的边界的,所以就会出现粘包问题

粘包问题演示

接下来我们用代码来演示一下粘包和半包问题,为了演示的直观性,我会设置两个角色:

  • 服务器端用来接收消息
  • 客户端用来发送一段固定的消息

服务端代码实现:

import java.io.*;
import java.net.*;

public class Server {
    private static final int BYTE_LENGTH = 20;

    public static void main(String[] args) throws IOException {
        // 创建 Socket 服务器
        ServerSocket serverSocket = new ServerSocket(8888);
        // 获取客户端连接
        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, 0, count));
                }
            }
        }
    }
}

客户端代码实现:

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;

public class Client {
    public static void main(String[] args) throws IOException {
        String serverAddress = "127.0.0.1";
        int port = 8888;

        try (Socket socket = new Socket(serverAddress, port);
             PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) {
            String message = "hello,world";
            OutputStream outputStream= socket.getOutputStream();
            for (int i = 0; i < 10; i++) {
                outputStream.write(message.getBytes());
            }
        }
    }
}

 程序执行结果:

此时我们发现出现了粘包问题,正常应该是直接输出10次hello world 才对

 解决方案

粘包问题的常见解决方案有以下三种:

  1. 固定数据大小:发送方和接收方固定发送消息的大小,当字符长度不够的时候用空字符弥补,有了固定大小就知道每条消息的边界了
  2. 自定义数据协议(定义数据边界):在TCP协议的基础上封装上一层自定义数据协议,在自定义的数据协议中,包含数据头(存储数据的大小)和数据的具体内容,这样服务端的得到的数据头就可以知道数据的具体长度,也就没有粘包问题
  3. 以特殊字符结尾:比如以“/n”字符结尾,这样就可以直到数据的具体边界,可以避免粘包问题(推荐使用)
解决方案一:固定数据大小

收、发固定大小的数据,服务端实现代码

import java.io.*;
import java.net.*;

public class Server {
    private static final int BYTE_LENGTH = 1024;

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

客户端实现代码:

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;

public class Client {
    private static final int BYTE_LENGTH = 1024;
    public static void main(String[] args) throws IOException, InterruptedException {
        String serverAddress = "127.0.0.1";
        int port = 8888;
        String message = "hello,world";
        try (Socket socket = new Socket(serverAddress, port)) {

            OutputStream outputStream= socket.getOutputStream();
            byte[] bytes = new byte[BYTE_LENGTH];
            int idx= 0;
            for(byte b:message.getBytes()){
                bytes[idx]= b;
                idx++;
            }
            for (int i = 0; i < 10; i++) {
                outputStream.write(bytes,0,BYTE_LENGTH);
            }
        }
    }
}

 运行结果:

后面是字符编码的问题

优缺点分析
从以上代码可以看出,虽然这种方式可以解决粘包问题,但这种固定数据大小的传输方式,当数据量比较小时会使用空字符来填充,所以会额外的增加网络传输的负担,因此不是理想的解决方案

解决方案二:自定义请求协议

这种解决方案的实现思路是将请求的数据封装成两部分:消息头(发送的数据大小)+消息体(发送的具体数据),如下图:

此解决方案的实现为以下三部分:

  1. 编写一个消息的封装类
  2. 编写客户端
  3. 编写服务器端 

消息的封装类:

import java.nio.charset.StandardCharsets;

public class CustomProtocol {
    private static final int HEAD_SIZE = 8; // 假设消息头固定为8个字节

    public static byte[] toBytes(String context) {
        // 协议体 byte 数组
        byte[] bodyByte = context.getBytes(StandardCharsets.UTF_8);
        int bodyByteLength = bodyByte.length;

        // 最终封装对象
        byte[] result = new byte[HEAD_SIZE + bodyByteLength];

        // 借助 NumberFormat 将int 转换为 byte[]
        NumberFormat numberFormat = NumberFormat.getNumberInstance();
        numberFormat.setMinimumIntegerDigits(HEAD_SIZE);
        numberFormat.setGroupingUsed(false);

        // 协议头 byte 数组
        byte[] headByte = numberFormat.format(bodyByteLength).getBytes();

        // 封装协议头
        System.arraycopy(headByte, 0, result, 0, HEAD_SIZE);

        // 封装协议体
        System.arraycopy(bodyByte, 0, result, HEAD_SIZE, bodyByteLength);
        
        return result;
    }
    public int getHeader(InputStream inputStream) throws IOException {
        int result = 0;
        byte[] bytes = new byte[HEAD_SIZE];
        inputStream.read(bytes, 0, HEAD_SIZE); // 得到消息体的字节长度
        result = Integer.valueOf(new String(bytes));
        return result;
    }
}

 客户端代码:

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

public class MySocketClient {
    public static void main(String[] args) throws IOException {
        // 启动 Socket 并尝试连接服务器
        Socket socket = new Socket("127.0.0.1", 9093);

        // 发送消息合集(随机发送一条消息)
        final String[] messages = {"hello world"};

        // 创建协议封装对象
        SocketPacket socketPacket = new SocketPacket();
        
        try (OutputStream outputStream = socket.getOutputStream()) {
            // 给服务器端发送 10 次消息
            for (int i = 0; i < 10; i++) {
                // 随机发送一条消息
                String msg = messages[new Random().nextInt(messages.length)];
                
                // 将内容封装为:协议头+协议体
                byte[] bytes = socketPacket.toBytes(msg);
                
                // 发送消息
                outputStream.write(bytes, 0, bytes.length);
                outputStream.flush();
            }
        }
    }
}

服务器端代码: 

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class MySocketServer {
    public static void main(String[] args) throws IOException {
        // 创建 Socket 服务器端
        ServerSocket serverSocket = new ServerSocket(9093);

        // 使用线程池处理更多的客户端
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(100, 150, 100, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));

        while (true) {
            // 获取客户端连接
            Socket clientSocket = serverSocket.accept();

            // 客户端消息处理
            threadPool.submit(() -> {
                processMessage(clientSocket);
            });
        }
    }

    // 客户端消息处理
    private static void processMessage(Socket clientSocket) {
        // Socket 封装对象
        SocketPacket socketPacket = new SocketPacket();

        // 获取客户端发送的消息对象
        try (InputStream inputStream = clientSocket.getInputStream()) {
            while (true) {
                // 获取消息头(也就是消息体的长度)
                int bodyLength = socketPacket.getHeader(inputStream);

                // 消息体 byte 数组
                byte[] bodyByte = new byte[bodyLength];

                // 每次实际读取字节数
                int readCount = 0;

                // 消息体赋值下标
                int bodyIndex = 0;

                // 循环接收消息头中定义的长度
                while (bodyIndex <= (bodyLength - 1) && (readCount = inputStream.read(bodyByte, bodyIndex, bodyLength)) != -1) {
                    bodyIndex += readCount;
                }

                bodyIndex = 0;

                // 成功接收到客户端的消息并打印
                System.out.println("接收到客户端的信息:" + new String(bodyByte));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

运行结果:

 优缺点分析:

 此解决方案虽然可以解决粘包问题,但消息的设计和代码的实现复杂度比较高,所以也不是理想的解决方案

解决方案三:特殊字符结尾

以特殊字符结尾就可以知道流的边界了,它的具体实现是:使用Java 中自带的 BufferedReader 和Bufferedwriter ,也就是带缓冲区的输入字符流和输出字符流,通过写入的时候加上 \n 来结尾,读取的时候使用 readLine按行来读取数据,这样就知道流的边界了,从而解决了粘包的问题

服务器端代码:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ServerSocketV3 {
    public static void main(String[] args) throws IOException {
        // 创建 Socket 服务器端
        ServerSocket serverSocket = new ServerSocket(9092);

        // 使用线程池处理更多的客户端
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(100, 150, 100, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));

        while (true) {
            // 获取客户端连接
            Socket clientSocket = serverSocket.accept();

            // 消息处理
            threadPool.submit(() -> {
                processMessage(clientSocket);
            });
        }
    }

    // 消息处理
    private static void processMessage(Socket clientSocket) {
        // 获取客户端发送的消息流对象
        try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()))) {
            while (true) {
                // 按行读取客户端发送的消息
                String msg = bufferedReader.readLine();
                if (msg != null) {
                    // 成功接收到客户端的消息并打印
                    System.out.println("接收到客户端的信息:" + msg);
                }
            }
        } catch (IOException ioException) {
            ioException.printStackTrace();
        }
    }
}

客户端代码:

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;

public class ClientSocketV3 {
    public static void main(String[] args) throws IOException {
        // 启动 Socket 并尝试连接服务器
        Socket socket = new Socket("127.0.0.1", 9092);
        final String message = "hello world";

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

                // 刷新缓冲区(此步骤不能省略)
                bufferedWriter.flush();
            }
        }
    }
}

运行结果:

优缺点分析:

以特殊符号作为粘包的解决方案的最大优点是实现简单,但存在一定的局限性,比如当一条消息中间如果出现了结束符就会造成半包的问题,所以如果是复杂的字符串要对内容进行编码和解码处理,这样才能保证结束符的正确性

;