TCP协议——粘包与拆包
TCP的基礎
TCP協議基礎,傳送門
TCP協議流量控制,傳送門
1.1 什么是TCP粘包/拆包
TCP是個“流”協議,所謂流,就是沒有界限的一串數據。大家可以想想河里的流水,是連成一片的,其間并沒有分界線。TCP底層并不了解上層業務數據的具體含義,它會根據TCP緩沖區的實際情況進行包的劃分,所以在業務上認為,一個完整的包可能會被TCP拆分成多個包進行發送,也有可能把多個小的包封裝成一個大的數據包發送,這就是所謂的TCP粘包和拆包問題。
我們都知道TCP屬于傳輸層的協議,傳輸層除了有TCP協議外還有UDP協議。那么UDP是否會發生粘包或拆包的現象呢?答案是不會。UDP是基于報文發送的,從UDP的幀結構可以看出,在UDP首部采用了16bit來指示UDP數據報文的長度,因此在應用層能很好的將不同的數據報文區分開,從而避免粘包和拆包的問題。而TCP是基于字節流的,雖然應用層和TCP傳輸層之間的數據交互是大小不等的數據塊,但是TCP把這些數據塊僅僅看成一連串無結構的字節流,沒有邊界;另外從TCP的幀結構也可以看出,在TCP的首部沒有表示數據長度的字段,基于上面兩點,在使用TCP傳輸數據時,才有粘包或者拆包現象發生的可能。
1.2 粘包、拆包表現形式
現在假設客戶端向服務端連續發送了兩個數據包,用packet1和packet2來表示,那么服務端收到的數據可以分為三種,現列舉如下:
第一種情況,接收端正常收到兩個數據包,即沒有發生拆包和粘包的現象,此種情況不在本文的討論范圍內。
第二種情況,接收端只收到一個數據包,由于TCP是不會出現丟包的,所以這一個數據包中包含了發送端發送的兩個數據包的信息,這種現象即為粘包。這種情況由于接收端不知道這兩個數據包的界限,所以對于接收端來說很難處理。
第三種情況,這種情況有兩種表現形式,如下圖。接收端收到了兩個數據包,但是這兩個數據包要么是不完整的,要么就是多出來一塊,這種情況即發生了拆包和粘包。這兩種情況如果不加特殊處理,對于接收端同樣是不好處理的。
1.3 粘包、拆包發生原因
發生TCP粘包或拆包有很多原因,現列出常見的幾點,可能不全面,歡迎補充,
-
1、要發送的數據大于TCP發送緩沖區剩余空間大小,將會發生拆包。
-
2、待發送數據大于MSS(最大報文長度),TCP在傳輸前將進行拆包。
-
3、要發送的數據小于TCP發送緩沖區的大小,TCP將多次寫入緩沖區的數據一次發送出去,將會發生粘包。
-
4、接收數據端的應用層沒有及時讀取接收緩沖區中的數據,將發生粘包。
1.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文件中,需要測試粘包和拆包處理程序的同學需要把這一段注釋給去掉。
總結
以上是生活随笔為你收集整理的TCP协议——粘包与拆包的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 醋加蜂蜜水可以减肥吗
- 下一篇: http1.0 http1.1 http