跳至主要內容

20、GET 请求参数偶发性丢失问题

安图新大约 5 分钟

20、GET 请求参数偶发性丢失问题

一、问题现象

最近偶遇一诡异棘手问题:一个用于获取 tokenGET 接口,在生产环境不定期偶发出现 参数不存在 的问题。一度怀疑是前端的锅,虽然前端同学再三以人格担保!经过长时间观察,发现每每出现问题时,“再点一下就好了”!错误信息简单明确,是大家熟知的参数缺失异常:

Required request parameter ‘phone’ for method parameter type String is not present

 
 

这是怎么回事呢?这只是再普通不过的一个 GET 接口!

 
 

二、问题分析

2.1 发生时间

由于项目使用的是 Spring Cloud 微服务框架,当请求从浏览器发送过来后,经过了以下步骤:

 
 

顺着这个思路逐层排查:

  • HTTP 请求: F12 查看参数正常,排除。
  • Nginx: 日志打印参数正常,排除。
  • Gateway: 日志打印参数正常,排除。
  • Controller: 参数丢失。。。

所以可以得出结论:参数丢失问题发生在 Spring Cloud 微服务内部

2.2 发生位置

我们进一步分析,在过滤器增加请求参数的打印:

LogFilter.java

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
public class LogFilter extends OncePerRequestFilter {



    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {


        log.info(">>>>>>>>>>【INFO】request.getQueryString(): {}", httpServletRequest.getQueryString());
        log.info(">>>>>>>>>>【INFO】request.getParameter(): {}", httpServletRequest.getParameter("phone"));

        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }
}

再次复现问题后,在同一个 traceId 对应的日志中,打印结果如下:

 
 

可以发现在问题请求中 request.queryString() 正常,而 request.getParameter() 值却没有获取到!

众所周知,SpringBoot 默认内置 tomcat 容器,SpringMVC 则通过 request.getParameter() 方法获取并绑定 Controller 接口参数的。因此,初步判断:在 tomcat 获取 parameter 参数的时候出现了问题

那么,parameter 参数的获取过程是怎样的?

1、 SpringMVC 框架通过DispatcherServlet实现;
2、 Tomcat 接收到外部请求,将由 connector 通过 Processor 受理 http 请求;
3、 SpringMVC 通过 request.getParameter()获取并绑定 Controller 接口参数;
4、 request.getParameter()方法在请求处理过程中仅在第一次调用时通过解析 queryString 获取 parameters 参数值,并设置didQueryParameter=true标识已解析处理;
5、 Http 请求处理完成,processor 通过 release 方法释放连接重置参数属性,request.recycle 方法重置 request 参数属性(注意:这里连接器及 request 对象并不会销毁,connector 再次受理新的请求时,将复用连接器、processor 及 request 对象而非创建);

 
 

2.3 源码解析

下面,我们可以看一些源码的片段来验证一下:

源码 1:SpringBoot 从 request 获取 parameter 参数。

RequestParamMethodArgumentResolve 类的 resovleName() 方法,可以看到这里调用了 request.getParameterValue() 方法。

 
 

源码 2:tomcat 封装了解析参数。

org.apache.catalina.connector.Request 类的 getParameterValues() 方法,request 通过 Parameters 获取 parameter 参数。

 
 
 
 

源码 3:Parameters 从 queryString 解析封装 parameter 参数。

org.apache.tomcat.util.http.Parameters 类的 handleQueryParameters() 方法,可以发现,参数在解析处理后会设置 didQueryParameters 参数为 true。

 
 

源码 4:请求处理结束,重置参数属性,并不销毁对象。

org.apache.tomcat.util.http.Parameters 类的 recycle() 方法。

 
 
 
 

2.4 Tomcat 机制

Tomcat 机制如下:

  • tomcat 可支持多个 service 示例;
  • 每个 service 实例维护了一个包含多个 connector 的连接池;
  • 当 service 接收到了一个 http 请求时,则从 connector 池中获取一个 connector 连接器进行响应处理。
  • connector 连接器是通过 Processor 对应 HTTP 请求进行响应处理。

Processor 封装了 requestresponse 对象,在请求处理开始时进行初始化封装(进封装参数属性,并不创建对象),请求处理完成后,则进行释放重置。(注意:这里的释放仅指重置参数属性,并不销毁对象!

 
 

2.5 原因总结

本次问题的根本原因在于 线程中引用了 request 对象,并在线程中调用了 request.getParameter() 方法使参数属性 didQueryParameter 错误而导致 http 请求无法正确获取参数值。

  • 假设第一次受理 http 请求的连接器为 connector1;
  • 请求 request 在子线程 thread1 中被引用;
  • connector1 完成 http 请求并执行 release 释放连接,这时 request.didQueryParameters 值为 false;
  • 如果子线程 thread1 处理任务的时间较长,调用了 getParameter() 方法,这时 request.didQueryParameters 值将再次被更新为 true;
  • 当 tomcat 再次通过 connector1 受理新的 http 请求时,由于 request.didQueryParameters=true,这时新请求调用 getParameter() 方法将不会再解析 queryString,因而无法正确获取 parameter 参数值。
 
 

三、问题复现

这里为了方便,我们使用 Hutool 的线程池工具。依赖如下:

<!-- Hutool -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.23</version>
</dependency>

复现代码如下:

DemoController.java

import cn.hutool.core.thread.ThreadUtil;
import com.demo.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@RestController
@RequestMapping("/demo")
public class DemoController {



    /**
     * 根据手机号获取token
     */
    @GetMapping("/getToken")
    public Result<Object> getToken(@RequestParam String phone) {


        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        ThreadUtil.execute(() -> {


            RequestContextHolder.setRequestAttributes(attributes);
            ThreadUtil.safeSleep(1000);
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
            System.out.println("********** " + request.getParameter(phone));
        });
        return Result.succeed();
    }

}

使用Jmeter 压测工具,设置 200 线程并发请求:

 
 

压测 http://localhost:8080/demo/test?phone=111111 接口,配置请求信息如下:

 
 

成功复现,结果如下所示:

 
 

四、问题修复

修复这个问题的话有两种方式:

  • 方式一: GET 请求改为 POST 请求,使用 JSON 格式传输数据。

(经过尝试,即使使用 POST 请求,不使用 JSON 格式传输数据的话,还是会丢失参数。)

  • 方式二: 将 tomcat 中间件替换为 undertow 中间件。修改后如下所示:
<dependency>
    <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.yaml</groupId>
            <artifactId>snakeyaml</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

将 tomcat 替换为 undertow 之后,发现不再出现参数丢失的情况。

 
 

整理完毕,完结撒花~ 🌻