聊聊基于tcp的應用層消息邊界如何定義

2018年筆者有幸接觸一個項目要用到長連接配接實作雲端到裝置端消息推送,是以借機了解過相關的内容,最終是通過rabbitmq+mqtt實作了相關功能,同時在心裡也打了一個問号“如果自己實作長連接配接架構,該怎麼定義消息的邊界呢?”,之後斷斷續續整理了一些,一直不成體系,最近放假了整理出來跟大家交流一番。
消息邊界并非長連接配接場景才需要,即使是短連接配接也可能需要,拿我們比較常用的http1.0協定(http1.1稍微複雜一些,後面會單獨說)來說,它基于tcp這個傳輸協定來傳遞消息,而tcp協定又是一個面向流的協定,怎麼能識别出已經到了流的末尾呢?我們需要一種規則來定義消息的邊界,告訴對方讀取已經到了末尾,可以結束了。
舉一個生活中的例子來幫助了解,2020年由于疫情的原因,平日裡都是線上下會議室開會,特殊時期演變成了線上會議。不知道大家有沒有遇到過這種情況,線下開會時通過觀察别人的動作、神情很容易知道他說完了,這時候下一個人就可以接着發言了,但是線上開會時這樣就行不通了,你如果想發言是不是得先确認下别人有沒有說完,如果直接發言可能會打斷别人,這樣很不禮貌,為什麼會出現這種情況呢?因為你不知道他到底有沒有結束發言,更專業一點說你不知道是否到達了消息的邊界。那怎麼改進呢,如果每個人發言完畢都顯示的告訴别人“我說完了”,是不是會好一些呢,“我說完了”這四個字就是一種消息的邊界,給接收方傳達一種消息結束的訊息。
在基于流的傳輸(例如TCP / IP)中,将接收到的資料存儲到套接字接收緩沖區中。不幸的是,基于流的傳輸的緩沖區不是資料包隊列而是位元組隊列。這意味着,即使您将兩個消息作為兩個獨立的資料包發送,作業系統也不會将它們視為兩個消息,而隻是一堆位元組。是以,不能保證讀取的内容與遠端寫的完全一樣。例如,假設作業系統的TCP / IP棧已收到三個資料包:
由于是基于流的協定,是以很有可能在應用程式中讀到以下四個分段:
是以,無論是伺服器端還是用戶端,接收方都應将接收到的資料整理到一個或多個有意義的幀中,以使應用程式邏輯易于了解。在上面的示例中,正确的資料應采用以下格式:
前面介紹了消息邊界的定義以及作用,這一節我們來看看大概會有哪幾種消息邊界。
1.特殊字元:比如上面提到的“我說完了”這就是一種特殊字元作為消息邊界的例子,以特殊字元為邊界的典型産品有我們熟知的redis,用戶端和伺服器發送的指令或資料一律以 \r\n (CRLF)結尾,還有Netty中的DelimiterBasedFrameDecoder。
2.基于消息長度:比如約定了消息長度為4k位元組,接收方每次讀取4k位元組以後就認為已到達消息邊界,結束本次讀取。當然現實中消息長度一般是變長的,這樣就需要設計一個約定好的消息頭部,将消息長度作為頭部的一部分傳輸過去,以長度為邊界的例子有Dubbo、http
、websocket,Netty中的FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder等。
附上一張dubbo協定頭,供大家體會
上面說過,redis是通過\r\n來作為消息邊界的,下面我将從源碼角度分析下redis具體是如何處理的。
1.這裡通過telnet來發送内聯格式指令請求redis,之是以沒有選用redis-cli是想模拟一條指令redis-server分多次收到的情況,在telnet模式下,每輸入一個字元,就會發送給redis-server端,而redis-cli不是,它是按下回車時才會發送整體輸入的指令,redis-server端是分多次還是一次收到完整的指令,這個取決于底層,如果想模拟分多次收到,這個過程較為複雜。
2.redis-server端每次有輸入時會觸發readQueryFromClient(networking.c)函數,對redis執行流程感興趣的可以參考我之前的文章“redis源碼學習之工作流程初探”。
3.redis-server将收到的内容暫存到redisClient的querybuf中,如果沒有收到\r\n就等待,直到收到\r\n才将querybuf中的内容解析成指令執行。
測試步驟如下:
telnet 中輸入g
debug檢視redisClient中querybuf的值,目前隻有g
telnet中輸完get a按回車以後,redisClient中querybuf儲存了所有的輸入get a \r\n
源碼分析如下:
readQueryFromClient
processInputBuffer
processInlineBuffer
有興趣的小夥伴可以看看FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder源碼的java doc說明,裡面講的比較詳細,在此不再重複。
網絡上其他作者将這類問題稱之為TCP“粘包”和“拆包”,與本文提到的消息邊界本質上沒有太多差別,之是以沒有繼續叫“拆包”是不想把概念複雜化,回到本質其實就是需要一種機制來定義消息的邊界,幫助應用層來正确的解析消息。
通過redis源碼的簡單分析,大體可以得到解決這類問題的關鍵點有以下兩步:
1.需要一種邊界的定義,基于特殊字元、基于長度等;
2.消息接收端需要暫存收到的内容,不到邊界時等待,直到符合邊界條件(收到了特殊字元或者收到的位元組數達到約定的長度)。
雖說不是一個高大上的知識點,但是通過查資料和閱讀源碼也解決了心中的困惑,過程中通過發散式的學習也了解到Netty架構針對這類問題的解決方案,算是對Netty的認識又深入了一點。