异步线程持有 request 导致后续请求参数丢失

问题现象

测试人员发现部分接口偶尔会出现请求参数缺失的错误,查看后台日志报错如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2025-04-07 17:51:25.974 DEBUG 1 --- [nio-7002-exec-3] o.s.s.w.a.i.FilterSecurityInterceptor    : Authorized filter invocation [GET /job/slurm-job/get?id=2266] with attributes [authenticated]
2025-04-07 17:51:25.974 DEBUG 1 --- [nio-7002-exec-3] o.s.security.web.FilterChainProxy : Secured GET /job/slurm-job/get?id=2266
2025-04-07 17:51:25.974 DEBUG 1 --- [nio-7002-exec-3] o.s.web.servlet.DispatcherServlet : GET "/job/slurm-job/get?id=2266", parameters={}
2025-04-07 17:51:25.975 DEBUG 1 --- [nio-7002-exec-3] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.ongineer.ogsp.job.controller.jobslurmjob.JobSlurmJobController#getSlurmJob(Long)
2025-04-07 17:51:25.975 DEBUG 1 --- [nio-7002-exec-3] o.s.web.method.HandlerMethod : Could not resolve parameter [0] in public com.ongineer.ogsp.common.core.util.R<com.ongineer.ogsp.job.controller.jobslurmjob.vo.JobSlurmJobRespVO> com.ongineer.ogsp.job.controller.jobslurmjob.JobSlurmJobController.getSlurmJob(java.lang.Long): Required request parameter 'id' for method parameter type Long is not present
2025-04-07 17:51:25.975 DEBUG 1 --- [nio-7002-exec-3] .m.m.a.ExceptionHandlerExceptionResolver : Using @ExceptionHandler com.ongineer.ogsp.common.feign.sentinel.handle.GlobalBizExceptionHandler#handleMissingServletRequestParameterException(MissingServletRequestParameterException)
2025-04-07 17:51:25.977 WARN 1 --- [nio-7002-exec-3] c.o.o.c.f.s.h.GlobalBizExceptionHandler : 请求参数缺失,ex = {}

org.springframework.web.bind.MissingServletRequestParameterException: Required request parameter 'id' for method parameter type Long is not present
at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValueInternal(RequestParamMethodArgumentResolver.java:218) ~[spring-web-5.3.31.jar!/:5.3.31]
at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValue(RequestParamMethodArgumentResolver.java:193) ~[spring-web-5.3.31.jar!/:5.3.31]
at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.resolveArgument(AbstractNamedValueMethodArgumentResolver.java:114) ~[spring-web-5.3.31.jar!/:5.3.31]
at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122) ~[spring-web-5.3.31.jar!/:5.3.31]
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:179) ~[spring-web-5.3.31.jar!/:5.3.31]
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:146) ~[spring-web-5.3.31.jar!/:5.3.31]

排查过程

0x00 尝试搜索

通过日志可以注意到 GET 请求中有正确携带 id 参数,但是在请求解析之后报了找不到 id 的错误。简单搜索下可以找到相关的问题描述:
偶现的MissingServletRequestParameterException,谁动了我的参数?

根据链接文档总结问题的发生流程如下:

  • 请求开始:Tomcat 从对象池取出一个 Request 对象,初始化参数(如 queryString)。
  • 参数解析:首次调用 request.getParameter() 时,Tomcat 会解析 queryString 并填充 parameterMap,同时标记 didQueryParameters=true(表示已解析)。
  • 请求结束:调用 recycle() 方法,清空 parameterMap 并重置 didQueryParameters=false,以便下次请求重新解析

那么问题的根本原因即是 Tomcat 的 Request 对象复用机制与异步操作冲突,需避免在请求生命周期外操作 Request

0x01 定位问题

有了大概的问题原因思路之后,开始排查问题发生的位置。由于问题出现的地方都在 job 模块,遂逐个筛选 job 模块用到的 @Async 注解和 CompletableFuture 异步方法,又由于大多注解地方的代码业务比较复杂,遂尝试先测试这些用到异步处理的前端接口。

逐步测试后发现创建作业请求的 /job/submit/create 接口调用后可以复现下一个 GET 请求获取不到 url 中参数的问题。定位到接口中 @Async 注解的 afterSubmitProcess 方法深处, getStdPath 中会执行 scontrol 命令获取作业路径等信息,而命令的调用是通过 Feign 调用管道服务完成,Feign 的调用本质也是构建一个 http request 请求进行 api 调用。

0x02 确认原因

找到具体问题出现的位置即可尝试复现来确定是否是之前提到的 request 重用导致的参数漏解析问题。

分别在 request 解析和回收的位置添加断点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 参数解析
public void handleQueryParameters() {
if (didQueryParameters) {
return;
}

didQueryParameters = true; // 判断参数是否已解析的变量

if (queryMB == null || queryMB.isNull()) {
return;
}

if (log.isDebugEnabled()) {
log.debug("Decoding query " + decodedQuery + " " + queryStringCharset.name());
}

try {
decodedQuery.duplicate(queryMB);
} catch (IOException e) {
// Can't happen, as decodedQuery can't overflow
log.error(sm.getString("parameters.copyFail"), e);
}
processParameters(decodedQuery, queryStringCharset);
}
1
2
3
4
5
6
7
8
9
// request 回收
public void recycle() {
parameterCount = 0;
paramHashValues.clear();
didQueryParameters = false; // 判断参数是否已解析的变量
charset = DEFAULT_BODY_CHARSET;
decodedQuery.recycle();
parseFailedReason = null;
}

debug 后可以注意到前端发起的请求在结束之后 request 被正常释放,而异步线程后来又重新在 Parameters 中解析了一遍参数,把 didQueryParameters 变量重置为 true

下次 GET 请求进入时,拿到 request 后由于 didQueryParameterstrue 而直接跳过了参数解析。

而这里的重新解析实际反正在 Feign 的拦截器中,由于项目使用了 pig-mesh 的微服务框架,跨服务调用时会解析当前请求携带的 token 并传递到下一个请求:

1
2
3
4
5
6
7
8
9
10
// 非 web 请求直接跳过
if (!WebUtils.getRequest().isPresent()) {
return;
}
HttpServletRequest request = WebUtils.getRequest().get();
// 避免请求参数的 query token 无法传递
String token = tokenResolver.resolve(request);
if (StringUtils.isBlank(token)) {
return;
}

0x03 找到原始问题

原来在 pig-mesh 框架中应该是不存在这个问题的,所以还得找一下引起问题的初始位置。

继续排查找到线程装饰器中使用 RequestContextHolder 传递了 request 上下文,也因此导致原来拦截器中非 web 请求直接跳过的逻辑失效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ThreadLocalDecorator implements TaskDecorator {

@Override
public Runnable decorate(Runnable runnable) {
RequestAttributes context = getRequestAttributesSafely();
return () -> {
try {
RequestContextHolder.setRequestAttributes(context);
runnable.run();
} finally {
RequestContextHolder.resetRequestAttributes();
}
};
}

private RequestAttributes getRequestAttributesSafely() {
RequestAttributes requestAttributes = null;
try {
requestAttributes = RequestContextHolder.currentRequestAttributes();
} catch (IllegalStateException e) {
requestAttributes = new NoWebRequestAttributes();
}
return requestAttributes;
}
}

把装饰器注释掉之后测试也可以发现后续请求丢失参数的问题消失。

到这里也可以确定问题触发的必要条件是前端发起的请求 + 异步线程使用 + Feign 服务调用

问题解决

为了避免 XY 问题,首先确认装饰器的添加是为了在异步线程传递 token 解决服务调用的鉴权问题——即原始异步请求通过 Feign 调用其他服务时,token 没有被传递。

那么合适的解决方案是尝试通过 Spring 提供的线程安全的上下文 Security Context 来传递 token。为了在异步线程中传递 Security Context,需要在异步线程配置中添加 DelegatingSecurityContextAsyncTaskExecutor

DelegatingSecurityContextAsyncTaskExecutor 是 Spring Security 提供的一个类,主要用于在异步任务执行时传递 Security Context,确保异步操作中仍能访问当前用户的认证和授权信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
@Bean(name = "commonTaskExecutor")
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
// 核心线程大小 默认区 CPU 数量
taskExecutor.setCorePoolSize(corePoolSize.orElse(cpuNum));
// 最大线程大小 默认区 CPU * 2 数量
taskExecutor.setMaxPoolSize(maxPoolSize.orElse(cpuNum << 1 + 1));
// 异步线程传递 request 上下文有线程安全问题
// taskExecutor.setTaskDecorator(new ThreadLocalDecorator());
// 队列最大容量
taskExecutor.setQueueCapacity(queueCapacity.orElse(1000));
taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
taskExecutor.setAwaitTerminationSeconds(awaitTerminationSeconds.orElse(60));
taskExecutor.setThreadNamePrefix("OG-Thread-");
taskExecutor.initialize();
// 传递 Spring Security Context
return new DelegatingSecurityContextAsyncTaskExecutor(taskExecutor);
}

接下来即可在 Feign 拦截器中从 SecurityContextHolder 获取 token:

1
2
3
4
5
6
7
8
9
10
// 优先从 SecurityContextHolder 中获取 token
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getCredentials() instanceof OAuth2AccessToken) {
OAuth2AccessToken token = (OAuth2AccessToken) authentication.getCredentials();
if (StringUtils.isNotBlank(token.getTokenValue())) {
template.header(HttpHeaders.AUTHORIZATION,
String.format("%s %s", OAuth2AccessToken.TokenType.BEARER.getValue(), token.getTokenValue()));
return;
}
}

这样的话保证了尽可能小的改动,同时从前端发起的请求在异步线程中也可以正常把 token 传递到下一个服务,并且避免了传递 request 可能会导致的 Parameters 参数污染和额外的性能开销问题。

如果真的需要在线程中传递 request,也可以参考 springboot 中如何正确的在异步线程中使用request,通过 AsyncContext 控制 request 的生命周期。但是在本项目中线程持有 request 并非必要且会牵涉到更多改动,遂放弃。

更进一步

记录的排查过程的突破点在于已知问题原因的验证,实际上深入探究的话可以参考这篇:
关于Request复用的那点破事儿。研究明白了,给你汇报一下。

根据上文实际 request 的复用机制是通过 Processor 缓存池实现的

  • Tomcat 的请求处理器(Processor)对象维护一个名为 recycledProcessors 的对象池。
  • 每个请求到来时,Tomcat 会从 recycledProcessors 池中取出一个 Processor 来处理请求,Processor 内部持有对应的 Request 对象。
  • 因此,Request 对象的复用实际上是与 Processor 的复用绑定的,哪个 Processor 被复用,哪个 Request 就被复用。
  • 线程和 Request 之间没有绑定关系,Request 的复用不依赖于特定线程,而是依赖于 Processor 池的状态和调度

recycledProcessors 的重用部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (processor == null) {
processor = recycledProcessors.pop();
if (getLog().isDebugEnabled()) {
getLog().debug(sm.getString("abstractConnectionHandler.processorPop", processor));
}
}
if (processor == null) {
processor = getProtocol().createProcessor();
register(processor);
if (getLog().isDebugEnabled()) {
getLog().debug(sm.getString("abstractConnectionHandler.processorCreate", processor));
}
}

recycledProcessors 的回收部分:

1
2
3
4
5
6
7
8
9
// After recycling, only instances of UpgradeProcessorBase
// will return true for isUpgrade().
// Instances of UpgradeProcessorBase should not be added to
// recycledProcessors since that pool is only for AJP or
// HTTP processors
recycledProcessors.push(processor);
if (getLog().isDebugEnabled()) {
getLog().debug("Pushed Processor [" + processor + "]");
}