本文为自己前段时间解决线上的两个疑难问题,而后复盘整理的一次技术分享。
- 内存泄漏之线程泄漏
- 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)
从这边看到 boundedElastic、baidu
这两个线程组是非常多的,而且接近1:1的程度。
通过baidu
这个可疑的名称,查到了 百度切面 (BaiduRequestAspect.java
)中有一行代码:
1 | return Mono.subscriberContext() |
找到两处可疑点。
.publishOn(Schedulers.elastic())
.publishOn(Schedulers.newParallel("baidu"))
Schedulers.elastic()
首先,查看了下 .publishOn(Schedulers.elastic())
的解释。
弹性线程池,以下是.publishOn(Schedulers.elastic())
这个方法的注释,可以看到两点:1.创建线程池的线程数量是不限制的;2.空闲时间60s之后会被废弃。(相当于也没有任务队列)
1 | /** |
可以看到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
的具体实现。
本地通过去创建了个测试类,也验证了这一点。
第一次改动
- 首先,
Schedulers.elastic()
是个弹性线程池,内置的已经创建的调度器,且线程可复用,应用在每次请求上是没有问题的,但是因为线程数量不限制(本来也是废弃了的方法),所以应该更改为Schedulers.boundedElastic()
。 - 第二,看到百度切面上是处理了两次分线程,是完全没有必要的(相当于一个请求进来,又分了两个线程去处理),只需要分一次就行(腾讯切面是仅分了一次)。
- 第三,
newParallel
这种写法要避免。所以将Schedulers.newParallel("baidu")
换成Schedulers.boundedElastic()
,将上面的.publishOn(Schedulers.elastic())
删除。
最后腾讯切面将Schedulers.elastic()
改为 Schedulers.boundedElastic()
。
百度切面改为:
1 | return Mono.subscriberContext() // 去除下面的分线程 |
测试结果
上到沙箱去压测。
压测了百度核酸的查库存接口,发现线程数还是会一直增长。主要是线程组boundedElastic
一直增长,baidu这个线程组是已经没有了。
所以还有个问题没有解决掉,就是 为什么弹性线程池里创建出来的线程是没有被销毁的。
当时很自然的以为 是我们用了 .publishOn(Schedulers.boundedElastic())
的缘故。所以之后我把所有的分线程的代码给去除了,继续上了沙箱去压测。
去除所有分线程处理后,压测结果:
发版后,线程数105。
压测百度核酸的查库存接口 11:19-11:29十分钟,并发200,每秒用户数20。
压测前内存:580M,压测后内存:1.655G。
跑完看了下进程1的线程数有11863,还不会回收线程。(在过了20分钟/下午13:42之后再看,还是一样的)
结果和预想的不同,和代码中写的 .publishOn(Schedulers.boundedElastic())
并没有关系。
所有怀疑是不是我们切面写法有问题,也是比较费解。
后来压测了下mobile服务的,压测前线程数:119
压测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 |
|
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。
后面还是通过线程这块的角度来尝试解决这个问题。
经过测试,每请求一次百度接口,会多一个boundedElastic
为前缀的线程,如下图,而且线程是不会Finished,一直在park状态。
然后去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
服务端的消息解码器,内部有个方法:
1 |
|
读取消息数据的时候,new一个DefaultPartHttpMessageReader()
实例,而
这个类内部:
1 | public class DefaultPartHttpMessageReader extends LoggingCodecSupport implements HttpMessageReader<Part> { |
所以可以看到,每次new这个对象的时候,都会调用 Schedulers.newBoundedElastic()
的这个方法去创建了一个弹性线程池的调度器。
跟上述所说的newParallel()
类似,总的来说,这个调度器一旦创建,是跟着进程销毁的,调度器里至少会存在一个work线程。
所以,谜底终于解开了。
第二次改动
可以通过以下这种修改配置的方式,指定一个multipartReader
给它,根据上述源码,是会走进if分支的。
1 |
|
如果这样修改的话,还是有所弊端吧,这样的话这个实例是跟着open服务启动就已经创建出来了,可能不一定是被用到,但是如果是在线程内部创建的话,是跟着线程的生命周期进行创建并销毁的。
但最后还是通过升级了Spring框架版本来解决这个问题:从2.4.1 升到了 2.4.7。
测试结果
改完之后上到沙箱测了遍 百度的接口:(总并发数200,每秒20,9:59-10:16,18分钟)
压测前:内存 738.1MB。线程数没看,基本上70/80的样子。
压测完:内存833.8M,线程数124。(过了10多分钟再看内存已经降到800M了)
分析压测完的dump文件:121个线程。
压测报告:
也压了一遍 腾讯的接口 (10:32-10.42,10分钟)。
压测完。内存722.1M,线程128。(内存没有升反降了,线程涨了4),RPS-19.5
和百度的压测报告差不多。
最后试了下百度核酸的参数比较大的压力测。(10:49-10:59,十分钟)
总并发数1000,每秒启动100,压完,内存1.072G,线程125,报告如下:RPS-73.1
问题已解决。
总结
- 对于调度器、线程池的了解加深,避免随意使用
`newSingle()
、newElastic()
和newParallel()
等方法。 - 线上性能监控 、内存泄漏的问题排查、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构建容器的时候自动下载。
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方式的调用:
H5有相对多的没有去完
医院、发票、报告邮寄、团检、金投、微医登录
open基本上已经去完了
顺丰获取海鸥快递订单(OpenSfCall)、调鼎open推送接口
platform也有一部分没有去完
大部分是各种联动套餐自动上下线、isp之类的、接口推送渠道(废弃)。
团检预约、套餐等接口
短信模板参数获取(报告、优惠券)
BeeCloud获取openAPP信息(BCPayAppComponet)
获取支付参数、BeeCloud退款等(但是rest接口内部没有同步代码块,因此可能不会影响)
渠道中心(基本上废弃)
医院中心
售后团检部分
经过调研,改了些 webclient建立连接的参数,之后经过长时间监控,问题应该已经解决。
1 | ConnectionProvider provider = ConnectionProvider.builder("fixed") |