踩坑实录:GET 请求参数偶发丢失,"再点一下就好"?我是怎么把它复现成必现的

禾几海
禾几海
发布于 2026-02-26 / 9 阅读
0
0

一个再普通不过的 GET 接口,不定期偶发报"参数不存在",你让他重试一次,好了。前端信誓旦旦传参正确,后端代码也没动过,日志里连个异常都查不到。这种问题,排查起来最磨人。

最近我们就撞上了这么一档子事。从"甩锅给前端"到"定位到 Tomcat 连接器复用",花了整整三天。这篇文章不打算只讲"是什么",重点讲"怎么想到的"——怎么把问题范围一步步圈死,怎么把偶发复现成必现。这两套本事,比这次的答案本身值钱。


一、问题现象:参数时有时无

背景是一个用于获取公钥数据的接口,典型到不能再典型的 GET 请求:

GET /api/public-key?userId=10001&channel=app

报错信息也很标准,就是大家熟知的参数缺失异常:

Required request parameter 'userId' for method parameter type String is not present

诡异的地方有三点:

  1. 偶发性:不是必现,可能一天出现几次,也可能几天不出现;
  2. 无规律:流量高低峰都可能发生,跟并发量没有明显相关性;
  3. 重试即恢复:报错后客户端再点一次,同一参数、同一请求,结果就正常了。

这种"时好时坏"的问题,多半是某个共享状态被污染了,系统本身反而没什么毛病。


二、定位:问题卡在 Spring 绑定参数之前

拿到现象,先做一次边界判断:前端传参没问题,后端业务处理也没问题,那问题只可能出在从请求进入到参数注入方法入参的这段过程。这个判断把排查范围从"整条链路"压缩到了"Spring 框架绑定参数"这一小段。

然后按请求链路逐层排除,把范围继续圈小:

排查层 检查结果 结论
HTTP 请求层 抓包确认请求 URL 中参数完整 已排除
Nginx 层 接收正常,转发正常 已排除
Gateway 层 url 与 queryString 完整透传 已排除
Controller 层 参数绑定为空 问题在这里

这里有条关键线索:同样的请求,在 Gateway 层能看到完整的 queryString,但进入 Controller 后参数却绑不上。

进一步在服务内部打印验证:

  • request.getQueryString() → 有值,参数完整
  • request.getParameter("userId") → null,拿不到

queryString 是好的,getParameter 却拿不到值。这说明问题不在网络层,在服务内部,准确说是 Tomcat 解析请求参数那一环出了问题。

到这里,定位收敛成一个待验证的假设:参数解析环节被"跳过"了,像是有人提前把"已解析"的标记置位,导致正常请求不再解析 queryString。下一步要做的就是验证它。


三、验证:把偶发复现成必现(全文最值钱的部分)

偶发问题最折磨人的是"复现不了、修了也验证不了"。所以开工第一件事不是改代码,是先把复现做出来。这套"把偶发变必现"的打法不限于这个 bug,任何"偶发 + 并发"类问题都能套用,建议收藏。

3.1 先梳理参数解析流程

看这个参数到底是怎么从 HTTP 请求一路绑定到 Controller 方法参数上的:

HTTP 请求 → Tomcat Connector → DispatcherServlet
  → RequestParamMethodArgumentResolver.resolveName()
    → request.getParameter() → Parameters.handleQueryParameters()
      → didQueryParameters 标志位
→ 绑定到 Controller 方法参数

这一步不是为了写代码,是为了确认"解析只发生一次、且有状态位"这个事实,它直接决定后面的复现思路。

3.2 编写最小化复现代码

基于上面的假设(异步线程持 request 调 getParameter),先写一段最小可复现的代码,在独立环境验证机制是否成立:

// 最小化复现:模拟"异步线程晚于请求回收后调用 getParameter"
@RestController
public class ReproController {

    private final ExecutorService pool = Executors.newFixedThreadPool(1);

    @GetMapping("/repro")
    public String repro(@RequestParam("userId") String userId, HttpServletRequest request) {
        pool.submit(() -> {
            sleep(500);                        // 模拟慢任务,等请求回收
            request.getParameter("userId");    // ⚠️ 在已回收的 request 上触发解析
        });
        return "ok";
    }

    private static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ignored) { }
    }
}

用 JMeter 对这个最小接口高频并发调用,能复现出 Required request parameter ... is not present,证明假设成立。

3.3 在真实系统中复现

最小化代码验证了机制,但还得确认线上代码真的踩中了这个坑。这一步的关键是放大触发概率——问题之所以偶发,是因为"慢异步线程恰好撞上同一连接器被复用"的窗口太小,那就把这个窗口撑大:

① 缩小 Tomcat 线程数。线程池越小,连接器复用越频繁,被污染的 request 撞上下一个请求的概率越大:

# application.yml:临时缩小 Tomcat 线程数,加速连接器复用,放大问题
server:
  tomcat:
    threads:
      max: 8          # 默认 200,调小后连接器复用频率大幅上升
      min-spare: 2

② 检索出问题代码的位置。全局搜索异步场景中引用 HttpServletRequest / HttpServletResponse / HttpSession 的地方,重点看线程池、@Async、定时任务、MQ 消费回调:

grep -rn "HttpServletRequest\|getParameter\|request" --include="*.java" | grep -i "async\|thread\|task\|submit\|executor"

③ JMeter 高频并发调用。对目标接口配置高频并发(如 100 线程 × 循环压测),反复触发"慢异步线程恰好撞上连接器复用"的窗口:

线程组:线程数 100,循环次数持续运行
请求:GET /api/export?bizId=xxx
断言:检查是否出现 "Required request parameter" 错误

运行一段时间后,错误率稳定出现,说明线上问题已稳定复现。

3.4 修复后回归验证

确认可以稳定复现之后,再做问题修改;修复完成后,用完全相同的复现手段回归验证:

  • 恢复/保持缩小的 Tomcat 线程数不变(保持相同的压力条件);
  • 用同一份 JMeter 压测脚本跑修复后的代码;
  • 预期结果:错误率为 0,兜底方案的 warning 日志也不再出现。

相同手段、相同压力、前后对比,才能证明"问题真的修好了",而不是"这次恰好没撞上"。


四、原理:参数是怎么绑定到 Controller 的

复现成功,只说明"现象存在、能触发",还得解释"为什么"。回到参数绑定本身。

完整的绑定链路:

HTTP 请求
  → Tomcat Connector (接收)
  → Processor (受理请求)
  → DispatcherServlet (SpringMVC 分发)
  → RequestParamMethodArgumentResolver (参数解析)
  → Controller 方法参数

这里头最关键的一步是,SpringMVC 的 RequestParamMethodArgumentResolver 在绑定 @RequestParam 时,底层调的是 request.getParameter()

request.getParameter() 在 Tomcat 内部是这么走的:

Request.getParameterValues()
  → Parameters.handleQueryParameters()   // 解析 queryString 封装为 parameter

Parameters.handleQueryParameters() 解析完成后,会设置一个标志位 didQueryParameters = true,表示"queryString 已经解析过了"。getParameter() 只在第一次调用时才真正去解析 queryString,之后再调用直接读解析结果:

public String getParameter(String name) {
    // 伪代码,省略细节
    if (!parameters.isDidQueryParameters()) {
        handleQueryParameters();  // 仅首次解析
        parameters.setDidQueryParameters(true);
    }
    return parameters.getParameter(name);
}

也就是说,一次请求生命周期内,queryString 只解析一次,开关就是 didQueryParameters


五、根因:连接器复用 + 线程污染

原理讲清楚了,最后一个问题:谁把 didQueryParameters 提前置位了?答案藏在 Tomcat 的一个底层机制里:连接器(Connector)和 Request 对象是池化复用的,每次请求并不新建。

Tomcat 处理请求的过程大致如下:

  1. 每个 Service 维护一个 Connector 连接池;
  2. 收到 HTTP 请求后,从连接池中取出一个 Connector 处理;
  3. Connector 通过 Processor 封装 request/response 对象;
  4. 请求处理完成后,Processor 调用 release() 释放连接,request.recycle() 重置 request 参数属性;
  5. 但 Connector、Processor、Request 对象本身并不销毁,而是放回池中,下次请求继续复用。

把现象和机制拼起来,根因就清楚了:

如果在子线程中引用了 request 对象,又在那里调用了 request.getParameter(),就会把 didQueryParameters 标志位污染掉,连累连接器复用时下一个请求的参数解析。

完整的事故时序如下:

sequenceDiagram autonumber participant C as 客户端 participant CT as Tomcat Connector participant RQ as Request 对象(池化复用) participant T as 子线程 Thread-1 participant N as 下一次请求 rect rgb(240, 128, 128) Note over C,T: 第一次请求 C->>CT: GET /api/public-key?userId=10001 CT->>RQ: 从池中复用 request<br/>didQueryParameters = false RQ->>RQ: getParameter() 首次调用<br/>解析 queryString,置 true RQ-->>T: 子线程引用了 request 对象 CT->>RQ: 请求处理完成<br/>release()/recycle() 重置参数 Note over T: 子线程耗时任务仍在执行... T->>RQ: ⚠️ 调用 request.getParameter()<br/>didQueryParameters 被再次置为 true end rect rgb(144, 238, 144) Note over C,N: 第二次请求(复用同一 Connector) C->>CT: GET /api/public-key?userId=10002 CT->>RQ: 复用同一 request 对象 RQ->>RQ: didQueryParameters = true<br/>跳过 queryString 解析 → 参数丢失 ❌ RQ-->>C: Required request parameter 'userId'<br/>is not present end

拆开看每一步:

第一次请求正常结束:

  • request 的 didQueryParametersrecycle() 时被重置为 false,对象放回池中;
  • 此时子线程还在跑,它手里拿着 request 的引用。

子线程的"暗算":

  • 如果子线程任务耗时较长,在请求已经回收之后才调用 request.getParameter(),didQueryParameters 会被再次置为 true;
  • 而这次置位发生在 recycle 之后,没人再给它重置了。

第二次请求被坑:

  • 连接池把同一个 request 对象分配给新请求;
  • 新请求的 getParameter() 一看 didQueryParameters = true,以为 queryString 已经解析过,直接跳过解析;
  • 结果参数取不到,抛出 Required request parameter ... is not present

这就是"再点一下就好"的原因:第二次请求大概率被分配到了另一个干净的连接器,自然就正常了。至于为什么偶发,因为只有当慢子线程恰好撞上同一连接器被复用的时机,才会触发。

我们实际遇到的触发场景:导出接口

这次线上事故的触发场景很典型,就是导出接口:

  1. 接口收到请求后,把耗时的数据导出任务丢给异步线程,接口立即正常返回;
  2. 异步线程中持有 request 引用,在导出数据期间调用 request.getParameter() 拿 URL 请求参数;
  3. 此时 request 对象大概率已被连接器回收,并复用于其他正常请求——异步线程的这次调用,相当于替"别的请求"提前触发了参数解析,置位了 didQueryParameters;
  4. 等那个正常请求继续往下走、轮到它自己解析参数时,发现已经"解析过"了,直接跳过,参数丢失,报 Required request parameter ... is not present

整个链路环环相扣,缺一环都不炸,所以它极度偶发,难复现。


六、问题代码长什么样

这类问题最常见的产生场景:在 Controller 里起了异步任务,子线程里直接用到了 request。

// ❌ 问题代码:子线程中引用了 request
@RestController
public class PublicKeyController {

    @Autowired
    private HttpServletRequest request;

    @GetMapping("/api/public-key")
    public String getPublicKey(@RequestParam("userId") String userId) {
        // 起了个异步任务,直接引用 request
        CompletableFuture.runAsync(() -> {
            // 子线程里再次调用 getParameter(),污染 didQueryParameters
            String uid = request.getParameter("userId");
            doSomethingSlow(uid);
        });
        return "ok";
    }
}

还有一个隐蔽变体:把 request 传给了自己封装的工具类、异步执行器,或者存在 ThreadLocal / 静态变量里被别的线程读取:

// ❌ 同样危险:request 被"传递"给异步组件
@GetMapping("/api/public-key")
public String getPublicKey(@RequestParam("userId") String userId) {
    AsyncTaskExecutor.submit(() -> {
        return someService.handle(request);  // request 逃离请求线程
    });
    return "ok";
}

而导出接口是这类问题的重灾区,导出天然要"快速返回 + 异步干活",request 逃逸几乎是必然:

// ❌ 导出接口:接口立即返回,异步线程里继续操作 request
@GetMapping("/api/export")
public String export(@RequestParam("bizId") String bizId) {
    // 异步导出任务,request 被一并传入
    exportTaskExecutor.submit(() -> exportService.doExport(request));
    return "ok";  // 接口已返回,但异步线程还握着 request
}

doExport 内部如果在某个时机调用了 request.getParameter(),就精准踩中了第五节描述的雷。


七、修复:根治 + 兜底双管齐下

这类问题不能只靠改一处收工,建议两条腿走路:先根治,再加兜底。

7.1 方案一(根治):提前提取参数,传值不传引用

修复方案一句话就能说清:别让 request 离开请求线程。特别是导出、定时任务这类"接口快速返回、异步慢慢干活"的场景,把需要的参数在接口返回之前取好,以值的形式交给异步线程。

正确的做法是在请求线程内把需要的参数先取出来、以值的形式传给子线程:

// ✅ 正确做法:请求线程内取值,子线程只拿值
@GetMapping("/api/export")
public String export(@RequestParam("bizId") String bizId) {
    // 参数在请求线程内已绑定好,直接传值,不再传 request
    exportTaskExecutor.submit(() -> exportService.doExport(bizId));
    return "ok";
}

如果子线程确实需要多个参数,可以封装成一个 DTO/上下文对象,在请求线程内一次性构建好:

// ✅ 推荐:构建不可变上下文,子线程只消费值
@GetMapping("/api/public-key")
public String getPublicKey(@RequestParam("userId") String userId,
                           @RequestParam(value = "channel", required = false) String channel) {
    PublicKeyContext ctx = new PublicKeyContext(userId, channel); // 请求线程内构建
    CompletableFuture.runAsync(() -> someService.handle(ctx));     // 子线程只消费值
    return "ok";
}

整改的时候要全面排查后台定时任务、异步导出、消息监听这些场景,凡是用了 request / response / session 的地方,一律改成进入异步逻辑之前先把参数取出来。

7.2 方案二(兜底):拦截器检测 + 包装类重解析

根治方案要把所有异步场景的代码都改一遍,短期不一定改得完,线上随时可能再踩。所以再加一层兜底:用拦截器判断参数是不是已经被解析了,如果是(queryString 有值但 getParameter 为空),就换一个新的 request 包装类重新解析,同时打一条 warning 日志。参数丢失从异常变成可自愈、可观测。

兜底原理:正常请求中,只要 queryString 有值,getParameterMap() 必然非空。一旦出现"queryString 有值但参数表为空",几乎可以断定 didQueryParameters 被异步线程提前置位、解析被跳过。此时用包装类绕过 Tomcat 的参数缓存,直接手动解析 queryString 兜底。

// ✅ 兜底方案:请求包装类,手动重新解析 queryString
public class ReparsedRequestWrapper extends HttpServletRequestWrapper {

    private final Map<String, String[]> reparsedParams = new HashMap<>();

    public ReparsedRequestWrapper(HttpServletRequest request) {
        super(request);
        parseQueryString(request.getQueryString());
    }

    private void parseQueryString(String queryString) {
        if (StringUtils.hasText(queryString)) {
            for (String pair : queryString.split("&")) {
                int idx = pair.indexOf('=');
                if (idx > 0) {
                    String key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);
                    String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
                    reparsedParams.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
                }
            }
        }
    }

    @Override
    public String getParameter(String name) {
        String[] values = reparsedParams.get(name);
        return (values != null && values.length > 0) ? values[0] : null;
    }

    @Override
    public String[] getParameterValues(String name) {
        return reparsedParams.get(name);
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        return Collections.unmodifiableMap(reparsedParams);
    }

    @Override
    public Enumeration<String> getParameterNames() {
        return Collections.enumeration(reparsedParams.keySet());
    }
}

拦截器负责判断与包装:

// ✅ 兜底方案:拦截器检测参数是否被跳过解析,已跳过则包装重解析 + 告警
@Slf4j
@Component
public class ParameterReparseInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String queryString = request.getQueryString();
        // 判定:queryString 有值,但参数表为空 → 说明解析被提前置位跳过
        if (StringUtils.hasText(queryString) && request.getParameterMap().isEmpty()) {
            log.warn("[参数兜底] 检测到 request 参数解析被跳过, queryString='{}' 但参数为空, 包装重解析", queryString);
            // 将包装类放入 request attribute, 供后续读取; 此处按需选择替换方式
            ReparsedRequestWrapper wrapper = new ReparsedRequestWrapper(request);
            // 实际替换 request 需结合 Spring 机制(如 RequestContextHolder / 自定义解析器),
            // 以下给出核心思路, 具体接入方式按项目实际配置
            request.setAttribute(ReparsedRequestWrapper.class.getName(), wrapper);
        }
        return true;
    }
}

注意:拦截器能检测、能告警,但要让包装类真正接管后续 @RequestParam 的解析,还得把包装后的 request 换进 Spring 的请求上下文。常见做法是结合 RequestContextHolder 或者自定义 HandlerMethodArgumentResolver 从包装类取参;更彻底一点,在 Filter 层用 chain.doFilter(wrapper, response) 直接替换 request。兜底层怎么接,看项目现状,核心是检测、重解析、告警三件事。

7.3 效果对比

  • 方案一消除根因,异步任务不再碰 request,一劳永逸,但得把存量异步代码全部改完;
  • 方案二兜住漏网之鱼,线上偶发时自动重解析不再报错,同时 warning 日志持续暴露问题点,反向逼着方案一整改;
  • 实际落地建议:方案二先行止血,方案一限期整改,用 warning 日志的数量当整改进度。

改完问题彻底消失,测试验证也一致。


八、避坑总结

这次踩坑,最值钱的收获是几条编码红线:

  1. 牢记 Tomcat 复用 Connector 的机制。Connector、Processor、Request 对象都是池化复用、不销毁的,任何"请求结束后还能用"的假设都是危险的。
  2. 不要在线程里引用 request 等任何 Tomcat 相关组件。不只是 HttpServletRequest,HttpServletResponseHttpSession 这类绑定在请求生命周期上的对象,都不能逃离请求线程。
  3. 异步任务只传值,不传引用。子线程需要的参数,在请求线程内先提取成 String、基本类型或 DTO,子线程只消费值。
  4. 排查"偶发性 + 重试即恢复"的问题,思路要放宽到共享状态。网络层、网关层逐层排除后,往"对象复用""线程污染""状态未重置"这些方向想,别反复怀疑前端。
  5. queryString 有值而 getParameter 为空,基本可以断定是解析状态被提前置位了,这是定位这类问题最直接的抓手。
  6. 导出、定时任务类接口是重灾区。这类接口天然"快速返回 + 异步干活",request 极易逃逸到异步线程。凡是丢给线程池、定时任务、MQ 消费者的活,参数必须在进入异步逻辑之前取好。
  7. 兜底策略不能代替根治。拦截器重解析只是止血,让线上不再报错;warning 日志是持续的体检报告,反推异步代码整改,两条腿走路才能收尾。

最后想说:这类问题难就难在三个"没有":没有报错日志,无法稳定复现,和并发强相关。但原理一旦讲透,说白了就是对象的生命周期边界没守住。写并发代码时,永远问自己一句:这个对象会不会被别的线程碰?它活着的时候,别让它跑出去。

如果已经线上踩了,也不用慌:根治是传值不传引用,兜底是拦截器检测加包装类重解析加 warning 告警,两头都堵上,问题就从"偶发事故"变成"可观测、可自愈"了。而这一切的前提,是先学会把偶发复现成必现——那是整场排查里最值钱的一步。


评论