Netty 如何駕馭 TCP 串流?黏包拆包問題全解與編解碼器優秀實踐
2025.04.12
當Netty涉及網路IO資料傳輸時,可能會涉及到下面這些面試題:
什麼是TCP黏包和拆包?為什麼UDP不會有這個問題?
發生黏包和拆包的原因是什麼?
Netty是如何解決TCP黏包和拆包的?
一、詳解TCP黏包拆包問題
1. 問題復現
在正式講解問題之前,我們先來看一段範例,查看TCP黏包和拆包問題是如何發生的,下面這兩段程式碼分別是服務端設定和業務處理器,它會在與客戶端建立連線之後,不斷輸出客戶端發送的資料:
服務端業務處理器核心程式碼,邏輯也非常簡單,收到訊息後直接列印輸出:
讓我們再來看看客戶端的業務處理器和配置類,業務處理器的程式碼非常簡單,在建立連線後連續發送1000條數據,數據內容為:hello Netty Server!:
而配置類別也是固定模板:
將服務端和客戶端啟動後,我們可以看到下面這段輸出,可以看到大量的hello Netty Server!資料黏在一起構成一個個黏包。
2. 原因剖析
在TCP程式設計中,在服務端與客戶端通訊時訊息都會有固定的訊息格式,這種格式我們通常稱之為protocol即協議,例如我們常見的應用層協定:HTTP、FTP等。
而上述例子出現黏包的原因本質就是我們服務端與客戶端進行通訊時,沒有確認協議的規範,因為TCP是面向連接、面向流的協議,它會因為各種原因導致完整的數據包被拆封無數個小的數據包進行發送,進而導致接收方收到數據後無法正確的處理數據包,出現粘包和拆包:
而出現TCP封包被拆分的原因大致有3個:
socket緩衝區與滑動窗口
nagle演算法
mss
先來談談socket緩衝區和滑動視窗的共同作用,我們都知道TCP是全雙工、面向流的協定。這意味著發送時必須要保證收發正常,所以TCP就提出了一個滑動窗口機制,即以滑動窗口的大小為單位,讓雙方基於這個窗口的大小進行數據收發,發送方只有在滑動窗口以內的數據才能被發送,接收方也只有在窗口以內的數據被接收和處理,只有在接收方的滑動窗口收到信號發送方的數據,處理窗口並發送方的數據,ACK
由於TCP是面向流的協議,在此期間雙方收發的資料也會存放到socket緩衝區中。這意味著這連個緩衝區是無法知曉這些資料是否屬於同一個資料包的。 同理socket緩衝區也分為發送緩衝區(SO_SNDBUF )和接收緩衝區(SO_RCVBUF),所有socket需要發送的資料也都是存放到socket的緩衝區中然後透過內核函數傳到內核協定堆疊進行資料傳送,socket接收緩衝區將資料也透過作業系統的核心核複製到貝氏緩衝區。
所以。 socket緩衝區和滑動視窗機制共同作用下就會出現以下兩種異常情況:
(1) 發送方發送的資料達到了滑動視窗的限制,停止發送,接收方的socket緩衝區拿到這些資料後,直接向應用層傳輸,因為包不是完整的,從接收方的角度來看,出現了拆包。
(2) 發送方發送多個資料包到接收方緩衝區,因為接收方socket緩衝區無法及時處理,導致真正開始處理時無法知曉資料包的邊界,只能一次將資料包向上傳遞,導致黏包。
再來談談Nagle演算法,考慮到每次發送資料包時都需要為資料加上TCP Header20位元組和IP header 20字節,以及還得等待發送方的ACK確認包,這就很可能出現下面這種非常浪費網路資源的情況:
為了1個位元組的有用資訊去組裝10位元組的頭部資訊!
對此,作業系統為了盡可能的利用網路頻寬,就提出了Nagle演算法,該演算法要求所有已發送出去的小資料包(長度小於MSS)必須等到接收方的都回覆ack訊號之後,然後再將這些小資料段一併打包成一個打包發送,從而盡可能利用頻寬及盡可能避免因為大量小的網路包的傳輸造成網路擁塞。
很明顯如果將多個小的資料包合併發送,接收方也很可能因為無法確認資料包的邊界而出現黏包或拆包問題:
最後就是mss,也就是Maximum Segement Size的縮寫,代表傳輸一次性可以發送的資料最大長度,如果資料超過MSS的最大值,那麼網路資料包就會被拆成多個小包發送,這種情況下也很可能因為零零散散的資料包發送而會出現黏包和拆包問題。
對此我們不妨透過WireShark進行抓包分析,基於服務端埠鍵入下列指令進行過濾:
查看每次服務端發送的數據,無論大小還是內容都沒有缺失,內核緩衝區空間也是充足的,所以原因很明顯,因為TCP協議是面向流傳輸,接收方從內核緩衝區讀取時,拿到了過多或者過少的數據導致粘包或拆包。
二、半包黏包的解決對策
1. 幾種解決對策簡介
其實上述的問題的原因都是因為TCP是面向流的協議,導致了資料包無法被正常切割成一個個正常資料包的流。就以上面的封包為例,傳送的資料為hello Netty Server!,其實我們做到下面這幾種分割方式:
如果發送的資料都是以"!"結尾,那麼我們的分割時就判斷收到的流是否包含"!",只有包含時再將資料裝成資料包發送。
上述發送的資料長度為19,我們也可以規定發送的資料長度為19字節,一旦收到的資料達到19個位元組之後,就組裝成一個資料包。
自訂一個協議,要求發送方根據協議要求組裝資料包發送,例如要求資料包包含長度length和data兩個字段,其中length記錄資料包長度,以上述資料為例,這個字段的值為19,而data包含的就是資料內容。
2. 基於分隔符號的解碼器DelimiterBasedFrameDecoder
先來看看基於分隔符號的,可以看到每個資料結尾都有一個感嘆號,所以我們可以透過判斷特殊符號來完成資料拆包。
程式碼如下,我們基於DelimiterBasedFrameDecoder完成基於特殊分隔符號進行拆包,每個參數對應意義為:
資料包最大長度。
解碼時是否去掉分隔符號。
分隔符號。
啟動之後可以看到問題也能解決:
3. 基於資料長度的解碼器FixedLengthFrameDecoder
同理,我們也可以基於資料長度,對資料包進行分割:
由上文可知,我們發送的資料長度都是19,所以第一種方案是在服務端的pipeline配置一個基於長度拆包的解碼器,確保在每19個位元組截取一次以確保資料包可以正確讀取和解析。 所以我們在pipeline新增一個FixedLengthFrameDecoder,長度設定為19。
4. 基於協定長度欄位的解碼器LengthFieldBasedFrameDecoder
最後一種,也是筆者比較推薦的一種長度,即自訂協議,我們在傳輸過程中,可能資料的長度或分隔符號都無法保證,所以我們可以和客戶端協商一下,在傳輸的資料頭部添加資料包長度,例如用4位元組表示資料包長度。
所以客戶端建立連線後寫資料的程式碼就改為:
最終的資料包結構如下圖所示:
而服務端的處理器則改為使用LengthFieldBasedFrameDecoder,建構方法如下:
依對應參數意義為:
maxFrameLength:封包最大長度,這裡我們設定為Integer.MAX_VALUE,等於不限制。
lengthFieldOffset:此數值代表取得描述封包長度的欄位的位置偏移量,以我們的封包為例,就是0,即從最初始的位置讀取長度。
lengthFieldLength:描述封包長度的欄位的位元組數,以我們的封包為例就是4位元組。
lengthAdjustment:要加到長度欄位值的補償值,這個欄位比較有意思,我們還是舉個例子說明,以下面這個資料包為例,假如我們需要得到data的數據,而長度記錄的值為12位元組(head+length+data),為了達到我們的預期即只取10位元組的資料,我們就可以設定為這個資料的長度。
對應的我們本次資料包長度記錄的值沒有錯,這裡直接直接設定為0,無需調整。
initialBytesToStrip:讀取時需要跳過資料包幾個字節,以我們的資料包為例就說4,代表我們要跳過4字節的length字段,只要data的數據,對應的我們也給出下面這個構造方法:
於是我們就有了下面這樣一個構造的解碼器,再次進行壓測後資料都是可以正常解析處理的:
5. 更多關於Netty內建解碼器
設計者也在註解上提供我們更多的使用案例,先來看看第一個範例,該資料包長度欄位2字節,偏移量為0。假如我們希望讀取整個資料包,那麼參數設定方式為:
lengthFieldOffset即偏移量設定為0,即長度欄位無需偏移就在資料包高位。
lengthFieldLength為2,即讀取2位元組的數據,即可取得封包長度。
lengthAdjustment 為0,代表長度欄位所描述的資料就是後續資料的長度,無需調整。
initialBytesToStrip 為0,即讀取時從數據包最開始位置讀取並加上長度字段裡描述的長度的數據,無需跳過。
再来看看示例2,数据包和上文相同,只不过希望读取的数据不包含length字段,所以参数设置为:
- lengthFieldOffset即偏移量设置为0,即长度字段无需偏移就在数据包高位。
- lengthFieldLength为2,即读取2字节的数据,即可获得数据包长度。
- lengthAdjustment 为0,代表长度字段描述的数据就是后续数据的长度,无需调整。
- initialBytesToStrip 为2,即读取时从数据包起始位置开始,跳过2字节数据,即跳过length字段。
再來看看情況3,2位元組長度描述長度,只不過該長度包含了描述長度的欄位長度,即length的值為length欄位長度2+後續HELLO, WORLD字串長度為14。如果我們希望取得一個完整的資料包,那麼參數就需要設定為:
lengthFieldOffset即偏移量設定為0,即長度欄位無需偏移就在資料包高位。
lengthFieldLength為2,即讀取2位元組的數據,即可取得封包長度。
lengthAdjustment 為-2,代表長度欄位描述的是整個包的長度,需要減去length欄位的長度。
initialBytesToStrip 為0,即讀取時從數據包最開始位置讀取並加上長度字段裡描述的長度的數據,無需跳過。
範例4需要跳過header字段讀取到長度字段,最後需要得到一個包含所有部分的資料包,所以參數如下:
lengthFieldOffset即偏移量設定為2,即跳過Header 。
lengthFieldLength為3,即讀取3位元組的數據,即可取得封包長度。
lengthAdjustment 為0,代表長度欄位描述的是就是後續資料的長度,無需調整。
initialBytesToStrip 為0,即讀取時從數據包最開始位置讀取並加上長度字段裡描述的長度的數據,無需跳過。
範例5情況比較特殊,length描述後文資料的長度,卻不包含後文header的長度,若我們希望取得到所有部分的資料包,則參數需要設定為:
lengthFieldOffset即偏移量設定為0,即無需偏移,長度就在資料包高位。
lengthFieldLength為3,即讀取3位元組的數據,即可取得封包長度。
lengthAdjustment 為2,也就是代表length欄位僅記錄的Actual Content的長度,length欄位後面還有一個header的長度需要計算,故設定為2,意義實際長度要+2。
initialBytesToStrip 為0,即讀取時從數據包最開始位置讀取並加上長度字段裡描述的長度的數據,無需跳過。
範例6,長度在hdr1後面,且最終讀取的資料是hdr2和Actual Content。參數設定為:
lengthFieldOffset即偏移量設定為1,即跳過HDR1。
lengthFieldLength為2,即讀取2位元組的數據,即可取得封包長度。
lengthAdjustment 為1,即代表length字段僅記錄的Actual Content的長度,length字段後面還有一個HDR2 的長度需要計算,故設置為1,意味著實際長度要+1。
initialBytesToStrip 為3,即跳過HDR1和length開始讀取。
範例7即可Length記錄的是整個包的長度,為了拿到HDR2和Actual Content的數據,對應參數設定如下:
lengthFieldOffset即偏移量設定為1,即跳過HDR1。
lengthFieldLength為2,即讀取2位元組的數據,即可取得封包長度。
lengthAdjustment 為-3,即代表減去HDR1和 LEN的欄位長度。
initialBytesToStrip 為3,即跳過HDR1和length開始讀取。
三、小結
以上便是筆者對於Netty如何解決半包與黏包問題的源碼解析與實踐的全部內容,希望對你有幫助。