為什么 TCP 協(xié)議有粘包問(wèn)題
TCP/IP 協(xié)議簇建立了互聯(lián)網(wǎng)中通信協(xié)議的概念模型,該協(xié)議簇中的兩個(gè)主要協(xié)議就是 TCP 和 IP 協(xié)議。TCP/ IP 協(xié)議簇中的 TCP 協(xié)議能夠保證數(shù)據(jù)段(Segment)的可靠性和順序,有了可靠的傳輸層協(xié)議之后,應(yīng)用層協(xié)議就可以直接使用 TCP 協(xié)議傳輸數(shù)據(jù),不在需要關(guān)心數(shù)據(jù)段的丟失和重復(fù)問(wèn)題。
圖 1 - TCP 協(xié)議與應(yīng)用層協(xié)議
IP 協(xié)議解決了數(shù)據(jù)包(Packet)的路由和傳輸,上層的 TCP 協(xié)議不再關(guān)注路由和尋址[^2],那么 TCP 協(xié)議解決的是傳輸?shù)目煽啃院晚樞騿?wèn)題,上層不需要關(guān)心數(shù)據(jù)能否傳輸?shù)侥繕?biāo)進(jìn)程,只要寫(xiě)入 TCP 協(xié)議的緩沖區(qū)的數(shù)據(jù),協(xié)議棧幾乎都能保證數(shù)據(jù)的送達(dá)。
當(dāng)應(yīng)用層協(xié)議使用 TCP 協(xié)議傳輸數(shù)據(jù)時(shí),TCP 協(xié)議可能會(huì)將應(yīng)用層發(fā)送的數(shù)據(jù)分成多個(gè)包依次發(fā)送,而數(shù)據(jù)的接收方收到的數(shù)據(jù)段可能有多個(gè)『應(yīng)用層數(shù)據(jù)包』組成,所以當(dāng)應(yīng)用層從 TCP 緩沖區(qū)中讀取數(shù)據(jù)時(shí)發(fā)現(xiàn)粘連的數(shù)據(jù)包時(shí),需要對(duì)收到的數(shù)據(jù)進(jìn)行拆分。
粘包并不是 TCP 協(xié)議造成的,它的出現(xiàn)是因?yàn)閼?yīng)用層協(xié)議設(shè)計(jì)者對(duì) TCP 協(xié)議的錯(cuò)誤理解,忽略了 TCP 協(xié)議的定義并且缺乏設(shè)計(jì)應(yīng)用層協(xié)議的經(jīng)驗(yàn)。本文將從 TCP 協(xié)議以及應(yīng)用層協(xié)議出發(fā),分析我們經(jīng)常提到的 TCP 協(xié)議中的粘包是如何發(fā)生的:
- TCP 協(xié)議是面向字節(jié)流的協(xié)議,它可能會(huì)組合或者拆分應(yīng)用層協(xié)議的數(shù)據(jù);
- 應(yīng)用層協(xié)議的沒(méi)有定義消息的邊界導(dǎo)致數(shù)據(jù)的接收方無(wú)法拼接數(shù)據(jù);
很多人可能會(huì)認(rèn)為粘包是一個(gè)比較低級(jí)的甚至不值得討論的問(wèn)題,但是在作者看來(lái)這個(gè)問(wèn)題還是很有趣的,不是所有人都系統(tǒng)性地學(xué)過(guò)基于 TCP 的應(yīng)用層協(xié)議設(shè)計(jì),也不是所有人對(duì) TCP 協(xié)議也沒(méi)有那么深入的理解,相信很多人學(xué)習(xí)編程的過(guò)程都是自底向上的,所以作者認(rèn)為這是一個(gè)值得回答的問(wèn)題,我們應(yīng)該傳遞正確的知識(shí),而不是負(fù)面的和居高臨下的情緒。
面向字節(jié)流
TCP 協(xié)議是面向連接的、可靠的、基于字節(jié)流的傳輸層通信協(xié)議[^3],應(yīng)用層交給 TCP 協(xié)議的數(shù)據(jù)并不會(huì)以消息為單位向目的主機(jī)傳輸,這些數(shù)據(jù)在某些情況下會(huì)被組合成一個(gè)數(shù)據(jù)段發(fā)送給目標(biāo)的主機(jī)。
Nagle 算法是一種通過(guò)減少數(shù)據(jù)包的方式提高 TCP 傳輸性能的算法[^4]。因?yàn)榫W(wǎng)絡(luò) 帶寬有限,它不會(huì)將小的數(shù)據(jù)塊直接發(fā)送到目的主機(jī),而是會(huì)在本地緩沖區(qū)中等待更多待發(fā)送的數(shù)據(jù),這種批量發(fā)送數(shù)據(jù)的策略雖然會(huì)影響實(shí)時(shí)性和網(wǎng)絡(luò)延遲,但是能夠降低網(wǎng)絡(luò)擁堵的可能性并減少額外開(kāi)銷。
在早期的互聯(lián)網(wǎng)中,Telnet 是被廣泛使用的應(yīng)用程序,然而使用 Telnet 會(huì)產(chǎn)生大量只有 1 字節(jié)負(fù)載的有效數(shù)據(jù),每個(gè)數(shù)據(jù)包都會(huì)有 40 字節(jié)的額外開(kāi)銷,帶寬的利用率只有 ~2.44%,Nagle 算法就是在當(dāng)時(shí)的這種場(chǎng)景下設(shè)計(jì)的。
當(dāng)應(yīng)用層協(xié)議通過(guò) TCP 協(xié)議傳輸數(shù)據(jù)時(shí),實(shí)際上待發(fā)送的數(shù)據(jù)先被寫(xiě)入了 TCP 協(xié)議的緩沖區(qū),如果用戶開(kāi)啟了 Nagle 算法,那么 TCP 協(xié)議可能不會(huì)立刻發(fā)送寫(xiě)入的數(shù)據(jù),它會(huì)等待緩沖區(qū)中數(shù)據(jù)超過(guò)最大數(shù)據(jù)段(MSS)或者上一個(gè)數(shù)據(jù)段被 ACK 時(shí)才會(huì)發(fā)送緩沖區(qū)中的數(shù)據(jù)。
圖 2 - Nagle 算法
幾十年前還會(huì)發(fā)生網(wǎng)絡(luò)擁塞的問(wèn)題,但是今天的網(wǎng)絡(luò)帶寬資源不再像過(guò)去那么緊張,在默認(rèn)情況下,Linux 內(nèi)核都會(huì)使用如下的方式默認(rèn)關(guān)閉 Nagle 算法:
- TCP_NODELAY = 1
Linux 內(nèi)核中使用如下所示的 tcp_nagle_test 函數(shù)測(cè)試我們是否應(yīng)該發(fā)送當(dāng)前的 TCP 數(shù)據(jù)段,感興趣的讀者可以以這段代碼為入口詳細(xì)了解 Nagle 算法在今天的實(shí)現(xiàn):
- 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 算法確實(shí)能夠在數(shù)據(jù)包較小時(shí)提高網(wǎng)絡(luò)帶寬的利用率并減少 TCP 和 IP 協(xié)議頭帶來(lái)的額外開(kāi)銷,但是使用該算法也可能會(huì)導(dǎo)致應(yīng)用層協(xié)議多次寫(xiě)入的數(shù)據(jù)被合并或者拆分發(fā)送,當(dāng)接收方從 TCP 協(xié)議棧中讀取數(shù)據(jù)時(shí)會(huì)發(fā)現(xiàn)不相關(guān)的數(shù)據(jù)出現(xiàn)在了同一個(gè)數(shù)據(jù)段中,應(yīng)用層協(xié)議可能沒(méi)有辦法對(duì)它們進(jìn)行拆分和重組。
除了 Nagle 算法之外,TCP 協(xié)議棧中還有另一個(gè)用于延遲發(fā)送數(shù)據(jù)的選項(xiàng) TCP_CORK,如果我們開(kāi)啟該選項(xiàng),那么當(dāng)發(fā)送的數(shù)據(jù)小于 MSS 時(shí),TCP 協(xié)議就會(huì)延遲 200ms 發(fā)送該數(shù)據(jù)或者等待緩沖區(qū)中的數(shù)據(jù)超過(guò) MSS[^5]。
無(wú)論是 TCP_NODELAY 還是 TCP_CORK,它們都會(huì)通過(guò)延遲發(fā)送數(shù)據(jù)來(lái)提高帶寬的利用率,它們會(huì)對(duì)應(yīng)用層協(xié)議寫(xiě)入的數(shù)據(jù)進(jìn)行拆分和重組,而這些機(jī)制和配置能夠出現(xiàn)的最重要原因是 — TCP 協(xié)議是基于字節(jié)流的協(xié)議,其本身沒(méi)有數(shù)據(jù)包的概念,不會(huì)按照數(shù)據(jù)包發(fā)送數(shù)據(jù)。
消息邊界
如果我們系統(tǒng)性地學(xué)習(xí)過(guò) TCP 協(xié)議以及基于 TCP 的應(yīng)用層協(xié)議設(shè)計(jì),那么設(shè)計(jì)一個(gè)能夠被 TCP 協(xié)議棧任意拆分和組裝數(shù)據(jù)包的應(yīng)用層協(xié)議就不會(huì)有什么問(wèn)題。既然 TCP 協(xié)議是基于字節(jié)流的,這其實(shí)就意味著應(yīng)用層協(xié)議要自己劃分消息的邊界。
如果我們能在應(yīng)用層協(xié)議中定義消息的邊界,那么無(wú)論 TCP 協(xié)議如何對(duì)應(yīng)用層協(xié)議的數(shù)據(jù)包進(jìn)程拆分和重組,接收方都能根據(jù)協(xié)議的規(guī)則恢復(fù)對(duì)應(yīng)的消息。在應(yīng)用層協(xié)議中,最常見(jiàn)的兩種解決方案就是基于長(zhǎng)度或者基于終結(jié)符(Delimiter)。
圖 3 - 實(shí)現(xiàn)消息邊界的方法
基于長(zhǎng)度的實(shí)現(xiàn)有兩種方式,一種是使用固定長(zhǎng)度,所有的應(yīng)用層消息都使用統(tǒng)一的大小,另一種方式是使用不固定長(zhǎng)度,但是需要在應(yīng)用層協(xié)議的協(xié)議頭中增加表示負(fù)載長(zhǎng)度的字段,這樣接收方才可以從字節(jié)流中分離出不同的消息,HTTP 協(xié)議的消息邊界就是基于長(zhǎng)度實(shí)現(xiàn)的:
- 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 消息的負(fù)載大小,當(dāng)應(yīng)用層協(xié)議解析到足夠的字節(jié)數(shù)后,就能從中分離出完整的 HTTP 消息,無(wú)論發(fā)送方如何處理對(duì)應(yīng)的數(shù)據(jù)包,我們都可以遵循這一規(guī)則完成 HTTP 消息的重組[^6]。
不過(guò) HTTP 協(xié)議除了使用基于長(zhǎng)度的方式實(shí)現(xiàn)邊界,也會(huì)使用基于終結(jié)符的策略,當(dāng) HTTP 使用塊傳輸(Chunked Transfer)機(jī)制時(shí),HTTPz 頭中就不再包含 Content-Length 了,它會(huì)使用負(fù)載大小為 0 的 HTTP 消息作為終結(jié)符表示消息的邊界。
當(dāng)然除了這兩種方式之外,我們可以基于特定的規(guī)則實(shí)現(xiàn)消息的邊界,例如:使用 TCP 協(xié)議發(fā)送 JSON 數(shù)據(jù),接收方可以根據(jù)接收到的數(shù)據(jù)是否能夠被解析成合法的 JSON 判斷消息是否終結(jié)。
總結(jié)
TCP 協(xié)議粘包問(wèn)題是因?yàn)閼?yīng)用層協(xié)議開(kāi)發(fā)者的錯(cuò)誤設(shè)計(jì)導(dǎo)致的,他們忽略了 TCP 協(xié)議數(shù)據(jù)傳輸?shù)暮诵臋C(jī)制 — 基于字節(jié)流,其本身不包含消息、數(shù)據(jù)包等概念,所有數(shù)據(jù)的傳輸都是流式的,需要應(yīng)用層協(xié)議自己設(shè)計(jì)消息的邊界,即消息幀(Message Framing),我們重新回顧一下粘包問(wèn)題出現(xiàn)的核心原因:
- TCP 協(xié)議是基于字節(jié)流的傳輸層協(xié)議,其中不存在消息和數(shù)據(jù)包的概念;
- 應(yīng)用層協(xié)議沒(méi)有使用基于長(zhǎng)度或者基于終結(jié)符的消息邊界,導(dǎo)致多個(gè)消息的粘連;
網(wǎng)絡(luò)協(xié)議的學(xué)習(xí)過(guò)程非常有趣,不斷思考背后的問(wèn)題能夠讓我們對(duì)定義有更深的認(rèn)識(shí)。到最后,我們還是來(lái)看一些比較開(kāi)放的相關(guān)問(wèn)題,有興趣的讀者可以仔細(xì)思考一下下面的問(wèn)題:
- 基于 UDP 協(xié)議的應(yīng)用層協(xié)議應(yīng)該如何設(shè)計(jì)?會(huì)出現(xiàn)粘包的問(wèn)題么?
- 有哪些應(yīng)用層協(xié)議使用基于長(zhǎng)度的分幀?又有哪些使用基于終結(jié)符的分幀?