【排障手记】WebFlux踩坑记——排查一次WebClient使用中的OOM(java.lang.OutOfMemoryError: Java heap space)异常的血泪史

3 篇文章 0 订阅
2 篇文章 0 订阅


环境

  • SpringBoot 2.3.8.RELEASE
  • Java 11
  • WebFlux

业务背景与异常现象

笔者负责的一个需要获取大量外部信息的业务模块(你懂的)。其中为了防止被风控,Http请求需要通过Proxy进行访问,同时,访问量也非常高。
为了方便WebClient的构建,笔者封装了一个方法

    /**
     * [基础创建方法]
     * 给到一个带超时时间,带代理,并带有不校验任何SSL整数的WebClient
     *
     * @param requestTimeOut     超时时间
     * @param proxyDO            代理实体
     * @param compressionEnabled 开启压缩?默认关闭
     * @return 返回一个带超时时间和默认全局信任的SSL请求校验器{@link WebClient.Builder}
     */
    public static WebClient.Builder getWebClientBuilderWithSslTrustAndPolicy(Duration requestTimeOut, ProxyDO proxyDO, boolean compressionEnabled) {
        if (requestTimeOut == null) {
            requestTimeOut = DEFAULT_REQUEST_TIMEOUT;
        }
        return WebClient.builder().clientConnector(new ReactorClientHttpConnector(HttpClient
                .create()
                //这里注入了一个抛弃一切SSL认证的sslContext
                .secure(sslContextSpec -> sslContextSpec.sslContext(SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)))
                .responseTimeout(requestTimeOut)
                .compress(compressionEnabled)
                //重新定向开启
                .followRedirect(true)
                .tcpConfiguration(tcpClient -> tcpClient.proxy(
                        p -> {
                            ProxyProvider.Builder pb = p.type(ProxyProvider.Proxy.HTTP)
                                    .address(InetSocketAddress.createUnresolved(proxyDO.getServiceAddress(), Integer.parseInt(proxyDO.getPort())));
                            if (StringUtils.isNotEmpty(proxyDO.getUserName())) {
                                pb.username(proxyDO.getUserName())
                                        .password(v -> proxyDO.getPassword());
                            }
                            Long proxyTimeOutMillis = proxyDO.getProxyTimeOutMillis();
                            if (proxyTimeOutMillis != null && proxyTimeOutMillis > 0) {
                                pb.connectTimeoutMillis(proxyTimeOutMillis);
                            } else {
                                pb.connectTimeoutMillis(DEFAULT_PROXY_TIMEOUT_MILLIS);
                            }
                        }
                ))
        ));
    }

其实真正的问题就出在这个方法中,下面会解释到。

在业务上线后,跑了一段时间就出现了异常
监控数据,隐藏了一些敏感信息
上图为调用方的监控数据,可以明显看到,业务开始跑后大约2个小时,业务调用开始出现大量失败,同时,调用时长增加明显

到出现异常的站点,可以找到报错java.lang.OutOfMemoryError: Java heap space(具体的截图没有保留~在忙着查找问题)

由于当前业务是第一个在WebFlux框架下,第一次使用WebClient完成的通过代理请求的高并发Http请求实现。所以在初步排除存在有溢出可能的公共变量之后,排查目标就锁定在了WebClient本身的使用上了。

顺带贴一个内存Dump后的分析图
Dump内存分析图


排查结论

很多老铁搜这篇文章只是为了解决生产中的问题~所以先给大家最简单明了的结论
实际上在上面提到的代码中,我们自定义了一个HttpClient,使用了HttpClient.create()方法,在下图中的这个位置
在这里插入图片描述
而这个方法实际未每一个请求都构建了一个链接,并且不知为何(惭愧,没能找到真正的原因),最终没有比关闭。导致所有的链接都堆积在了PooledConnectionProvider的公共变量Map中,最终导致溢出。
内存溢出位置
公共Map位置

解决方案

不使用HttpClient.create()方法,而使用HttpClient.create(ConnectionProvider)方法,并指定ConnectionProviderPooledConnectionProvider,可使用ConnectionProvider.builder().build()方法构建
修改后的代码如下图

    /**
     * [基础创建方法]
     * 给到一个带超时时间,带代理,并带有不校验任何SSL整数的WebClient
     * 注意,由于使用代理的,基本都是一次性使用,故默认对连接进行关闭后释放操作
     *
     * @param requestTimeOut        超时时间
     * @param proxyDO               代理实体
     * @param compressionEnabled    开启压缩?默认关闭
     * @param disposeOnDisconnected true则在构建连接池的时候额外加上.doOnDisconnected(DisposableChannel::dispose)属性,否则不设定
     * @return 返回一个带超时时间和默认全局信任的SSL请求校验器{@link WebClient.Builder}
     */
    public static WebClient.Builder getWebClientBuilderWithSslTrustAndProxy(
            Duration requestTimeOut
            , ProxyDO proxyDO
            , boolean compressionEnabled
            , boolean disposeOnDisconnected) {
        if (requestTimeOut == null) {
            requestTimeOut = DEFAULT_REQUEST_TIMEOUT;
        }

        return WebClient.builder().clientConnector(new ReactorClientHttpConnector(HttpClient
                .create(
                        /*
                        这里使用ConnectionProvider.builder().build()进行构建
                        构建出的PooledConnectionProvider用于HttpClient的连接池
                        注意,不要使用HttpClient.create()方法,大批量调用会导致OOM的问题
                         */
                        ConnectionProvider.builder(Thread.currentThread().getStackTrace()[2].getClass().getSimpleName() + "_" + proxyDO.getProxyContentStr()).build()
                )
                //这里注入了一个抛弃一切SSL认证的sslContext
                .secure(sslContextSpec -> sslContextSpec.sslContext(SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)))
                .responseTimeout(requestTimeOut)
                .compress(compressionEnabled)
                //重新定向开启
                .followRedirect(true)
                .tcpConfiguration(tcpClient -> {
                            TcpClient tcpClientNew =
                                    tcpClient.proxy(
                                            p -> {
                                                ProxyProvider.Builder pb = p.type(ProxyProvider.Proxy.HTTP)
                                                        .address(InetSocketAddress.createUnresolved(proxyDO.getServiceAddress(), Integer.parseInt(proxyDO.getPort())));
                                                if (StringUtils.isNotEmpty(proxyDO.getUserName())) {
                                                    pb.username(proxyDO.getUserName())
                                                            .password(v -> proxyDO.getPassword());
                                                }
                                                Long proxyTimeOutMillis = proxyDO.getProxyTimeOutMillis();
                                                if (proxyTimeOutMillis != null && proxyTimeOutMillis > 0) {
                                                    pb.connectTimeoutMillis(proxyTimeOutMillis);
                                                } else {
                                                    pb.connectTimeoutMillis(DEFAULT_PROXY_TIMEOUT_MILLIS);
                                                }
                                            }
                                    );
                            if (disposeOnDisconnected) {
                                //重要,在完成连接后关闭连接
                                return tcpClientNew.doOnDisconnected(DisposableChannel::dispose);
                            } else {
                                return tcpClientNew;
                            }
                        }
                )
        ));
    }


排查过程

可谓历尽艰辛

exchange()

其实在Google上,关于WebClient和OOM关键字相关,最常见的搜索结果为.exchange()方法导致的内存溢出,比较有代表性的可以参看下面这个issue
Memory is leaking when the response body is not consumed #910
其实在官方文档中也存在着这样的描述
WebClient exchange()

Unlike retrieve(), when using exchange(), it is the responsibility of the application to consume any response content regardless of the scenario (success, error, unexpected data, etc). Not doing so can cause a memory leak. The Javadoc for ClientResponse lists all the available options for consuming the body. Generally prefer using retrieve() unless you have a good reason for using exchange() which does allow to check the response status and headers before deciding how to or if to consume the response.

而笔者的这个项目中,也正好用到了exchange()的类似场景,即仅判断code(实际上也按照要求释放了链接)。所以首先对这部分进行了修改。参照官方Doc中的要求:

NOTE: When using a ClientResponse through the WebClient exchange() method, you have to make sure that the body is consumed or released by using one of the following methods:
body(BodyExtractor)
bodyToMono(Class) or bodyToMono(ParameterizedTypeReference)
bodyToFlux(Class) or bodyToFlux(ParameterizedTypeReference)
toEntity(Class) or toEntity(ParameterizedTypeReference)
toEntityList(Class) or toEntityList(ParameterizedTypeReference)
toBodilessEntity()
releaseBody()
You can also use bodyToMono(Void.class) if no response content is expected. However keep in mind the connection will be closed, instead of being placed back in the pool, if any content does arrive. This is in contrast to releaseBody() which does consume the full body and releases any content received.

但是,并未卵

PooledConnectionProvider

初步排查无果,那么就得上大招了:dump内存
内存溢出位置
如图,能找到对象堆积的位置
公共Map位置
经过断点调试,发现channelPools会在真正发起请求时创建一个新链接的并放入Map中。而且诡异的是上图中的类的持有者并非造成堆积的真正原因。可能是因为IBM这款分析软件并不使用Reactor环境吧。
最终,很遗憾,并没有理清整个工作机制

HttpClient.create()

能读懂源码固然好,但是在短时间内无法彻底解析清除的情况下,只能先尝试其他办法了。
由于项目中的使用方法是在每次调用前,都构建一个WebClient.Builder,所以从上面构建builder的方法尝试入手
经过各种尝试后发现 HttpClient.create()还有个有参的重载方法
HttpClient.Create()及重载方法
实际上,这两个方法的本质都是TcpClient.create(ConnectionProvider),只是这个ConnectionProvider的构建方法不同。
ConnectionProvider的实现类
咦!是不是发现了熟悉的身影:PooledConnectionProvider这不正是上面导致OOM的Map的持有者么?这让我想到了Apache的HttpClientConnectionManager的实现PoolingHttpClientConnectionManager
难道这个也是一个类似的池化管理实现?(其实是后来才明悟到的。。)
正巧,发现ConnectionProvider中也有个builder()方法,而构建出来的ConnectionProvider恰好就是PooledConnectionProvider实现
死马当活马医:手动构建PooledConnectionProvider

成功!
问题解决

同病相怜的老铁

在查找资料时,发现有个兄弟似乎存在跟我一样的问题
使用Spring WebClient的内存泄漏

体悟

感触良多,给小伙伴几点建议,希望能给大家一些启发

  • 关于源码
    • 能读懂源码固然好,这样能找到根源,还能提升自己的技术实力。排查问题之最优选择
    • 如果短时间内无法理解源码的逻辑,也不要放弃,可以尝试从代码变更中(或者新启用的技术)找突破口
  • 排查思路
    • 先Google,大部分的坑都有前人踩过,所以可以快速解决问题
    • GitHub上有很多很有用的issue,英文不好也别着急跳过,耐心读一定会有收获!
    • 当遇到比较小众或比较新的框架,可查资料不多,可以尝试使用类比思维来推导。比如在这个问题中,PooledConnectionProvider是和PoolingHttpClientConnectionManager类似的池化管理实现,在高并发的Http请求中尤为重要。
  • 坚持就是胜利
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

技术流奶爸奶爸

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值