TCP粘包,拆包及解決方法
在進行Java NIO學習時,發現,如果客戶端連續不斷的向服務端傳送資料包時,服務端接收的資料會出現兩個資料包粘在一起的情況,這就是TCP協議中經常會遇到的粘包以及拆包的問題。
我們都知道TCP屬於傳輸層的協議,傳輸層除了有TCP協議外還有UDP協議。那麼UDP是否會發生粘包或拆包的現象呢?答案是不會。UDP是基於報文傳送的,從UDP的幀結構可以看出,在UDP首部採用了16bit來指示UDP資料報文的長度,因此在應用層能很好的將不同的資料報文區分開,從而避免粘包和拆包的問題。而TCP是基於位元組流的,雖然應用層和TCP傳輸層之間的資料互動是大小不等的資料塊,但是TCP把這些資料塊僅僅看成一連串無結構的位元組流,沒有邊界;另外從TCP的幀結構也可以看出,在TCP的首部沒有表示資料長度的欄位,基於上面兩點,在使用TCP傳輸資料時,才有粘包或者拆包現象發生的可能。
粘包、拆包表現形式
現在假設客戶端向服務端連續傳送了兩個資料包,用packet1和packet2來表示,那麼服務端收到的資料可以分為三種,現列舉如下:
第一種情況,接收端正常收到兩個資料包,即沒有發生拆包和粘包的現象,此種情況不在本文的討論範圍內。
第二種情況,接收端只收到一個數據包,由於TCP是不會出現丟包的,所以這一個資料包中包含了傳送端傳送的兩個資料包的資訊,這種現象即為粘包。這種情況由於接收端不知道這兩個資料包的界限,所以對於接收端來說很難處理。
第三種情況,這種情況有兩種表現形式,如下圖。接收端收到了兩個資料包,但是這兩個資料包要麼是不完整的,要麼就是多出來一塊,這種情況即發生了拆包和粘包。這兩種情況如果不加特殊處理,對於接收端同樣是不好處理的。
粘包、拆包發生原因
發生TCP粘包或拆包有很多原因,現列出常見的幾點,可能不全面,歡迎補充,
1、要傳送的資料大於TCP傳送緩衝區剩餘空間大小,將會發生拆包。
2、待發送資料大於MSS(最大報文長度),TCP在傳輸前將進行拆包。
3、要傳送的資料小於TCP傳送緩衝區的大小,TCP將多次寫入緩衝區的資料一次傳送出去,將會發生粘包。
4、接收資料端的應用層沒有及時讀取接收緩衝區中的資料,將發生粘包。
等等。
粘包、拆包解決辦法
通過以上分析,我們清楚了粘包或拆包發生的原因,那麼如何解決這個問題呢?解決問題的關鍵在於如何給每個資料包新增邊界資訊,常用的方法有如下幾個:
1、傳送端給每個資料包新增包首部,首部中應該至少包含資料包的長度,這樣接收端在接收到資料後,通過讀取包首部的長度欄位,便知道每一個數據包的實際長度了。
2、傳送端將每個資料包封裝為固定長度(不夠的可以通過補0填充),這樣接收端每次從接收緩衝區中讀取固定長度的資料就自然而然的把每個資料包拆分開來。
3、可以在資料包之間設定邊界,如新增特殊符號,這樣,接收端通過這個邊界就可以將不同的資料包拆分開。
等等。
樣例程式
我將在程式中使用兩種方法來解決粘包和拆包問題,固定資料包長度和新增長度首部,這兩種方法各有優劣。固定資料包長度傳輸效率一般,尤其是在要傳送的資料長度長短差別很大的時候效率會比較低,但是程式設計實現比較簡單;新增長度首部雖然可以獲得較高的傳輸效率,冗餘資訊少且固定,但是程式設計實現較為複雜。下面給出的樣例程式是基於之前的文章《Java中BIO,NIO和AIO使用樣例》中提到的NIO例項的,如果對NIO的使用還不是很熟悉,可以先了解一下Java中NIO程式設計。
固定資料包長度
這種處理方式的思路很簡單,傳送端在傳送實際資料前先把資料封裝為固定長度,然後在傳送出去,接收端接收到資料後按照這個固定長度進行拆分即可。傳送端程式如下:
// 傳送端
String msg = "hello world " + number++;
socketChannel.write(ByteBuffer.wrap(new FixLengthWrapper(msg).getBytes()));
// 封裝固定長度的工具類
public class FixLengthWrapper {
public static final int MAX_LENGTH = 32;
private byte[] data;
public FixLengthWrapper(String msg) {
ByteBuffer byteBuffer = ByteBuffer.allocate(MAX_LENGTH);
byteBuffer.put(msg.getBytes());
byte[] fillData = new byte[MAX_LENGTH - msg.length()];
byteBuffer.put(fillData);
data = byteBuffer.array();
}
public FixLengthWrapper(byte[] msg) {
ByteBuffer byteBuffer = ByteBuffer.allocate(MAX_LENGTH);
byteBuffer.put(msg);
byte[] fillData = new byte[MAX_LENGTH - msg.length];
byteBuffer.put(fillData);
data = byteBuffer.array();
}
public byte[] getBytes() {
return data;
}
public String toString() {
StringBuilder sb = new StringBuilder();
for (byte b : getBytes()) {
sb.append(String.format("0x%02X ", b));
}
return sb.toString();
}
}
可以看到客戶端在傳送資料前首先把資料封裝為長度為32bytes的資料包,這個長度是根據目前實際資料包長度來規定的,這個長度必須要大於所有可能出現的資料包的長度,這樣才不會出現把資料“截斷”的情況。接收端程式如下:
private static void processByFixLength(SocketChannel socketChannel) throws IOException {
while (socketChannel.read(byteBuffer) > 0) {
byteBuffer.flip();
while (byteBuffer.remaining() >= FixLengthWrapper.MAX_LENGTH) {
byte[] data = new byte[FixLengthWrapper.MAX_LENGTH];
byteBuffer.get(data, 0, FixLengthWrapper.MAX_LENGTH);
System.out.println(new String(data) + " <---> " + number++);
}
byteBuffer.compact();
}
}
可以看出接收端的處理很簡單,只需要每次讀取固定的長度即可區分出來不同的資料包。
新增長度首部
這種方式的處理較上面提到的方式稍微複雜一點。在傳送端需要給待發送的資料新增固定的首部,然後再發送出去,然後在接收端需要根據這個首部的長度資訊進行資料包的組合或拆分,傳送端程式如下:
// 傳送端
String msg = "hello world " + number++;
// add the head represent the data length
socketChannel.write(ByteBuffer.wrap(new PacketWrapper(msg).getBytes()));
// 新增長度首部的工具類
public class PacketWrapper {
private int length;
private byte[] payload;
public PacketWrapper(String payload) {
this.payload = payload.getBytes();
this.length = this.payload.length;
}
public PacketWrapper(byte[] payload) {
this.payload = payload;
this.length = this.payload.length;
}
public byte[] getBytes() {
ByteBuffer byteBuffer = ByteBuffer.allocate(this.length + 4);
byteBuffer.putInt(this.length);
byteBuffer.put(payload);
return byteBuffer.array();
}
public String toString() {
StringBuilder sb = new StringBuilder();
for (byte b : getBytes()) {
sb.append(String.format("0x%02X ", b));
}
return sb.toString();
}
}
從程式可以看到,傳送端在傳送資料前首先給待發送資料添加了代表長度的首部,首部長為4bytes(即int型長度),這樣接收端在收到這個資料之後,首先需要讀取首部,拿到實際資料長度,然後再繼續讀取實際長度的資料,即實現了組包和拆包的操作。程式如下:
private static void processByHead(SocketChannel socketChannel) throws IOException {
while (socketChannel.read(byteBuffer) > 0) {
// 儲存bytebuffer狀態
int position = byteBuffer.position();
int limit = byteBuffer.limit();
byteBuffer.flip();
// 判斷資料長度是否夠首部長度
if (byteBuffer.remaining() < 4) {
byteBuffer.position(position);
byteBuffer.limit(limit);
continue;
}
// 判斷bytebuffer中剩餘資料是否足夠一個包
int length = byteBuffer.getInt();
if (byteBuffer.remaining() < length) {
byteBuffer.position(position);
byteBuffer.limit(limit);
continue;
}
// 拿到實際資料包
byte[] data = new byte[length];
byteBuffer.get(data, 0, length);
System.out.println(new String(data) + " <---> " + number++);
byteBuffer.compact();
}
}
關鍵資訊已經在程式中做了註釋,可以很明顯的感覺到這種方法的處理難度相對於固定長度要大一些,不過這種方式可以獲取更大的傳輸效率。
這裡需要提醒各位同學一個問題,由於我在測試的時候採用的是一臺機器連續傳送資料來模擬高併發的場景,所以在測試的時候會發現伺服器端收到的資料包的個數經常會小於包的序號,好像發生了丟包。但經過仔細分析可以發現,這種情況是因為TCP傳送快取溢位導致的丟包,也就是這個資料包根本沒有發出來。也就是說,傳送端傳送資料過快,導致接收端快取很快被填滿,這個時候接收端會把通知視窗設定為0從而控制傳送端的流量,這樣新到的資料只能暫存在傳送端的傳送快取中,當傳送快取溢位後,就出現了我上面提到的丟包,這個問題可以通過增大發送端快取來緩解這個問題,
socketChannel.socket().setSendBufferSize(102400);
當然這個話題不在本文的討論範圍,如果有興趣的同學可以參閱《TCP/IP詳解卷一》中的擁塞視窗一章。
關於原始碼說明,原始碼預設是把粘包和拆包處理這一部分註釋掉了,分別位於NIOTcpServer和NIOTcpClient檔案中,需要測試粘包和拆包處理程式的同學需要把這一段註釋給去掉。