TCP傳輸協議是面向流的,就是沒有界限的一串數據。TCP底層并不了解上層業務數據的具體含義,它會根據TCP緩沖區的實際情況進行包的劃分,所以在業務上認為,一個完整的包可能會被TCP拆分成多個包就行發送,也有可能把多個小的包封裝成一個大的數據包發送,這就是所謂的TCP拆包和粘包問題。
TCP拆包/粘包問題
假設客戶端分別發送了兩個數據包D1和D2給服務端,由于服務端一次讀取到字節數是不確定的,故可能存在以下四種情況:
服務端分兩次讀取到了兩個獨立的數據包,分別是D1和D2,沒有粘包和拆包
服務端一次接受到了兩個數據包,D1和D2粘合在一起,稱之為TCP粘包
服務端分兩次讀取到了數據包,第一次讀取到了完整的D1包和D2包的部分內容,第二次讀取到了D2包的剩余內容,這稱之為TCP拆包
服務端分兩次讀取到了數據包,第一次讀取到了D1包的部分內容D1_1,第二次讀取到了D1包的剩余部分內容D1_2和完整的D2包。
特別要注意的是,如果TCP的接受滑窗非常小,而數據包D1和D2比較大,很有可能會發生第五種情況,即服務端分多次才能將D1和D2包完全接受,期間發生多次拆包。
TCP拆包/粘包發生的原因
在網絡通信的過程中,每次可以發送的數據包大小是受多種因素限制的,如 MTU 傳輸單元大小、MSS 最大分段大小、滑動窗口等。如果一次傳輸的網絡包數據大小超過傳輸單元大小,那么我們的數據可能會拆分為多個數據包發送出去。如果每次請求的網絡包數據都很小,一共請求了 10000 次,TCP 并不會分別發送 10000 次。因為 TCP 采用的 Nagle 算法對此作出了優化。
MTU 最大傳輸單元和 MSS 最大分段大小
MTU(Maxitum Transmission Unit) 是鏈路層一次最大傳輸數據的大小。MTU 一般來說大小為 1500 byte。MSS(Maximum Segement Size) 是指 TCP 最大報文段長度,它是傳輸層一次發送最大數據的大小。如下圖所示,MTU 和 MSS 一般的計算關系為:MSS = MTU - IP 首部 - TCP首部,如果 MSS + TCP 首部 + IP 首部 > MTU,那么數據包將會被拆分為多個發送。這就是拆包現象。
滑動窗口
滑動窗口是 TCP 傳輸層用于流量控制的一種有效措施,也被稱為通告窗口?;瑒哟翱谑菙祿邮辗皆O置的窗口大小,隨后接收方會把窗口大小告訴發送方,以此限制發送方每次發送數據的大小,從而達到流量控制的目的。這樣數據發送方不需要每發送一組數據就阻塞等待接收方確認,允許發送方同時發送多個數據分組,每次發送的數據都會被限制在窗口大小內。由此可見,滑動窗口可以大幅度提升網絡吞吐量。
現在來看一下滑動窗口是如何造成粘包、拆包的?
粘包:假設發送方的每256 bytes表示一個完整的報文,接收方由于數據處理不及時,這256個字節的數據都會被緩存到SO_RCVBUF(接收緩存區)中。如果接收方的SO_RCVBUF中緩存了多個報文,那么對于接收方而言,這就是粘包。
拆包:考慮另外一種情況,假設接收方的窗口只剩了128,意味著發送方最多還可以發送128字節,而由于發送方的數據大小是256字節,因此只能發送前128字節,等到接收方ack后,才能發送剩余字節。這就造成了拆包。
Nagle算法
TCP/IP協議中,無論發送多少數據,總是要在數據(DATA)前面加上協議頭(TCP Header+IP Header),同時,對方接收到數據,也需要發送ACK表示確認。
即使從鍵盤輸入的一個字符,占用一個字節,可能在傳輸上造成41字節的包,其中包括1字節的有用信息和40字節的首部數據。這種情況轉變成了4000%的消耗,這樣的情況對于重負載的網絡來是無法接受的。
為了盡可能的利用網絡帶寬,TCP總是希望盡可能的發送足夠大的數據。(一個連接會設置MSS參數,因此,TCP/IP希望每次都能夠以MSS尺寸的數據塊來發送數據)。
Nagle算法就是為了盡可能發送大塊數據,避免網絡中充斥著許多小數據塊。
Nagle算法的基本定義是任意時刻,最多只能有一個未被確認的小段。 所謂“小段”,指的是小于MSS尺寸的數據塊,所謂“未被確認”,是指一個數據塊發送出去后,沒有收到對方發送的ACK確認該數據已收到。
Nagle算法的規則:
- 如果SO_SNDBUF(發送緩沖區)中的數據長度達到MSS,則允許發送;
- 如果該SO_SNDBUF中含有FIN,表示請求關閉連接,則先將SO_SNDBUF中的剩余數據發送,再關閉;
- 設置了TCP_NODELAY=true選項,則允許發送。TCP_NODELAY是取消TCP的確認延遲機制,相當于禁用了Nagle 算法。
- 未設置TCP_CORK選項時,若所有發出去的小數據包(包長度小于MSS)均被確認,則允許發送;
- 上述條件都未滿足,但發生了超時(一般為200ms),則立即發送。
TCP拆包/粘包問題的解決策略
由于底層的TCP無法理解上層的業務數據,所以在底層是無法保證數據包不被拆分和重組的,這個問題只能通過上層的應用協議棧設計來解決,根據業界的主流協議的解決方案,可以歸納如下:
- 消息定長,例如每個報文的大小為固定長度200字節,如果不夠,空位補空格。假設我們需要發送的數據是| AB | CDEF | GHIJ | K | LM | 5條數據,我們的固定長度為 4 字節,那么這5 條數據一共需要發送 4 個報文:
+------+------+------+------+
| ABCD | EFGH | IJKL | M000 |
+------+------+------+------+
消息定長法使用非常簡單,但是缺點也非常明顯,無法很好設定固定長度的值,如果長度太大會造成字節浪費,長度太小又會影響消息傳輸,所以在一般情況下消息定長法不會被采用。
- 在包尾增加回車換行符進行分割,以下例子采用\n來分割
+-------------------------+
| AB\nCDEF\nGHIJ\nK\nLM\n |
+-------------------------+
由于在發送報文時尾部需要添加特定分隔符,所以對于分隔符的選擇一定要避免和消息體中字符相同,以免沖突。否則可能出現錯誤的消息拆分。比較推薦的做法是將消息進行編碼,例如 base64 編碼,然后可以選擇 64 個編碼字符之外的字符作為特定分隔符。特定分隔符法在消息協議足夠簡單的場景下比較高效,例如大名鼎鼎的 Redis 在通信過程中采用的就是換行分隔符。
- 將消息分為消息頭和消息體,消息頭中包含表示消息總長度(或消息體長度)的字段,通常設計思路為消息頭的第一個字段使用int32表示消息的總長度
消息頭 消息體
+--------+----------+
| Length | Content |
+--------+----------+
+-----+-------+-------+----+-----+
| 2AB | 4CDEF | 4GHIJ | 1K | 2LM |
+-----+-------+-------+----+-----+
消息長度 + 消息內容的使用方式非常靈活,且不會存在消息定長法和特定分隔符法的明顯缺陷。當然在消息頭中不僅只限于存放消息的長度,而且可以自定義其他必要的擴展字段,例如消息版本、算法類型等。