Netty防止內存泄漏措施

謹以此文獻給李林鋒新生的愛女。
李林鋒此後還將在 InfoQ 上開設 Netty 專題持續出稿,感興趣的同學可以持續關注。

1. 背景

1.1 直播平臺內存泄漏問題

某直播平臺,一些網紅的直播間在業務高峯期,會有10W+的粉絲接入,如果瞬間發生大量客戶端連接掉線、或者一些客戶端網絡比較慢,發現基於Netty構建的服務端內存會飆升,發生內存泄漏(OOM),導致直播卡頓、或者客戶端接收不到服務端推送的消息,用戶體驗受到很大影響。

1.2 問題分析

首先對GC數據進行分析,發現老年代已滿,發生多次Full GC,耗時達3分多,系統已經無法正常運行(示例):

圖1 直播高峯期服務端GC統計數據

Dump內存堆棧進行分析,發現大量的發送任務堆積,導致內存溢出(示例):

圖2 直播高峯期服務端內存Dump文件分析

通過以上分析可以看出,在直播高峯期,服務端向上萬客戶端推送消息時,發生了發送隊列積壓,引起內存泄漏,最終導致服務端頻繁GC,無法正常處理業務。

1.3 解決策略

服務端在進行消息發送的時候做保護,具體策略如下:

  1. 根據可接入的最大用戶數做客戶端併發接入數流控,需要根據內存、CPU處理能力,以及性能測試結果做綜合評估。

  2. 設置消息發送的高低水位,針對消息的平均大小、客戶端併發接入數、JVM內存大小進行計算,得出一個合理的高水位取值。服務端在推送消息時,對Channel的狀態進行判斷,如果達到高水位之後,Channel的狀態會被Netty置爲不可寫,此時服務端不要繼續發送消息,防止發送隊列積壓。

服務端基於上述策略優化了代碼,內存泄漏問題得到解決。

1.4.總結

儘管Netty框架本身做了大量的可靠性設計,但是對於具體的業務場景,仍然需要用戶做針對特定領域和場景的可靠性設計,這樣才能提升應用的可靠性。

除了消息發送積壓導致的內存泄漏,Netty還有其它常見的一些內存泄漏點,本文將針對這些可能導致內存泄漏的功能點進行分析和總結。

2. 消息收發防內存泄漏策略

2.1.消息接收

2.1.1 消息讀取

Netty的消息讀取並不存在消息隊列,但是如果消息解碼策略不當,則可能會發生內存泄漏,主要有如下幾點:

1.畸形碼流攻擊:如果客戶端按照協議規範,將消息長度值故意僞造的非常大,可能會導致接收方內存溢出。

2.代碼BUG:錯誤的將消息長度字段設置或者編碼成一個非常大的值,可能會導致對方內存溢出。

3.高併發場景:單個消息長度比較大,例如幾十M的小視頻,同時併發接入的客戶端過多,會導致所有Channel持有的消息接收ByteBuf內存總和達到上限,發生OOM。

避免內存泄漏的策略如下:

  1. 無論採用哪種解碼器實現,都對消息的最大長度做限制,當超過限制之後,拋出解碼失敗異常,用戶可以選擇忽略當前已經讀取的消息,或者直接關閉鏈接。

以Netty的DelimiterBasedFrameDecoder代碼爲例,創建DelimiterBasedFrameDecoder對象實例時,指定一個比較合理的消息最大長度限制,防止內存溢出:

/**

 * Creates a new instance.

 *

 * @param maxFrameLength  the maximum length of the decoded frame.

 *                        A {@link TooLongFrameException} is thrown if

 *                        the length of the frame exceeds this value.

 * @param stripDelimiter  whether the decoded frame should strip out the

 *                        delimiter or not

 * @param delimiter  the delimiter

 */

public DelimiterBasedFrameDecoder(

        int maxFrameLength, boolean stripDelimiter, ByteBuf delimiter) {

    this(maxFrameLength, stripDelimiter, true, delimiter);

}
  1. 需要根據單個Netty服務端可以支持的最大客戶端併發連接數、消息的最大長度限制以及當前JVM配置的最大內存進行計算,並結合業務場景,合理設置maxFrameLength的取值。

2.1.2 ChannelHandler的併發執行

Netty的ChannelHandler支持串行和異步併發執行兩種策略,在將ChannelHandler加入到ChannelPipeline時,如果指定了EventExecutorGroup,則ChannelHandler將由EventExecutorGroup中的EventExecutor異步執行。這樣的好處是可以實現Netty I/O線程與業務ChannelHandler邏輯執行的分離,防止ChannelHandler中耗時業務邏輯的執行阻塞I/O線程。

ChannelHandler異步執行的流程如下所示:

圖3 ChannelHandler異步併發執行流程

如果業務ChannelHandler中執行的業務邏輯耗時較長,消息的讀取速度又比較快,很容易發生消息在EventExecutor中積壓的問題,如果創建EventExecutor時沒有通過io.netty.eventexecutor.maxPendingTasks參數指定積壓的最大消息個數,則默認取值爲0x7fffffff,長時間的積壓將導致內存溢出,相關代碼如下所示(異步執行ChannelHandler,將消息封裝成Task加入到taskQueue中):

public void execute(Runnable task) {

    if (task == null) {

        throw new NullPointerException("task");

    }

    boolean inEventLoop = inEventLoop();

    if (inEventLoop) {

        addTask(task);

    } else {

        startThread();

        addTask(task);

        if (isShutdown() && removeTask(task)) {

            reject();

        }

    }

解決對策:對EventExecutor中任務隊列的容量做限制,可以通過io.netty.eventexecutor.maxPendingTasks參數做全局設置,也可以通過構造方法傳參設置。結合EventExecutorGroup中EventExecutor的個數來計算taskQueue的個數,根據taskQueue * N * 任務隊列平均大小 * maxPendingTasks < 係數K(0 < K < 1)* 總內存的公式來進行計算和評估。

2.2.消息發送

2.2.1 如何防止發送隊列積壓

爲了防止高併發場景下,由於對方處理慢導致自身消息積壓,除了服務端做流控之外,客戶端也需要做併發保護,防止自身發生消息積壓。

利用Netty提供的高低水位機制,可以實現客戶端更精準的流控,它的工作原理如下:

圖4 Netty高水位接口說明

當發送隊列待發送的字節數組達到高水位上限時,對應的Channel就變爲不可寫狀態。由於高水位並不影響業務線程調用write方法並把消息加入到待發送隊列中,因此,必須要在消息發送時對Channel的狀態進行判斷:當到達高水位時,Channel的狀態被設置爲不可寫,通過對Channel的可寫狀態進行判斷來決定是否發送消息。

在消息發送時設置高低水位並對Channel狀態進行判斷,相關代碼示例如下:

public void channelActive(final ChannelHandlerContext ctx) {

        **ctx.channel().config().setWriteBufferHighWaterMark(10 \* 1024 * 1024);**

        loadRunner = new Runnable() {

            @Override

            public void run() {

                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());

                    }

                }

            }

        };

        new Thread(loadRunner, "LoadRunner-Thread").start();

    }

對上述代碼做驗證,客戶端代碼中打印隊列積壓相關日誌,說明基於高水位的流控機制生效,日誌如下:

警告: The write queue is busy : 17

通過內存監控,發現內存佔用平穩:

圖5 進行高低水位保護優化之後內存佔用情況

在實際項目中,根據業務QPS規劃、客戶端處理性能、網絡帶寬、鏈路數、消息平均碼流大小等綜合因素計算並設置高水位(WriteBufferHighWaterMark)閾值,利用高水位做消息發送速率的流控,既可以保護自身,同時又能減輕服務端的壓力,防止服務端被壓掛。

2.2.2 其它可能導致發送隊列積壓的因素

需要指出的是,並非只有高併發場景纔會觸發消息積壓,在一些異常場景下,儘管系統流量不大,但仍然可能會導致消息積壓,可能的場景包括:

  1. 網絡瓶頸,發送速率超過網絡鏈接處理能力時,會導致發送隊列積壓。

  2. 對端讀取速度小於己方發送速度,導致自身TCP發送緩衝區滿,頻繁發生write 0字節時,待發送消息會在Netty發送隊列排隊。

當出現大量排隊時,很容易導致Netty的直接內存泄漏,示例如下:

圖6 消息積壓導致內存泄漏相關堆棧

我們在設計系統時,需要根據業務的場景、所處的網絡環境等因素進行綜合設計,爲潛在的各種故障做容錯和保護,防止因爲外部因素導致自身發生內存泄漏。

3. ByteBuf的申請和釋放策略

3.1 ByteBuf申請和釋放的理解誤區

有一種說法認爲Netty框架分配的ByteBuf框架會自動釋放,業務不需要釋放;業務創建的ByteBuf則需要自己釋放,Netty框架不會釋放。

事實上,這種觀點是錯誤的,即便ByteBuf是Netty創建的,如果使用不當仍然會發生內存泄漏。在實際項目中如何更好的管理ByteBuf,下面我們分四種場景進行說明。

3.2 ByteBuf的釋放策略

3.2.1 基於內存池的請求ByteBuf

這類ByteBuf主要包括PooledDirectByteBuf和PooledHeapByteBuf,它由Netty的NioEventLoop線程在處理Channel的讀操作時分配,需要在業務ChannelInboundHandler處理完請求消息之後釋放(通常是解碼之後),它的釋放有2種策略:

  1. 策略1:業務ChannelInboundHandler繼承自SimpleChannelInboundHandler,實現它的抽象方法channelRead0(ChannelHandlerContext ctx, I msg),ByteBuf的釋放業務不用關心,由SimpleChannelInboundHandler負責釋放,相關代碼如下所示(SimpleChannelInboundHandler):
 @Override

  public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {

        boolean release = 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);**

            **}**

        }

    }

如果當前業務ChannelInboundHandler需要執行,則調用完channelRead0之後執行ReferenceCountUtil.release(msg)釋放當前請求消息。如果沒有匹配上需要繼續執行後續的ChannelInboundHandler,則不釋放當前請求消息,調用ctx.fireChannelRead(msg)驅動ChannelPipeline繼續執行。

繼承自SimpleChannelInboundHandler,即便業務不釋放請求ByteBuf對象,依然不會發生內存泄漏,相關示例代碼如下所示:

 public class RouterServerHandlerV2 **extends SimpleChannelInboundHandler<ByteBuf>** {

//代碼省略...

@Override

    public void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {

        byte [] body = new byte[msg.readableBytes()];

        executorService.execute(()->

        {

            //解析請求消息,做路由轉發,代碼省略...

            //轉發成功,返回響應給客戶端

            ByteBuf respMsg = allocator.heapBuffer(body.length);

            respMsg.writeBytes(body);//作爲示例,簡化處理,將請求返回

            ctx.writeAndFlush(respMsg);

        });

    }

對上述代碼做性能測試,發現內存佔用平穩,無內存泄漏問題,驗證了之前的分析結論。

  1. 策略2:在業務ChannelInboundHandler中調用ctx.fireChannelRead(msg)方法,讓請求消息繼續向後執行,直到調用到DefaultChannelPipeline的內部類TailContext,由它來負責釋放請求消息,代碼如下所示(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);**

        **}**

    }

3.2.2 基於非內存池的請求ByteBuf

如果業務使用非內存池模式覆蓋Netty默認的內存池模式創建請求ByteBuf,例如通過如下代碼修改內存申請策略爲Unpooled:

//代碼省略... 

.childHandler(new ChannelInitializer<SocketChannel>() {

                 @Override

                 public void initChannel(SocketChannel ch) throws Exception {

                     ChannelPipeline p = ch.pipeline();                     ch.config().setAllocator(UnpooledByteBufAllocator.DEFAULT);

                     p.addLast(new RouterServerHandler());

                 }

             }); 

    }

也需要按照內存池的方式去釋放內存。

3.2.3 基於內存池的響應ByteBuf

只要調用了writeAndFlush或者flush方法,在消息發送完成之後都會由Netty框架進行內存釋放,業務不需要主動釋放內存。

它的工作原理如下:

調用ctx.writeAndFlush(respMsg)方法,當消息發送完成之後,Netty框架會主動幫助應用來釋放內存,內存的釋放分爲兩種場景:

  1. 如果是堆內存(PooledHeapByteBuf),則將HeapByteBuffer轉換成DirectByteBuffer,並釋放PooledHeapByteBuf到內存池,代碼如下(AbstractNioChannel類):
protected final ByteBuf newDirectBuffer(ByteBuf buf) {

​        final int readableBytes = buf.readableBytes();

​        if (readableBytes == 0) {

​            **ReferenceCountUtil.safeRelease(buf);**

​            return Unpooled.EMPTY_BUFFER;

​        }

​        final ByteBufAllocator alloc = alloc();

​        if (alloc.isDirectBufferPooled()) {

​            ByteBuf directBuf = alloc.directBuffer(readableBytes);

​            directBuf.writeBytes(buf, buf.readerIndex(), readableBytes);

​            **ReferenceCountUtil.safeRelease(buf);**

​            return directBuf;

​        }        }

   //後續代碼省略

}

如果消息完整的被寫到SocketChannel中,則釋放DirectByteBuffer,代碼如下(ChannelOutboundBuffer)所示:

public boolean remove() {

​        Entry e = flushedEntry;

​        if (e == null) {

​            clearNioBuffers();

​            return false;

​        }

​        Object msg = e.msg;

​        ChannelPromise promise = e.promise;

​        int size = e.pendingSize;

​        removeEntry(e);

​        if (!e.cancelled) {

​            **ReferenceCountUtil.safeRelease(msg);**

​            safeSuccess(promise);

​            decrementPendingOutboundBytes(size, false, true);

​        } 

   //後續代碼省略

}

對Netty源碼進行斷點調試,驗證上述分析:

斷點1:在響應消息發送處打印斷點,獲取到PooledUnsafeHeapByteBuf實例ID爲1506。

圖7 響應發送處斷點調試

斷點2:在HeapByteBuffer轉換成DirectByteBuffer處打斷點,發現實例ID爲1506的PooledUnsafeHeapByteBuf被釋放。

圖8 響應消息釋放處斷點

斷點3:轉換之後待發送的響應消息PooledUnsafeDirectByteBuf實例ID爲1527。

圖9 響應消息轉換處斷點

斷點4:響應消息發送完成之後,實例ID爲1527的PooledUnsafeDirectByteBuf被釋放到內存池。

圖10 轉換之後的響應消息釋放處斷點
  1. 如果是DirectByteBuffer,則不需要轉換,當消息發送完成之後,由ChannelOutboundBuffer的remove()負責釋放。

3.2.4 基於非內存池的響應ByteBuf

無論是基於內存池還是非內存池分配的ByteBuf,如果是堆內存,則將堆內存轉換成堆外內存,然後釋放HeapByteBuffer,待消息發送完成之後,再釋放轉換後的DirectByteBuf;如果是DirectByteBuffer,則無需轉換,待消息發送完成之後釋放。因此對於需要發送的響應ByteBuf,由業務創建,但是不需要業務來釋放。

4. Netty服務端高併發保護

4.1 高併發場景下的OOM問題

在RPC調用時,如果客戶端併發連接數過多,服務端又沒有針對併發連接數的流控機制,一旦服務端處理慢,就很容易發生批量超時和斷連重連問題。

以Netty HTTPS服務端爲例,典型的業務組網示例如下所示:

圖11 Netty HTTPS組網圖

客戶端採用HTTP連接池的方式與服務端進行RPC調用,單個客戶端連接池上限爲200,客戶端部署了30個實例,而服務端只部署了3個實例。在業務高峯期,每個服務端需要處理6000個HTTP連接,當服務端時延增大之後,會導致客戶端批量超時,超時之後客戶端會關閉連接重新發起connect操作,在某個瞬間,幾千個HTTPS連接同時發起SSL握手操作,由於服務端此時也處於高負荷運行狀態,就會導致部分連接SSL握手失敗或者超時,超時之後客戶端會繼續重連,進一步加重服務端的處理壓力,最終導致服務端來不及釋放客戶端close的連接,引起NioSocketChannel大量積壓,最終OOM。

通過客戶端的運行日誌可以看到一些SSL握手發生了超時,示例如下:

圖12 SSL握手超時日誌

服務端並沒有對客戶端的連接數做限制,這會導致儘管ESTABLISHED狀態的連接數並不會超過6000上限,但是由於一些SSL連接握手失敗,再加上積壓在服務端的連接並沒有及時釋放,最終引起了NioSocketChannel的大量積壓。

4.2.Netty HTTS併發連接數流控

在服務端增加對客戶端併發連接數的控制,原理如下所示:

圖13 服務端HTTS連接數流控

基於Netty的Pipeline機制,可以對SSL握手成功、SSL連接關閉做切面攔截(類似於Spring的AOP機制,但是沒采用反射機制,性能更高),通過流控切面接口,對HTTPS連接做計數,根據計數器做流控,服務端的流控算法如下:

  1. 獲取流控閾值。

  2. 從全局上下文中獲取當前的併發連接數,與流控閾值對比,如果小於流控閾值,則對當前的計數器做原子自增,允許客戶端連接接入。

  3. 如果等於或者大於流控閾值,則拋出流控異常給客戶端。

  4. SSL連接關閉時,獲取上下文中的併發連接數,做原子自減。

在實現服務端流控時,需要注意如下幾點:

  1. 流控的ChannelHandler聲明爲@ChannelHandler.Sharable,這樣全局創建一個流控實例,就可以在所有的SSL連接中共享。

  2. 通過userEventTriggered方法攔截SslHandshakeCompletionEvent和SslCloseCompletionEvent事件,在SSL握手成功和SSL連接關閉時更新流控計數器。

  3. 流控並不是單針對ESTABLISHED狀態的HTTP連接,而是針對所有狀態的連接,因爲客戶端關閉連接,並不意味着服務端也同時關閉了連接,只有SslCloseCompletionEvent事件觸發時,服務端才真正的關閉了NioSocketChannel,GC纔會回收連接關聯的內存。

  4. 流控ChannelHandler會被多個NioEventLoop線程調用,因此對於相關的計數器更新等操作,要保證併發安全性,避免使用全局鎖,可以通過原子類等提升性能。

5. 總結

5.1.其它的防內存泄漏措施

5.1.1 NioEventLoop

執行它的execute(Runnable task)以及定時任務相關接口時,如果任務執行耗時過長、任務執行頻度過高,可能會導致任務隊列積壓,進而引起OOM:

圖14 NioEventLoop定時任務執行接口

建議業務在使用時,對NioEventLoop隊列的積壓情況進行採集和告警。

5.1.2 客戶端連接池

業務在初始化連接池時,如果採用每個客戶端連接對應一個EventLoopGroup實例的方式,即每創建一個客戶端連接,就會同時創建一個NioEventLoop線程來處理客戶端連接以及後續的網絡讀寫操作,採用的策略是典型的1個TCP連接對應一個NIO線程的模式。當系統的連接數很多、堆內存又不足時,就會發生內存泄漏或者線程創建失敗異常。問題示意如下:

圖15 錯誤的客戶端線程模型

優化策略:客戶端創建連接池時,EventLoopGroup可以重用,優化之後的連接池線程模型如下所示:

圖16 正確的客戶端線程模型

5.2 內存泄漏問題定位

5.2.1 堆內存泄漏

通過jmap -dump:format=b,file=xx pid命令Dump內存堆棧,然後使用MemoryAnalyzer工具對內存佔用進行分析,查找內存泄漏點,然後結合代碼進行分析,定位內存泄漏的具體原因,示例如下所示:

圖17 通過MemoryAnalyzer工具分析內存堆棧

5.2.2 堆外內存泄漏

建議策略如下:

  1. 排查下業務代碼,看使用堆外內存的地方是否存在忘記釋放問題。

  2. 如果使用到了Netty的TLS/SSL/openssl,建議到Netty社區查下BUG列表,看是否是Netty老版本已知的BUG,此類BUG通過升級Netty版本可以解決。

  3. 如果上述兩個步驟排查沒有結果,則可以通過google-perftools工具協助進行堆外內存分析。

6. 作者簡介

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

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

Email:[email protected]

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