鏈接:https://draveness.me/whys-the-design-tcp-message-frame/
為什么這么設計(Why’s THE Design)是一系列關于計算機領域中程序設計決策的文章,我們在這個系列的每一篇文章中都會提出一個具體的問題并從不同的角度討論這種設計的優缺點、對具體實現造成的影響。如果你有想要了解的問題,可以在文章下面留言。
TCP/IP 協議簇建立了互聯網中通信協議的概念模型,該協議簇中的兩個主要協議就是 TCP 和 IP 協議。TCP/ IP 協議簇中的 TCP 協議能夠保證數據段(Segment)的可靠性和順序,有了可靠的傳輸層協議之后,應用層協議就可以直接使用 TCP 協議傳輸數據,不在需要關心數據段的丟失和重復問題[^1]。
tcp-and-Application-protocols
圖 1 - TCP 協議與應用層協議
IP 協議解決了數據包(Packet)的路由和傳輸,上層的 TCP 協議不再關注路由和尋址[^2],那么 TCP 協議解決的是傳輸的可靠性和順序問題,上層不需要關心數據能否傳輸到目標進程,只要寫入 TCP 協議的緩沖區的數據,協議棧幾乎都能保證數據的送達。
當應用層協議使用 TCP 協議傳輸數據時,TCP 協議可能會將應用層發送的數據分成多個包依次發送,而數據的接收方收到的數據段可能有多個『應用層數據包』組成,所以當應用層從 TCP 緩沖區中讀取數據時發現粘連的數據包時,需要對收到的數據進行拆分。
粘包并不是 TCP 協議造成的,它的出現是因為應用層協議設計者對 TCP 協議的錯誤理解,忽略了 TCP 協議的定義并且缺乏設計應用層協議的經驗。本文將從 TCP 協議以及應用層協議出發,分析我們經常提到的 TCP 協議中的粘包是如何發生的:
- TCP 協議是面向字節流的協議,它可能會組合或者拆分應用層協議的數據;
- 應用層協議的沒有定義消息的邊界導致數據的接收方無法拼接數據;
很多人可能會認為粘包是一個比較低級的甚至不值得討論的問題,但是在作者看來這個問題還是很有趣的,不是所有人都系統性地學過基于 TCP 的應用層協議設計,也不是所有人對 TCP 協議也沒有那么深入的理解,相信很多人學習編程的過程都是自底向上的,所以作者認為這是一個值得回答的問題,我們應該傳遞正確的知識,而不是負面的和居高臨下的情緒。
面向字節流
TCP 協議是面向連接的、可靠的、基于字節流的傳輸層通信協議[^3],應用層交給 TCP 協議的數據并不會以消息為單位向目的主機傳輸,這些數據在某些情況下會被組合成一個數據段發送給目標的主機。
Nagle 算法是一種通過減少數據包的方式提高 TCP 傳輸性能的算法[^4]。因為網絡帶寬有限,它不會將小的數據塊直接發送到目的主機,而是會在本地緩沖區中等待更多待發送的數據,這種批量發送數據的策略雖然會影響實時性和網絡延遲,但是能夠降低網絡擁堵的可能性并減少額外開銷。
在早期的互聯網中,Telnet 是被廣泛使用的應用程序,然而使用 Telnet 會產生大量只有 1 字節負載的有效數據,每個數據包都會有 40 字節的額外開銷,帶寬的利用率只有 ~2.44%,Nagle 算法就是在當時的這種場景下設計的。
當應用層協議通過 TCP 協議傳輸數據時,實際上待發送的數據先被寫入了 TCP 協議的緩沖區,如果用戶開啟了 Nagle 算法,那么 TCP 協議可能不會立刻發送寫入的數據,它會等待緩沖區中數據超過最大數據段(MSS)或者上一個數據段被 ACK 時才會發送緩沖區中的數據。
nagle-algorithm
圖 2 - Nagle 算法
幾十年前還會發生網絡擁塞的問題,但是今天的網絡帶寬資源不再像過去那么緊張,在默認情況下,linux 內核都會使用如下的方式默認關閉 Nagle 算法:
TCP_NODELAY = 1
Linux 內核中使用如下所示的 tcp_nagle_test 函數測試我們是否應該發送當前的 TCP 數據段,感興趣的讀者可以以這段代碼為入口詳細了解 Nagle 算法在今天的實現:
static inline bool tcp_nagle_test(const struct tcp_sock *tp, const struct sk_buff *skb,
unsigned int cur_mss, int nonagle)
{
if (nonagle & TCP_NAGLE_PUSH)
return true;
if (tcp_urg_mode(tp) || (TCP_SKB_CB(skb)->tcp_flags & TCPHDR_FIN))
return true;
if (!tcp_nagle_check(skb->len < cur_mss, tp, nonagle))
return true;
return false;
}
Nagle 算法確實能夠在數據包較小時提高網絡帶寬的利用率并減少 TCP 和 IP 協議頭帶來的額外開銷,但是使用該算法也可能會導致應用層協議多次寫入的數據被合并或者拆分發送,當接收方從 TCP 協議棧中讀取數據時會發現不相關的數據出現在了同一個數據段中,應用層協議可能沒有辦法對它們進行拆分和重組。
除了 Nagle 算法之外,TCP 協議棧中還有另一個用于延遲發送數據的選項 TCP_CORK,如果我們開啟該選項,那么當發送的數據小于 MSS 時,TCP 協議就會延遲 200ms 發送該數據或者等待緩沖區中的數據超過 MSS[^5]。
無論是 TCP_NODELAY 還是 TCP_CORK,它們都會通過延遲發送數據來提高帶寬的利用率,它們會對應用層協議寫入的數據進行拆分和重組,而這些機制和配置能夠出現的最重要原因是 — TCP 協議是基于字節流的協議,其本身沒有數據包的概念,不會按照數據包發送數據。
消息邊界
如果我們系統性地學習過 TCP 協議以及基于 TCP 的應用層協議設計,那么設計一個能夠被 TCP 協議棧任意拆分和組裝數據包的應用層協議就不會有什么問題。既然 TCP 協議是基于字節流的,這其實就意味著應用層協議要自己劃分消息的邊界。
如果我們能在應用層協議中定義消息的邊界,那么無論 TCP 協議如何對應用層協議的數據包進程拆分和重組,接收方都能根據協議的規則恢復對應的消息。在應用層協議中,最常見的兩種解決方案就是基于長度或者基于終結符(Delimiter)。
message-framing
圖 3 - 實現消息邊界的方法
基于長度的實現有兩種方式,一種是使用固定長度,所有的應用層消息都使用統一的大小,另一種方式是使用不固定長度,但是需要在應用層協議的協議頭中增加表示負載長度的字段,這樣接收方才可以從字節流中分離出不同的消息,HTTP 協議的消息邊界就是基于長度實現的:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 138
Connection: close
<html>
<head>
<title>An Example Page</title>
</head>
<body>
<p>Hello World, this is a very simple HTML document.</p>
</body>
</html>
在上述 HTTP 消息中,我們使用 Content-Length 頭表示 HTTP 消息的負載大小,當應用層協議解析到足夠的字節數后,就能從中分離出完整的 HTTP 消息,無論發送方如何處理對應的數據包,我們都可以遵循這一規則完成 HTTP 消息的重組[^6]。
不過 HTTP 協議除了使用基于長度的方式實現邊界,也會使用基于終結符的策略,當 HTTP 使用塊傳輸(Chunked Transfer)機制時,HTTPz 頭中就不再包含 Content-Length 了,它會使用負載大小為 0 的 HTTP 消息作為終結符表示消息的邊界。
當然除了這兩種方式之外,我們可以基于特定的規則實現消息的邊界,例如:使用 TCP 協議發送 JSON 數據,接收方可以根據接收到的數據是否能夠被解析成合法的 JSON 判斷消息是否終結。
總結
TCP 協議粘包問題是因為應用層協議開發者的錯誤設計導致的,他們忽略了 TCP 協議數據傳輸的核心機制 — 基于字節流,其本身不包含消息、數據包等概念,所有數據的傳輸都是流式的,需要應用層協議自己設計消息的邊界,即消息幀(Message Framing),我們重新回顧一下粘包問題出現的核心原因:
- TCP 協議是基于字節流的傳輸層協議,其中不存在消息和數據包的概念;
- 應用層協議沒有使用基于長度或者基于終結符的消息邊界,導致多個消息的粘連;
網絡協議的學習過程非常有趣,不斷思考背后的問題能夠讓我們對定義有更深的認識。到最后,我們還是來看一些比較開放的相關問題,有興趣的讀者可以仔細思考一下下面的問題:
- 基于 UDP 協議的應用層協議應該如何設計?會出現粘包的問題么?
- 有哪些應用層協議使用基于長度的分幀?又有哪些使用基于終結符的分幀?