背景
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項目也做了一些修正,以解決限制對象增長的問題:
從升級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
Netty 的詳細介紹:請點這裡
Netty 的下載地址:請點這裡