SpringCloudGateway堆外內存溢出,看我如何解決!

大家好,我是飄渺!

生產環境網關模塊偶發的 OutOfDirectMemoryError 錯誤排查起來困難且曲折,2021-02-05 號也出現過此問題,起初以爲是 JVM 堆內存過小 (當時是 2g) 導致,後調整到 8g (2 月 5 號調整)。但是經過上次調整後 5 月 7 號又出現此問題,於是猜測可能是由於網關模塊存在內存泄露導致。

症狀

報錯詳情

網關模塊偶現 OutOfDirectMemoryError 錯誤,兩次問題出現相隔大概 3 個月。兩次發生的時機都是正在大批量接收數據 (大約 500w),TPS 60 左右,網關服務波動不大,完全能抗住,按理不應該出現此錯誤。

詳細報錯信息如下:

2021-05-06 13:44:18|WARN |[reactor-http-epoll-5]|[AbstractChannelHandlerContext.java : 311]|An exception 'io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16384 byte(s) of direct memory (used: 8568993562, max: 8589934592)' [enable DEBUG level for full stacktrace] was thrown by a user handler's exceptionCaught() method while handling the following exception:
io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16384 byte(s) of direct memory (used: 8568993562, max: 8589934592)
        at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:754)
        at io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner(PlatformDependent.java:709)
        at io.netty.buffer.UnpooledUnsafeNoCleanerDirectByteBuf.allocateDirect(UnpooledUnsafeNoCleanerDirectByteBuf.java:30)
        at io.netty.buffer.UnpooledDirectByteBuf.<init>(UnpooledDirectByteBuf.java:64)
        at io.netty.buffer.UnpooledUnsafeDirectByteBuf.<init>(UnpooledUnsafeDirectByteBuf.java:41)
        at io.netty.buffer.UnpooledUnsafeNoCleanerDirectByteBuf.<init>(UnpooledUnsafeNoCleanerDirectByteBuf.java:25)
        at io.netty.buffer.UnsafeByteBufUtil.newUnsafeDirectByteBuf(UnsafeByteBufUtil.java:625)
        at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:359)
        at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
        at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:178)
        at io.netty.channel.unix.PreferredDirectByteBufAllocator.ioBuffer(PreferredDirectByteBufAllocator.java:53)
        at io.netty.channel.DefaultMaxMessagesRecvByteBufAllocator$MaxMessageHandle.allocate(DefaultMaxMessagesRecvByteBufAllocator.java:114)
        at io.netty.channel.epoll.EpollRecvByteAllocatorHandle.allocate(EpollRecvByteAllocatorHandle.java:75)
        at io.netty.channel.epoll.AbstractEpollStreamChannel$EpollStreamUnsafe.epollInReady(AbstractEpollStreamChannel.java:777)
        at io.netty.channel.epoll.EpollEventLoop.processReady(EpollEventLoop.java:475)
        at io.netty.channel.epoll.EpollEventLoop.run(EpollEventLoop.java:378)
        at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
        at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        at java.lang.Thread.run(Thread.java:748)

JVM 配置

-server -Xmx8g -Xms8g -Xmn1024m 
-XX:PermSize=512m -Xss256k 
-XX:+DisableExplicitGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled 
-XX:+UseCMSCompactAtFullCollection -XX:LargePageSizeInBytes=128
-XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly 
-XX:CMSInitiatingOccupancyFraction=70 -Djava.awt.headless=true 
-Djava.net.preferIPv4Stack=true

版本信息

spring cloud : Hoxton.SR5
spring cloud starter gateway : 2.2.3.RELEASE
spring boot starter : 2.3.0.RELEASE
netty : 4.1.54.Final
reactor-netty: 0.9.7.RELEASE

山重水複疑無路

JVM 參數詳解:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

報錯的信息是 OutOfDirectMemoryError,即堆外內存不足,於是複習了下 JVM 堆外內存的相關知識。

  1. 堆外內存是在 NIO 中使用的;
  2. 堆外內存通過 -XX:MaxDirectMemorySize 參數控制大小,注意和 -XX:+DisableExplicitGC 參數的搭配使用;
  3. JDK8 中堆外內存默認和堆內存一樣大(-Xmx);
  4. JDK8 如果配置 -XX:MaxDirectMemorySize 參數,則堆外內存大小以設置的參數爲準;

SpringCloudGateway 是基於 WebFlux 框架實現的,而 WebFlux 框架底層則使用了高性能的 Reactor 模式通信框架 Netty。

網上查閱相關資料,有些場景是因爲堆外內存沒有手動 release 導致,於是簡單查看了網關模塊的相關代碼發現並無此問題,關鍵的地方也都調用了相關方法釋放內存。堆外內存通過操作堆的命令無法看到,只能監控實例總內存走勢判斷。

// 釋放內存方法
DataBufferUtils.release(dataBuffer);

Dump 堆內存下來也沒有發現有什麼問題:

柳暗花明又一村

抱着試一試的想法到 SpringCloudGateway 官方倉庫 issue 搜索有沒有人遇到相同的問題,果不其然,有人提了類似的 issue。

https://github.com/spring-cloud/spring-cloud-gateway/issues/1704

在 issue 中開發人員也給出了迴應,確實是 SpringCloudGateway 的 BUG!此問題已在 2.2.6.RELEASE 版本中修復。而我們項目中使用版本爲 2.2.3.RELEASE,所以就會出現這個問題。

原因是:包裝原生的 pool 後沒有釋放內存。

出乎意料

問題原因已經找到,想着在測試環境復現後升級版本再驗證即可。可結果卻出乎了我的意料。

  1. 測試環境將堆內存調小嚐試進行復現生產問題,在壓測將近 1 個小時後出現了同樣的問題,復現成功。
  2. 升級 SpringCloudGateway 的版本至 2.2.6.RELEASE
  3. 重新壓測,問題再次出現。

你沒看錯,問題再次出現,且報錯信息一模一樣。我很快又陷入了沉思。

深究原因

排除了組件的問題,剩下的就是代碼的問題了,最有可能的就是程序中沒有顯示調用釋放內存導致。

網關模塊共定義了三個過濾器,一個全局過濾器 RequestGatewayFilter implements GlobalFilter。兩個自定義過濾器 RequestDecryptGatewayFilterFactory extends AbstractGatewayFilterFactoryResponseEncryptGatewayFilterFactory extends AbstractGatewayFilterFactory

依次仔細排查相關邏輯,在全局過濾器 RequestGatewayFilter 中有一塊代碼引起了我的注意:

// 僞代碼
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    HttpHeaders headers = request.getHeaders();
    return DataBufferUtils.join(exchange.getRequest().getBody())
            .flatMap(dataBuffer -> {
                DataBufferUtils.retain(dataBuffer);
                Flux<DataBuffer> cachedFlux = Flux.defer(() -> Flux.just(dataBuffer.slice(0, dataBuffer.readableByteCount())));
                
                ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
                    @Override
                    public Flux<DataBuffer> getBody() {
                        return cachedFlux;
                    }

                    @Override
                    public HttpHeaders getHeaders() {
                        return headers;
                    }
                };
                return chain.filter(exchange.mutate().request(mutatedRequest).build());
            });
}

我們知道,Request 的 Body 是隻能讀取一次的,如果直接通過在 Filter 中讀取,而不封裝回去回導致後面的服務無法讀取數據。

此全局過濾器的目的就是把原有的 request 請求中的 body 內容讀出來,並且使用ServerHttpRequestDecorator 這個請求裝飾器對 request 進行包裝,重寫 getBody 方法,並把包裝後的請求放到過濾器鏈中傳遞下去。這樣後面的過濾器中再使用 exchange.getRequest ().getBody () 來獲取 body 時,實際上就是調用的重載後的 getBody() 方法,獲取的最先已經緩存了的 body 數據。這樣就能夠實現 body 的多次讀取了。

但是將 DataBuffer 讀取出來後並沒有手動釋內存,會導致堆外內存持續增長。於是添加了一行代碼手動釋放堆外內存:

DataBufferUtils.release(dataBuffer);
// 僞代碼
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    HttpHeaders headers = request.getHeaders();
    return DataBufferUtils.join(exchange.getRequest().getBody())
                .flatMap(dataBuffer -> {
                    byte[] bytes = new byte[dataBuffer.readableByteCount()];
                    dataBuffer.read(bytes);
                  // 釋放堆外內存
                    DataBufferUtils.release(dataBuffer);
                    ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
                        @Override
                        public Flux<DataBuffer> getBody() {
                            return Flux.defer(() -> {
                                DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
                                DataBufferUtils.retain(buffer);
                                return Mono.just(buffer);
                            });
                        }

                        @Override
                        public HttpHeaders getHeaders() {
                            return headers;
                        }
                    };
                    return chain.filter(exchange.mutate().request(mutatedRequest).build());
                });
}

再次壓測未出現堆外內存溢出問題。終究還是自己大意了。。

後在網絡上查詢到了類似的案例:https://github.com/reactor/reactor-netty/issues/788

總結

這個問題排查花費了自己不少的時間,自己也沒有想到這麼曲折。問題是解決了,但是暴露了自身的很多問題,比如針對不同版本 JVM 內存分配不夠熟悉、對 SpringCloudGateway 不夠熟悉及太過相信官方開源版本。在直接內存中排查了很久,浪費了不少時間。同時自己也學到了不少東西:

  1. 遇到問題主要先去思考,要全面且細緻,慢慢去分析,抽絲剝繭;
  2. 一定要細緻再細緻,耐心再耐心的去還原問題,思考問題;
  3. JVM 直接內存的使用和配置、場景;
  4. 不要對開源組件過分信任,遇到問題時,對開源組件持懷疑態度;

作者: 彩虹馬
原文鏈接: 
https://my5353.com/poLDV

好了,今天的文章就到這裏了,希望能對你有所幫助。
最後,我是飄渺Jam,一名寫代碼的架構師,做架構的程序員,期待您的關注。


轉發

收藏

點贊

在看

本文分享自微信公衆號 - JAVA日知錄(javadaily)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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