大家好,我是飄渺!
生產環境網關模塊偶發的 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=128m
-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 堆外內存的相關知識。
-
堆外內存是在 NIO 中使用的; -
堆外內存通過 -XX:MaxDirectMemorySize 參數控制大小,注意和 -XX:+DisableExplicitGC 參數的搭配使用; -
JDK8 中堆外內存默認和堆內存一樣大(-Xmx); -
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 個小時後出現了同樣的問題,復現成功。 -
升級 SpringCloudGateway
的版本至2.2.6.RELEASE
。 -
重新壓測,問題再次出現。
你沒看錯,問題再次出現,且報錯信息一模一樣。我很快又陷入了沉思。
深究原因
排除了組件的問題,剩下的就是代碼的問題了,最有可能的就是程序中沒有顯示調用釋放內存導致。
網關模塊共定義了三個過濾器,一個全局過濾器 RequestGatewayFilter implements GlobalFilter
。兩個自定義過濾器 RequestDecryptGatewayFilterFactory extends AbstractGatewayFilterFactory
和 ResponseEncryptGatewayFilterFactory 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
不夠熟悉及太過相信官方開源版本。在直接內存中排查了很久,浪費了不少時間。同時自己也學到了不少東西:
-
遇到問題主要先去思考,要全面且細緻,慢慢去分析,抽絲剝繭; -
一定要細緻再細緻,耐心再耐心的去還原問題,思考問題; -
JVM 直接內存的使用和配置、場景; -
不要對開源組件過分信任,遇到問題時,對開源組件持懷疑態度;
作者: 彩虹馬
原文鏈接: https://my5353.com/poLDV
好了,今天的文章就到這裏了,希望能對你有所幫助。
最後,我是飄渺Jam,一名寫代碼的架構師,做架構的程序員,期待您的關注。
轉發
收藏
點贊
在看
本文分享自微信公衆號 - JAVA日知錄(javadaily)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。