2021-05-12 14:32:11
Square從Netty 3升級到Netty 4的經驗
背景
Tracon是Square公司的反向代理軟體,最初它主要用於協調後段架構從傳統單體架構向微服務架構的轉換。作為反向代理前端,Tracon需要有非常優秀的效能,同時能夠支撐微服務架構下的各種功能客製化,例如:服務發現、設定和生命週期管理等。因此Tracon網路層基於Netty構建,以提供高效代理服務。
Tracon已經上線執行3年,其程式碼行數也增加到30000行。基於Netty 3的代理模組在如此龐大複雜的應用中運轉正常,並抽離成獨立模組應用到Square內部認證代理服務中。
Netty 4?
Netty 4已經發布3年了,相比於Netty 3,Netty 4在記憶體模型和執行緒模型上都進行了修改。現在Netty 4已經非常成熟,並且對於Square公司來說,Netty 4還有一個重大特性:對HTTP/2協定的原生支援。Square期望其移動裝置都使用HTTP/2協定,並且正在將後台RPC框架切換到gRPC:一個基於HTTP/2協定的RPC框架。因此,Tracon作為代理服務,必須支援HTTP/2協定。
Tracon已經完成了到Netty 4的升級,整個升級過程也不是一帆風順的,以下著重介紹一些在升級過程中容易遇到的問題。
單執行緒channel
和Netty 3不同,Netty 4的inbound(資料輸入)事件和outbound(資料輸出)事件的所有處理器(handler)都在同一個執行緒中。這是得在編寫處理器的時候,可以移除執行緒安全相關的程式碼。但是,這個變化也使得在升級過程中遇到條件競爭導致的問題。
在Netty 3中,針對pipeline的操作都是執行緒安全的,但是在Netty 4中,所有操作都會以事件的形式放入事件迴圈中非同步執行。作為代理服務的Tracon,會有一個獨立的inbound channel和上游伺服器進行互動,一個獨立的outbound channel和下游伺服器進行互動。為了提高效能,和下游伺服器的連線會被快取起來,因此當事件迴圈中的事件觸發了寫操作時,這些寫操作可能會並行進行。這對於Netty 3來說沒有問題,每個寫操作都會完成後再返回;但是對於Netty 4,這些操作都進入了事件迴圈,可能會導致訊息的亂序。
因此,在分塊測試中,偶爾會遇到發出去的資料不是按照順序到達,導致測試失敗。
當從Netty 3升級到Netty 4時,如果有事件在事件回圈外觸發時,必須特別注意這些事件會被非同步的排程。
連線何時真正建立?
Netty 3中,連線建立之後會發出channelConnected
事件;而在Netty 4中,這個事件變成了channelActive
。對於一般應用程式來說,這個改動變化不大,修改一下對應的事件處理方法即可。但是Tracon使用了雙向TLS認證以確認對方身份。
對於兩個版本的SslHandler
,TLS握手完成訊息處理方式完全不同。在Netty 3中,SslHandler
在channelConnected
事件處理方法中阻塞,並完成整個TLS握手。因此後續的處理器在channelConnected
事件處理方法中就可以獲得完成握手的SSLSession
。Netty 4則不同,由於其事件機制,SslHandler
完成TLS握手也是非同步進行的,因此直接在channelConnected
事件中,是無法獲取到SSLSession
的,此時TLS握手還沒有完成。對應的SslHandler
會在TLS握手完成之後,發出自定義的SslHandshakeCompletionEvent
事件。
對於Netty 4,TLS握手完成後的邏輯應該改成:
@Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws if (evt.equals(SslHandshakeCompletionEvent.SUCCESS)) { Principal peerPrincipal = engine.getSession().getPeerPrincipal(); // 身份驗證 // ... } super.userEventTriggered(ctx, evt); }
NIO記憶體漏失
由於NIO使用direct記憶體,對於Netty這類網路庫,監控direct記憶體是很有必要的,這可以通過使用JMX beanjava.nio:type=BufferPool,name=direct
來進行。
Netty 4引入了基於執行緒區域性變數的回收器(thread-local Recyler)來回收物件池。預設情況下,一個回收器可以最多持有262k個物件,對於ByteBuf來說,小於64k的物件都預設共用快取。也就是說,每個回收器最多可以持有17G的direct記憶體。
通常情況下,NIO快取足夠應付瞬間的資料量。但是如果有一個讀取速度很慢的後端,會大大增加記憶體使用。另外,當快取中的NIO記憶體在被其他執行緒讀寫時,分配該記憶體的執行緒會無法回收這些記憶體。
對於回收器無法回收導致記憶體耗盡的問題,Netty專案也做了一些修正,以解決限制物件增長的問題:
- 允許設定每個執行緒的WeakOrderQueue範例的最大數量;
- 回收器中引入記憶體分配/共用比例;
- 從Netty 3.10移植SendBufferPooled,當使用非共用ByteBuf分配器(ByteBufAllocator)時使用;
從升級Netty 4的經驗來看,建議所有開發者基於可用記憶體和執行緒數來設定回收器。回收器最大持有物件數可以通過-Dio.netty.recycler.maxCapacity
引數設定,共用記憶體最大限制可以通過-Dio.netty.threadLocalDirectBufferSize
引數設定。如果要完全關閉回收器,可以將-Dio.netty.recycler.maxCapacity
設定為0,從Tracon的使用過程來看,使用回收器並沒有對效能又多大的提升。
Tracon在記憶體漏失上還做了一個小的改動:當JVM丟擲錯誤時,通過一個全域性的例外處理類(UncaughtExceptionHandler
)直接退出應用。因為通常情況下,當應用程式遇到了OutOfMemoryError
錯誤時,已經無法自我恢復。
class LoggingExceptionHandler implements Thread.UncaughtExceptionHandler { private static final Logger logger = Logger.getLogger(LoggingExceptionHandler.class); /** 註冊成預設處理器 */ static void registerAsDefault() { Thread.setDefaultUncaughtExceptionHandler(new LoggingExceptionHandler()); } @Override public void uncaughtException(Thread t, Throwable e) { if (e instanceof Exception) { logger.error("Uncaught exception killed thread named '" + t.getName() + "'.", e); } else { logger.fatal("Uncaught error killed thread named '" + t.getName() + "'." + " Exiting now.", e); System.exit(1); } } }
限制回收器使用解決了洩漏問題,但是一個讀取速度很慢的後端還是會消耗大量快取。Tracon中通過使用channelWritabilityChanged
事件來緩解寫入快取壓力。通過增加如下處理器,可以關聯兩個channel的讀寫:
/** * 監聽當前inbound管道是否可寫,設定關聯的channel是否自動讀取。 * 這可以讓代理通知另外一端當前channel有一個讀取很慢的消費者, * 僅當消費者準備完成後再進行資料讀取。 */ public class WritabilityHandler extends ChannelInboundHandlerAdapter { private final Channel otherChannel; public WritabilityHandler(Channel otherChannel) { this.otherChannel = otherChannel; } @Override public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception { boolean writable = ctx.channel().isWritable(); otherChannel.config().setOption(ChannelOption.AUTO_READ, writable); super.channelWritabilityChanged(ctx); } }
當傳送快取到達高水位線時,將被標記為不可寫,當傳送快取降低到低水位線時,重新被標記為可寫。預設情況下,高水位線為64kb,低水位線為32kb。這些引數可以根據實際情況進行修改。
避免寫異常丟失
當發生寫操作失敗時,如果沒有對promise設定監聽器,寫操作失敗會被忽略,這對於系統穩定性的分析會有很大影響。為了避免這種情況的發生,針對promise的監聽器非常重要,但是如果每次建立promise時都需要設定一個紀錄檔記錄的監聽器,成本比較高,也容易遺忘。針對這種情況,Tracon中針對outbound事件設定了專門的處理器,統一為寫操作的promise設定紀錄檔記錄監聽器:
@Singleton @Sharable public class PromiseFailureHandler extends ChannelOutboundHandlerAdapter { private final Logger logger = Logger.getLogger(PromiseFailureHandler.class); @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { promise.addListener(future -> { if (!future.isSuccess()) { logger.info("Write on channel %s failed", promise.cause(), ctx.channel()); } }); super.write(ctx, msg, promise); } }
這樣,只需要在pipeline中新增該處理器即可記錄所有的寫異常紀錄檔。
HTTP解碼器重構
Netty 4對HTTP解碼器做了重構,特別完善了對分塊資料的支援。HTTP訊息體被拆分成HttpContent
物件,如果HTTP資料通過分塊的方式傳輸,會有多個HttpContent
順序到達,當資料塊傳輸結束時,會有一個LastHttpContent
物件達到。這裡需要特別注意的是,LastHttpContent
繼承自HttpContent
,千萬不能用以下方式來處理:
if (msg instanceof HttpContent) { ... } if (msg instanceof LastHttpContent) { … // 最後一個分塊會重複處理,前面的if已經包含了LastHttpContent }
對於LastHttpContent
還有一個需要注意的是,接收到這個物件時,HTTP訊息體可能已經傳輸完了,此時LastHttpContent
只是作為HTTP傳輸的結束符(類似EOF)。
灰度發布
這次升級Netty 4,涉及到100多個檔案共8000多行程式碼。並且,由於執行緒模型和記憶體模型的修改,Tracon的替換必須非常小心。
在完成了發布前的單元測試、整合測試之後,首先需要部署到生產環境,並關閉流量。這樣,代理服務能夠和後端服務互動,同時避免使用者真實流量匯入。此時,需要正對這些服務做最終的確認,確保和線上後端服務互動沒有任何問題。
完成驗證之後,才能夠開始逐步引入使用者流量,最終完成Netty 4版本的Tracon升級。經過實際驗證,使用UnpooledByteBufAllocator
分配記憶體和之前Netty 3版本效能基本相同,期待以後使用PooledByteBufAllocator
會有更好的效能。
總結
從Netty 3升級升級到Netty 4,在帶來了效能提升和新特性的同時,對原有程式碼的修改需要特別注意Netty 4執行緒模型和記憶體模型的改變。以上這些遇到的問題,希望能夠作為參考,避免在Netty 4應用開發過程中再遇到類似問題。
Netty權威指南 PDF完整版帶目錄書籤+原始碼 http://www.linuxidc.com/Linux/2016-07/133575.htm
運用Spring註解實現Netty伺服器端UDP應用程式 http://www.linuxidc.com/Linux/2013-09/89780.htm
Netty原始碼學習筆記 http://www.linuxidc.com/Linux/2013-09/89778.htm
Netty使用範例 http://www.linuxidc.com/Linux/2013-09/89779.htm
Java NIO框架--Netty4的簡單範例 http://www.linuxidc.com/Linux/2015-01/111335.htm
相關文章