Socket粘包問題的3種解決方案,最後一種最完美!
阿新 • • 發佈:2021-01-06
在 Java 語言中,傳統的 Socket 程式設計分為兩種實現方式,這兩種實現方式也對應著兩種不同的傳輸層協議:TCP 協議和 UDP 協議,但作為網際網路中最常用的傳輸層協議 TCP,在使用時卻會導致粘包和半包問題,於是為了徹底的解決此問題,便誕生了此篇文章。
# 什麼是 TCP 協議?
TCP 全稱是 Transmission Control Protocol(傳輸控制協議),它由 IETF 的 RFC 793 定義,是一種面向連線的點對點的傳輸層通訊協議。
TCP 通過使用序列號和確認訊息,從傳送節點提供有關傳輸到目標節點的資料包的傳遞的資訊。TCP 確保資料的可靠性,端到端傳遞,重新排序和重傳,直到達到超時條件或接收到資料包的確認為止。
![image.png](https://cdn.nlark.com/yuque/0/2021/png/92791/1609849619545-6e823b01-343c-4a0a-94be-85994338cda8.png#align=left&display=inline&height=524&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1048&originWidth=2304&size=875303&status=done&style=none&width=1152)
TCP 是 Internet 上最常用的協議,它也是實現 HTTP(HTTP 1.0/HTTP 2.0)通訊的基礎,當我們在瀏覽器中請求網頁時,計算機會將 TCP 資料包傳送到 Web 伺服器的地址,要求它將網頁返還給我們,Web 伺服器通過傳送 TCP 資料包流進行響應,然後瀏覽器將這些資料包縫合在一起以形成網頁。
TCP 的全部意義在於它的可靠性,它通過對資料包編號來對其進行排序,而且它會通過讓伺服器將響應傳送回瀏覽器說“已收到”來進行錯誤檢查,因此在傳輸過程中不會丟失或破壞任何資料。
目前市場上主流的 HTTP 協議使用的版本是 HTTP/1.1,如下圖所示:
![image.png](https://cdn.nlark.com/yuque/0/2021/png/92791/1609844893350-586e593f-71dd-4a06-ab5f-484b098bea42.png#align=left&display=inline&height=304&margin=%5Bobject%20Object%5D&name=image.png&originHeight=608&originWidth=702&size=73747&status=done&style=none&width=351)
# 什麼是粘包和半包問題?
粘包問題是指當傳送兩條訊息時,比如傳送了 ABC 和 DEF,但另一端接收到的卻是 ABCD,像這種一次性讀取了兩條資料的情況就叫做粘包(正常情況應該是一條一條讀取的)。
![image.png](https://cdn.nlark.com/yuque/0/2021/png/92791/1609848617470-e3c6dc94-17e6-429d-abad-aa9ce71c84f1.png#align=left&display=inline&height=262&margin=%5Bobject%20Object%5D&name=image.png&originHeight=524&originWidth=1118&size=37355&status=done&style=none&width=559)
半包問題是指,當傳送的訊息是 ABC 時,另一端卻接收到的是 AB 和 C 兩條資訊,像這種情況就叫做半包。
![image.png](https://cdn.nlark.com/yuque/0/2021/png/92791/1609848676317-81754963-e040-4216-82a6-9ffa4d4eb84b.png#align=left&display=inline&height=261&margin=%5Bobject%20Object%5D&name=image.png&originHeight=522&originWidth=1102&size=35199&status=done&style=none&width=551)
# 為什麼會有粘包和半包問題?
這是**因為 TCP 是面向連線的傳輸協議,TCP 傳輸的資料是以流的形式,而流資料是沒有明確的開始結尾邊界,所以 TCP 也沒辦法判斷哪一段流屬於一個訊息**。
### 粘包的主要原因:
- 傳送方每次寫入資料 < 套接字(Socket)緩衝區大小;
- 接收方讀取套接字(Socket)緩衝區資料不夠及時。
### 半包的主要原因:
- 傳送方每次寫入資料 > 套接字(Socket)緩衝區大小;
- 傳送的資料大於協議的 MTU (Maximum Transmission Unit,最大傳輸單元),因此必須拆包。
### 小知識點:什麼是緩衝區?
緩衝區又稱為快取,它是記憶體空間的一部分。也就是說,在記憶體空間中預留了一定的儲存空間,這些儲存空間用來緩衝輸入或輸出的資料,這部分預留的空間就叫做緩衝區。
緩衝區的優勢以檔案流的寫入為例,如果我們不使用緩衝區,那麼每次寫操作 CPU 都會和低速儲存裝置也就是磁碟進行互動,那麼整個寫入檔案的速度就會受制於低速的儲存裝置(磁碟)。但如果使用緩衝區的話,每次寫操作會先將資料儲存在高速緩衝區記憶體上,當緩衝區的資料到達某個閾值之後,再將檔案一次性寫入到磁碟上。因為記憶體的寫入速度遠遠大於磁碟的寫入速度,所以當有了緩衝區之後,檔案的寫入速度就被大大提升了。
# 粘包和半包問題演示
接下來我們用程式碼來演示一下粘包和半包問題,為了演示的直觀性,我會設定兩個角色:
- 伺服器端用來接收訊息;
- 客戶端用來發送一段固定的訊息。
然後通過列印伺服器端接收到的資訊來觀察粘包和半包問題。
伺服器端程式碼如下:
```java
/**
* 伺服器端(只負責接收訊息)
*/
class ServSocket {
// 位元組陣列的長度
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;
}
}
}
}
```
客戶端程式碼如下:
```java
/**
* 客戶端(只負責傳送訊息)
*/
static class ClientSocket {
public static void main(String[] args) throws IOException {
// 建立 Socket 客戶端並嘗試連線伺服器端
Socket socket = new Socket("127.0.0.1", 9999);
// 傳送的訊息內容
final String message = "Hi,Java.";
// 使用輸出流傳送訊息
try (OutputStream outputStream = socket.getOutputStream()) {
// 給伺服器端傳送 10 次訊息
for (int i = 0; i < 10; i++) {
// 傳送訊息
outputStream.write(message.getBytes());
}
}
}
}
```
以上程式的通訊結果如下圖所示:
![image.png](https://cdn.nlark.com/yuque/0/2021/png/92791/1609812807558-ea5595ca-b185-4482-889d-47546b6448f0.png#align=left&display=inline&height=181&margin=%5Bobject%20Object%5D&name=image.png&originHeight=362&originWidth=1044&size=64260&status=done&style=none&width=522)
通過上述結果我們可以看出,伺服器端發生了粘包和半包的問題,因為客戶端傳送了 10 次固定的“Hi,Java.”的訊息,正常的結果應該是伺服器端也接收到了 10 次固定的訊息才對,但現實的結果並非如此。
# 粘包和半包的解決方案
粘包和半包的解決方案有以下 3 種:
1. 傳送方和接收方規定固定大小的緩衝區,也就是傳送和接收都使用固定大小的 byte[] 陣列長度,當字元長度不夠時使用空字元彌補;
1. 在 TCP 協議的基礎上封裝一層資料請求協議,既將資料包封裝成資料頭(儲存資料正文大小)+ 資料正文的形式,這樣在服務端就可以知道每個資料包的具體長度了,知道了傳送資料的具體邊界之後,就可以解決半包和粘包的問題了;
1. 以特殊的字元結尾,比如以“\n”結尾,這樣我們就知道結束字元,從而避免了半包和粘包問題(**推薦解決方案**)。
那麼接下來我們就來演示一下,以上解決方案的具體程式碼實現。
## 解決方案1:固定緩衝區大小
固定緩衝區大小的實現方案,只需要控制伺服器端和客戶端傳送和接收位元組的(陣列)長度相同即可。
伺服器端實現程式碼如下:
```java
/**
* 伺服器端,改進版本一(只負責接收訊息)
*/
static class ServSocketV1 {
private static final int BYTE_LENGTH = 1024; // 位元組陣列長度(收訊息用)
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9091);
// 獲取到連線
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;
}
}
}
}
```
客戶端實現程式碼如下:
```java
/**
* 客戶端,改進版一(只負責接收訊息)
*/
static class ClientSocketV1 {
private static final int BYTE_LENGTH = 1024; // 位元組長度
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 9091);
final String message = "Hi,Java."; // 傳送訊息
try (OutputStream outputStream = socket.getOutputStream()) {
// 將資料組裝成定長位元組陣列
byte[] bytes = new byte[BYTE_LENGTH];
int idx = 0;
for (byte b : message.getBytes()) {
bytes[idx] = b;
idx++;
}
// 給伺服器端傳送 10 次訊息
for (int i = 0; i < 10; i++) {
outputStream.write(bytes, 0, BYTE_LENGTH);
}
}
}
}
```
以上程式碼的執行結果如下圖所示:
![image.png](https://cdn.nlark.com/yuque/0/2021/png/92791/1609816146839-f5b38c5b-6c6b-4282-b2de-4519c27d1f5d.png#align=left&display=inline&height=320&margin=%5Bobject%20Object%5D&name=image.png&originHeight=640&originWidth=628&size=94024&status=done&style=none&width=314)
### 優缺點分析
從以上程式碼可以看出,雖然這種方式可以解決粘包和半包的問題,但這種固定緩衝區大小的方式增加了不必要的資料傳輸,因為這種方式當傳送的資料比較小時會使用空字元來彌補,所以這種方式就大大的增加了網路傳輸的負擔,所以它也不是最佳的解決方案。
## 解決方案二:封裝請求協議
這種解決方案的實現思路是將請求的資料封裝為兩部分:資料頭+資料正文,在資料頭中儲存資料正文的大小,當讀取的資料小於資料頭中的大小時,繼續讀取資料,直到讀取的資料長度等於資料頭中的長度時才停止。
因為這種方式可以拿到資料的邊界,所以也不會導致粘包和半包的問題,但這種實現方式的編碼成本較大也不夠優雅,因此不是最佳的實現方案,因此我們這裡就略過,直接來看最終的解決方案吧。
## 解決方案三:特殊字元結尾,按行讀取
以特殊字元結尾就可以知道流的邊界了,因此也可以用來解決粘包和半包的問題,**此實現方案是我們推薦最終解決方案**。
這種解決方案的核心是,使用 Java 中自帶的 `BufferedReader` 和 `BufferedWriter`,也就是帶緩衝區的輸入字元流和輸出字元流,通過寫入的時候加上 `\n` 來結尾,讀取的時候使用 `readLine` 按行來讀取資料,這樣就知道流的邊界了,從而解決了粘包和半包的問題。
伺服器端實現程式碼如下:
```java
/**
* 伺服器端,改進版三(只負責收訊息)
*/
static class ServSocketV3 {
public static void main(String[] args) throws IOException {
// 建立 Socket 伺服器端
ServerSocket serverSocket = new ServerSocket(9092);
// 獲取客戶端連線
Socket clientSocket = serverSocket.accept();
// 使用執行緒池處理更多的客戶端
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(100, 150, 100,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));
threadPool.submit(() -> {
// 訊息處理
processMessage(clientSocket);
});
}
/**
* 訊息處理
* @param 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();
}
}
}
```
> PS:上述程式碼使用了執行緒池來解決多個客戶端同時訪問伺服器端的問題,從而實現了一對多的伺服器響應。
客戶端的實現程式碼如下:
```java
/**
* 客戶端,改進版三(只負責傳送訊息)
*/
static class ClientSocketV3 {
public static void main(String[] args) throws IOException {
// 啟動 Socket 並嘗試連線伺服器
Socket socket = new Socket("127.0.0.1", 9092);
final String message = "Hi,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();
}
}
}
}
```
以上程式碼的執行結果如下圖所示:
![image.png](https://cdn.nlark.com/yuque/0/2021/png/92791/1609816939523-5f5fb0db-7ce9-44bb-a56a-bd0071e01d21.png#align=left&display=inline&height=324&margin=%5Bobject%20Object%5D&name=image.png&originHeight=648&originWidth=580&size=88360&status=done&style=none&width=290)
# 總結
本文我們講了 TCP 粘包和半包問題,粘包是指讀取到了兩條資訊,正常情況下訊息應該是一條一條讀取的,而半包問題是指讀取了一半資訊。導致粘包和半包的原因是 TCP 的傳輸是以流的形式進行的,而流資料是沒有明確的開始和結尾標識的,因此就導致了此問題。
本文我們提供了 3 種粘包和半包的解決方案,其中最推薦的是使用 `BufferedReader` 和 `BufferedWriter` 按行來讀、寫和區分訊息,也就是本文的第三種解決方案。
#### 參考 & 鳴謝
[https://zhuanlan.zhihu.com/p/126279630](https://zhuanlan.zhihu.com/p/126279630)
[https://www.jianshu.com/p/6a4ec6095f2c](https://www.jianshu.com/p/6a4ec6095f2c)
> 關注公眾號「Java中文社群」發現更多幹貨。
>
> 檢視 Github 發現更多精彩:https://github.com/vipstone/algorithm