1. 背景
1.1 直播平臺(tái)內(nèi)存泄漏問題
某直播平臺(tái)赊颠,一些網(wǎng)紅的直播間在業(yè)務(wù)高峰期润樱,會(huì)有 10W+ 的粉絲接入,如果瞬間發(fā)生大量客戶端連接掉線赚哗、或者一些客戶端網(wǎng)絡(luò)比較慢,發(fā)現(xiàn)基于 Netty 構(gòu)建的服務(wù)端內(nèi)存會(huì)飆升硅堆,發(fā)生內(nèi)存泄漏(OOM)屿储,導(dǎo)致直播卡頓、或者客戶端接收不到服務(wù)端推送的消息渐逃,用戶體驗(yàn)受到很大影響够掠。
1.2 問題分析
首先對(duì) GC 數(shù)據(jù)進(jìn)行分析,發(fā)現(xiàn)老年代已滿茄菊,發(fā)生多次 Full GC疯潭,耗時(shí)達(dá) 3 分多,系統(tǒng)已經(jīng)無法正常運(yùn)行(示例):
圖 1 直播高峰期服務(wù)端 GC 統(tǒng)計(jì)數(shù)據(jù)
Dump 內(nèi)存堆棧進(jìn)行分析买羞,發(fā)現(xiàn)大量的發(fā)送任務(wù)堆積袁勺,導(dǎo)致內(nèi)存溢出(示例):
圖 2 直播高峰期服務(wù)端內(nèi)存 Dump 文件分析
通過以上分析可以看出,在直播高峰期畜普,服務(wù)端向上萬客戶端推送消息時(shí)期丰,發(fā)生了發(fā)送隊(duì)列積壓,引起內(nèi)存泄漏吃挑,最終導(dǎo)致服務(wù)端頻繁 GC钝荡,無法正常處理業(yè)務(wù)。
1.3 解決策略
服務(wù)端在進(jìn)行消息發(fā)送的時(shí)候做保護(hù)舶衬,具體策略如下:
根據(jù)可接入的最大用戶數(shù)做客戶端并發(fā)接入數(shù)流控埠通,需要根據(jù)內(nèi)存、CPU 處理能力逛犹,以及性能測(cè)試結(jié)果做綜合評(píng)估端辱。
設(shè)置消息發(fā)送的高低水位,針對(duì)消息的平均大小虽画、客戶端并發(fā)接入數(shù)舞蔽、JVM 內(nèi)存大小進(jìn)行計(jì)算,得出一個(gè)合理的高水位取值码撰。服務(wù)端在推送消息時(shí)渗柿,對(duì) Channel 的狀態(tài)進(jìn)行判斷,如果達(dá)到高水位之后脖岛,Channel 的狀態(tài)會(huì)被 Netty 置為不可寫朵栖,此時(shí)服務(wù)端不要繼續(xù)發(fā)送消息颊亮,防止發(fā)送隊(duì)列積壓。
服務(wù)端基于上述策略優(yōu)化了代碼陨溅,內(nèi)存泄漏問題得到解決终惑。
1.4. 總結(jié)
盡管 Netty 框架本身做了大量的可靠性設(shè)計(jì),但是對(duì)于具體的業(yè)務(wù)場(chǎng)景声登,仍然需要用戶做針對(duì)特定領(lǐng)域和場(chǎng)景的可靠性設(shè)計(jì)狠鸳,這樣才能提升應(yīng)用的可靠性。
除了消息發(fā)送積壓導(dǎo)致的內(nèi)存泄漏悯嗓,Netty 還有其它常見的一些內(nèi)存泄漏點(diǎn)件舵,本文將針對(duì)這些可能導(dǎo)致內(nèi)存泄漏的功能點(diǎn)進(jìn)行分析和總結(jié)。
2. 消息收發(fā)防內(nèi)存泄漏策略
2.1. 消息接收
2.1.1 消息讀取
Netty 的消息讀取并不存在消息隊(duì)列脯厨,但是如果消息解碼策略不當(dāng)铅祸,則可能會(huì)發(fā)生內(nèi)存泄漏,主要有如下幾點(diǎn):
1. 畸形碼流攻擊:如果客戶端按照協(xié)議規(guī)范合武,將消息長(zhǎng)度值故意偽造的非常大临梗,可能會(huì)導(dǎo)致接收方內(nèi)存溢出。
2. 代碼 BUG:錯(cuò)誤的將消息長(zhǎng)度字段設(shè)置或者編碼成一個(gè)非常大的值稼跳,可能會(huì)導(dǎo)致對(duì)方內(nèi)存溢出盟庞。
3. 高并發(fā)場(chǎng)景:?jiǎn)蝹€(gè)消息長(zhǎng)度比較大,例如幾十 M 的小視頻汤善,同時(shí)并發(fā)接入的客戶端過多什猖,會(huì)導(dǎo)致所有 Channel 持有的消息接收 ByteBuf 內(nèi)存總和達(dá)到上限,發(fā)生 OOM红淡。
避免內(nèi)存泄漏的策略如下:
無論采用哪種解碼器實(shí)現(xiàn)不狮,都對(duì)消息的最大長(zhǎng)度做限制,當(dāng)超過限制之后在旱,拋出解碼失敗異常摇零,用戶可以選擇忽略當(dāng)前已經(jīng)讀取的消息,或者直接關(guān)閉鏈接桶蝎。
以 Netty 的 DelimiterBasedFrameDecoder 代碼為例驻仅,創(chuàng)建 DelimiterBasedFrameDecoder 對(duì)象實(shí)例時(shí),指定一個(gè)比較合理的消息最大長(zhǎng)度限制登渣,防止內(nèi)存溢出:
?復(fù)制代碼
/**{1}* Creates a newinstance.{1}*{1}*@parammaxFrameLength the maximum lengthofthe decoded frame.{1}* A {@linkTooLongFrameException} is thrownif{1}* the lengthofthe frame exceeds thisvalue.{1}*@paramstripDelimiter whether the decoded frame shouldstripout the{1}* delimiter or not{1}*@paramdelimiter the delimiter{1}*/publicDelimiterBasedFrameDecoder(intmaxFrameLength,booleanstripDelimiter, ByteBuf delimiter) {this(maxFrameLength, stripDelimiter,true, delimiter);}
需要根據(jù)單個(gè) Netty 服務(wù)端可以支持的最大客戶端并發(fā)連接數(shù)雾家、消息的最大長(zhǎng)度限制以及當(dāng)前 JVM 配置的最大內(nèi)存進(jìn)行計(jì)算,并結(jié)合業(yè)務(wù)場(chǎng)景绍豁,合理設(shè)置 maxFrameLength 的取值。
2.1.2 ChannelHandler 的并發(fā)執(zhí)行
Netty 的 ChannelHandler 支持串行和異步并發(fā)執(zhí)行兩種策略牙捉,在將 ChannelHandler 加入到 ChannelPipeline 時(shí)竹揍,如果指定了 EventExecutorGroup敬飒,則 ChannelHandler 將由 EventExecutorGroup 中的 EventExecutor 異步執(zhí)行。這樣的好處是可以實(shí)現(xiàn) Netty I/O 線程與業(yè)務(wù) ChannelHandler 邏輯執(zhí)行的分離芬位,防止 ChannelHandler 中耗時(shí)業(yè)務(wù)邏輯的執(zhí)行阻塞 I/O 線程无拗。
ChannelHandler 異步執(zhí)行的流程如下所示:
圖 3 ChannelHandler 異步并發(fā)執(zhí)行流程
如果業(yè)務(wù) ChannelHandler 中執(zhí)行的業(yè)務(wù)邏輯耗時(shí)較長(zhǎng),消息的讀取速度又比較快昧碉,很容易發(fā)生消息在 EventExecutor 中積壓的問題英染,如果創(chuàng)建 EventExecutor 時(shí)沒有通過 io.netty.eventexecutor.maxPendingTasks 參數(shù)指定積壓的最大消息個(gè)數(shù),則默認(rèn)取值為 0x7fffffff被饿,長(zhǎng)時(shí)間的積壓將導(dǎo)致內(nèi)存溢出四康,相關(guān)代碼如下所示(異步執(zhí)行 ChannelHandler,將消息封裝成 Task 加入到 taskQueue 中):
?復(fù)制代碼
publicvoidexecute(Runnabletask) {if(task==null) {thrownewNullPointerException("task");}booleaninEventLoop =inEventLoop();if(inEventLoop) {addTask(task);}else{startThread();addTask(task);if(isShutdown()&&removeTask(task)) {reject();}}
解決對(duì)策:對(duì) EventExecutor 中任務(wù)隊(duì)列的容量做限制狭握,可以通過 io.netty.eventexecutor.maxPendingTasks 參數(shù)做全局設(shè)置闪金,也可以通過構(gòu)造方法傳參設(shè)置。結(jié)合 EventExecutorGroup 中 EventExecutor 的個(gè)數(shù)來計(jì)算 taskQueue 的個(gè)數(shù)论颅,根據(jù) taskQueue * N * 任務(wù)隊(duì)列平均大小 * maxPendingTasks < 系數(shù) K(0 < K < 1)* 總內(nèi)存的公式來進(jìn)行計(jì)算和評(píng)估哎垦。
2.2. 消息發(fā)送
2.2.1 如何防止發(fā)送隊(duì)列積壓
為了防止高并發(fā)場(chǎng)景下,由于對(duì)方處理慢導(dǎo)致自身消息積壓恃疯,除了服務(wù)端做流控之外漏设,客戶端也需要做并發(fā)保護(hù),防止自身發(fā)生消息積壓今妄。
利用 Netty 提供的高低水位機(jī)制郑口,可以實(shí)現(xiàn)客戶端更精準(zhǔn)的流控,它的工作原理如下:
圖 4 Netty 高水位接口說明
當(dāng)發(fā)送隊(duì)列待發(fā)送的字節(jié)數(shù)組達(dá)到高水位上限時(shí)蛙奖,對(duì)應(yīng)的 Channel 就變?yōu)椴豢蓪憼顟B(tài)潘酗。由于高水位并不影響業(yè)務(wù)線程調(diào)用 write 方法并把消息加入到待發(fā)送隊(duì)列中,因此雁仲,必須要在消息發(fā)送時(shí)對(duì) Channel 的狀態(tài)進(jìn)行判斷:當(dāng)?shù)竭_(dá)高水位時(shí)仔夺,Channel 的狀態(tài)被設(shè)置為不可寫,通過對(duì) Channel 的可寫狀態(tài)進(jìn)行判斷來決定是否發(fā)送消息攒砖。
在消息發(fā)送時(shí)設(shè)置高低水位并對(duì) Channel 狀態(tài)進(jìn)行判斷缸兔,相關(guān)代碼示例如下:
?復(fù)制代碼
publicvoidchannelActive(finalChannelHandlerContextctx){**ctx.channel().config().setWriteBufferHighWaterMark(10\*1024*1024);**loadRunner =newRunnable(){@Overridepublicvoidrun(){try{TimeUnit.SECONDS.sleep(30);}catch(InterruptedException e) {e.printStackTrace();}ByteBuf msg =null;while(true) {**if(ctx.channel().isWritable()) {**msg =Unpooled.wrappedBuffer("Netty OOM Example".getBytes());ctx.writeAndFlush(msg);}else{LOG.warning("The write queue is busy : "+ ctx.channel().unsafe().outboundBuffer().nioBufferSize());}}}};newThread(loadRunner,"LoadRunner-Thread").start();}
對(duì)上述代碼做驗(yàn)證,客戶端代碼中打印隊(duì)列積壓相關(guān)日志吹艇,說明基于高水位的流控機(jī)制生效惰蜜,日志如下:
警告: The write queue is busy : 17
通過內(nèi)存監(jiān)控,發(fā)現(xiàn)內(nèi)存占用平穩(wěn):
圖 5 進(jìn)行高低水位保護(hù)優(yōu)化之后內(nèi)存占用情況
在實(shí)際項(xiàng)目中受神,根據(jù)業(yè)務(wù) QPS 規(guī)劃抛猖、客戶端處理性能、網(wǎng)絡(luò)帶寬、鏈路數(shù)财著、消息平均碼流大小等綜合因素計(jì)算并設(shè)置高水位(WriteBufferHighWaterMark)閾值联四,利用高水位做消息發(fā)送速率的流控,既可以保護(hù)自身撑教,同時(shí)又能減輕服務(wù)端的壓力朝墩,防止服務(wù)端被壓掛。
2.2.2 其它可能導(dǎo)致發(fā)送隊(duì)列積壓的因素
需要指出的是伟姐,并非只有高并發(fā)場(chǎng)景才會(huì)觸發(fā)消息積壓收苏,在一些異常場(chǎng)景下,盡管系統(tǒng)流量不大愤兵,但仍然可能會(huì)導(dǎo)致消息積壓鹿霸,可能的場(chǎng)景包括:
網(wǎng)絡(luò)瓶頸,發(fā)送速率超過網(wǎng)絡(luò)鏈接處理能力時(shí)恐似,會(huì)導(dǎo)致發(fā)送隊(duì)列積壓杜跷。
對(duì)端讀取速度小于己方發(fā)送速度,導(dǎo)致自身 TCP 發(fā)送緩沖區(qū)滿矫夷,頻繁發(fā)生 write 0 字節(jié)時(shí)葛闷,待發(fā)送消息會(huì)在 Netty 發(fā)送隊(duì)列排隊(duì)。
當(dāng)出現(xiàn)大量排隊(duì)時(shí)双藕,很容易導(dǎo)致 Netty 的直接內(nèi)存泄漏淑趾,示例如下:
圖 6 消息積壓導(dǎo)致內(nèi)存泄漏相關(guān)堆棧
我們?cè)谠O(shè)計(jì)系統(tǒng)時(shí),需要根據(jù)業(yè)務(wù)的場(chǎng)景忧陪、所處的網(wǎng)絡(luò)環(huán)境等因素進(jìn)行綜合設(shè)計(jì)扣泊,為潛在的各種故障做容錯(cuò)和保護(hù),防止因?yàn)橥獠恳蛩貙?dǎo)致自身發(fā)生內(nèi)存泄漏嘶摊。
3. ByteBuf 的申請(qǐng)和釋放策略
3.1 ByteBuf 申請(qǐng)和釋放的理解誤區(qū)
有一種說法認(rèn)為 Netty 框架分配的 ByteBuf 框架會(huì)自動(dòng)釋放延蟹,業(yè)務(wù)不需要釋放;業(yè)務(wù)創(chuàng)建的 ByteBuf 則需要自己釋放叶堆,Netty 框架不會(huì)釋放阱飘。
事實(shí)上,這種觀點(diǎn)是錯(cuò)誤的虱颗,即便 ByteBuf 是 Netty 創(chuàng)建的沥匈,如果使用不當(dāng)仍然會(huì)發(fā)生內(nèi)存泄漏。在實(shí)際項(xiàng)目中如何更好的管理 ByteBuf忘渔,下面我們分四種場(chǎng)景進(jìn)行說明高帖。
3.2 ByteBuf 的釋放策略
3.2.1 基于內(nèi)存池的請(qǐng)求 ByteBuf
這類 ByteBuf 主要包括 PooledDirectByteBuf 和 PooledHeapByteBuf,它由 Netty 的 NioEventLoop 線程在處理 Channel 的讀操作時(shí)分配畦粮,需要在業(yè)務(wù) ChannelInboundHandler 處理完請(qǐng)求消息之后釋放(通常是解碼之后)散址,它的釋放有 2 種策略:
策略 1:業(yè)務(wù) ChannelInboundHandler 繼承自 SimpleChannelInboundHandler乖阵,實(shí)現(xiàn)它的抽象方法 channelRead0(ChannelHandlerContext ctx, I msg),ByteBuf 的釋放業(yè)務(wù)不用關(guān)心爪飘,由 SimpleChannelInboundHandler 負(fù)責(zé)釋放义起,相關(guān)代碼如下所示(SimpleChannelInboundHandler):
?復(fù)制代碼
@OverridepublicvoidchannelRead(ChannelHandlerContextctx, Objectmsg)throwsException{booleanrelease =true;try{if(acceptInboundMessage(msg)) {I imsg = (I) msg;channelRead0(ctx,imsg);}else{release =false;ctx.fireChannelRead(msg);}}finally{**if(autoRelease&&release) {****ReferenceCountUtil.release(msg);****}**}}
如果當(dāng)前業(yè)務(wù) ChannelInboundHandler 需要執(zhí)行,則調(diào)用完 channelRead0 之后執(zhí)行 ReferenceCountUtil.release(msg) 釋放當(dāng)前請(qǐng)求消息师崎。如果沒有匹配上需要繼續(xù)執(zhí)行后續(xù)的 ChannelInboundHandler,則不釋放當(dāng)前請(qǐng)求消息椅棺,調(diào)用 ctx.fireChannelRead(msg) 驅(qū)動(dòng) ChannelPipeline 繼續(xù)執(zhí)行犁罩。
繼承自 SimpleChannelInboundHandler,即便業(yè)務(wù)不釋放請(qǐng)求 ByteBuf 對(duì)象两疚,依然不會(huì)發(fā)生內(nèi)存泄漏床估,相關(guān)示例代碼如下所示:
?復(fù)制代碼
publicclassRouterServerHandlerV2**extendsSimpleChannelInboundHandler**{// 代碼省略...@OverridepublicvoidchannelRead0(ChannelHandlerContext ctx, ByteBuf msg){byte[] body =newbyte[msg.readableBytes()];executorService.execute(()->{// 解析請(qǐng)求消息,做路由轉(zhuǎn)發(fā)诱渤,代碼省略...// 轉(zhuǎn)發(fā)成功丐巫,返回響應(yīng)給客戶端ByteBuf respMsg = allocator.heapBuffer(body.length);respMsg.writeBytes(body);// 作為示例,簡(jiǎn)化處理勺美,將請(qǐng)求返回ctx.writeAndFlush(respMsg);});}
對(duì)上述代碼做性能測(cè)試巫玻,發(fā)現(xiàn)內(nèi)存占用平穩(wěn)鼎姐,無內(nèi)存泄漏問題,驗(yàn)證了之前的分析結(jié)論。
策略 2:在業(yè)務(wù) ChannelInboundHandler 中調(diào)用 ctx.fireChannelRead(msg) 方法袜茧,讓請(qǐng)求消息繼續(xù)向后執(zhí)行,直到調(diào)用到 DefaultChannelPipeline 的內(nèi)部類 TailContext涣觉,由它來負(fù)責(zé)釋放請(qǐng)求消息尝蠕,代碼如下所示(TailContext):
?復(fù)制代碼
protectedvoidonUnhandledInboundMessage(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);****}**}
3.2.2 基于非內(nèi)存池的請(qǐng)求 ByteBuf
如果業(yè)務(wù)使用非內(nèi)存池模式覆蓋 Netty 默認(rèn)的內(nèi)存池模式創(chuàng)建請(qǐng)求 ByteBuf,例如通過如下代碼修改內(nèi)存申請(qǐng)策略為 Unpooled:
?復(fù)制代碼
// 代碼省略...
.childHandler(newChannelInitializer<SocketChannel>() {
@Override
publicvoidinitChannel(SocketChannel ch)throwsException{
ChannelPipeline p = ch.pipeline(); ch.config().setAllocator(UnpooledByteBufAllocator.DEFAULT);
p.addLast(newRouterServerHandler());
}
});
}
也需要按照內(nèi)存池的方式去釋放內(nèi)存华蜒。
3.2.3 基于內(nèi)存池的響應(yīng) ByteBuf
只要調(diào)用了 writeAndFlush 或者 flush 方法辙纬,在消息發(fā)送完成之后都會(huì)由 Netty 框架進(jìn)行內(nèi)存釋放,業(yè)務(wù)不需要主動(dòng)釋放內(nèi)存叭喜。
它的工作原理如下:
調(diào)用 ctx.writeAndFlush(respMsg) 方法贺拣,當(dāng)消息發(fā)送完成之后,Netty 框架會(huì)主動(dòng)幫助應(yīng)用來釋放內(nèi)存域滥,內(nèi)存的釋放分為兩種場(chǎng)景:
如果是堆內(nèi)存(PooledHeapByteBuf)纵柿,則將 HeapByteBuffer 轉(zhuǎn)換成 DirectByteBuffer,并釋放 PooledHeapByteBuf 到內(nèi)存池启绰,代碼如下(AbstractNioChannel 類):
?復(fù)制代碼
protectedfinalByteBufnewDirectBuffer(ByteBufbuf){? finalintreadableBytes = buf.readableBytes();?if(readableBytes==0) {? **ReferenceCountUtil.safeRelease(buf);**?returnUnpooled.EMPTY_BUFFER;? }?finalByteBufAllocator alloc = alloc();?if(alloc.isDirectBufferPooled()) {? ByteBuf directBuf = alloc.directBuffer(readableBytes);? directBuf.writeBytes(buf,buf.readerIndex(), readableBytes);? **ReferenceCountUtil.safeRelease(buf);**?returndirectBuf;? } }// 后續(xù)代碼省略}
如果消息完整的被寫到 SocketChannel 中昂儒,則釋放 DirectByteBuffer,代碼如下(ChannelOutboundBuffer)所示:
?復(fù)制代碼
publicbooleanremove(){? Entry e = flushedEntry;?if(e==null) {? clearNioBuffers();? returnfalse;? }? Object msg = e.msg;? ChannelPromise promise = e.promise;?intsize = e.pendingSize;? removeEntry(e);?if(!e.cancelled) {? **ReferenceCountUtil.safeRelease(msg);**? safeSuccess(promise);? decrementPendingOutboundBytes(size,false,true);? }// 后續(xù)代碼省略}
對(duì) Netty 源碼進(jìn)行斷點(diǎn)調(diào)試委可,驗(yàn)證上述分析:
斷點(diǎn) 1:在響應(yīng)消息發(fā)送處打印斷點(diǎn)渊跋,獲取到 PooledUnsafeHeapByteBuf 實(shí)例 ID 為 1506腊嗡。
圖 7 響應(yīng)發(fā)送處斷點(diǎn)調(diào)試
斷點(diǎn) 2:在 HeapByteBuffer 轉(zhuǎn)換成 DirectByteBuffer 處打斷點(diǎn),發(fā)現(xiàn)實(shí)例 ID 為 1506 的 PooledUnsafeHeapByteBuf 被釋放拾酝。
圖 8 響應(yīng)消息釋放處斷點(diǎn)
斷點(diǎn) 3:轉(zhuǎn)換之后待發(fā)送的響應(yīng)消息 PooledUnsafeDirectByteBuf 實(shí)例 ID 為 1527燕少。
圖 9 響應(yīng)消息轉(zhuǎn)換處斷點(diǎn)
斷點(diǎn) 4:響應(yīng)消息發(fā)送完成之后,實(shí)例 ID 為 1527 的 PooledUnsafeDirectByteBuf 被釋放到內(nèi)存池蒿囤。
圖 10 轉(zhuǎn)換之后的響應(yīng)消息釋放處斷點(diǎn)
如果是 DirectByteBuffer客们,則不需要轉(zhuǎn)換,當(dāng)消息發(fā)送完成之后材诽,由 ChannelOutboundBuffer 的 remove() 負(fù)責(zé)釋放底挫。
3.2.4 基于非內(nèi)存池的響應(yīng) ByteBuf
無論是基于內(nèi)存池還是非內(nèi)存池分配的 ByteBuf,如果是堆內(nèi)存脸侥,則將堆內(nèi)存轉(zhuǎn)換成堆外內(nèi)存建邓,然后釋放 HeapByteBuffer,待消息發(fā)送完成之后睁枕,再釋放轉(zhuǎn)換后的 DirectByteBuf官边;如果是 DirectByteBuffer,則無需轉(zhuǎn)換外遇,待消息發(fā)送完成之后釋放注簿。因此對(duì)于需要發(fā)送的響應(yīng) ByteBuf,由業(yè)務(wù)創(chuàng)建臀规,但是不需要業(yè)務(wù)來釋放滩援。
4. Netty 服務(wù)端高并發(fā)保護(hù)
4.1 高并發(fā)場(chǎng)景下的 OOM 問題
在 RPC 調(diào)用時(shí),如果客戶端并發(fā)連接數(shù)過多塔嬉,服務(wù)端又沒有針對(duì)并發(fā)連接數(shù)的流控機(jī)制玩徊,一旦服務(wù)端處理慢,就很容易發(fā)生批量超時(shí)和斷連重連問題谨究。
以 Netty HTTPS 服務(wù)端為例恩袱,典型的業(yè)務(wù)組網(wǎng)示例如下所示:
圖 11 Netty HTTPS 組網(wǎng)圖
客戶端采用 HTTP 連接池的方式與服務(wù)端進(jìn)行 RPC 調(diào)用,單個(gè)客戶端連接池上限為 200胶哲,客戶端部署了 30 個(gè)實(shí)例畔塔,而服務(wù)端只部署了 3 個(gè)實(shí)例。在業(yè)務(wù)高峰期鸯屿,每個(gè)服務(wù)端需要處理 6000 個(gè) HTTP 連接澈吨,當(dāng)服務(wù)端時(shí)延增大之后,會(huì)導(dǎo)致客戶端批量超時(shí)寄摆,超時(shí)之后客戶端會(huì)關(guān)閉連接重新發(fā)起 connect 操作谅辣,在某個(gè)瞬間,幾千個(gè) HTTPS 連接同時(shí)發(fā)起 SSL 握手操作婶恼,由于服務(wù)端此時(shí)也處于高負(fù)荷運(yùn)行狀態(tài)桑阶,就會(huì)導(dǎo)致部分連接 SSL 握手失敗或者超時(shí)柏副,超時(shí)之后客戶端會(huì)繼續(xù)重連,進(jìn)一步加重服務(wù)端的處理壓力蚣录,最終導(dǎo)致服務(wù)端來不及釋放客戶端 close 的連接割择,引起 NioSocketChannel 大量積壓,最終 OOM萎河。
通過客戶端的運(yùn)行日志可以看到一些 SSL 握手發(fā)生了超時(shí)荔泳,示例如下:
圖 12 SSL 握手超時(shí)日志
服務(wù)端并沒有對(duì)客戶端的連接數(shù)做限制,這會(huì)導(dǎo)致盡管 ESTABLISHED 狀態(tài)的連接數(shù)并不會(huì)超過 6000 上限公壤,但是由于一些 SSL 連接握手失敗换可,再加上積壓在服務(wù)端的連接并沒有及時(shí)釋放,最終引起了 NioSocketChannel 的大量積壓厦幅。
4.2.Netty HTTS 并發(fā)連接數(shù)流控
在服務(wù)端增加對(duì)客戶端并發(fā)連接數(shù)的控制,原理如下所示:
圖 13 服務(wù)端 HTTS 連接數(shù)流控
基于 Netty 的 Pipeline 機(jī)制慨飘,可以對(duì) SSL 握手成功确憨、SSL 連接關(guān)閉做切面攔截(類似于 Spring 的 AOP 機(jī)制,但是沒采用反射機(jī)制瓤的,性能更高)休弃,通過流控切面接口,對(duì) HTTPS 連接做計(jì)數(shù)圈膏,根據(jù)計(jì)數(shù)器做流控塔猾,服務(wù)端的流控算法如下:
獲取流控閾值。
從全局上下文中獲取當(dāng)前的并發(fā)連接數(shù)稽坤,與流控閾值對(duì)比丈甸,如果小于流控閾值,則對(duì)當(dāng)前的計(jì)數(shù)器做原子自增尿褪,允許客戶端連接接入睦擂。
如果等于或者大于流控閾值,則拋出流控異常給客戶端杖玲。
SSL 連接關(guān)閉時(shí)顿仇,獲取上下文中的并發(fā)連接數(shù),做原子自減摆马。
在實(shí)現(xiàn)服務(wù)端流控時(shí)臼闻,需要注意如下幾點(diǎn):
流控的 ChannelHandler 聲明為 @ChannelHandler.Sharable,這樣全局創(chuàng)建一個(gè)流控實(shí)例囤采,就可以在所有的 SSL 連接中共享述呐。
通過 userEventTriggered 方法攔截 SslHandshakeCompletionEvent 和 SslCloseCompletionEvent 事件,在 SSL 握手成功和 SSL 連接關(guān)閉時(shí)更新流控計(jì)數(shù)器斑唬。
流控并不是單針對(duì) ESTABLISHED 狀態(tài)的 HTTP 連接市埋,而是針對(duì)所有狀態(tài)的連接黎泣,因?yàn)榭蛻舳岁P(guān)閉連接,并不意味著服務(wù)端也同時(shí)關(guān)閉了連接缤谎,只有 SslCloseCompletionEvent 事件觸發(fā)時(shí)抒倚,服務(wù)端才真正的關(guān)閉了 NioSocketChannel,GC 才會(huì)回收連接關(guān)聯(lián)的內(nèi)存坷澡。
流控 ChannelHandler 會(huì)被多個(gè) NioEventLoop 線程調(diào)用托呕,因此對(duì)于相關(guān)的計(jì)數(shù)器更新等操作,要保證并發(fā)安全性频敛,避免使用全局鎖项郊,可以通過原子類等提升性能。
5. 總結(jié)
5.1. 其它的防內(nèi)存泄漏措施
5.1.1 NioEventLoop
執(zhí)行它的 execute(Runnable task) 以及定時(shí)任務(wù)相關(guān)接口時(shí)斟赚,如果任務(wù)執(zhí)行耗時(shí)過長(zhǎng)着降、任務(wù)執(zhí)行頻度過高,可能會(huì)導(dǎo)致任務(wù)隊(duì)列積壓拗军,進(jìn)而引起 OOM:
圖 14 NioEventLoop 定時(shí)任務(wù)執(zhí)行接口
建議業(yè)務(wù)在使用時(shí)任洞,對(duì) NioEventLoop 隊(duì)列的積壓情況進(jìn)行采集和告警。
5.1.2 客戶端連接池
業(yè)務(wù)在初始化連接池時(shí)发侵,如果采用每個(gè)客戶端連接對(duì)應(yīng)一個(gè) EventLoopGroup 實(shí)例的方式交掏,即每創(chuàng)建一個(gè)客戶端連接,就會(huì)同時(shí)創(chuàng)建一個(gè) NioEventLoop 線程來處理客戶端連接以及后續(xù)的網(wǎng)絡(luò)讀寫操作刃鳄,采用的策略是典型的 1 個(gè) TCP 連接對(duì)應(yīng)一個(gè) NIO 線程的模式盅弛。當(dāng)系統(tǒng)的連接數(shù)很多、堆內(nèi)存又不足時(shí)叔锐,就會(huì)發(fā)生內(nèi)存泄漏或者線程創(chuàng)建失敗異常挪鹏。問題示意如下:
圖 15 錯(cuò)誤的客戶端線程模型
優(yōu)化策略:客戶端創(chuàng)建連接池時(shí),EventLoopGroup 可以重用掌腰,優(yōu)化之后的連接池線程模型如下所示:
圖 16 正確的客戶端線程模型
5.2 內(nèi)存泄漏問題定位
5.2.1 堆內(nèi)存泄漏
通過 jmap -dump:format=b,file=xx pid 命令 Dump 內(nèi)存堆棧狰住,然后使用 MemoryAnalyzer 工具對(duì)內(nèi)存占用進(jìn)行分析,查找內(nèi)存泄漏點(diǎn)齿梁,然后結(jié)合代碼進(jìn)行分析催植,定位內(nèi)存泄漏的具體原因,示例如下所示:
圖 17 通過 MemoryAnalyzer 工具分析內(nèi)存堆棧
5.2.2 堆外內(nèi)存泄漏
建議策略如下:
排查下業(yè)務(wù)代碼勺择,看使用堆外內(nèi)存的地方是否存在忘記釋放問題创南。
如果使用到了 Netty 的 TLS/SSL/openssl,建議到 Netty 社區(qū)查下 BUG 列表省核,看是否是 Netty 老版本已知的 BUG稿辙,此類 BUG 通過升級(jí) Netty 版本可以解決。
如果上述兩個(gè)步驟排查沒有結(jié)果气忠,則可以通過 google-perftools 工具協(xié)助進(jìn)行堆外內(nèi)存分析
歡迎學(xué)Java和大數(shù)據(jù)的朋友們加入java架構(gòu)交流: 855835163
加群鏈接:https://jq.qq.com/?_wv=1027&k=5dPqXGI???????
群內(nèi)提供免費(fèi)的架構(gòu)資料還有:Java工程化邻储、高性能及分布式赋咽、高性能、深入淺出吨娜。高架構(gòu)脓匿。性能調(diào)優(yōu)、Spring宦赠,MyBatis陪毡,Netty源碼分析和大數(shù)據(jù)等多個(gè)知識(shí)點(diǎn)高級(jí)進(jìn)階干貨的免費(fèi)直播講解 ?可以進(jìn)來一起學(xué)習(xí)交流哦