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


歧義在“TCP”上,這個“粘包”跟TCP其實沒關係。這裏的“粘包”其實是應用程序中沒有處理好數據包分割,兩個應用層的數據包粘在一塊了。不過面試都那麼問,所以把問題複述一遍。在面試過程中可以說明一下不是TCP協議的問題,而是因爲沒有處理好數據包分割,兩個應用層的數據包粘在一塊了;這也是讓面試官眼前一亮的一次機會。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章