环境
- 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后的分析图
排查结论
很多老铁搜这篇文章只是为了解决生产中的问题~所以先给大家最简单明了的结论
实际上在上面提到的代码中,我们自定义了一个HttpClient,使用了HttpClient.create()方法,在下图中的这个位置
而这个方法实际未每一个请求都构建了一个链接,并且不知为何(惭愧,没能找到真正的原因),最终没有比关闭。导致所有的链接都堆积在了PooledConnectionProvider的公共变量Map中,最终导致溢出。
解决方案
不使用HttpClient.create()方法,而使用HttpClient.create(ConnectionProvider)方法,并指定ConnectionProvider为PooledConnectionProvider,可使用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内存
如图,能找到对象堆积的位置
经过断点调试,发现channelPools会在真正发起请求时创建一个新链接的并放入Map中。而且诡异的是上图中的类的持有者并非造成堆积的真正原因。可能是因为IBM这款分析软件并不使用Reactor环境吧。
最终,很遗憾,并没有理清整个工作机制
HttpClient.create()
能读懂源码固然好,但是在短时间内无法彻底解析清除的情况下,只能先尝试其他办法了。
由于项目中的使用方法是在每次调用前,都构建一个WebClient.Builder,所以从上面构建builder的方法尝试入手
经过各种尝试后发现 HttpClient.create()还有个有参的重载方法
实际上,这两个方法的本质都是TcpClient.create(ConnectionProvider),只是这个ConnectionProvider的构建方法不同。
咦!是不是发现了熟悉的身影:PooledConnectionProvider这不正是上面导致OOM的Map的持有者么?这让我想到了Apache的HttpClientConnectionManager的实现PoolingHttpClientConnectionManager
难道这个也是一个类似的池化管理实现?(其实是后来才明悟到的。。)
正巧,发现ConnectionProvider中也有个builder()方法,而构建出来的ConnectionProvider恰好就是PooledConnectionProvider实现
死马当活马医:手动构建PooledConnectionProvider!
成功!
同病相怜的老铁
在查找资料时,发现有个兄弟似乎存在跟我一样的问题
使用Spring WebClient的内存泄漏
体悟
感触良多,给小伙伴几点建议,希望能给大家一些启发
- 关于源码
- 能读懂源码固然好,这样能找到根源,还能提升自己的技术实力。排查问题之最优选择
- 如果短时间内无法理解源码的逻辑,也不要放弃,可以尝试从代码变更中(或者新启用的技术)找突破口
- 排查思路
- 先Google,大部分的坑都有前人踩过,所以可以快速解决问题
- GitHub上有很多很有用的issue,英文不好也别着急跳过,耐心读一定会有收获!
- 当遇到比较小众或比较新的框架,可查资料不多,可以尝试使用类比思维来推导。比如在这个问题中,PooledConnectionProvider是和PoolingHttpClientConnectionManager类似的池化管理实现,在高并发的Http请求中尤为重要。
- 坚持就是胜利