Netty消息接收類故障案例分析

《Netty 進階之路》、《分佈式服務框架原理與實踐》作者李林鋒深入剖析Netty消息接收類故障案例。李林鋒此後還將在 InfoQ 上開設 Netty 專題持續出稿,感興趣的同學可以持續關注。

1. 背景

1.1 消息接收類故障

儘管Netty應用廣泛,非常成熟,但是由於對Netty底層機制不太瞭解,用戶在實際使用中還是會經常遇到各種問題,大部分問題都是業務使用不當導致的。Netty使用者需要學習Netty的故障定位技巧,以便出了問題能夠獨立、快速的解決。

在各種故障中,Netty服務端接收不到客戶端消息是一種比較常見的異常,大部分場景下都是用戶使用不當導致的,下面我們對常見的消息接收接類故障進行分析和總結。

1.2 消息接收類故障定位技巧

如果業務的ChannelHandler接收不到消息,可能的原因如下:

  1. 業務的解碼ChannelHandler存在BUG,導致消息解碼失敗,沒有投遞到後端。

  2. 業務發送的是畸形或者錯誤碼流(例如長度錯誤),導致業務解碼ChannelHandler無法正確解碼出業務消息。

  3. 業務ChannelHandler執行了一些耗時或者阻塞操作,導致Netty的NioEventLoop被掛住,無法讀取消息。

  4. 執行業務ChannelHandler的線程池隊列積壓,導致新接收的消息在排隊,沒有得到及時處理。

  5. 對方確實沒有發送消息。

定位策略如下:

  1. 在業務的首個ChannelHandler的channelRead方法中打斷點調試,看是否讀取到消息。

  2. 在ChannelHandler中添加LoggingHandler,打印接口日誌。

  3. 查看NioEventLoop線程狀態,看是否發生了阻塞。

  4. 通過tcpdump抓包看消息是否發送成功。

2. 服務端接收不到車載終端消息

2.1 業務場景

車聯網服務端使用Netty構建,接收車載終端的請求消息,然後下發給後端其它系統,最後返回應答給車載終端。系統運行一段時間後發現服務端接收不到車載終端消息,導致業務中斷,需要儘快定位出問題原因。

2.2 故障現象

服務端運行一段時間之後,發現無法接收到車載終端的消息,相關日誌示例如下:

圖1 車聯網服務端無法接收消息日誌

從日誌看,服務端每隔一段時間(示例中是15秒,實際業務時間是隨機的)就會接收不到消息,隔一段時間之後恢復,然後又沒消息,周而復始。跟車載終端確認,終端設備每隔固定週期就會發送消息給服務端(日誌分析),因此排除是終端沒發消息導致的問題。懷疑是不是服務端負載過重,搶佔不到CPU資源導致的週期性阻塞,採集CPU使用率,發現CPU資源不是瓶頸,排除CPU佔用率高問題。

排除CPU之後,懷疑是不是內存有問題,導致頻繁GC引起業務線程暫停。採集GC統計數據,示例如下:

圖2 GC數據採集

通過CPU和內存資源佔用監控分析,發現硬件資源不是瓶頸,問題應該出在服務端代碼側。

2.3 故障分析

從現象上看,服務端接收不到消息,排除GC、網絡等問題之後,很有可能是Netty的NioEventLoop線程阻塞,導致TCP緩衝區的數據沒有及時讀取,故障期間採集服務端的線程堆棧進行分析,示例如下:


圖3 故障期間服務端線程堆棧

從線程堆棧分析,Netty的NioEventLoop讀取到消息後,調用業務線程池執行業務邏輯時,發生了RejectedExecutionException異常,由於後續業務邏輯由NioEventLoop線程執行,因此可以判斷業務使用了CallerRunsPolicy策略,即當業務線程池消息隊列滿之後,由調用方線程來執行當前的Runnable。NioEventLoop在執行業務任務時發生了阻塞,導致NioEventLoop線程無法處理網絡讀寫消息,因此會看到服務端沒有消息接入,當從阻塞狀態恢復之後,就可以繼續接收消息。

如果後端業務邏輯處理慢,則會導致業務線程池阻塞隊列積壓,當積壓達到上限容量之後,JDK會拋出RejectedExecutionException異常,由於業務設置了CallerRunsPolicy策略,就會由調用方線程NioEventLoop執行業務邏輯,最終導致NioEventLoop線程被阻塞,無法讀取請求消息。

除了JDK線程池異常處理策略使用不當之外,有些業務喜歡自己寫阻塞隊列,當隊列滿之後,向隊列加入新的消息會阻塞當前線程,直到消息能夠加入到隊列中。案例中的車聯網服務端真實業務代碼就是此類問題:當轉發給下游系統發生某些故障時,會導致業務定義的阻塞隊列無法彈出消息進行處理,當隊列積壓滿之後,就會阻塞Netty的NIO線程,而且無法自動恢復。

2.4 NioEventLoop線程防掛死策略

由於ChannelHandler是業務代碼和Netty框架交匯的地方,ChannelHandler裏面的業務邏輯通常由NioEventLoop線程執行,因此防止業務代碼阻塞NioEventLoop線程就顯得非常重要,常見的阻塞情況有兩類:

  1. 直接在ChannelHandler寫可能導致程序阻塞的代碼,包括但不限於數據庫操作、第三方服務調用、中間件服務調用、同步獲取鎖、Sleep等。

  2. 切換到業務線程池或者業務消息隊列做異步處理時發生了阻塞,最典型的如阻塞隊列、同步獲取鎖等。

在實際項目中,推薦業務處理線程和Netty網絡I/O線程分離策略,原因如下:

  1. 充分利用多核的並行處理能力:I/O線程和業務線程分離,雙方可以並行的處理網絡I/O和業務邏輯,充分利用多核的並行計算能力,提升性能。

  2. 故障隔離:後端的業務線程池處理各種類型的業務消息,有些是I/O密集型、有些是CPU密集型、有些是純內存計算型,不同的業務處理時延,以及發生故障的概率都是不同的。如果把業務線程和I/O線程合併,就會存在如下問題:

    某類業務處理較慢,阻塞I/O線程,導致其它處理較快的業務消息的響應無法及時發送出去。

    即便是同類業務,如果使用同一個I/O線程同時處理業務邏輯和I/O讀寫,如果請求消息的業務邏輯處理較慢,同樣會導致響應消息無法及時發送出去。

  3. 可維護性:I/O線程和業務線程分離之後,雙方職責單一,有利於代碼維護和問題定位。如果合併在一起執行,當RPC調用時延增大之後,到底是網絡問題、還是I/O線程問題、還是業務邏輯問題導致的時延大,糾纏在一起,問題定位難度非常大。例如業務線程中訪問緩存或者數據庫偶爾時延增大,就會導致I/O線程被阻塞,時延出現毛刺,這些時延毛刺的定位,難度非常大。

Netty I/O線程和業務邏輯處理線程分離之後,線程模型如下所示:

圖4 Netty業務線程和網絡I/O線程分離

3. MQTT服務端拒絕接入

3.1 問題現象

生產環境的MQTT服務運行一段時間之後,發現有新的端側設備無法接入,連接超時。分析MQTT服務端日誌,沒有明顯的異常,但是內存佔用較高,查看連接數,發現有數10萬個TCP連接處於ESTABLISHED狀態,實際的MQTT連接數應該在1萬個左右,顯然這麼多的連接肯定存在問題。

由於MQTT服務端的內存是按照2萬個左右連接數規模配置的,因此當連接數達到數十萬規模之後,導致了服務端大量SocketChannel積壓,內存暴漲,高頻率的GC和較長的STW時間對端側設備的接入造成了很大影響,導致部分設備MQTT握手超時,無法接入。

3.2 客戶端連接數膨脹原因分析

通過抓包分析發現,一些端側設備並沒有按照MQTT協議規範進行處理,包括:

  1. 客戶端發起CONNECT連接,SSL握手成功之後沒有按照協議規範繼續處理,例如發送PING命令。

  2. 客戶端發起TCP連接,不做SSL握手,也不做後續處理,導致TCP連接被掛起。

由於服務端是嚴格按照MQTT協議規範實現的,上述端側設備不按規範接入,實際上消息調度不到MQTT應用協議層。MQTT服務端依賴Keep Alive機制做超時檢測,當一段時間接收不到客戶端的心跳和業務消息時,就會觸發心跳超時,關閉連接。針對上述兩種接入場景,由於MQTT的連接流程沒有完成,MQTT協議棧不認爲這個是合法的MQTT連接,因此心跳保護機制無法對上述TCP連接做檢測。客戶端和服務端都沒有主動關閉這個連接,導致TCP連接一直保持。

問題原因如下所示:

圖5 MQTT連接建立過程

3.3 無效連接的關閉策略

針對這種不遵循MQTT規範的端側設備,除了要求對方按照規範修改之外,服務端還需要做可靠性保護,具體策略如下:

  1. 端側設備的TCP連接接入之後,啓動一個鏈路檢測定時器加入到Channel對應的NioEventLoop中。

  2. 鏈路檢測定時器一旦觸發,就主動關閉TCP連接。

  3. TCP連接完成MQTT協議層的CONNECT之後,刪除之前創建的鏈路檢測定時器。

3.4 問題總結

生產環境升級補丁版本之後,平穩運行,查看MQTT連接數,穩定在1萬個左右,與預期一致,問題得到解決。

對於MQTT服務端,除了要遵循協議規範之外,還需要對那些不遵循規範的客戶端接入做保護,不能因爲一些客戶端沒按照規範實現,導致服務端無法正常工作。系統的可靠性設計更多的是在異常場景下保護系統穩定運行。

4. HTTP消息被多次讀取問題

針對Channel上發生的各種網絡操作,例如鏈路創建、鏈路關閉、消息讀寫、鏈路註冊和去註冊等,Netty將這些消息封裝成事件,觸發ChannelPipeline調用ChannelHandler鏈,由系統或者用戶實現的ChannelHandler對網絡事件做處理。

由於網絡事件種類比較多,觸發和執行機制也存在一些差異,如果掌握不到位,很有可能遇到一些莫名其妙的問題。而且有些問題只有在高併發或者生產環境出現,測試牀不容易復現,因此這類問題定位難度很大。

4.1 channelReadComplete方法被調用多次

故障場景:業務基於Netty開發了HTTP Server,在生產環境運行一段時間之後,部分消息邏輯處理錯誤,但是在灰度測試環境驗證卻無法重現問題,需要儘快定位並解決。

在生產環境中將某一個服務實例的調測日誌打開一段時間,以便定位問題。通過接口日誌分析,發現同一個HTTP請求消息,當發生問題時,業務ChannelHandler的channelReadComplete方法會被調用多次,但是大部分消息都是調用一次,按照業務的設計初衷,當服務端讀取到一個完整的HTTP請求消息之後,在channelReadComplete方法中進行業務邏輯處理。如果一個請求消息它的channelReadComplete方法被調用多次,則業務邏輯就會出現異常。

通過對客戶端請求消息和Netty框架源碼分析,找到了問題根因:TCP底層並不瞭解上層業務數據的具體含義,它會根據TCP緩衝區的實際情況進行包的拆分,所以在業務上認爲一個完整的HTTP報文可能會被TCP拆分成多個包進行發送,也有可能把多個小的包封裝成一個大的數據包發送。導致數據報拆分和重組的原因如下:

  1. 應用程序write寫入的字節大小大於套接口發送緩衝區大小。

  2. 進行MSS大小的TCP分段。

  3. 以太網幀的payload大於MTU進行IP分片。

  4. 開啓了TCP Nagle’s algorithm。

由於底層的TCP無法理解上層的業務數據,所以在底層是無法保證數據包不被拆分和重組的,這個問題只能通過上層的應用協議棧設計來解決,根據業界的主流協議的解決方案,可以歸納如下:

  1. 消息定長,例如每個報文的大小爲固定長度200字節,如果不夠,空位補空格。

  2. 在包尾增加回車換行符(或者其它分隔符)進行分割,例如FTP協議。

  3. 將消息分爲消息頭和消息體,消息頭中包含表示消息總長度(或者消息體長度)的字段,通常設計思路爲消息頭的第一個字段使用int32來表示消息的總長度。

對於HTTP請求消息,當業務併發量比較大時,無法保證一個完整的HTTP消息會被一次全部讀取到服務端。當採用chunked方式進行編碼時,HTTP報文也是分段發送的,此時服務端讀取到的也不是完整的HTTP報文。爲了解決這個問題,Netty提供了HttpObjectAggregator,保證後端業務ChannelHandler接收到的是一個完整的HTTP報文,相關示例代碼如下所示:

 *//**代碼省略...*

 *ChannelPipeline p = ...;*

 *p.addLast("decoder", new HttpRequestDecoder());*

 *p.addLast("encoder", new HttpResponseEncoder());*

 *p.addLast("aggregator", new HttpObjectAggregator(10240));*

 *p.addLast("service", new ServiceChannelHandler());* 

 *//**代碼省略...*

通過HttpObjectAggregator可以保證當Netty讀取到完整的HTTP請求報文之後纔會調用一次業務ChannelHandler的channelRead方法,無論這條報文底層經過了幾次SocketChannel的read調用。但是對於channelReadComplete方法,它並不是業務語義上的讀取消息完成之後觸發,而是每次從SocketChannel成功讀取到消息之後,系統就會觸發對channelReadComplete方法的調用,也就是說如果一個HTTP消息被TCP協議棧發送了N次,則服務端的channelReadComplete方法就會被調用N次。

在灰度測試環境中,由於客戶端並沒有採用chunked的編碼方式,併發壓力也不是很高,所以一直沒有發現該問題,到了生產環境有些客戶端採用了chunked方式發送HTTP請求消息,客戶端併發量也比較高,所以觸發了服務端BUG。

4.2 ChannelHandler使用的一些誤區

ChannelHandler由ChannelPipeline觸發,業務經常使用的方法包括channelRead方法、channelReadComplete方法和exceptionCaught方法等,它的使用比較簡單,但是裏面還是有一些容易出錯的地方,使用不當就會導致諸如上述案例中的問題。

4.2.1 channelReadComplete****方法調用

對於channelReadComplete方法的調用,很容易誤認爲前面已經增加了對應協議的編解碼器,所以只有消息解碼成功之後纔會調用channelReadComplete方法。實際上它的調用與用戶是否添加協議解碼器無關,只要對應的SocketChannel成功讀取到了ByteBuf,它就會被觸發,相關代碼如下所示(NioByteUnsafe類):

*public final void read() {*

           *//**代碼省略...*

            *try {*

                *do {*

                    *byteBuf = allocHandle.allocate(allocator);*

                   *allocHandle.lastBytesRead(doReadBytes(byteBuf));*

                    *if (allocHandle.lastBytesRead() <= 0) {*

                        *byteBuf.release();*

                        *byteBuf = null;*

                        *close = allocHandle.lastBytesRead() < 0;*

                        *if (close) {*

                            *readPending = false;*

                        *}*

                        *break;*

                    *}*

                    *allocHandle.incMessagesRead(1);*

                    *readPending = false;*

                    *pipeline.fireChannelRead(byteBuf);*

                    *byteBuf = null;*

                *} while (allocHandle.continueReading());*

                *allocHandle.readComplete();*

                *pipeline.fireChannelReadComplete();*

         *//**代碼省略...*

*}*

對於大部分的協議解碼器,例如Netty內置的ByteToMessageDecoder,它會調用具體的協議解碼器對ByteBuf做解碼,只有解碼成功之後,纔會調用後續ChannelHandler的channelRead方法,代碼如下所示(ByteToMessageDecoder類):

*static void fireChannelRead(ChannelHandlerContext ctx, CodecOutputList msgs, int numElements) {*

        *for (int i = 0; i < numElements; i ++) {*

            *ctx.fireChannelRead(msgs.getUnsafe(i));*

        *}*

*}*

但是對於channelReadComplete方法則是透傳調用,即無論是否有完整的消息被解碼成功,只要讀取到消息,都會觸發後續ChannelHandler的channelReadComplete方法調用,代碼如下所示(ByteToMessageDecoder類):

*public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {*

        *numReads = 0;*

        *discardSomeReadBytes();*

        *if (decodeWasNull) {*

            *decodeWasNull = false;*

            *if (!ctx.channel().config().isAutoRead()) {*

                *ctx.read();*

            *}*

        *}*

        *ctx.fireChannelReadComplete();*

*}*

4.2.2 ChannelHandler職責鏈調用

ChannelPipeline以鏈表的方式管理某個Channel對應的所有ChannelHandler,需要說明的是下一個ChannelHandler的觸發需要在當前ChannelHandler中顯式調用,而不是自動觸發式調用,相關代碼示例如下(SslHandler類):

*public void channelActive(final ChannelHandlerContext ctx) throws Exception {*

        *if (!startTls) {*

            *startHandshakeProcessing();*

        *}*

        *ctx.fireChannelActive();*

*}*

如果遺忘了調用ctx.fireChannelActive方法,則SslHandler後續的ChannelHandler的channelActive方法將不會被執行,職責鏈執行到SslHandler就會中斷。

Netty內置的TailContext有時候會執行一些系統性的清理操作,例如當channelRead方法執行完成,將請求消息(例如ByteBuf)釋放掉,防止因爲業務遺漏釋放而導致內存泄漏(內存池模式下會導致內存泄漏),相關代碼如下所示(TailContext類):

*protected void onUnhandledInboundMessage(Object msg) {*

        *try {*

            *logger.debug(*

                    *"Discarded inbound message {} that reached at the tail of the pipeline. " +*

                            *"Please check your pipeline configuration.", msg);*

        *} \**finally {***

            **ReferenceCountUtil.release(msg);**

        **}**

*}*

當執行完業務最後一個ChannelHandler時,需要判斷是否需要調用系統的TailContext,如果需要,則通過ctx.firexxx方法調用。

4.3 總結

通常情況下,在功能測試或者併發壓力不大時,HTTP請求消息可以一次性接收完成,此時ChannelHandler的channelReadComplete方法會被調用一次,但是當一個整包消息經過多次讀取才能完成解碼時,channelReadComplete方法就會被觸發調用多次。如果業務的功能正確性依賴channelReadComplete方法的調用次數,當客戶端併發壓力大或者採用chunked編碼時,功能就會出錯。因此,需要熟悉和掌握Netty的事件觸發機制以及ChannelHandler的調用策略,這樣才能防止在生成環境踩坑。

5. 作者簡介

李林鋒,10年Java NIO、平臺中間件設計和開發經驗,精通Netty、Mina、分佈式服務框架、API Gateway、PaaS等,《Netty進階之路》、《分佈式服務框架原理與實踐》作者。目前在華爲終端應用市場負責業務微服務化、雲化、全球化等相關設計和開發工作。

聯繫方式:新浪微博 Nettying 微信:Nettying

Email:[email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章