flyEn'blog

线上问题排查复盘

本文为自己前段时间解决线上的两个疑难问题,而后复盘整理的一次技术分享。

  1. 内存泄漏之线程泄漏
  2. WebClient连接池问题

技术背景:Java、Spring Webflux、Reactor

1. open服务内存溢出频繁重启

通过线上内存资源用尽的过程中导出来的dump文件,进行分析。

命令:jcmd 1 Dump.java <dump_file_name>,生成.dump后缀的dump文件。
(jdk版本:adoptOpenJDK-openj9)
在线分析: https://fastthread.io/

如下图,发现线程数3215,可以看到分析出来的提示。(过高的线程数可能会导致OOM)

image-20211112111844772

image-20211112111910069

从这边看到 boundedElastic、baidu 这两个线程组是非常多的,而且接近1:1的程度。

通过baidu这个可疑的名称,查到了 百度切面 (BaiduRequestAspect.java)中有一行代码:

1
2
3
4
5
6
7
8
return Mono.subscriberContext()
.publishOn(Schedulers.elastic())
.map(ctx -> ctx.get(ServerHttpRequest.class))
.flatMap(serverHttpRequest -> {
....
return openCommonService.getOpenAppByIdAndCheck(baiduSignData.getCipherid())
.publishOn(Schedulers.newParallel("baidu"))
.flatMap(...)

找到两处可疑点。

  1. .publishOn(Schedulers.elastic())
  2. .publishOn(Schedulers.newParallel("baidu"))

Schedulers.elastic()

首先,查看了下 .publishOn(Schedulers.elastic()) 的解释。

image-20211112113646441

弹性线程池,以下是.publishOn(Schedulers.elastic())这个方法的注释,可以看到两点:1.创建线程池的线程数量是不限制的;2.空闲时间60s之后会被废弃。(相当于也没有任务队列)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* {@link Scheduler} that dynamically creates ExecutorService-based Workers and caches
* the thread pools, reusing them once the Workers have been shut down.
* <p>
* The maximum number of created thread pools is unbounded.
* <p>
* The default time-to-live for unused thread pools is 60 seconds, use the appropriate
* factory to set a different value.
* <p>
* This scheduler is not restartable.
*
* @return default instance of a {@link Scheduler} that dynamically creates ExecutorService-based
* Workers and caches the threads, reusing them once the Workers have been shut
* down
* @deprecated use {@link #boundedElastic()}, to be removed in 3.5.0
*/

可以看到3.5.0之后这个是被废弃了,后面用Schedulers.boundedElastic()代替,boundedElastic()是限制线程池的线程数量最大为CPU核的10倍,任务队列默认最多10万个(可以通过去查看该方法的注释看出)。

所以可以看出来一个问题,Schedulers.elastic()线程数不限制,对于线程数一直累加肯定是会有所影响的。

这个是可以用Schedulers.boundedElastic()去优化的。

但是比较奇怪的是 为什么创建的线程没有被销毁的

对于这个问题,在第一次优化修改的时候还是有所疑问。

Schedulers.newParallel()

还有比较明显的一行就是 Schedulers.newParallel("baidu") ,每次请求进来,都分出一个线程来处理后续请求,线程组名为baidu,也和上面分析出来的对上了。

说明下为什么以前要加分线程来处理请求的背景。

为了解决之前 调用 同步/阻塞接口(腾讯报告获取接口),因为要转成base64,是阻塞方法。返回时长也比较久。导致其他open接口在其返回之前不可用,也一同被阻塞。

关于这个 Schedulers.newParallel(..)这个问题,这种new一个的形式都是新建一个调度器(而不是一个线程),Parallel()是固定线程大小的线程池,有固定的worker数,一个worker代表调度器可调度的工作线程。

但对于我们现在这种场景,是请求进来应该是服用一个调度器,去分配任务给工作线程去处理。而不是每一个请求去新建一个调度器(调度器是至少一个线程存活的),如果没有任务进去就是一直waiting状态。

所以这种写法也导致了我们 baidu 这个线程组有那么多的线程实例的问题所在。

Schedulers类已经预先创建了几种常用的不同线程池模型的调度器:使用single()elastic()parallel()方法创建的调度器可以分别使用内置的单线程、弹性线程池和固定大小线程池。如果想创建新的调度器,可以使用newSingle()newElastic()newParallel()方法。这些方法都是返回一个Scheduler的具体实现。

本地通过去创建了个测试类,也验证了这一点。

第一次改动

  1. 首先,Schedulers.elastic()是个弹性线程池,内置的已经创建的调度器,且线程可复用,应用在每次请求上是没有问题的,但是因为线程数量不限制(本来也是废弃了的方法),所以应该更改为 Schedulers.boundedElastic()
  2. 第二,看到百度切面上是处理了两次分线程,是完全没有必要的(相当于一个请求进来,又分了两个线程去处理),只需要分一次就行(腾讯切面是仅分了一次)。
  3. 第三,newParallel这种写法要避免。所以将Schedulers.newParallel("baidu")换成 Schedulers.boundedElastic(),将上面的 .publishOn(Schedulers.elastic())删除。

最后腾讯切面Schedulers.elastic()改为 Schedulers.boundedElastic()

百度切面改为:

1
2
3
4
5
6
7
return Mono.subscriberContext() // 去除下面的分线程
.map(ctx -> ctx.get(ServerHttpRequest.class))
.flatMap(serverHttpRequest -> {
....
return openCommonService.getOpenAppByIdAndCheck(baiduSignData.getCipherid())
.publishOn(Schedulers.boundedElastic()) // 去除newParallel写法
.flatMap(...)

测试结果

上到沙箱去压测。

压测了百度核酸的查库存接口,发现线程数还是会一直增长。主要是线程组boundedElastic一直增长,baidu这个线程组是已经没有了。

所以还有个问题没有解决掉,就是 为什么弹性线程池里创建出来的线程是没有被销毁的

当时很自然的以为 是我们用了 .publishOn(Schedulers.boundedElastic())的缘故。所以之后我把所有的分线程的代码给去除了,继续上了沙箱去压测。

去除所有分线程处理后,压测结果:

发版后,线程数105。

image-20211108104858038

压测百度核酸的查库存接口 11:19-11:29十分钟,并发200,每秒用户数20。

压测前内存:580M,压测后内存:1.655G

跑完看了下进程1的线程数有11863,还不会回收线程。(在过了20分钟/下午13:42之后再看,还是一样的)

image-20211108114153802

结果和预想的不同,和代码中写的 .publishOn(Schedulers.boundedElastic()) 并没有关系。

所有怀疑是不是我们切面写法有问题,也是比较费解

后来压测了下mobile服务的,压测前线程数:119

image-20211108115901034

压测12:03-12:06(3分钟),内存基本上没增加什么,差不多20m的样子,最后总共线程数126。

因为百度和腾讯的切面是不同的,所以再去压了下 腾讯的查库存接口 (13:47-13:50,3分钟),发现内存没有持续升高,线程数也是没有怎么增加,内存:1.671G,线程数:11867。(只是多了4个线程数)

不知道是不是线程复用了的情况,还是腾讯切面的效果。所以再一次测了百度的接口

结果就是刚开始一压,内存就开始飙升,线程数也马上涨了很快,才几秒就到了12185。

所有结果就是,只有请求百度的接口才会出现这个问题

怀疑是百度切面写法问题,然后去本地进行各种验证。

百度的每次请求都会新增一个线程(无一例外),并且不回收,一直在waiting状态。而腾讯的是正常的,会增加线程,但多请求是会复用/回收的(基本最后也就多几个)。

通过多种验证,最后发现与切面代码也没有关系 ,与通用返回处理也无关,是百度的接口 请求头有关Content-Type ,只要是这个类型application/x-www-form-urlencoded的参数值,去请求接口,都是会创建出来一个新线程(boundedElastic-xx),并且不会回收

问题定位完成,与Content-Type:application/x-www-form-urlencoded有关。

application/x-www-form-urlencoded

经查阅,怀疑可能是框架内部的处理HTTP请求的消息体解析器,是阻塞同步的。

尝试更换配置,尝试切换其他的解析器去解析。可能也是没有找到对应的,总之试了一些配置修改,没有效果。

1
2
3
4
5
6
7
8
9
10
@Bean
WebFluxConfigurer webFluxConfigurer(){
return new WebFluxConfigurer() {
@Override
public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
FormHttpMessageReader formHttpMessageReader = new FormHttpMessageReader();
configurer.customCodecs().reader(formHttpMessageReader);
}
};
}

HTTP消息编解码

spring-web模块定义HttpMessageReader和HttpMessageWriter协议,通过Rective Streams Publisher’s对HTTP请求和响应的主体进行编码和解码。 这些行为在客户端使用,例如, 在WebClient中,在服务器端,例如 在注解的控制器和功能端点中。

Spring-core模块定义了独立于HTTP的Encoder和Decoder,并依赖于(如Netty ByteBuf和java.nio.ByteBuffer(请参阅数据缓冲区和编解码器))的DataBuffer协议。 Encoder可以用EncoderHttpMessageWriter包装以用作HttpMessageWriter,而Decoder可以用DecoderHttpMessageReader包装以用作HttpMessageReader。

Spring-core模块包含byte [],ByteBuffer,DataBuffer,Resource和String的基本编码器和解码器实现。 Spring-Web模块为Jackson JSON,Jackson Smile和JAXB2增加了Encoder和Decoder。 Spring-Web模块还包含一些针对服务器发送事件,表单数据和多部分请求的特定于Web的readers 和writers 。

要配置或自定义readers 和writers ,通常会使用ClientCodecConfigurer或ServerCodecConfigurer。

https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-form-data

https://s0docs0spring0io.4x5.top/spring-framework/docs/5.2.x/spring-framework-reference/web-reactive.html#webflux-codecs

后面还是通过线程这块的角度来尝试解决这个问题。

经过测试,每请求一次百度接口,会多一个boundedElastic为前缀的线程,如下图,而且线程是不会Finished,一直在park状态。

image-20211109153630433

然后去Chrome查了下,关键词:boundedElastic Threads park

网上也有类似的问题:
https://stackoverflow.com/questions/65262816/webflux-lots-of-boundedelastic-evictor-timed-waiting

最后,确认是框架的bug。
https://github.com/spring-projects/spring-framework/issues/26263

框架内部:
spring-framework/spring-web/src/main/java/org/springframework/http/codec/support/ServerDefaultCodecsImpl.java

服务端的消息解码器,内部有个方法:

1
2
3
4
5
6
7
8
9
10
@Override
protected void extendTypedReaders(List<HttpMessageReader<?>> typedReaders) {
if (this.multipartReader != null) {
addCodec(typedReaders, this.multipartReader);
return;
}
DefaultPartHttpMessageReader partReader = new DefaultPartHttpMessageReader(); // 这一行
addCodec(typedReaders, partReader);
addCodec(typedReaders, new MultipartHttpMessageReader(partReader));
}

读取消息数据的时候,new一个DefaultPartHttpMessageReader()实例,而

spring-framework/spring-web/src/main/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReader.java

这个类内部:

1
2
3
4
5
6
7
8
9
10
11
public class DefaultPartHttpMessageReader extends LoggingCodecSupport implements HttpMessageReader<Part> {

private static final String IDENTIFIER = "spring-multipart";
...

private Scheduler blockingOperationScheduler = Schedulers.newBoundedElastic(Schedulers.DEFAULT_BOUNDED_ELASTIC_SIZE,
Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE, IDENTIFIER, 60, true); // 这一行

private Mono<Path> fileStorageDirectory = Mono.defer(this::defaultFileStorageDirectory).cache();
...
}

所以可以看到,每次new这个对象的时候,都会调用 Schedulers.newBoundedElastic()的这个方法去创建了一个弹性线程池的调度器。

跟上述所说的newParallel()类似,总的来说,这个调度器一旦创建,是跟着进程销毁的,调度器里至少会存在一个work线程。

所以,谜底终于解开了。

第二次改动

可以通过以下这种修改配置的方式,指定一个multipartReader给它,根据上述源码,是会走进if分支的。

1
2
3
4
5
6
7
8
9
10
11
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

@Override
public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
DefaultPartHttpMessageReader multipartReader = new DefaultPartHttpMessageReader();
configurer.defaultCodecs().multipartReader(multipartReader);
}

}

如果这样修改的话,还是有所弊端吧,这样的话这个实例是跟着open服务启动就已经创建出来了,可能不一定是被用到,但是如果是在线程内部创建的话,是跟着线程的生命周期进行创建并销毁的。

但最后还是通过升级了Spring框架版本来解决这个问题:从2.4.1 升到了 2.4.7

测试结果

改完之后上到沙箱测了遍 百度的接口:(总并发数200,每秒20,9:59-10:16,18分钟)

压测前:内存 738.1MB。线程数没看,基本上70/80的样子。

image-20211110100036900

压测完:内存833.8M,线程数124。(过了10多分钟再看内存已经降到800M了)

image-20211110101955091

image-20211110101743684

分析压测完的dump文件:121个线程。

image-20211110103444921

image-20211110103525625

压测报告:

image-20211110103847720

image-20211110103942395

也压了一遍 腾讯的接口 (10:32-10.42,10分钟)。

压测完。内存722.1M,线程128。(内存没有升反降了,线程涨了4),RPS-19.5

image-20211110104226447

image-20211110104412559

image-20211110110401242

和百度的压测报告差不多。

最后试了下百度核酸的参数比较大的压力测。(10:49-10:59,十分钟)

总并发数1000,每秒启动100,压完,内存1.072G,线程125,报告如下:RPS-73.1

image-20211110110557287

image-20211110110645454

问题已解决。

总结

  1. 对于调度器、线程池的了解加深,避免随意使用 `newSingle()newElastic()newParallel()等方法。
  2. 线上性能监控 、内存泄漏的问题排查、dump文件的分析经验。jdk自带的调优工具(jps/jmap/jcmd等)及啊arthas监控使用、熟悉使用visualvm 、MAT、或者其他在线分析工具。

相关命令:

jcmd 1 Dump.java <dump_file_name>,生成.dump后缀的dump文件。

1
2
3
> `jcmd <pid> GC.heap_dump dump.hprof` 等价于 `jmap -dump:live,format=b,file=dump.hprof <pid>`
> `jcmd <pid> GC.heap_dump -all dump.hprof` 等价于 `jmap -dump:format=b,file=dump.hprof <pid>`
>

监控:docker stats
查看:docker service ls

下载dump文件:docker exec -it de8712e06efe jcmd 1 Dump.java sandbox2-11101022.dump
容器里复制到外面服务器:docker cp d63a1830a3d2:sandbox2-11081057.dump .
将服务器下的某文件下载到本地:scp sandbox2:/root/sandbox2-11081057.dump ~/Downloads

下载arthas至容器并运行:
docker exec -it ${containerId} sh-c "wget https://arthas.aliyun.com/arthas-boot.jar && java -jar arthas-boot.jar"
或者已经下载下来,直接运行:java -jar arthas-boot.jar
或者直接放入项目的Dockerfile中,build构建容器的时候自动下载。

arthas文档: https://arthas.gitee.io/advanced-use.html

2. Connection reset by peer

1
readAddress(..) failed: Connection reset by peer; nested exception is io.netty.channel.unix.Errors$NativeIoException: readAddress(..) failed: Connection reset by peer

之前webclient进行服务间调用的时候,会偶现这个问题。

一般情况下都是在 隔了比较长一段时间,没有去访问的时候会报错。

上半年的时候为了解决这个问题,open的服务间调用直接砍掉了,改成了service内部调用。

之前粗略的统计了下,还有以下部分还剩余webClient方式的调用:

  1. H5有相对多的没有去完

    医院、发票、报告邮寄、团检、金投、微医登录

  2. open基本上已经去完了

    顺丰获取海鸥快递订单(OpenSfCall)、调鼎open推送接口

  3. platform也有一部分没有去完

    大部分是各种联动套餐自动上下线、isp之类的、接口推送渠道(废弃)。

    团检预约、套餐等接口

  4. 短信模板参数获取(报告、优惠券)

  5. BeeCloud获取openAPP信息(BCPayAppComponet)

    获取支付参数、BeeCloud退款等(但是rest接口内部没有同步代码块,因此可能不会影响)

  6. 渠道中心(基本上废弃)

  7. 医院中心

  8. 售后团检部分

经过调研,改了些 webclient建立连接的参数,之后经过长时间监控,问题应该已经解决。

https://github.com/reactor/reactor-netty/issues/1774

1
2
3
4
5
6
7
ConnectionProvider provider = ConnectionProvider.builder("fixed")
.maxConnections(500)
.maxIdleTime(Duration.ofSeconds(20))
.maxLifeTime(Duration.ofSeconds(60))
.pendingAcquireTimeout(Duration.ofSeconds(60))
.evictInBackground(Duration.ofSeconds(120)).build();
return builder.clientConnector(new ReactorClientHttpConnector(HttpClient.create(provider))).build();
Fork me on GitHub