TCP 粘包 - 拆包問題及解決方案

HOsystem發表於2021-10-21


TCP粘包拆包問題

- TCP 全稱是 Transmission Control Protocol(傳輸控制協議),它由 IETF 的 RFC 793 定義,是一種面向連線的點對點的傳輸層通訊協議。
- 粘包拆包問題是處於⽹絡⽐較底層的問題,在資料鏈路層、⽹絡層以及傳輸層都有可能發⽣;
- TCP會發生粘包問題;TCP⽆訊息保護邊界,需要在接收端處理訊息邊界問題,也就是我們所說的粘包、拆包問題;
- UDP不會發生粘包問題;UDP具有保護訊息邊界,在每個UDP包中就有了訊息頭(UDP長度、源埠、目的埠、校驗和)。

什麼是粘包 - 拆包問題

  • 粘包問題
- 粘包問題指,當傳送方傳送了資料包 `訊息1 - ABC` 和 `訊息2 - DEF` 時,但接收方接收到的資料包卻是 `訊息 -  ABCDEF`,像這種一次性讀取了兩條資料包的資料粘連在一起的情況就叫做粘包(正常情況應該是一條一條讀取的)。

pastepacket1

  • 拆包問題
- 拆包問題是指,當傳送方傳送了資料包 ` 訊息1 -  ABC ` 和 `訊息2 - DEF` 時,接收方接收到資料包經拆分後獲得了 `ABCD` 和 `EF` 兩個資料包資訊的情況,像這種情況有時候也叫做半包。

pastepacket2

為什麼存在粘包 - 拆包問題

- 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();
        }
    }
}

image-20211021015138976

通過上述結果我們可以看出,伺服器端發生了粘包和拆包問題,因為客戶端傳送了 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();
        }
    }
}

image-20211021023001542

方式二: 封裝請求協議

將請求的資料封裝為兩部分:資料頭+資料正文,在資料頭中儲存資料正文的大小,當讀取的資料小於資料頭中的大小時,繼續讀取資料,直到讀取的資料長度等於資料頭中的長度時才停止。

實現起來較為複雜,這裡不給出程式碼,可以使用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();
        }
    }
}

image-20211021023001542

相關文章