事情從一個健身教練說起吧。
李東,自稱亞健康終結者,嘗試使用網際網路+的模式擴充自己的業務。在某款新開發的聊天軟體琛琛上釋出廣告。
鍵盤說來就來。瘋狂傳送”李東”,回車傳送!,”亞健康終結者”,再回車傳送!
還記得四層網路協議長什麼樣子嗎?
四層網路模型每層各司其職,訊息在進入每一層時都會多加一個報頭,每多一個報頭可以理解為資料包多戴一頂帽子。這個報頭上面記錄著訊息從哪來,到哪去,以及訊息多長等資訊。比如,mac頭部
記錄的是硬體的唯一地址,IP頭
記錄的是從哪來和到哪去,傳輸層頭記錄到是到達目的主機後具體去哪個程式。
在從訊息發到網路的時候給訊息帶上報頭,訊息和紛繁複雜的網路中通過這些資訊在路由器間流轉,最後到達目的機器上,接受者再通過這些報頭,一步一步還原出傳送者最原始要傳送的訊息。
為什麼要將資料切片
軟體琛琛是屬於應用層上的。
而”李東”,”亞健康終結者”這兩條訊息在進入傳輸層時使用的是傳輸層上的 TCP 協議。訊息在進入傳輸層(TCP)時會被切片為一個個資料包。這個資料包的長度是MSS
。
可以把網路比喻為一個水管,是有一定的粗細的,這個粗細由網路介面層(資料鏈路層)提供給網路層,一般認為是的MTU
(1500),直接傳入整個訊息,會超過水管的最大承受範圍,那麼,就需要進行切片,成為一個個資料包,這樣訊息才能正常通過“水管”。
MTU 和 MSS 有什麼區別
MTU: Maximum Transmit Unit,最大傳輸單元。 由網路介面層(資料鏈路層)提供給網路層最大一次傳輸資料的大小;一般 MTU=1500 Byte。
假設IP層有 <= 1500 byte 需要傳送,只需要一個 IP 包就可以完成傳送任務;假設 IP 層有> 1500 byte 資料需要傳送,需要分片才能完成傳送,分片後的 IP Header ID 相同。MSS:Maximum Segment Size 。 TCP 提交給 IP 層最大分段大小,不包含 TCP Header 和 TCP Option,只包含 TCP Payload ,MSS 是 TCP 用來限制應用層最大的傳送位元組數。
假設 MTU= 1500 byte,那麼 MSS = 1500- 20(IP Header) -20 (TCP Header) = 1460 byte,如果應用層有 2000 byte 傳送,那麼需要兩個切片才可以完成傳送,第一個 TCP 切片 = 1460,第二個 TCP 切片 = 540。
什麼是粘包
那麼當李東在手機上鍵入”李東””亞健康終結者”的時候,在 TCP 中把訊息分成 MSS 大小後,訊息順著網線順利發出。
網路穩得很,將訊息分片傳到了對端手機 B 上。經過 TCP 層訊息重組。變成”李東亞健康終結者”這樣的位元組流(stream)。
但由於聊天軟體琛琛是新開發的,而且開發者叫小白,完了,是個臭名昭著的造 bug 工程師。經過他的程式碼,在處理位元組流的時候訊息從”李東”,”亞健康終結者”變成了”李東亞”,”健康終結者”。”李東”作為上一個包的內容與下一個包裡的”亞”粘在了一起被錯誤地當成了一個資料包解析了出來。這就是所謂的粘包。
一個號稱健康終結者的健身教練,大概運氣也不會很差吧,就祝他客源滾滾吧。
為什麼會出現粘包
那就要從 TCP 是啥說起。
TCP,Transmission Control Protocol。傳輸控制協議,是一種面向連線的、可靠的、基於位元組流的傳輸層通訊協議。
其中跟粘包關係最大的就是基於位元組流這個特點。
位元組流可以理解為一個雙向的通道里流淌的資料,這個資料其實就是我們常說的二進位制資料,簡單來說就是一大堆 01 串。這些 01 串之間沒有任何邊界。
應用層傳到 TCP 協議的資料,不是以訊息報為單位向目的主機傳送,而是以位元組流的方式傳送到下游,這些資料可能被切割和組裝成各種資料包,接收端收到這些資料包後沒有正確還原原來的訊息,因此出現粘包現象。
為什麼要組裝傳送的資料
上面提到 TCP 切割資料包是為了能順利通過網路這根水管。相反,還有一個組裝的情況。如果前後兩次 TCP 發的資料都遠小於 MSS,比如就幾個位元組,每次都單獨傳送這幾個位元組,就比較浪費網路 io 。
比如小白爸讓小白出門給買一瓶醬油,小白出去買醬油回來了。小白媽又讓小白出門買一瓶醋回來。小白前後結結實實跑了兩趟,影響了打遊戲的時間。
優化的方法也比較簡單。當小白爸讓小白去買醬油的時候,小白先等待,繼續打會遊戲,這時候如果小白媽讓小白買瓶醋回來,小白可以一次性帶著兩個需求出門,再把東西帶回來。
上面說的其實就是TCP
的 Nagle 演算法優化,目的是為了避免傳送小的資料包。
在 Nagle 演算法開啟的狀態下,資料包在以下兩個情況會被髮送:
- 如果包長度達到
MSS
(或含有Fin
包),立刻傳送,否則等待下一個包到來;如果下一包到來後兩個包的總長度超過MSS
的話,就會進行拆分傳送; - 等待超時(一般為
200ms
),第一個包沒到MSS
長度,但是又遲遲等不到第二個包的到來,則立即傳送。
- 由於啟動了Nagle演算法, msg1 小於 mss ,此時等待
200ms
內來了一個 msg2 ,msg1 + msg2 > MSS,因此把 msg2 分為 msg2(1) 和 msg2(2),msg1 + msg2(1) 包的大小為MSS
。此時傳送出去。 - 剩餘的 msg2(2) 也等到了 msg3, 同樣 msg2(2) + msg3 > MSS,因此把 msg3 分為 msg3(1) 和 msg3(2),msg2(2) + msg3(1) 作為一個包傳送。
- 剩餘的 msg3(2) 長度不足
mss
,同時在200ms
內沒有等到下一個包,等待超時,直接傳送。 - 此時三個包雖然在圖裡顏色不同,但是實際場景中,他們都是一整個 01 串,如果處理開發者把第一個收到的 msg1 + msg2(1) 就當做是一個完整訊息進行處理,就會看上去就像是兩個包粘在一起,就會導致粘包問題。
關掉 Nagle 演算法就不會粘包了嗎?
Nagle 演算法其實是個有些年代的東西了,誕生於 1984 年。對於應用程式一次傳送一位元組資料的場景,如果沒有 Nagle 的優化,這樣的包立馬就發出去了,會導致網路由於太多的包而過載。
但是今天網路環境比以前好太多,Nagle 的優化幫助就沒那麼大了。而且它的延遲傳送,有時候還可能導致呼叫延時變大,比如打遊戲的時候,你操作如此絲滑,但卻因為 Nagle 演算法延遲傳送導致慢了一拍,就問你難受不難受。
所以現在一般也會把它關掉。
看起來,Nagle 演算法的優化作用貌似不大,還會導致粘包”問題”。那麼是不是關掉這個演算法就可以解決掉這個粘包”問題”呢?
TCP_NODELAY = 1
- 接受端應用層在收到 msg1 時立馬就取走了,那此時 msg1 沒粘包問題
- msg2 *到了後,應用層在忙,沒來得及取走,就呆在 *TCP Recv Buffer 中了
- msg3 *此時也到了,跟 *msg2 和 msg3 一起放在了 TCP Recv Buffer 中
- 這時候應用層忙完了,來取資料,圖裡是兩個顏色作區分,但實際場景中都是 01 串,此時一起取走,發現還是粘包。
因此,就算關閉 Nagle 演算法,接收資料端的應用層沒有及時讀取 TCP Recv Buffer 中的資料,還是會發生粘包。
怎麼處理粘包
粘包出現的根本原因是不確定訊息的邊界。接收端在面對“無邊無際”的二進位制流的時候,根本不知道收了多少 01 才算一個訊息。一不小心拿多了就說是粘包。其實粘包根本不是 TCP 的問題,是使用者對於 TCP 的理解有誤導致的一個問題。
只要在傳送端每次傳送訊息的時候給訊息帶上識別訊息邊界的資訊,接收端就可以根據這些資訊識別出訊息的邊界,從而區分出每個訊息。
常見的方法有
加入特殊標誌
可以通過特殊的標誌作為頭尾,比如當收到了
0xfffffe
或者回車符,則認為收到了新訊息的頭,此時繼續取資料,直到收到下一個頭標誌0xfffffe
或者尾部標記,才認為是一個完整訊息。類似的像 HTTP 協議裡當使用 chunked 編碼 傳輸時,使用若干個 chunk 組成訊息,最後由一個標明長度為 0 的 chunk 結束。加入訊息長度資訊
這個一般配合上面的特殊標誌一起使用,在收到頭標誌時,裡面還可以帶上訊息長度,以此表明在這之後多少 byte 都是屬於這個訊息的。如果在這之後正好有符合長度的 byte,則取走,作為一個完整訊息給應用層使用。在實際場景中,HTTP 中的Content-Length
就起了類似的作用,當接收端收到的訊息長度小於 Content-Length 時,說明還有些訊息沒收到。那接收端會一直等,直到拿夠了訊息或超時,關於這一點上一篇文章裡有更詳細的說明。
可能這時候會有朋友會問,採用0xfffffe
標誌位,用來標誌一個資料包的開頭,你就不怕你發的某個資料里正好有這個內容嗎?
是的,怕,所以一般除了這個標誌位,傳送端在傳送時還會加入各種校驗欄位(校驗和
或者對整段完整資料進行 CRC
之後獲得的資料)放在標誌位後面,在接收端拿到整段資料後校驗下確保它就是傳送端發來的完整資料。
UDP 會粘包嗎
跟 TCP
同為傳輸層的另一個協議,UDP,User Datagram Protocol。使用者資料包協議,是面向無連線,不可靠的,基於資料包的傳輸層通訊協議。
基於資料包是指無論應用層交給 UDP 多長的報文,UDP 都照樣傳送,即一次傳送一個報文。至於如果資料包太長,需要分片,那也是IP層的事情,大不了效率低一些。UDP 對應用層交下來的報文,既不合並,也不拆分,而是保留這些報文的邊界。而接收方在接收資料包的時候,也不會像面對 TCP 無窮無盡的二進位制流那樣不清楚啥時候能結束。正因為基於資料包和基於位元組流的差異,TCP 傳送端發 10 次位元組流資料,而這時候接收端可以分 100 次去取資料,每次取資料的長度可以根據處理能力作調整;但 UDP 傳送端發了 10 次資料包,那接收端就要在 10 次收完,且發了多少,就取多少,確保每次都是一個完整的資料包。
我們先看下IP報頭
注意這裡面是有一個 16 位的總長度的,意味著 IP 報頭裡記錄了整個 IP 包的總長度。接著我們再看下 UDP 的報頭。
在報頭中有16bit
用於指示 UDP 資料包文的長度,假設這個長度是 n ,以此作為資料邊界。因此在接收端的應用層能清晰地將不同的資料包文區分開,從報頭開始取 n 位,就是一個完整的資料包,從而避免粘包和拆包的問題。
當然,就算沒有這個位(16位 UDP 長度),因為 IP 的頭部已經包含了資料的總長度資訊,此時如果 IP 包(網路層)裡放的資料使用的協議是 UDP(傳輸層),那麼這個總長度其實就包含了 UDP 的頭部和 UDP 的資料。
因為 UDP 的頭部長度固定為 8 位元組( 1 位元組= 8 位,8 位元組= 64 位,上圖中除了資料和選項
以外的部分),那麼這樣就很容易的算出 UDP 的資料的長度了。因此說 UDP 的長度資訊其實是冗餘的。
UDP Data 的長度 = IP 總長度 - IP Header 長度 - UDP Header 長度
可以再來看下 TCP 的報頭
TCP首部裡是沒有長度這個資訊的,跟UDP類似,同樣可以通過下面的公式獲得當前包的TCP資料長度。
TCP Data 的長度 = IP 總長度 - IP Header 長度 - TCP Header 長度。
跟 UDP 不同在於,TCP 傳送端在發的時候就不保證發的是一個完整的資料包,僅僅看成一連串無結構的位元組流,這串位元組流在接收端收到時哪怕知道長度也沒用,因為它很可能只是某個完整訊息的一部分。
為什麼長度欄位冗餘還要加到 UDP 首部中
關於這一點,查了很多資料,《 TCP-IP 詳解(卷2)》
裡說可能是因為要用於計算校驗和。也有的說是因為UDP底層使用的可以不是IP協議,畢竟 IP 頭裡帶了總長度,正好可以用於計算 UDP 資料的長度,萬一 UDP 的底層不是IP層協議,而是其他網路層協議,就不能繼續這麼計算了。
但我覺得,最重要的原因是,IP 層是網路層的,而 UDP 是傳輸層的,到了傳輸層,資料包就已經不存在IP頭資訊了,那麼此時的UDP資料會被放在 UDP 的 Socket Buffer
中。當應用層來不及取這個 UDP 資料包,那麼兩個資料包在資料層面其實都是一堆 01 串。此時讀取第一個資料包的時候,會先讀取到 UDP 頭部,如果這時候 UDP 頭不含 UDP 長度資訊,那麼應用層應該取多少資料才算完整的一個資料包呢?
因此 UDP 頭的這個長度其實跟 TCP 為了防止粘包而在訊息體里加入的邊界資訊是起一樣的作用的。
面試的時候我們就把這些全說出去,顯得我們好像經過了深深的思考一樣,面試官可能會覺得我們特別愛思考,加分加分。
如果我說錯了,請把我的這篇文章轉發給更多的人,讓大家記住這個滿嘴胡話的人,在關注之後狠狠的私信罵我,拜託了!
IP 層有粘包問題嗎
IP 層會對大包進行切片,是不是也有粘包問題?
先說結論,不會。首先前文提到了,粘包其實是由於使用者無法正確區分訊息邊界導致的一個問題。
先看看 IP 層的切片分包是怎麼回事。
如果訊息過長,
IP層
會按 MTU 長度把訊息分成 N 個切片,每個切片帶有自身在包裡的位置(offset)和同樣的IP頭資訊。各個切片在網路中進行傳輸。每個資料包切片可以在不同的路由中流轉,然後在最後的終點匯合後再組裝。
在接收端收到第一個切片包時會申請一塊新記憶體,建立IP包的資料結構,等待其他切片分包資料到位。
等訊息全部到位後就把整個訊息包給到上層(傳輸層)進行處理。
可以看出整個過程,IP 層
從按長度切片到把切片組裝成一個資料包的過程中,都只管運輸,都不需要在意訊息的邊界和內容,都不在意訊息內容了,那就不會有粘包一說了。
IP 層
表示:我只管把傳送端給我的資料傳到接收端就完了,我也不瞭解裡頭放了啥東西。
聽起來就像 “我不管產品的需求傻不傻X,我實現了就行,我不問,也懶得爭了”,這思路值得每一位優秀的划水程式設計師學習,respect。
總結
粘包這個問題的根因是由於開發人員沒有正確理解 TCP 面向位元組流的資料傳輸方式,本身並不是 TCP 的問題,是開發者的問題。
- TCP 不管傳送端要發什麼,都基於位元組流把資料發到接收端。這個位元組流裡可能包含上一次想要發的資料的部分資訊。接收端根據需要在訊息里加上識別訊息邊界的資訊。不加就可能出現粘包問題。
- TCP 粘包跟Nagle演算法有關係,但關閉 Nagle 演算法並不解決粘包問題。
- UDP 是基於資料包的傳輸協議,不會有粘包問題。
- IP 層也切片,但是因為不關心訊息裡有啥,因此有不會有粘包問題。
TCP
傳送端可以發10 次
位元組流資料,接收端可以分100 次
去取;UDP
傳送端發了10 次
資料包,那接收端就要在10 次
收完。
資料包也只是按著 TCP 的方式進行組裝和拆分,如果資料包有錯,那資料包也只是犯了每個資料包都會犯的錯而已。
最後,李東工作沒了,而小白表示
文章推薦:
別說了,一起在知識的海洋裡嗆水吧
關注公眾號:【golang小白成長記】
本作品採用《CC 協議》,轉載必須註明作者和本文連結