当前位置: 首页 > 科技观察

记得异步处理导致JettyRequest对象泄露

时间:2023-03-18 23:16:55 科技观察

最近查了一个bug,发现了一系列有趣的事情。对“自定义线程池”和“Jetty线程模型”有了一些新的认识。本文预计阅读时间10分钟,包括:常见问题原因筛选根本原因及源码分析最佳实践一些小TIPS1、交付前环境出现的问题偶尔请求失败异常、服务器上显示的错误信息is:RequiredStringparameter'seriesbaid'isnotpresentcorresponding控制器的API乍一看是一个很简单的异常。请求参数中没有seriesbaid,导致失败。但经确认,前端请求参数中已经携带了seriesbaid,属于“偶发故障”,并非常见的传参问题。2.常见原因筛选2.1网关参数丢失?由于前端请求到达后端服务,会经过网关,所以一开始怀疑是网关丢了传参。经过调用链分析,在偶尔失败的请求中,也确认querystring已经通过。所以网关没有丢失参数传递。2.2特殊字符导致参数转换失败?现在querystring已经传给后端服务了,常见的原因是queryString中有特殊字符导致解析成queryParam失败。这会是问题吗?我们通过在服务中继承一个spring-webOncePerRequestFilter来打印请求参数。Intheoccasionalfailedrequest,thefollowinglogwasfound:2021-12-2915:36:05,536INFO[com.xxx.interceptor.RequestLoggingFilter]-shouldLog-swanparameter:traceId:fb2266d3687911ecb5f3cf045ea19ac3;query:seriesbaid=3FO4K4SLX2IW&x_plugin=cus&x_pluginzh_CN&x_resourceId=&x_resourceVersion=;参数映射:{};不幸的是,我们确认请求中确实有一个querystring,但没有成功解析成queryParam。但是查询字符串中没有预期的特殊字符,所以可以成功解析。既然常见的原因无法解释,那就只能去源码里捞一把了。2.3去源码拿点钱。我们的网络容器使用的是jetty,所以HttpServletRequest的实现是jetty的Request类。在Request类中,queryString的解析是在getParameters()中。我们发现当有异常请求进来的时候,这里的判断_queryParameter不是null,而是一个空对象。对于正常的请求,这里判断_queryParameter为null,然后解析。所以,还是要从源码上分析。3.根本原因及源码分析3.1为什么_queryParamter不为null?我们通过在Request类中设置多个断点找到了原因。整理过程如下图所示。1)同步请求A返回很快。当requestA进来时,一个Http请求结束后(controller方法返回给client),会进行相应的recycle()操作,其中包括对Requst对象执行recycle()方法,清理相关参数,包括_查询参数。2)异步任务的响应延迟,recycle()后_queryParameter属性被重置。在请求A执行过程中,使用“自定义线程池”异步执行了一个方法B(方法较慢)。方法B中,HttpServletRequest是从RequestContextHolder中获取,然后通过request.getParameter()获取请求头。因为此时_queryParameters为null,所以extractQueryParameters()方法解析出一个空对象放入。3)新请求C进入,返回异常。当一个新的请求C进入后端服务,获取到同一个Request对象时,由于此时_queryParameters不为null,会跳过extractQueryParameters(),导致该解析的queryString无法解析,controller抛出异常。总结:一旦主线程执行完recycle过程,异步线程执行缓慢,异步线程中任何request.getParameter()行为都会破坏Request对象的recycle,导致_queryParameters属性为空object而不是null,导致Thenewrequestfailed。3.2在异步线程中,RequestContextHolder是否还能获取到Request对象?(根本原因)我们知道RequestContextHolder是基于ThreadLocal实现的。所以在异步线程中,是不可能直接通过RequestContextHolder.getRequestAttributes()来获取主线程的HttpServletRequest的。问题出在“自定义线程池”ThreadPoolExecutorWithMonitor。在内部,自定义了一个内部类DecorateRunnableTask来处理任务。内部类DecorateRunnableTask继承了内部类DecorateTask,保存了主线程的RequestAttributes对象。然后在异步线程执行之前,通过before()方法设置到当前线程的RequestContextHolder中。总结:将RequestAttributes对象传递给异步线程是Request对象泄露的根本原因!3.3两个请求,为什么共享一个Request对象?本来上面的分析基本上找到了bug的原因,但是仔细一想,又觉得有些奇怪。为什么两个请求共享一个Request对象?如果使用相关的池化技术,如何在两次请求中找到同一个对象,然后稳定地重现呢?因此,我继续研究jetty的相关内容。jetty9.x的整体架构图:SelectorManager+ManagedSelector+QueuedThreadPool构成了“Reactor线程模型”。对于一个http请求,SelectorManager分配一个ManagedSelector创建一个HttpConnection对象,然后在QueuedThreadPool中进行相应的IO操作。HttpConnection对象持有HttpChannel对象,HttpChannel持有Request对象(即HttpServletRequest)。网关和后端服务之间使用Http请求,默认是长连接。因此,短时间内(长连接结束前)的新请求会重用同一个HttpConnection对象。4.最好的做法是不要将RequestAttributes对象传递给异步线程并保存。如果需要相关的请求参数,可以新建一个上下文对象存储参数并传递。或者使用TransmittableThreadLocal。5.一些小TIPS5.1Jetty源码不匹配在调试jetty的Request类的时候,一开始这里有个小坑,idea的源码一直没有匹配到。从github上拉下jetty源码,按照导入的jetty版本进行本地mvninstall,还是不一致。根据pom的依赖分析可以看出引入的jetty版本是9.4.12。后来突然想起来,这个项目虽然是springboot项目,但是并不是通过内置的jetty容器打成jar包启动的。而是打包成war包,在本地通过jetty:runofjetty-maven-plugin启动。这里使用的jetty版本是9.4.9。所以我们需要根据jetty-maven-plugin的版本来选择jetty的源码。5.2“偶发问题”难以复现考虑到篇幅和阅读体验,本文没有说明排查过程中的一个非常难点——如何在本地稳定复现“偶发问题”的异常请求。在实际调查过程中,需要花费大量时间来重现局部稳定性。如果不能在本地稳定复现,后面调试就无从谈起了。后来主要是根据最近代码的变化,发现引入了异步请求。将异步改为同步后,我们发现不会再出现这个问题。所以我们从异步请求入手,经过多次尝试,我们做出了稳定的复现。因此,本次排查的一个重要成果是,对于一些故障的排查,可以考虑从最近的“各种变化”中寻找蛛丝马迹。