主题
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 | 用统一接口调用不同类型的 Handler | RequestMappingHandlerAdapter |
| ArgumentResolver | HTTP 数据 → 方法参数 | @RequestBody、@PathVariable、@RequestParam |
| ReturnValueHandler | 返回值 → 响应 | @ResponseBody、ResponseEntity、视图名 |
| HttpMessageConverter | Java 对象 ↔ Body 字节 | Jackson(JSON)、String、ByteArray |
@ResponseBody 默认返回 200,但不是固定 200:@ResponseStatus、ResponseEntity、@ExceptionHandler 都可以改状态码。旧说法“@ResponseBody 只能 200”不准确。
Filter 与 Interceptor
| 维度 | Filter | Interceptor |
|---|---|---|
| 规范/框架 | 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_matcherjava
// 长期做法:把模式改成 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 + HttpMessageNotWritableException | converter 匹配但序列化抛错 | 看栈里的字段名,通常是 LocalDateTime、循环引用或 getter 抛异常 |
| 400 | converter 匹配但反序列化失败 | 看 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/Shanghaijava
// 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。