TCP 粘包?粘包警察是什麼梗?

語言: CN / TW / HK

​本文圍繞 TCP​ 協議展開,先來回顧下 TCP 協議的特點:

  • TCP 是面向連線的傳輸層協議。
  • 每一條TCP​ 連線只有兩個端點,每一條TCP 連線只能是點對點的(一對一)。
  • TCP 提供可靠的交付服務,保證傳輸的資料無差錯、不丟失、不重複且有序。
  • TCP​ 提供全雙工通訊,TCP​ 允許通訊雙方的應用程序在任何時候都能傳送資料,為此TCP 連線的兩端都設有傳送快取和接收快取,用來臨時存放雙向通訊的資料。
  • TCP​ 是面向位元組流的。(本文重點)

雖然應用程式和 TCP​ 的互動是一次一個資料塊(大小不等),但 TCP​ 把應用程式交下來的資料看成僅僅是一連串的無結構的位元組流。

粘包警察由來?粘包由來?

粘包警察 ,一詞首次看到是在 v2​。粘包警察認為 “粘包” 這詞侮辱了 TCP​,在 TCP​ 下討論 “粘包” 是偽命題。相反,粘包學家認為 “粘包” 就是 TCP​ 問題。遂粘包警察頻頻現身『TCP​粘包』帖子下,試圖改正這偏見,提醒各位: TCP 是面向位元組流的。

粘包由來小故事:

據說以前有一群基礎不紮實的程式設計師經常使用 VC​ 寫各種 Windows​ 客戶端程式,喜歡使用 UDP​ 程式設計(VC​ 的 UDP​ 程式設計,程式碼簡單,收發邏輯簡單明)。 因為通訊應用的複雜性以及需求需要,他們嘗試將多條資料放在一個 UDP​ 資料包裡進行傳送,遂碰到『粘包問題』。同時他們開始接觸並使用 TCP​,慣性思維套用之前 UDP​ 程式設計方式來使用 TCP​,非常容易遇到所謂的 『粘包問題』。隨著硬體升級,多物理核的 CPU 普及,多執行緒與並行程式設計開始流程,對程式設計師基本功提出更高的要求,這群人仍在並行程式使用序列思維進行程式設計,必定遇到『粘包問題』。 於是這群人把這個問題總結出來,稱之為 『粘包問題』。

什麼是粘包/拆包?

所謂粘包: 就是幾個資料包粘在一起了,如果要處理得先拆包。

所謂拆包: 就是收到一批資料包碎片,要把這些碎片粘起來才能合成一個完整的資料包。

舉個栗子:客戶端傳送資料給服務端,可能會出現以下五種情況:

  • 栗子一:客戶端分別傳送完整的資料包 A 和 B,服務端先接收了完整資料包 A,沒有出現拆包/粘包問題。
  • 栗子二:客戶端一次一口次傳送 A 和 B 粘在一起的資料包,服務端接收到這個資料包,服務端需要解析出 A 和 B,出現粘包問題。
  • 栗子三:客戶端傳送A|B-1​資料包和B-2資料包,服務端先接收到完整的 A 和 B 的一部分資料包 B-1,服務端需要解析出完整的 A,並等待讀取完整的 B 資料包,出現粘包/拆包問題。
  • 栗子四:客戶端傳送A-1​資料包和B|A-2資料包,服務端接收到 A 的一部分資料包 A-1,此時需要等待接收到完整的 A 資料包,出現拆包問題。
  • 栗子五:資料包 A 較大,客戶端分段傳送資料包A,服務端需要多次才可以接收完資料包 A,出現拆包問題。

小結: 由於拆包/粘包問題的存在,如何識別一個完整的資料包就成了問題?難點在於如何定義一個數據包的邊界。

為什麼會有人說 TCP 粘包?

先來看下應用程式使用 TCP​ 套接字的流程: 對應 TCP/IP 4層協議:

  • 應用程序呼叫write 時,核心從該應用程序的緩衝區中複製所有資料到所寫套接字的傳送緩衝區。
  • 本端TCP​ 以MSS​ 大小的或更小的塊把資料傳遞給IP。
  • TCP​分段加上IP​ 首部構成 IP​ 資料包,並按照其目的IP 地址查詢路由表項以確定外出介面,然後把資料報傳遞給相應的資料鏈路。

這裡解釋下 MSS​ 和 MTU:

  • MTU(Maxitum Transmission Unit​) 是鏈路層一次最大傳輸資料的大小。一般來說大小為 1500byte。
  • MSS(Maximum Segement Size​) 是指TCP 最大報文段長度,它是傳輸層一次傳送最大資料的大小。

MTU​ 和 MSS​ 一般的計算關係為:MSS​ = MTU​ - IP​ 首部 - TCP首部。

『粘包學家』認為 TCP 粘包/拆包發生原因有三:

  • 應用程式write 寫入的位元組大小大於套接字傳送緩衝區大小。
  • MSS​ +TCP​ 首部 +IP​ 首部 >MTU​,就要 TCP 分段。
  • 乙太網幀的payload​ 大於MTU​ 就要進行 IP 分片。

說白了,『粘包學家』認為我怎麼給你的,你就該怎麼還給我。

『粘包警察』認為這根本不是 TCP 的鍋:

  • TCP 是面向位元組流:根本沒有包這個概念,談何粘包/拆包。
  • 『粘包/拆包』本質問題在於:如何從二進位制流中提取資料,如何定義資料的邊界。

說白了,『粘包警察』認為怎麼解析資料是你應用層的問題,TCP 只管傳輸並提供可靠的交付服務。

拓展:Nagle 演算法

Nagle​ 演算法於 1984 年被福特航空和通訊公司定義為 TCP/IP​ 擁塞控制方法,這使福特經營的最早的專用 TCP/IP 網路減少擁塞控制,從那以後這一方法得到了廣泛應用。

優勢:為了儘可能傳送大塊資料,避免網路中充斥著許多小資料塊。

如果每次需要傳送的資料只有 1 位元組,加上 20 個位元組的 IP​首部 和 20 個位元組的 TCP首部,每次傳送的資料包大小為 41 位元組,但是隻有 1 位元組是有效資訊,這就造成了非常大的浪費。

Nagle​ 演算法的規則(可參考tcp_output.c​ 檔案裡 tcp_nagle_check 函式註釋):

  • 如果包長度達到MSS,則允許傳送;
  • 如果該包含有FIN,則允許傳送;
  • 設定了TCP_NODELAY 選項,則允許傳送;
  • 未設定TCP_CORK​ 選項時,若所有發出去的小資料包(包長度小於MSS)均被確認,則允許傳送;
  • 上述條件都未滿足,但發生了超時(一般為200ms),則立即傳送。

Linux​ 在預設情況下是開啟 Nagle 演算法的,在大量小資料包的場景下可以有效地降低網路開銷。

  • 可以通過Linux​ 提供的TCP_NODELAY​ 引數禁用Nagle 演算法。
  • Netty​ 中為了使資料傳輸延遲最小化,就預設禁用了Nagle 演算法。

Tips:​ 還有一個延遲 ACK(Delay ACK​),TCP​ 何時傳送 ACK 有如下規定:

  • 當有響應資料傳送的時候,ACK 會隨著資料一塊傳送。
  • .如果沒有響應資料,ACK 就會有一個延遲,以等待是否有響應資料一塊傳送,但是這個延遲一般在40ms~500ms之間,一般情況下在40ms左右。
  • 如果在等待發送ACK​ 期間,第二個資料又到了,這時候就要立即傳送ACK。

拓展:UDP 為什麼不分段?

先來回顧下 UDP 的特點:

  • UDP 無需建立連線。
  • 無連線狀態。
  • 分組首部開銷小。(首部 8位元組)
  • UDP​ 是面向報文的。(重點)

傳送方 UDP​ 對應用層交下來的報文,在新增首部後就向下交付給 IP​ 層,既不合並,也不拆分,而是保留這些報文的邊界; 接收方 UDP​ 對 IP​ 層交上來 UDP​ 使用者資料報,在去除首部後就原封不動地交付給上層應用程序,一次交付一個完整的報文。因此報文不可分割,是 UDP​ 資料報處理的最小單位。

再看 UDP 資料報格式:

可知一個 UDP​ 資料報可攜帶最大使用者資料長度為:2^16 - 8 = 65535 - 8 = 65527 (B)

小結下 UDP 為什麼不分段?

  • UDP​ 協議特性:面向報文。16位UDP 長度。沒有分段的能力:標記分段先後順序的能力,即編號(ID​)、尾部編號的標識 (Flag)
  • UDP​ 應用特性:常用於一次性傳輸比較少量資料的網路應用,如DNS、SNMP​ 等。

當 DNS​ 查詢超過 512位元組 時,協議的 TC​ 標誌出現刪除標誌,這時則使用 TCP​ 傳送。通常傳統的 UDP​ 報文一般不會大於512位元組。

拆包/粘包解決方案

由上文可知我們需要一種定義來資料包的邊界,這也是解決拆包/粘包的唯一方法:定義應用層的通訊協議。

主流協議解決方案有:

  • 訊息長度固定
  • 特定分隔符
  • 訊息長度 + 訊息內容

Netty 對三種常用封幀方式的支援:

方式

解碼

編碼

固定長度

​FixedLengthFrameDecoder​

簡單

分隔符

​DelimiterBasedFrameDecoder​

簡單

固定長度欄位存內容長度

​LengthFieldBasedFrameDecoder​

​LengthFieldPrepender​

固定訊息長度

Netty​ 中提供了類 FixedLengthFrameDecoder:

  • 每個資料報文都需要一個固定的長度。
  • 當接收方累計讀取到固定長度的報文後,就認為已經獲得一個完整的訊息。
  • 當傳送方的資料小於固定長度時,則需要空位補齊。
# 舉個栗子:假定固定訊息長度是 3位元組,當你收到如下報文:
+---+----+------+----+
| A | BC | DEFG | HI |
+---+----+------+----+

# 將它們解碼成以下 3個固定長度的資料包:
+-----+-----+-----+
| ABC | DEF | GHI |
+-----+-----+-----+

專案地址:對應程式碼:

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)
    .childHandler(new ChannelInitializer<SocketChannel>() {
        @Override
        public void initChannel(SocketChannel ch) {
            ch.pipeline().addLast(new FixedLengthFrameDecoder(3));
            //... ...
        }
    });

通過 telnet​ 去訪問:telnet localhost 8088

優缺點:

  • 優點:訊息定長法使用非常簡單
  • 缺點:無法很好設定固定長度的值,如果長度太大會造成位元組浪費,長度太小又會影響訊息傳輸,所以在一般情況下訊息定長法不會被採用。

特殊分隔符

既然接收方無法區分訊息的邊界,那麼可以在每次傳送報文的尾部加上特定分隔符,接收方就可以根據特殊分隔符進行訊息拆分。

DelimiterBasedFrameDecoder 自動完成以分隔符做結束標誌的訊息的解碼:

# 舉個栗子:以下報文根據特定分隔符 `\n` 按行解析
+--------------+
| ABC\nDEF\r\n |
+--------------+

# 解析後得到:
+-----+-----+
| ABC | DEF |
+-----+-----+

專案地址:程式碼

ServerBootstrap b = new ServerBootstrap();

b.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)
    .childHandler(new ChannelInitializer<SocketChannel>() {

        @Override
        public void initChannel(SocketChannel ch) {
            // 以 & 為分隔符
            ByteBuf delimiter = Unpooled.copiedBuffer("&".getBytes());
            // 10 表示單條訊息的最大長度,當達到該長度後扔沒有查詢到分隔符,就丟擲異常
            // TooLongFrameException,防止由於異常碼流失分隔符導致的記憶體溢位
            ch.pipeline().addLast(new DelimiterBasedFrameDecoder(10, delimiter));
            // ... ...
        }
    });

通過 telnet​ 去訪問:telnet localhost 8088

比較推薦的做法是:將訊息進行編碼,例如 base64 編碼,然後可以選擇 64 個編碼字元之外的字元作為特定分隔符。

特定分隔符法在訊息協議足夠簡單的場景下比較高效,Redis 在通訊過程中採用的就是換行分隔符。

  • Redis 2.0​ 以後的通訊統一為RESP​ 協議(Redis Serialization Protocol)
  • RESP​ 是一個二進位制安全的文字協議,工作於TCP​ 協議上。RESP​ 以行作為單位,客戶端和伺服器傳送的命令或資料一律以\r\n(CRLF)作為換行符。

訊息長度 + 訊息內容

訊息長度 + 訊息內容是專案開發中最常用的一種協議,如下展示了該協議的基本格式。

+--------|----------+
|訊息頭    |訊息體    |
+--------|----------+
| Length | Content  |
+--------|----------+

訊息頭中存放訊息的總長度,例如使用 4 位元組的 int 值記錄訊息的長度,訊息體實際的二進位制的位元組資料。

接收方在解析資料時:

  • 首先讀取訊息頭的長度欄位Len
  • 然後緊接著讀取長度為Len 的位元組資料,該資料即判定為一個完整的資料報文

依然以上述提到的原始位元組資料為例,使用該協議進行編碼後的結果如下所示:

+-----|-------|-------|----|-----+
| 2AB | 4CDEF | 4GHIJ | 1K | 2LM |
+-----|-------|-------|----|-----+

訊息長度 + 訊息內容的使用方式非常靈活,且不會存在訊息定長法和特定分隔符法的明顯缺陷。

當然在訊息頭中不僅只限於存放訊息的長度,而且可以自定義其他必要的擴充套件欄位:

  • 訊息版本
  • 演算法型別
  • 等等