跳到正文

Spring MVC 请求链路 ​

一句话:DispatcherServlet 不处理请求,它先按 HandlerMapping 找到 Handler,再用 HandlerAdapter 调用,最后把参数和返回值交给解析器链;换来统一的扩展点,代价是链路上每一环都可能成为 404/400/415 的来源。

适用版本:Spring Framework 6.x / Spring Boot 3.x(默认使用 PathPatternParser 做路径匹配)。

为什么需要 ​

没有统一分发器时,每个 Servlet 都要自己解析 URL、参数、Content-Type、序列化结果。业务代码和协议细节混在一起,鉴权、日志、异常处理也无法统一插入。

Spring MVC 把一次请求拆成固定阶段:找 Handler、找 Adapter、执行拦截器、解析参数、调用方法、处理返回值、渲染或写响应。每个阶段都有接口,框架和业务可以分别扩展。

核心机制 ​

一次请求的完整链路 ​

注意:postHandle 在 Controller 抛异常时不会执行;afterCompletion 无论成功失败都会执行,适合清理 ThreadLocal、记录耗时。

HandlerMapping:不是所有路径都 O(1) ​

RequestMappingHandlerMapping 启动时扫描 @Controller / @RequestMapping,把方法注册到 MappingRegistry。注册表包含按路径、按名称等索引;对固定字面量路径,可以按路径直接定位候选,接近常量时间。

但下面这些情况需要遍历候选并比较:

  • 路径含 {} 模板变量、* 通配符或正则。
  • 同一路径要按 HTTP Method、params、headers、consumes、produces 区分。
  • 多个模式都能匹配,需要选“最具体”的一个。

所以“URL 查表 O(1)”只在最简单的情况下成立;含模板和条件时,复杂度随候选数量增长。Spring 6 默认用 PathPatternParser,匹配语义和旧的 AntPathMatcher 有差异,升级时以官方文档为准。

默认的 HandlerMapping 按 order 值从小到大尝试,RequestMappingHandlerMapping 通常最先匹配注解 Controller;后面还有静态资源、欢迎页、Bean 名映射等。具体顺序以当前版本源码/文档为准,不要硬编码数字。

HandlerAdapter 与参数/返回值 ​

组件作用典型实现
HandlerMapping请求 → Handler + 拦截器链RequestMappingHandlerMapping
HandlerAdapter用统一接口调用不同类型的 HandlerRequestMappingHandlerAdapter
ArgumentResolverHTTP 数据 → 方法参数@RequestBody、@PathVariable、@RequestParam
ReturnValueHandler返回值 → 响应@ResponseBody、ResponseEntity、视图名
HttpMessageConverterJava 对象 ↔ Body 字节Jackson(JSON)、String、ByteArray

@ResponseBody 默认返回 200,但不是固定 200:@ResponseStatus、ResponseEntity、@ExceptionHandler 都可以改状态码。旧说法“@ResponseBody 只能 200”不准确。

Filter 与 Interceptor ​

维度FilterInterceptor
规范/框架Servlet 规范Spring MVC
能否注入 Spring Bean可以,但取决于注册方式可以
拦截范围所有进入该 Filter 的请求进入 DispatcherServlet 且找到 Handler 的请求
能否拿到 Handler不能能
中断方式不调用 chain.doFilter()preHandle() 返回 false

编码、XSS、CORS 这类不依赖 Handler 的逻辑放 Filter;鉴权、日志、权限校验需要 Handler 信息时放 Interceptor。

实战场景 ​

一、升级 Spring 6 后一批接口集体 404 ​

现象:从 Boot 2.7 升到 3.2 的当天,约 30 个对外接口开始返回 404,全部来自两个老模块;开发自测用的是新写的接口,一切正常。老客户端无法升级,必须先止血。

排查:404 只有一个含义——没有 Handler 匹配。差别在“为什么以前匹配、现在不匹配”。

定位层取证据判定
路径是否注册curl /actuator/mappings,按 URL 搜列表里没有该模式 → 根本没扫到 Controller
扫描范围@SpringBootApplication(scanBasePackages=...) 与 Controller 包名覆盖不到 → 注册缺失
匹配语义差异对比报错路径:是否带 //、尾斜杠、%2F、{} 与 **Spring 6 默认 PathPatternParser,** 只允许出现在模式末尾,尾斜杠与编码字符处理也不同
是否被静态资源接走spring.web.resources.add-mappings 与 No mapping for GET ... 日志命中静态资源 Handler 时也是 404 表现
请求是否进入分发器打开 logging.level.org.springframework.web.servlet.DispatcherServlet=DEBUG无日志 → 被 Filter 或网关提前拦截

本例命中的是匹配语义:老模块用 @GetMapping("/a/**/b") 和带尾斜杠的调用,PathPatternParser 下 ** 不在末尾会启动告警并改变匹配结果,尾斜杠默认不再等价。

对症做法:

properties
# 过渡期先恢复旧语义,作为止血而不是长期方案
spring.mvc.pathmatch.matching-strategy=ant_path_matcher
java
// 长期做法:把模式改成 PathPatternParser 支持的写法,显式区分变量段
@GetMapping("/a/{x}/b")                 // 明确一层
@GetMapping("/files/**")                 // ** 只在末尾,用 PathContainer 取剩余路径

止血后要补齐回归面:把 /actuator/mappings 输出的全部模式导成清单,对每个模式生成一条“正常路径 + 尾斜杠 + 编码字符”的用例;只测新接口是不够的,这类问题只出现在老写法上。

复盘要点:匹配语义是版本行为,不是配置偏好。升级说明里要单列“路由匹配变更”,并把 mappings 快照做发布前后 diff;ant_path_matcher 是兼容路径,Boot 后续版本会弱化它,不要当成终态。

二、415 / 406 / 500:Converter 链上的三种失败 ​

现象:财务系统对接批次里出现三种不同表现——同一份代码、三个不同报错。逐个排查后发现都落在 HttpMessageConverter 上。

排查:状态码直接指向失败环节,先分类再取证。

状态码失败环节取证据
415请求 Content-Type 没有 converter 支持,或 consumes 条件不匹配抓请求头;查 @RequestMapping(consumes=...);确认 body 非空
406返回值能序列化,但没有 converter 满足 Accept抓 Accept,常见于客户端要 text/xml 而服务端只有 Jackson
500 + HttpMessageNotWritableExceptionconverter 匹配但序列化抛错看栈里的字段名,通常是 LocalDateTime、循环引用或 getter 抛异常
400converter 匹配但反序列化失败看 DTO 字段名与 JSON 是否一致、是否有 @JsonIgnoreProperties(ignoreUnknown)

批次里三种命中的原因分别是:客户端用 application/x-www-form-urlencoded 调 @RequestBody 接口(415);网关统一改写了 Accept 为 *;/* 之类的非法形式(406);DTO 里新增了 LocalDateTime 而 Jackson 的 JSR-310 模块没生效(500)。

对症做法:

java
// 1. 406:让服务端决定表示,不让客户端的 Accept 左右错误结构
//    异常统一映射成 406 的稳定响应,而不是默认 HTML 错误页
@RestControllerAdvice
class ApiErrorAdvice {
    @ExceptionHandler({ HttpMediaTypeNotAcceptableException.class,
                        HttpMediaTypeNotSupportedException.class })
    ResponseEntity<ApiError> onMediaType(Exception e) {
        return ResponseEntity.status(e instanceof HttpMediaTypeNotSupportedException ? 415 : 406)
                .body(ApiError.of("UNSUPPORTED_MEDIA_TYPE", e.getClass().getSimpleName()));
    }
}
properties
# 2. 500:时间字段统一策略,避免每个 DTO 手写格式化
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=Asia/Shanghai
java
// 3. 400:入站 DTO 容忍未知字段,出站契约由 DTO 控制
@JsonIgnoreProperties(ignoreUnknown = true)
public class FinanceRequest { ... }

@ResponseBody 的状态码不是固定 200:@ResponseStatus、ResponseEntity 和 @ExceptionHandler 都能改写。排查时先确认状态码来自哪一层,再决定改 Converter 还是改异常处理。

复盘要点:对接故障的信息量集中在请求头。给对外接口约定固定 Content-Type/Accept,并在联调清单里写“错误码语义”一栏(400 参数、401 未认证、415 表示不支持、429 限流、500 未知);契约文档不含状态码约定,就会反复出现这一类问题。

三、traceId 串号:清理点写在 postHandle 里 ​

现象:全链路日志里出现请求 A 带着请求 B 的 traceId,一天 20 多次(经验值,需校准),只在压测和异常高发时出现;单线程调试永远不复现。

排查:这类问题的证据在“哪个环节没执行清理”。

定位层取证据判定
清理写在哪搜 TraceInterceptor.postHandle / afterCompletion 里的 ThreadLocal.remove()写在 postHandle → 异常路径不执行
preHandle 是否返回 false在返回 false 前是否已 set 了上下文set 之后返回 false,该拦截器的 afterCompletion 不会被调用
线程是否复用容器线程名(http-nio-8080-exec-N)与 ThreadLocal 值组合出现同一线程先后处理两个请求 → 残留被读到
是否异步返回 Callable/DeferredResult 时清理线程与工作线程不同RequestContextHolder 属性未 inheritable

链路里的事实是:postHandle 在 Controller 抛异常时不执行,afterCompletion 才无论成败都执行;而 preHandle 返回 false 时,该拦截器自身的 afterCompletion 也不会执行,只有之前已通过的拦截器会收到。把上下文 set 放在 preHandle、清理放在 postHandle,异常与鉴权拦截两条路径都会留下脏值。

对症做法:

java
// 1. 清理下沉到 Filter 的 finally:所有进入容器的请求都保证经过
public class TraceFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest r, HttpServletResponse s, FilterChain c)
            throws ServletException, IOException {
        TraceContext.set(newTraceId());
        try { c.doFilter(r, s); }
        finally { TraceContext.clear(); }   // 不放 postHandle,不放拦截器
    }
}

// 2. 拦截器里 set 与清理必须同一侧:set 之前先判空,返回 false 前主动清
public boolean preHandle(HttpServletRequest r, HttpServletResponse s, Object h) {
    if (!authed(r)) { TraceContext.clear(); return false; }   // 自己 set 的自己清
    return true;
}
public void afterCompletion(HttpServletRequest r, HttpServletResponse s, Object h, Exception ex) {
    TraceContext.clear();   // 成功失败都执行,包括 ex != null
}

// 3. 异步请求显式传播,而不是靠线程继承
//    Callable / DeferredResult 用 AsyncTaskInterceptor 或在返回前把上下文放进任务对象

判定规则可以背下来:绝对拦截用 Filter,需要 Handler 信息用 Interceptor;上下文的生命周期以“最外层保证执行的 finally”为界。

复盘要点:串号在高并发才暴露,所以要有对应的用例:用两个租户并发压 1 万次,断言每条日志的 traceId 与其请求体租户一致。把 ThreadLocal 必须成对清理写成静态检查(set 与 clear 同文件),比在 review 里靠人记住有效。相关做法见 企业级可观测性与故障定位。

最小上手 ​

Spring Boot 3.x,一个 Controller 加三条 curl,观察链路不同环节产生的状态码:

java
@RestController
class HelloController {
    @GetMapping("/hello/{name}")
    public Map<String, Object> hello(@PathVariable String name,
                                     @RequestParam(defaultValue = "1") int n) {
        return Map.of("name", name, "n", n);
    }
}
bash
curl -i 'http://localhost:8080/hello/abc?n=2'
# HTTP/1.1 200,Body: {"name":"abc","n":2}

curl -i 'http://localhost:8080/hello/abc?n=x'
# HTTP/1.1 400,参数类型转换失败

curl -i -X POST 'http://localhost:8080/hello/abc'
# HTTP/1.1 405,HTTP Method 不匹配

再加一行日志验证 Handler 匹配:

properties
logging.level.org.springframework.web.servlet.DispatcherServlet=DEBUG

日志里会看到 GET "/hello/abc", parameters={n=[2]} 以及 Completed 200 OK;把路径改成不存在的 /nope,会看到没有 Handler 匹配,最终由错误处理返回 404。

生产动作 ​

  • spring.mvc.throw-exception-if-no-handler-found=false(Boot 3.x 默认 false):默认没找到 Handler 时直接 sendError(404);改成 true 后抛 NoHandlerFoundException,可以进 @ExceptionHandler 统一处理。改之前确认没有依赖默认 404 行为。
  • spring.web.resources.add-mappings=true(默认 true):静态资源映射会参与 Handler 匹配;接口 404 时先确认请求是不是被静态资源 Handler 接走了。
  • 404 / 405 / 415 / 406 的区分:404 是路径没匹配;405 是路径匹配但 Method 不允许;415 是 Content-Type 与 @RequestBody 的 converter 不匹配;406 是 Accept 与返回值 converter 不匹配。先看状态码,再查对应的匹配条件。
  • 排障路径:现象“接口 404” → 查启动日志里 RequestMappingHandlerMapping 是否注册了该路径 → 查 @RequestMapping 的 value、method、consumes、produces → 查 scanBasePackages 是否覆盖 Controller → 用 Actuator /actuator/mappings 对照。
  • 排障路径:现象“参数为 null” → 查参数是否被 @RequestParam(required=false) 或 defaultValue 处理 → 查 @RequestBody 的 JSON 字段名与 DTO 是否一致 → 查自定义 HandlerMethodArgumentResolver 是否改变了默认行为。
  • 统一异常:用 @RestControllerAdvice + @ExceptionHandler 把参数转换异常、校验异常、业务异常映射成稳定状态码;不要在每个 Controller 里手写 try/catch。

边界 ​

  • 不是所有请求都走 Interceptor:静态资源可能由其他 Handler 处理;Filter 才是所有请求的入口。需要“绝对拦截”时用 Filter。
  • @ResponseBody 不等于 REST:它只负责把返回值写入 Body;状态码、Header、错误结构仍要显式设计。
  • 同步模型有线程上限:一个请求占一个容器线程;长耗时或高并发场景要评估异步 Servlet、WebFlux 或超时配置,不要只加线程数。
  • 路径匹配规则会随版本变化:Spring 6 默认 PathPatternParser,旧项目从 AntPathMatcher 迁移时要回归通配符、尾斜杠、编码路径等边界。
  • 不要在 Controller 里做事务和重业务:Controller 负责协议适配,事务边界和业务编排放在 Service;否则 404/参数错误也会进入事务。

讲清楚 ​

Spring MVC 的入口是 DispatcherServlet,它本身不处理业务,只做分发。一次请求先经过 Filter,再进入 doDispatch():HandlerMapping 找到 Handler 和拦截器链,HandlerAdapter 负责用统一方式调用,参数解析器把 URL、Header、Body 转成方法参数,返回值处理器和 HttpMessageConverter 把结果写回响应,最后按顺序执行 postHandle 和 afterCompletion。

两个常见误解要纠正:一是“URL 查表一定 O(1)”,只有固定字面量路径接近常量时间,模板、通配符和 Method/Header 条件都要比较候选;二是“@ResponseBody 固定 200”,状态码可以被 ResponseEntity、@ResponseStatus 和异常处理器改写。

排障时先看状态码:404 找映射,405 看 Method,415 看 Content-Type,406 看 Accept。再用 DispatcherServlet 的 DEBUG 日志和 Actuator mappings 确认请求到底匹配到了哪个 Handler。

把一门技术讲透到既懂也会用