from:http://www.infoq.com/cn/articles/netty-version-upgrade-history-thread-part
1. 背景
1.1. Netty 3.X系列版本現(xiàn)狀
根據(jù)對(duì)Netty社區(qū)部分用戶的調(diào)查,結(jié)合Netty在其它開源項(xiàng)目中的使用情況,我們可以看出目前Netty商用的主流版本集中在3.X和4.X上,其中以Netty 3.X系列版本使用最為廣泛。
Netty社區(qū)非?;钴S,3.X系列版本從2011年2月7日發(fā)布的netty-3.2.4 Final版本到2014年12月17日發(fā)布的netty-3.10.0 Final版本,版本跨度達(dá)3年多,期間共推出了61個(gè)Final版本。
1.2. 升級(jí)還是堅(jiān)守老版本
相比于其它開源項(xiàng)目,Netty用戶的版本升級(jí)之路更加艱辛,最根本的原因就是Netty 4對(duì)Netty 3沒有做到很好的前向兼容。
由于版本不兼容,大多數(shù)老版本使用者的想法就是既然升級(jí)這么麻煩,我暫時(shí)又不需要使用到Netty 4的新特性,當(dāng)前版本還挺穩(wěn)定,就暫時(shí)先不升級(jí),以后看看再說。
堅(jiān)守老版本還有很多其它的理由,例如考慮到線上系統(tǒng)的穩(wěn)定性、對(duì)新版本的熟悉程度等。無論如何升級(jí)Netty都是一件大事,特別是對(duì)Netty有直接強(qiáng)依賴的產(chǎn)品。
從上面的分析可以看出,堅(jiān)守老版本似乎是個(gè)不錯(cuò)的選擇;但是,“理想是美好的,現(xiàn)實(shí)卻是殘酷的”,堅(jiān)守老版本并非總是那么容易,下面我們就看下被迫升級(jí)的案例。
1.3. “被迫”升級(jí)到Netty 4.X
除了為了使用新特性而主動(dòng)進(jìn)行的版本升級(jí),大多數(shù)升級(jí)都是“被迫的”。下面我們對(duì)這些升級(jí)原因進(jìn)行分析。
- 公司的開源軟件管理策略:對(duì)于那些大廠,不同部門和產(chǎn)品線依賴的開源軟件版本經(jīng)常不同,為了對(duì)開源依賴進(jìn)行統(tǒng)一管理,降低安全、維護(hù)和管理成本,往往會(huì)指定優(yōu)選的軟件版本。由于Netty 4.X 系列版本已經(jīng)非常成熟,因?yàn)椋芏喙径純?yōu)選Netty 4.X版本。
- 維護(hù)成本:無論是依賴Netty 3.X,還是Netty4.X,往往需要在原框架之上做定制。例如,客戶端的短連重連、心跳檢測(cè)、流控等。分別對(duì)Netty 4.X和3.X版本實(shí)現(xiàn)兩套定制框架,開發(fā)和維護(hù)成本都非常高。根據(jù)開源軟件的使用策略,當(dāng)存在版本沖突的時(shí)候,往往會(huì)選擇升級(jí)到更高的版本。對(duì)于Netty,依然遵循這個(gè)規(guī)則。
- 新特性:Netty 4.X相比于Netty 3.X,提供了很多新的特性,例如優(yōu)化的內(nèi)存管理池、對(duì)MQTT協(xié)議的支持等。如果用戶需要使用這些新特性,最簡(jiǎn)便的做法就是升級(jí)Netty到4.X系列版本。
- 更優(yōu)異的性能:Netty 4.X版本相比于3.X老版本,優(yōu)化了內(nèi)存池,減少了GC的頻率、降低了內(nèi)存消耗;通過優(yōu)化Rector線程池模型,用戶的開發(fā)更加簡(jiǎn)單,線程調(diào)度也更加高效。
1.4. 升級(jí)不當(dāng)付出的代價(jià)
表面上看,類庫(kù)包路徑的修改、API的重構(gòu)等似乎是升級(jí)的重頭戲,大家往往把注意力放到這些“明槍”上,但真正隱藏和致命的卻是“暗箭”。如果對(duì)Netty底層的事件調(diào)度機(jī)制和線程模型不熟悉,往往就會(huì)“中槍”。
本文以幾個(gè)比較典型的真實(shí)案例為例,通過問題描述、問題定位和問題總結(jié),讓這些隱藏的“暗箭”不再傷人。
由于Netty 4線程模型改變導(dǎo)致的升級(jí)事故還有很多,限于篇幅,本文不一一枚舉,這些問題萬變不離其宗,只要抓住線程模型這個(gè)關(guān)鍵點(diǎn),所謂的疑難雜癥都將迎刃而解。
2. Netty升級(jí)之后遭遇內(nèi)存泄露
2.1. 問題描述
隨著JVM虛擬機(jī)和JIT即時(shí)編譯技術(shù)的發(fā)展,對(duì)象的分配和回收是個(gè)非常輕量級(jí)的工作。但是對(duì)于緩沖區(qū)Buffer,情況卻稍有不同,特別是對(duì)于堆外直接內(nèi)存的分配和回收,是一件耗時(shí)的操作。為了盡量重用緩沖區(qū),Netty4.X提供了基于內(nèi)存池的緩沖區(qū)重用機(jī)制。性能測(cè)試表明,采用內(nèi)存池的ByteBuf相比于朝生夕滅的ByteBuf,性能高23倍左右(性能數(shù)據(jù)與使用場(chǎng)景強(qiáng)相關(guān))。
業(yè)務(wù)應(yīng)用的特點(diǎn)是高并發(fā)、短流程,大多數(shù)對(duì)象都是朝生夕滅的短生命周期對(duì)象。為了減少內(nèi)存的拷貝,用戶期望在序列化的時(shí)候直接將對(duì)象編碼到PooledByteBuf里,這樣就不需要為每個(gè)業(yè)務(wù)消息都重新申請(qǐng)和釋放內(nèi)存。
業(yè)務(wù)的相關(guān)代碼示例如下:
//在業(yè)務(wù)線程中初始化內(nèi)存池分配器,分配非堆內(nèi)存 ByteBufAllocator allocator = new PooledByteBufAllocator(true); ByteBuf buffer = allocator.ioBuffer(1024); //構(gòu)造訂購(gòu)請(qǐng)求消息并賦值,業(yè)務(wù)邏輯省略 SubInfoReq infoReq = new SubInfoReq (); infoReq.setXXX(......); //將對(duì)象編碼到ByteBuf中 codec.encode(buffer, info); //調(diào)用ChannelHandlerContext進(jìn)行消息發(fā)送 ctx.writeAndFlush(buffer);
業(yè)務(wù)代碼升級(jí)Netty版本并重構(gòu)之后,運(yùn)行一段時(shí)間,Java進(jìn)程就會(huì)宕機(jī),查看系統(tǒng)運(yùn)行日志發(fā)現(xiàn)系統(tǒng)發(fā)生了內(nèi)存泄露(示例堆棧):

圖2-1 OOM內(nèi)存溢出堆棧
對(duì)內(nèi)存進(jìn)行監(jiān)控(切換使用堆內(nèi)存池,方便對(duì)內(nèi)存進(jìn)行監(jiān)控),發(fā)現(xiàn)堆內(nèi)存一直飆升,如下所示(示例堆內(nèi)存監(jiān)控):

圖2-2 堆內(nèi)存監(jiān)控
2.2. 問題定位
使用jmap -dump:format=b,file=netty.bin PID 將堆內(nèi)存dump出來,通過IBM的HeapAnalyzer工具進(jìn)行分析,發(fā)現(xiàn)ByteBuf發(fā)生了泄露。
因?yàn)槭褂昧藘?nèi)存池,所以首先懷疑是不是申請(qǐng)的ByteBuf沒有被釋放導(dǎo)致?查看代碼,發(fā)現(xiàn)消息發(fā)送完成之后,Netty底層已經(jīng)調(diào)用ReferenceCountUtil.release(message)對(duì)內(nèi)存進(jìn)行了釋放。這是怎么回事呢?難道Netty 4.X的內(nèi)存池有Bug,調(diào)用release操作釋放內(nèi)存失敗?
考慮到Netty 內(nèi)存池自身Bug的可能性不大,首先從業(yè)務(wù)的使用方式入手分析:
- 內(nèi)存的分配是在業(yè)務(wù)代碼中進(jìn)行,由于使用到了業(yè)務(wù)線程池做I/O操作和業(yè)務(wù)操作的隔離,實(shí)際上內(nèi)存是在業(yè)務(wù)線程中分配的;
- 內(nèi)存的釋放操作是在outbound中進(jìn)行,按照Netty 3的線程模型,downstream(對(duì)應(yīng)Netty 4的outbound,Netty 4取消了upstream和downstream)的handler也是由業(yè)務(wù)調(diào)用者線程執(zhí)行的,也就是說釋放跟分配在同一個(gè)業(yè)務(wù)線程中進(jìn)行。
初次排查并沒有發(fā)現(xiàn)導(dǎo)致內(nèi)存泄露的根因,一籌莫展之際開始查看Netty的內(nèi)存池分配器PooledByteBufAllocator的Doc和源碼實(shí)現(xiàn),發(fā)現(xiàn)內(nèi)存池實(shí)際是基于線程上下文實(shí)現(xiàn)的,相關(guān)代碼如下:
final ThreadLocal<PoolThreadCache> threadCache = new ThreadLocal<PoolThreadCache>() { private final AtomicInteger index = new AtomicInteger(); @Override protected PoolThreadCache initialValue() { final int idx = index.getAndIncrement(); final PoolArena<byte[]> heapArena; final PoolArena<ByteBuffer> directArena; if (heapArenas != null) { heapArena = heapArenas[Math.abs(idx % heapArenas.length)]; } else { heapArena = null; } if (directArenas != null) { directArena = directArenas[Math.abs(idx % directArenas.length)]; } else { directArena = null; } return new PoolThreadCache(heapArena, directArena); }
也就是說內(nèi)存的申請(qǐng)和釋放必須在同一線程上下文中,不能跨線程??缇€程之后實(shí)際操作的就不是同一塊內(nèi)存區(qū)域,這會(huì)導(dǎo)致很多嚴(yán)重的問題,內(nèi)存泄露便是其中之一。內(nèi)存在A線程申請(qǐng),切換到B線程釋放,實(shí)際是無法正確回收的。
通過對(duì)Netty內(nèi)存池的源碼分析,問題基本鎖定。保險(xiǎn)起見進(jìn)行簡(jiǎn)單驗(yàn)證,通過對(duì)單條業(yè)務(wù)消息進(jìn)行Debug,發(fā)現(xiàn)執(zhí)行釋放的果然不是業(yè)務(wù)線程,而是Netty的NioEventLoop線程:當(dāng)某個(gè)消息被完全發(fā)送成功之后,會(huì)通過ReferenceCountUtil.release(message)方法釋放已經(jīng)發(fā)送成功的ByteBuf。
問題定位出來之后,繼續(xù)溯源,發(fā)現(xiàn)Netty 4修改了Netty 3的線程模型:在Netty 3的時(shí)候,upstream是在I/O線程里執(zhí)行的,而downstream是在業(yè)務(wù)線程里執(zhí)行。當(dāng)Netty從網(wǎng)絡(luò)讀取一個(gè)數(shù)據(jù)報(bào)投遞給業(yè)務(wù)handler的時(shí)候,handler是在I/O線程里執(zhí)行;而當(dāng)我們?cè)跇I(yè)務(wù)線程中調(diào)用write和writeAndFlush向網(wǎng)絡(luò)發(fā)送消息的時(shí)候,handler是在業(yè)務(wù)線程里執(zhí)行,直到最后一個(gè)Header handler將消息寫入到發(fā)送隊(duì)列中,業(yè)務(wù)線程才返回。
Netty4修改了這一模型,在Netty 4里inbound(對(duì)應(yīng)Netty 3的upstream)和outbound(對(duì)應(yīng)Netty 3的downstream)都是在NioEventLoop(I/O線程)中執(zhí)行。當(dāng)我們?cè)跇I(yè)務(wù)線程里通過ChannelHandlerContext.write發(fā)送消息的時(shí)候,Netty 4在將消息發(fā)送事件調(diào)度到ChannelPipeline的時(shí)候,首先將待發(fā)送的消息封裝成一個(gè)Task,然后放到NioEventLoop的任務(wù)隊(duì)列中,由NioEventLoop線程異步執(zhí)行。后續(xù)所有handler的調(diào)度和執(zhí)行,包括消息的發(fā)送、I/O事件的通知,都由NioEventLoop線程負(fù)責(zé)處理。
下面我們分別通過對(duì)比Netty 3和Netty 4的消息接收和發(fā)送流程,來理解兩個(gè)版本線程模型的差異:
Netty 3的I/O事件處理流程:

圖2-3 Netty 3 I/O事件處理線程模型
Netty 4的I/O消息處理流程:

圖2-4 Netty 4 I/O事件處理線程模型
2.3. 問題總結(jié)
Netty 4.X版本新增的內(nèi)存池確實(shí)非常高效,但是如果使用不當(dāng)則會(huì)導(dǎo)致各種嚴(yán)重的問題。諸如內(nèi)存泄露這類問題,功能測(cè)試并沒有異常,如果相關(guān)接口沒有進(jìn)行壓測(cè)或者穩(wěn)定性測(cè)試而直接上線,則會(huì)導(dǎo)致嚴(yán)重的線上問題。
內(nèi)存池PooledByteBuf的使用建議:
- 申請(qǐng)之后一定要記得釋放,Netty自身Socket讀取和發(fā)送的ByteBuf系統(tǒng)會(huì)自動(dòng)釋放,用戶不需要做二次釋放;如果用戶使用Netty的內(nèi)存池在應(yīng)用中做ByteBuf的對(duì)象池使用,則需要自己主動(dòng)釋放;
- 避免錯(cuò)誤的釋放:跨線程釋放、重復(fù)釋放等都是非法操作,要避免。特別是跨線程申請(qǐng)和釋放,往往具有隱蔽性,問題定位難度較大;
- 防止隱式的申請(qǐng)和分配:之前曾經(jīng)發(fā)生過一個(gè)案例,為了解決內(nèi)存池跨線程申請(qǐng)和釋放問題,有用戶對(duì)內(nèi)存池做了二次包裝,以實(shí)現(xiàn)多線程操作時(shí),內(nèi)存始終由包裝的管理線程申請(qǐng)和釋放,這樣可以屏蔽用戶業(yè)務(wù)線程模型和訪問方式的差異。誰知運(yùn)行一段時(shí)間之后再次發(fā)生了內(nèi)存泄露,最后發(fā)現(xiàn)原來調(diào)用ByteBuf的write操作時(shí),如果內(nèi)存容量不足,會(huì)自動(dòng)進(jìn)行容量擴(kuò)展。擴(kuò)展操作由業(yè)務(wù)線程執(zhí)行,這就繞過了內(nèi)存池管理線程,發(fā)生了“引用逃逸”。該Bug只有在ByteBuf容量動(dòng)態(tài)擴(kuò)展的時(shí)候才發(fā)生,因此,上線很長(zhǎng)一段時(shí)間沒有發(fā)生,直到某一天......因此,大家在使用Netty 4.X的內(nèi)存池時(shí)要格外當(dāng)心,特別是做二次封裝時(shí),一定要對(duì)內(nèi)存池的實(shí)現(xiàn)細(xì)節(jié)有深刻的理解。
3. Netty升級(jí)之后遭遇數(shù)據(jù)被篡改
3.1. 問題描述
某業(yè)務(wù)產(chǎn)品,Netty3.X升級(jí)到4.X之后,系統(tǒng)運(yùn)行過程中,偶現(xiàn)服務(wù)端發(fā)送給客戶端的應(yīng)答數(shù)據(jù)被莫名“篡改”。
業(yè)務(wù)服務(wù)端的處理流程如下:
- 將解碼后的業(yè)務(wù)消息封裝成Task,投遞到后端的業(yè)務(wù)線程池中執(zhí)行;
- 業(yè)務(wù)線程處理業(yè)務(wù)邏輯,完成之后構(gòu)造應(yīng)答消息發(fā)送給客戶端;
- 業(yè)務(wù)應(yīng)答消息的編碼通過繼承Netty的CodeC框架實(shí)現(xiàn),即Encoder ChannelHandler;
- 調(diào)用Netty的消息發(fā)送接口之后,流程繼續(xù),根據(jù)業(yè)務(wù)場(chǎng)景,可能會(huì)繼續(xù)操作原發(fā)送的業(yè)務(wù)對(duì)象。
業(yè)務(wù)相關(guān)代碼示例如下:
//構(gòu)造訂購(gòu)應(yīng)答消息 SubInfoResp infoResp = new SubInfoResp(); //根據(jù)業(yè)務(wù)邏輯,對(duì)應(yīng)答消息賦值 infoResp.setResultCode(0); infoResp.setXXX(); 后續(xù)賦值操作省略...... //調(diào)用ChannelHandlerContext進(jìn)行消息發(fā)送 ctx.writeAndFlush(infoResp); //消息發(fā)送完成之后,后續(xù)根據(jù)業(yè)務(wù)流程進(jìn)行分支處理,修改infoResp對(duì)象 infoResp.setXXX(); 后續(xù)代碼省略......
3.2. 問題定位
首先對(duì)應(yīng)答消息被非法“篡改”的原因進(jìn)行分析,經(jīng)過定位發(fā)現(xiàn)當(dāng)發(fā)生問題時(shí),被“篡改”的內(nèi)容是調(diào)用writeAndFlush接口之后,由后續(xù)業(yè)務(wù)分支代碼修改應(yīng)答消息導(dǎo)致的。由于修改操作發(fā)生在writeAndFlush操作之后,按照Netty 3.X的線程模型不應(yīng)該出現(xiàn)該問題。
在Netty3中,downstream是在業(yè)務(wù)線程里執(zhí)行的,也就是說對(duì)SubInfoResp的編碼操作是在業(yè)務(wù)線程中執(zhí)行的,當(dāng)編碼后的ByteBuf對(duì)象被投遞到消息發(fā)送隊(duì)列之后,業(yè)務(wù)線程才會(huì)返回并繼續(xù)執(zhí)行后續(xù)的業(yè)務(wù)邏輯,此時(shí)修改應(yīng)答消息是不會(huì)改變已完成編碼的ByteBuf對(duì)象的,所以肯定不會(huì)出現(xiàn)應(yīng)答消息被篡改的問題。
初步分析應(yīng)該是由于線程模型發(fā)生變更導(dǎo)致的問題,隨后查驗(yàn)了Netty 4的線程模型,果然發(fā)生了變化:當(dāng)調(diào)用outbound向外發(fā)送消息的時(shí)候,Netty會(huì)將發(fā)送事件封裝成Task,投遞到NioEventLoop的任務(wù)隊(duì)列中異步執(zhí)行,相關(guān)代碼如下:
@Override public void invokeWrite(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { if (msg == null) { throw new NullPointerException("msg"); } validatePromise(ctx, promise, true); if (executor.inEventLoop()) { invokeWriteNow(ctx, msg, promise); } else { AbstractChannel channel = (AbstractChannel) ctx.channel(); int size = channel.estimatorHandle().size(msg); if (size > 0) { ChannelOutboundBuffer buffer = channel.unsafe().outboundBuffer(); // Check for null as it may be set to null if the channel is closed already if (buffer != null) { buffer.incrementPendingOutboundBytes(size); } } safeExecuteOutbound(WriteTask.newInstance(ctx, msg, size, promise), promise, msg); } }
通過上述代碼可以看出,Netty首先對(duì)當(dāng)前的操作的線程進(jìn)行判斷,如果操作本身就是由NioEventLoop線程執(zhí)行,則調(diào)用寫操作;否則,執(zhí)行線程安全的寫操作,即將寫事件封裝成Task,放入到任務(wù)隊(duì)列中由Netty的I/O線程執(zhí)行,業(yè)務(wù)調(diào)用返回,流程繼續(xù)執(zhí)行。
通過源碼分析,問題根源已經(jīng)很清楚:系統(tǒng)升級(jí)到Netty 4之后,線程模型發(fā)生變化,響應(yīng)消息的編碼由NioEventLoop線程異步執(zhí)行,業(yè)務(wù)線程返回。這時(shí)存在兩種可能:
- 如果編碼操作先于修改應(yīng)答消息的業(yè)務(wù)邏輯執(zhí)行,則運(yùn)行結(jié)果正確;
- 如果編碼操作在修改應(yīng)答消息的業(yè)務(wù)邏輯之后執(zhí)行,則運(yùn)行結(jié)果錯(cuò)誤。
由于線程的執(zhí)行先后順序無法預(yù)測(cè),因此該問題隱藏的相當(dāng)深。如果對(duì)Netty 4和Netty3的線程模型不了解,就會(huì)掉入陷阱。
Netty 3版本業(yè)務(wù)邏輯沒有問題,流程如下:

圖3-1 升級(jí)之前的業(yè)務(wù)流程線程模型
升級(jí)到Netty 4版本之后,業(yè)務(wù)流程由于Netty線程模型的變更而發(fā)生改變,導(dǎo)致業(yè)務(wù)邏輯發(fā)生問題:

圖3-2 升級(jí)之后的業(yè)務(wù)處理流程發(fā)生改變
3.3. 問題總結(jié)
很多讀者在進(jìn)行Netty 版本升級(jí)的時(shí)候,只關(guān)注到了包路徑、類和API的變更,并沒有注意到隱藏在背后的“暗箭”- 線程模型變更。
升級(jí)到Netty 4的用戶需要根據(jù)新的線程模型對(duì)已有的系統(tǒng)進(jìn)行評(píng)估,重點(diǎn)需要關(guān)注outbound的ChannelHandler,如果它的正確性依賴于Netty 3的線程模型,則很可能在新的線程模型中出問題,可能是功能問題或者其它問題。
4. Netty升級(jí)之后性能嚴(yán)重下降
4.1. 問題描述
相信很多Netty用戶都看過如下相關(guān)報(bào)告:
在Twitter,Netty 4 GC開銷降為五分之一:Netty 3使用Java對(duì)象表示I/O事件,這樣簡(jiǎn)單,但會(huì)產(chǎn)生大量的垃圾,尤其是在我們這樣的規(guī)模下。Netty 4在新版本中對(duì)此做出了更改,取代生存周期短的事件對(duì)象,而以定義在生存周期長(zhǎng)的通道對(duì)象上的方法處理I/O事件。它還有一個(gè)使用池的專用緩沖區(qū)分配器。
每當(dāng)收到新信息或者用戶發(fā)送信息到遠(yuǎn)程端,Netty 3均會(huì)創(chuàng)建一個(gè)新的堆緩沖區(qū)。這意味著,對(duì)應(yīng)每一個(gè)新的緩沖區(qū),都會(huì)有一個(gè)‘new byte[capacity]’。這些緩沖區(qū)會(huì)導(dǎo)致GC壓力,并消耗內(nèi)存帶寬:為了安全起見,新的字節(jié)數(shù)組分配時(shí)會(huì)用零填充,這會(huì)消耗內(nèi)存帶寬。然而,用零填充的數(shù)組很可能會(huì)再次用實(shí)際的數(shù)據(jù)填充,這又會(huì)消耗同樣的內(nèi)存帶寬。如果Java虛擬機(jī)(JVM)提供了創(chuàng)建新字節(jié)數(shù)組而又無需用零填充的方式,那么我們本來就可以將內(nèi)存帶寬消耗減少50%,但是目前沒有那樣一種方式。
在Netty 4中,代碼定義了粒度更細(xì)的API,用來處理不同的事件類型,而不是創(chuàng)建事件對(duì)象。它還實(shí)現(xiàn)了一個(gè)新緩沖池,那是一個(gè)純Java版本的 jemalloc (Facebook也在用)?,F(xiàn)在,Netty不會(huì)再因?yàn)橛昧闾畛渚彌_區(qū)而浪費(fèi)內(nèi)存帶寬了。
我們比較了兩個(gè)分別建立在Netty 3和4基礎(chǔ)上echo協(xié)議服務(wù)器。(Echo非常簡(jiǎn)單,這樣,任何垃圾的產(chǎn)生都是Netty的原因,而不是協(xié)議的原因)。我使它們服務(wù)于相同的分布式echo協(xié)議客戶端,來自這些客戶端的16384個(gè)并發(fā)連接重復(fù)發(fā)送256字節(jié)的隨機(jī)負(fù)載,幾乎使千兆以太網(wǎng)飽和。
根據(jù)測(cè)試結(jié)果,Netty 4:
- GC中斷頻率是原來的1/5: 45.5 vs. 9.2次/分鐘
- 垃圾生成速度是原來的1/5: 207.11 vs 41.81 MiB/秒
正是看到了相關(guān)的Netty 4性能提升報(bào)告,很多用戶選擇了升級(jí)。事后一些用戶反饋Netty 4并沒有跟產(chǎn)品帶來預(yù)期的性能提升,有些甚至還發(fā)生了非常嚴(yán)重的性能下降,下面我們就以某業(yè)務(wù)產(chǎn)品的失敗升級(jí)經(jīng)歷為案例,詳細(xì)分析下導(dǎo)致性能下降的原因。
4.2. 問題定位
首先通過JMC等性能分析工具對(duì)性能熱點(diǎn)進(jìn)行分析,示例如下(信息安全等原因,只給出分析過程示例截圖):

圖4-1 JMC性能監(jiān)控分析
通過對(duì)熱點(diǎn)方法的分析,發(fā)現(xiàn)在消息發(fā)送過程中,有兩處熱點(diǎn):
- 消息發(fā)送性能統(tǒng)計(jì)相關(guān)Handler;
- 編碼Handler。
對(duì)使用Netty 3版本的業(yè)務(wù)產(chǎn)品進(jìn)行性能對(duì)比測(cè)試,發(fā)現(xiàn)上述兩個(gè)Handler也是熱點(diǎn)方法。既然都是熱點(diǎn),為啥切換到Netty4之后性能下降這么厲害呢?
通過方法的調(diào)用樹分析發(fā)現(xiàn)了兩個(gè)版本的差異:在Netty 3中,上述兩個(gè)熱點(diǎn)方法都是由業(yè)務(wù)線程負(fù)責(zé)執(zhí)行;而在Netty 4中,則是由NioEventLoop(I/O)線程執(zhí)行。對(duì)于某個(gè)鏈路,業(yè)務(wù)是擁有多個(gè)線程的線程池,而NioEventLoop只有一個(gè),所以執(zhí)行效率更低,返回給客戶端的應(yīng)答時(shí)延就大。時(shí)延增大之后,自然導(dǎo)致系統(tǒng)并發(fā)量降低,性能下降。
找出問題根因之后,針對(duì)Netty 4的線程模型對(duì)業(yè)務(wù)進(jìn)行專項(xiàng)優(yōu)化,性能達(dá)到預(yù)期,遠(yuǎn)超過了Netty 3老版本的性能。
Netty 3的業(yè)務(wù)線程調(diào)度模型圖如下所示:充分利用了業(yè)務(wù)多線程并行編碼和Handler處理的優(yōu)勢(shì),周期T內(nèi)可以處理N條業(yè)務(wù)消息。

圖4-2 Netty 3業(yè)務(wù)調(diào)度性能模型
切換到Netty 4之后,業(yè)務(wù)耗時(shí)Handler被I/O線程串行執(zhí)行,因此性能發(fā)生比較大的下降:

圖4-3 Netty 4業(yè)務(wù)調(diào)度性能模型
4.3. 問題總結(jié)
該問題的根因還是由于Netty 4的線程模型變更引起,線程模型變更之后,不僅影響業(yè)務(wù)的功能,甚至對(duì)性能也會(huì)造成很大的影響。
對(duì)Netty的升級(jí)需要從功能、兼容性和性能等多個(gè)角度進(jìn)行綜合考慮,切不可只盯著API變更這個(gè)芝麻,而丟掉了性能這個(gè)西瓜。API的變更會(huì)導(dǎo)致編譯錯(cuò)誤,但是性能下降卻隱藏于無形之中,稍不留意就會(huì)中招。
對(duì)于講究快速交付、敏捷開發(fā)和灰度發(fā)布的互聯(lián)網(wǎng)應(yīng)用,升級(jí)的時(shí)候更應(yīng)該要當(dāng)心。
5. Netty升級(jí)之后上下文丟失
5.1. 問題描述
為了提升業(yè)務(wù)的二次定制能力,降低對(duì)接口的侵入性,業(yè)務(wù)使用線程變量進(jìn)行消息上下文的傳遞。例如消息發(fā)送源地址信息、消息Id、會(huì)話Id等。
業(yè)務(wù)同時(shí)使用到了一些第三方開源容器,也提供了線程級(jí)變量上下文的能力。業(yè)務(wù)通過容器上下文獲取第三方容器的系統(tǒng)變量信息。
升級(jí)到Netty 4之后,業(yè)務(wù)繼承自Netty的ChannelHandler發(fā)生了空指針異常,無論是業(yè)務(wù)自定義的線程上下文、還是第三方容器的線程上下文,都獲取不到傳遞的變量值。
5.2. 問題定位
首先檢查代碼,看業(yè)務(wù)是否傳遞了相關(guān)變量,確認(rèn)業(yè)務(wù)傳遞之后懷疑跟Netty 版本升級(jí)相關(guān),調(diào)試發(fā)現(xiàn),業(yè)務(wù)ChannelHandler獲取的線程上下文對(duì)象和之前業(yè)務(wù)傳遞的上下文不是同一個(gè)。這就說明執(zhí)行ChannelHandler的線程跟處理業(yè)務(wù)的線程不是同一個(gè)線程!
查看Netty 4線程模型的相關(guān)Doc發(fā)現(xiàn),Netty修改了outbound的線程模型,正好影響了業(yè)務(wù)消息發(fā)送時(shí)的線程上下文傳遞,最終導(dǎo)致線程變量丟失。
5.3. 問題總結(jié)
通常業(yè)務(wù)的線程模型有如下幾種:
- 業(yè)務(wù)自定義線程池/線程組處理業(yè)務(wù),例如使用JDK 1.5提供的ExecutorService;
- 使用J2EE Web容器自帶的線程模型,常見的如JBoss和Tomcat的HTTP接入線程等;
- 隱式的使用其它第三方框架的線程模型,例如使用NIO框架進(jìn)行協(xié)議處理,業(yè)務(wù)代碼隱式使用的就是NIO框架的線程模型,除非業(yè)務(wù)明確的實(shí)現(xiàn)自定義線程模型。
在實(shí)踐中我們發(fā)現(xiàn)很多業(yè)務(wù)使用了第三方框架,但是只熟悉API和功能,對(duì)線程模型并不清楚。某個(gè)類庫(kù)由哪個(gè)線程調(diào)用,糊里糊涂。為了方便變量傳遞,又隨意的使用線程變量,實(shí)際對(duì)背后第三方類庫(kù)的線程模型產(chǎn)生了強(qiáng)依賴。當(dāng)容器或者第三方類庫(kù)升級(jí)之后,如果線程模型發(fā)生了變更,則原有功能就會(huì)發(fā)生問題。
鑒于此,在實(shí)際工作中,盡量不要強(qiáng)依賴第三方類庫(kù)的線程模型,如果確實(shí)無法避免,則必須對(duì)它的線程模型有深入和清晰的了解。當(dāng)?shù)谌筋悗?kù)升級(jí)之后,需要檢查線程模型是否發(fā)生變更,如果發(fā)生變化,相關(guān)的代碼也需要考慮同步升級(jí)。
6. Netty3.X VS Netty4.X 之線程模型
通過對(duì)三個(gè)具有典型性的升級(jí)失敗案例進(jìn)行分析和總結(jié),我們發(fā)現(xiàn)有個(gè)共性:都是線程模型改變?nèi)堑牡?
下面小節(jié)我們就詳細(xì)得對(duì)Netty3和Netty4版本的I/O線程模型進(jìn)行對(duì)比,以方便大家掌握兩者的差異,在升級(jí)和使用中盡量少踩雷。
6.1 Netty 3.X 版本線程模型
Netty 3.X的I/O操作線程模型比較復(fù)雜,它的處理模型包括兩部分:
- Inbound:主要包括鏈路建立事件、鏈路激活事件、讀事件、I/O異常事件、鏈路關(guān)閉事件等;
- Outbound:主要包括寫事件、連接事件、監(jiān)聽綁定事件、刷新事件等。
我們首先分析下Inbound操作的線程模型:

圖6-1 Netty 3 Inbound操作線程模型
從上圖可以看出,Inbound操作的主要處理流程如下:
- I/O線程(Work線程)將消息從TCP緩沖區(qū)讀取到SocketChannel的接收緩沖區(qū)中;
- 由I/O線程負(fù)責(zé)生成相應(yīng)的事件,觸發(fā)事件向上執(zhí)行,調(diào)度到ChannelPipeline中;
- I/O線程調(diào)度執(zhí)行ChannelPipeline中Handler鏈的對(duì)應(yīng)方法,直到業(yè)務(wù)實(shí)現(xiàn)的Last Handler;
- Last Handler將消息封裝成Runnable,放入到業(yè)務(wù)線程池中執(zhí)行,I/O線程返回,繼續(xù)讀/寫等I/O操作;
- 業(yè)務(wù)線程池從任務(wù)隊(duì)列中彈出消息,并發(fā)執(zhí)行業(yè)務(wù)邏輯。
通過對(duì)Netty 3的Inbound操作進(jìn)行分析我們可以看出,Inbound的Handler都是由Netty的I/O Work線程負(fù)責(zé)執(zhí)行。
下面我們繼續(xù)分析Outbound操作的線程模型:

圖6-2 Netty 3 Outbound操作線程模型
從上圖可以看出,Outbound操作的主要處理流程如下:
業(yè)務(wù)線程發(fā)起Channel Write操作,發(fā)送消息;
- Netty將寫操作封裝成寫事件,觸發(fā)事件向下傳播;
- 寫事件被調(diào)度到ChannelPipeline中,由業(yè)務(wù)線程按照Handler Chain串行調(diào)用支持Downstream事件的Channel Handler;
- 執(zhí)行到系統(tǒng)最后一個(gè)ChannelHandler,將編碼后的消息Push到發(fā)送隊(duì)列中,業(yè)務(wù)線程返回;
- Netty的I/O線程從發(fā)送消息隊(duì)列中取出消息,調(diào)用SocketChannel的write方法進(jìn)行消息發(fā)送。
6.2 Netty 4.X 版本線程模型
相比于Netty 3.X系列版本,Netty 4.X的I/O操作線程模型比較簡(jiǎn)答,它的原理圖如下所示:

圖6-3 Netty 4 Inbound和Outbound操作線程模型
從上圖可以看出,Outbound操作的主要處理流程如下:
- I/O線程N(yùn)ioEventLoop從SocketChannel中讀取數(shù)據(jù)報(bào),將ByteBuf投遞到ChannelPipeline,觸發(fā)ChannelRead事件;
- I/O線程N(yùn)ioEventLoop調(diào)用ChannelHandler鏈,直到將消息投遞到業(yè)務(wù)線程,然后I/O線程返回,繼續(xù)后續(xù)的讀寫操作;
- 業(yè)務(wù)線程調(diào)用ChannelHandlerContext.write(Object msg)方法進(jìn)行消息發(fā)送;
- 如果是由業(yè)務(wù)線程發(fā)起的寫操作,ChannelHandlerInvoker將發(fā)送消息封裝成Task,放入到I/O線程N(yùn)ioEventLoop的任務(wù)隊(duì)列中,由NioEventLoop在循環(huán)中統(tǒng)一調(diào)度和執(zhí)行。放入任務(wù)隊(duì)列之后,業(yè)務(wù)線程返回;
- I/O線程N(yùn)ioEventLoop調(diào)用ChannelHandler鏈,進(jìn)行消息發(fā)送,處理Outbound事件,直到將消息放入發(fā)送隊(duì)列,然后喚醒Selector,進(jìn)而執(zhí)行寫操作。
通過流程分析,我們發(fā)現(xiàn)Netty 4修改了線程模型,無論是Inbound還是Outbound操作,統(tǒng)一由I/O線程N(yùn)ioEventLoop調(diào)度執(zhí)行。
6.3. 線程模型對(duì)比
在進(jìn)行新老版本線程模型PK之前,首先還是要熟悉下串行化設(shè)計(jì)的理念:
我們知道當(dāng)系統(tǒng)在運(yùn)行過程中,如果頻繁的進(jìn)行線程上下文切換,會(huì)帶來額外的性能損耗。多線程并發(fā)執(zhí)行某個(gè)業(yè)務(wù)流程,業(yè)務(wù)開發(fā)者還需要時(shí)刻對(duì)線程安全保持警惕,哪些數(shù)據(jù)可能會(huì)被并發(fā)修改,如何保護(hù)?這不僅降低了開發(fā)效率,也會(huì)帶來額外的性能損耗。
為了解決上述問題,Netty 4采用了串行化設(shè)計(jì)理念,從消息的讀取、編碼以及后續(xù)Handler的執(zhí)行,始終都由I/O線程N(yùn)ioEventLoop負(fù)責(zé),這就意外著整個(gè)流程不會(huì)進(jìn)行線程上下文的切換,數(shù)據(jù)也不會(huì)面臨被并發(fā)修改的風(fēng)險(xiǎn),對(duì)于用戶而言,甚至不需要了解Netty的線程細(xì)節(jié),這確實(shí)是個(gè)非常好的設(shè)計(jì)理念,它的工作原理圖如下:

圖6-4 Netty 4的串行化設(shè)計(jì)理念
一個(gè)NioEventLoop聚合了一個(gè)多路復(fù)用器Selector,因此可以處理成百上千的客戶端連接,Netty的處理策略是每當(dāng)有一個(gè)新的客戶端接入,則從NioEventLoop線程組中順序獲取一個(gè)可用的NioEventLoop,當(dāng)?shù)竭_(dá)數(shù)組上限之后,重新返回到0,通過這種方式,可以基本保證各個(gè)NioEventLoop的負(fù)載均衡。一個(gè)客戶端連接只注冊(cè)到一個(gè)NioEventLoop上,這樣就避免了多個(gè)I/O線程去并發(fā)操作它。
Netty通過串行化設(shè)計(jì)理念降低了用戶的開發(fā)難度,提升了處理性能。利用線程組實(shí)現(xiàn)了多個(gè)串行化線程水平并行執(zhí)行,線程之間并沒有交集,這樣既可以充分利用多核提升并行處理能力,同時(shí)避免了線程上下文的切換和并發(fā)保護(hù)帶來的額外性能損耗。
了解完了Netty 4的串行化設(shè)計(jì)理念之后,我們繼續(xù)看Netty 3線程模型存在的問題,總結(jié)起來,它的主要問題如下:
- Inbound和Outbound實(shí)質(zhì)都是I/O相關(guān)的操作,它們的線程模型竟然不統(tǒng)一,這給用戶帶來了更多的學(xué)習(xí)和使用成本;
- Outbound操作由業(yè)務(wù)線程執(zhí)行,通常業(yè)務(wù)會(huì)使用線程池并行處理業(yè)務(wù)消息,這就意味著在某一個(gè)時(shí)刻會(huì)有多個(gè)業(yè)務(wù)線程同時(shí)操作ChannelHandler,我們需要對(duì)ChannelHandler進(jìn)行并發(fā)保護(hù),通常需要加鎖。如果同步塊的范圍不當(dāng),可能會(huì)導(dǎo)致嚴(yán)重的性能瓶頸,這對(duì)開發(fā)者的技能要求非常高,降低了開發(fā)效率;
- Outbound操作過程中,例如消息編碼異常,會(huì)產(chǎn)生Exception,它會(huì)被轉(zhuǎn)換成Inbound的Exception并通知到ChannelPipeline,這就意味著業(yè)務(wù)線程發(fā)起了Inbound操作!它打破了Inbound操作由I/O線程操作的模型,如果開發(fā)者按照Inbound操作只會(huì)由一個(gè)I/O線程執(zhí)行的約束進(jìn)行設(shè)計(jì),則會(huì)發(fā)生線程并發(fā)訪問安全問題。由于該場(chǎng)景只在特定異常時(shí)發(fā)生,因此錯(cuò)誤非常隱蔽!一旦在生產(chǎn)環(huán)境中發(fā)生此類線程并發(fā)問題,定位難度和成本都非常大。
講了這么多,似乎Netty 4 完勝 Netty 3的線程模型,其實(shí)并不盡然。在特定的場(chǎng)景下,Netty 3的性能可能更高,就如本文第4章節(jié)所講,如果編碼和其它Outbound操作非常耗時(shí),由多個(gè)業(yè)務(wù)線程并發(fā)執(zhí)行,性能肯定高于單個(gè)NioEventLoop線程。
但是,這種性能優(yōu)勢(shì)不是不可逆轉(zhuǎn)的,如果我們修改業(yè)務(wù)代碼,將耗時(shí)的Handler操作前置,Outbound操作不做復(fù)雜業(yè)務(wù)邏輯處理,性能同樣不輸于Netty 3,但是考慮內(nèi)存池優(yōu)化、不會(huì)反復(fù)創(chuàng)建Event、不需要對(duì)Handler加鎖等Netty 4的優(yōu)化,整體性能Netty 4版本肯定會(huì)更高。
總而言之,如果用戶真正熟悉并掌握了Netty 4的線程模型和功能類庫(kù),相信不僅僅開發(fā)會(huì)更加簡(jiǎn)單,性能也會(huì)更優(yōu)!
6.4. 思考
就Netty 而言,掌握線程模型的重要性不亞于熟悉它的API和功能。很多時(shí)候我遇到的功能、性能等問題,都是由于缺乏對(duì)它線程模型和原理的理解導(dǎo)致的,結(jié)果我們就以訛傳訛,認(rèn)為Netty 4版本不如3好用等。
不能說所有開源軟件的版本升級(jí)一定都勝過老版本,就Netty而言,我認(rèn)為Netty 4版本相比于老的Netty 3,確實(shí)是歷史的一大進(jìn)步。
7. 作者簡(jiǎn)介
李林鋒,2007年畢業(yè)于東北大學(xué),2008年進(jìn)入華為公司從事高性能通信軟件的設(shè)計(jì)和開發(fā)工作,有7年NIO設(shè)計(jì)和開發(fā)經(jīng)驗(yàn),精通Netty、Mina等NIO框架和平臺(tái)中間件,現(xiàn)任華為軟件平臺(tái)架構(gòu)部架構(gòu)師,《Netty權(quán)威指南》作者。
聯(lián)系方式:新浪微博 Nettying 微信:Nettying 微信公眾號(hào):Netty之家
感謝郭蕾對(duì)本文的策劃和審校。
給InfoQ中文站投稿或者參與內(nèi)容翻譯工作,請(qǐng)郵件至editors@cn.infoq.com。也歡迎大家通過新浪微博(@InfoQ)或者騰訊微博(@InfoQ)關(guān)注我們,并與我們的編輯和其他讀者朋友交流。