概述
通过前面十余篇文章的深入剖析,Spring Web 层从启动、请求处理、参数绑定、消息转换到异步模型、响应式引擎的完整知识图谱已经呈现。本文将前面的正向设计知识转化为逆向排错能力,集中曝光那些反复出现、容易导致生产事故的 Web 层反模式,并提炼出一套以 Actuator 端点、日志、线程堆栈和条件断点为核心的标准化诊断工具箱。
Spring Web 框架以其强大的抽象能力和高度的可扩展性著称,但正是这些复杂的调度链——从 Filter 到 DispatcherServlet,从 HandlerMapping 到 HandlerAdapter,从 HttpMessageConverter 到 ViewResolver——使得问题排查变得极具挑战性。一个 404 错误可能源自路由冲突、过滤器拦截,甚至是父子容器配置失误;一个 415 错误可能是消息转换器缺失、Content-Type 头错误或者参数注解误用。本文将 Spring Web 开发中常见的 21 个反模式归纳为九大领域,每个反模式都结合前文讲过的核心链路进行深度剖析,并提炼出以 Actuator 端点、日志、线程堆栈和条件断点为核心的通用排查方法,帮助开发者在面对复杂的 Web 问题时快速定位根因。
核心要点
- 反模式九大领域:请求路由与映射、消息转换与序列化、拦截器与过滤器、异步处理、异常处理、WebFlux 响应式、父子容器与上下文、安全与并发、静态资源与缓存。
- 统一剖析结构:每个反模式都按照“错误示例→现象描述→排查思路→根因分析→修正方案→最佳实践”的固定结构展开,确保诊断路径清晰可复现。
- 诊断工具箱:整合 Actuator(
/mappings、/httptrace、/metrics)、容器访问日志、jstack线程栈、ReactorHooks.onOperatorDebug、BlockHound、条件断点等工具,形成一套从现象到根因的标准化决策树。 - 根因溯源:所有反模式的根因都直接回溯到前文讲解过的
DispatcherServlet.doDispatch、AbstractMessageConverterMethodProcessor.writeWithMessageConverters、WebAsyncManager.startCallableProcessing等核心源码机制,形成“正向学习→逆向排错”的完整闭环。
文章组织架构图
flowchart TD
subgraph 1 [反模式总览与分类]
direction LR
A1[九大领域] --> A2[21个典型案例] --> A3[风险等级与现象映射]
end
subgraph 2 [根因剖析模块]
direction TB
B1[2. 请求路由与映射<br/>案例1-3]
B2[3. 消息转换与序列化<br/>案例4-6]
B3[4. 拦截器与过滤器<br/>案例7-9]
B4[5. 异步处理<br/>案例10-12]
B5[6. 异常处理<br/>案例13-14]
B6[7. WebFlux与响应式<br/>案例15-16]
B7[8. 父子容器与上下文<br/>案例17]
B8[9. 安全与并发<br/>案例18-19]
B9[10. 静态资源与缓存<br/>案例20-21]
end
subgraph 3 [11. 诊断工具集与标准化排查流程]
direction LR
C1[Actuator端点] --> C2[日志与线程栈] --> C3[响应式诊断工具] --> C4[决策树流程图]
end
subgraph 4 [12. 面试高频专题]
D1[20+高频面试题] --> D2[系统设计题]
end
1 --> 2 --> 3 --> 4
架构图说明
- 总览说明:全文 12 个模块从反模式分类全景出发,首先构建九大领域、21 个案例的风险矩阵(模块 1)。随后深入九大领域,逐一揭露典型错误配置与代码,并结合
DispatcherServlet调度链、消息转换器、异步管理等内核机制进行根因剖析(模块 2-10)。最后,通过整合 Actuator、日志、线程分析等工具形成标准化排查流程(模块 11),并以面试专题形式将排错能力升华为系统化知识(模块 12)。 - 逐模块说明:
- 模块 2-10:每个反模式都映射到一个或多个前文讲解过的核心组件。例如,路由反模式直指
HandlerMapping的匹配逻辑,异步反模式深挖WebAsyncManager的线程模型。 - 模块 11:诊断工具集提供了一条从现象(404、415、500、悬挂)到具体检查项的决策树。例如,遇到 404,首先查看
/actuator/mappings,然后检查 Filter 链日志。 - 模块 12:面试专题不仅考察知识点,更考察排查思路。例如,“如何排查一个偶发性的 404 错误?”要求面试者能系统地阐述从路由、过滤器到容器上下文的完整排查路径。
- 模块 2-10:每个反模式都映射到一个或多个前文讲解过的核心组件。例如,路由反模式直指
- 关键结论:掌握 Spring Web 全链路调度机制的内部细节,是高效排查 404、415、500、异步失效及响应式阻塞等一切问题的基础。 本文所有反模式及其修正方案,都是对这一基础原理的逆向应用。
1. 反模式总览与分类
在深入每一个具体反模式之前,一份全景分类图是高效导航的关键。下表概括了九大领域、21 个典型反模式,并标注了其风险等级与可能产生的现象,读者可将其作为线上应急时的快速索引。
| 编号 | 领域 | 反模式名称 | 风险等级 | 可能现象 |
|---|---|---|---|---|
| 1 | 请求路由与映射 | @RequestMapping 路径使用 /** 导致静态资源 404 | 高 | 静态资源 (css, js, images) 返回 404,页面样式丢失 |
| 2 | 请求路由与映射 | 多个 Controller 方法路径重叠导致 AmbiguousHandlerException | 高 | 应用启动成功,但访问特定路径时报 500 错误,日志显示模糊映射异常 |
| 3 | 请求路由与映射 | RouterFunction 与注解映射 @RequestMapping 混合时路由优先级混乱 | 中 | WebFlux 应用中,某个路径始终由注解 Controller 处理,而预期的函数式路由不生效 |
| 4 | 消息转换与序列化 | 缺失 Jackson 依赖导致 HttpMessageNotWritableException / 415 | 高 | 请求返回 415 Unsupported Media Type 或 500,日志提示没有合适的转换器 |
| 5 | 消息转换与序列化 | @RequestBody 与 @RequestParam 误用导致 Content-Type 错误 | 中 | POST 请求返回 400 Bad Request,提示必需的请求体缺失,或 GET 请求参数无法绑定 |
| 6 | 消息转换与序列化 | 自定义 ObjectMapper 意外覆盖 Spring Boot 默认自动配置 | 中 | 全局日期格式、空值序列化策略等发生非预期变更,仅影响部分接口 |
| 7 | 拦截器与过滤器 | Filter 和 Interceptor 重复执行相同逻辑 | 中 | CORS 响应头被重复添加、性能监控指标被重复记录,逻辑正确但行为异常 |
| 8 | 拦截器与过滤器 | HandlerInterceptor.preHandle 返回 false 后未正确清理资源 | 高 | 请求被拦截后,临时文件未删除、锁未释放,长时间运行导致资源泄漏 |
| 9 | 拦截器与过滤器 | 异步请求下 afterCompletion 在 afterConcurrentHandlingStarted 前执行 | 高 | 拦截器的清理逻辑在异步线程完成前执行,导致上下文(如 MDC)被意外清空 |
| 10 | 异步处理 | @Async 或 Callable 使用 SimpleAsyncTaskExecutor 导致线程爆炸 | 高 | 高并发下应用崩溃,线程数暴增,OOM (unable to create native thread) |
| 11 | 异步处理 | DeferredResult 未在超时或错误时设置结果,请求永久悬挂 | 高 | 客户端请求长时间等待,服务端线程不释放,最终线程池耗尽,应用无响应 |
| 12 | 异步处理 | 异步方法中异常被吞没,客户端得到空响应或 200 OK 但无数据 | 高 | 异步处理中发生异常,但客户端收不到任何错误信息,表现为“幽灵”般的数据丢失 |
| 13 | 异常处理 | @ControllerAdvice 与局部 @ExceptionHandler 优先级衝突 | 中 | 某个异常被全局异常处理器提前捕获,而非预期的、更具体的局部处理器 |
| 14 | 异常处理 | BasicErrorController 根据请求头 Accept 返回 HTML 而非 JSON | 中 | 微服务 API 调用返回 HTML Whitelabel Error Page,客户端 (如 RestTemplate) 解析失败 |
| 15 | WebFlux 响应式 | 在响应式事件循环线程中调用阻塞 API (如 JDBC) | 高 | 应用吞吐量极低,BlockHound 报警,整个事件循环被卡死,所有请求延迟增加 |
| 16 | WebFlux 响应式 | Flux.create 忽略背压,导致 MissingBackpressureException 或 OOM | 高 | 生产者速度远快于消费者,下游被淹没,内存飙升,应用崩溃 |
| 17 | 父子容器与上下文 | 根容器与 Servlet 容器组件扫描重叠,导致 Bean 重复创建 | 高 | @Service 层事务失效,@Autowired 注入 Bean 版本不一致,应用行为诡异 |
| 18 | 安全与并发 | SecurityContextHolder 在 @Async 或 DeferredResult 线程中丢失 | 高 | 异步执行的方法中无法获取当前登录用户信息,导致 NullPointerException 或权限错误 |
| 19 | 安全与并发 | 虚拟线程中 synchronized 块内调用远程服务导致平台线程 Pinning | 高 | (JDK 21+) 虚拟线程被 pinning 到平台线程,无法卸载,平台线程池迅速耗尽,吞吐量下降 |
| 20 | 静态资源与缓存 | ShallowEtagHeaderFilter 用于大文件或流式响应导致 OOM | 高 | 请求大文件下载时,服务端因在内存中计算 ETag 而 OOM |
| 21 | 静态资源与缓存 | Cache-Control: private 与 CDN 结合,导致敏感数据被共享缓存 | 高 | 用户 A 的个人信息在 CDN 节点被缓存,用户 B 看到用户 A 的数据,严重数据安全问题 |
反模式分类与影响全景图
flowchart LR
subgraph A ["反模式九大领域"]
direction LR
A1["路由与映射"]
A2["消息转换"]
A3["拦截器与过滤器"]
A4["异步处理"]
A5["异常处理"]
A6["WebFlux"]
A7["父子容器"]
A8["安全与并发"]
A9["静态资源"]
end
subgraph B ["红线:典型故障现象"]
direction LR
B1["404 Not Found"]
B2["415/400 Bad Request"]
B3["头重复/资源泄漏"]
B4["线程耗尽/悬挂"]
B5["异常覆盖/HTML错误页"]
B6["事件循环阻塞/OOM"]
B7["Bean冲突/事务失效"]
B8["上下文丢失/Pinning"]
B9["内存溢出/数据泄漏"]
end
A1 --> B1
A2 --> B2
A3 --> B3
A4 --> B4
A5 --> B5
A6 --> B6
A7 --> B7
A8 --> B8
A9 --> B9
style A1 fill:#ffcccc,stroke:#333
style A2 fill:#ffe6cc,stroke:#333
style A3 fill:#ffffcc,stroke:#333
style A4 fill:#e6ffcc,stroke:#333
style A5 fill:#ccffe6,stroke:#333
style A6 fill:#ccf2ff,stroke:#333
style A7 fill:#ccccff,stroke:#333
style A8 fill:#f2ccff,stroke:#333
style A9 fill:#ffcce6,stroke:#333
-
图例与风险说明:
- 路由与映射 (A1) 是最前线的关卡,错误直接导致 404,是最高频的故障之一。
- 消息转换 (A2) 问题影响 API 契约,导致客户端与服务端交互失败,表现为 4xx 错误。
- 异步与响应式 (A4, A6) 问题最为隐蔽,常在并发升高时才暴露,可能导致整个服务雪崩。
- 安全与并发 (A8) 在生产环境具有最高风险等级,可能导致数据泄露或服务完全不可用。
-
排查优先级:面对未知问题,可按此图的依赖顺序排查。先检查请求是否成功路由 (B1),再检查内容协商与消息转换 (B2),然后才是业务逻辑、异步及安全等高级特性。
2. 请求路由与映射反模式
路由是 Spring Web 处理请求的第一步,此处的配置错误将直接导致 404,是最直观也最需要优先排查的问题。
案例 1:@RequestMapping 路径误用 /** 导致静态资源 404
错误示例
// 错误示例:一个试图捕获所有路径的Controller
@RestController
@RequestMapping("/**") // 反模式:过于宽泛的路径匹配
public class CatchAllController {
@GetMapping
public String catchAll() {
return "This catches everything!";
}
}
同时,静态资源配置可能使用了默认的 /** 映射,但是被该 Controller 覆盖。
现象描述
浏览器访问 /index.html 或 /css/app.css 等静态资源时,返回的不是文件内容,而是“This catches everything!”,或者直接出现 404 错误(如果 Controller 方法未匹配具体路径)。应用的页面完全无法加载样式和脚本。
排查思路
- 路径确认:访问具体的静态资源 URL,确认返回状态码(200 非预期,或 404)。
- Actuator 端点:访问
/actuator/mappings,查看所有请求映射。可以清晰地看到/**的映射条目覆盖了所有路径层级,并且优先级可能高于静态资源处理器ResourceHttpRequestHandler的映射。 - 条件断点:在
AbstractHandlerMapping.getHandler(HttpServletRequest request)方法上设置条件断点,条件是request.getRequestURI().contains(".css")。观察返回的HandlerExecutionChain是CatchAllController还是ResourceHttpRequestHandler。
根因分析
问题的根源在于 HandlerMapping 的匹配顺序和策略。在 DispatcherServlet.doDispatch 中,请求到达后会遍历所有注册的 HandlerMapping 来获取处理器。虽然 Spring Boot 自动配置的 SimpleUrlHandlerMapping(用于静态资源)通常有较低的优先级,但 @RequestMapping 映射的 /** 模式扩展性同样很高。Spring 的 AntPathMatcher 在匹配时,对于更具体的模式可能有不同行为,但 /** 被视为最宽泛的模式之一。当用户定义的 RequestMappingHandlerMapping 中的 /** 拦截了请求后,本应处理静态资源的 SimpleUrlHandlerMapping 根本没有机会执行。
// 来自 DispatcherServlet.doDispatch 的核心逻辑
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
// ... 省略文件上传处理 ...
// 遍历 HandlerMapping,找到第一个匹配的 Handler
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
// ... 后续处理 ...
}
一旦 getHandler 返回了 CatchAllController 的处理器链,后续的静态资源处理器就再无机会。
修正方案
永远不要在 @RequestMapping 中使用 /** 来捕获所有请求。 如需实现“捕获所有未匹配请求”的逻辑,应使用其他方式。
// 修正方案1:精确映射
@RestController
public class HelloController {
@GetMapping("/api/hello")
public String hello() { return "hello"; }
}
// 修正方案2:处理404,实现ErrorController或使用@ControllerAdvice
@ControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleNoHandlerFoundException(
NoHandlerFoundException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
// 返回自定义JSON或页面
return new ResponseEntity<>("Custom 404", HttpStatus.NOT_FOUND);
}
}
// 并且必须配置 spring.mvc.throw-exception-if-no-handler-found=true
// 以及 spring.web.resources.add-mappings=false (若要自定义)
最佳实践
- 命名空间隔离:为所有 REST API 统一增加前缀,如
/api/*。 - 静态资源路径显式化:使用
spring.mvc.static-path-pattern或spring.webflux.static-path-pattern将静态资源映射到特定路径,如/static/**,使其与业务 API 路径在物理上分离。 - 慎用尾随斜杠匹配:了解 Spring 的
PathMatchConfigurer和尾随斜杠匹配策略,避免因配置不当导致意外的模糊匹配或 404。
案例 2:多个 Controller 方法路径重叠,AmbiguousHandlerException
错误示例
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User getUserById(@PathVariable Long id) {
return userService.findById(id);
}
@GetMapping("/users/search") // 与上面的模式潜在冲突
public List<User> searchUsers(@RequestParam String name) {
return userService.searchByName(name);
}
}
现象描述
应用启动正常,但当客户端发起 GET /users/search?name=John 请求时,服务端抛出 500 错误。日志中显示类似 java.lang.IllegalStateException: Ambiguous handler methods mapped for '/users/search' 的异常。
排查思路
- Actuator 端点:访问
/actuator/mappings,展开/users/search和/users/{id}的映射详情,确认它们映射到不同的方法。 - 启动日志:更仔细地查看应用启动日志。
RequestMappingHandlerMapping在注册映射时,如果检测到模棱两可的情况,可能会输出 WARN 级别日志。 - 条件断点:在
AbstractHandlerMethodMapping的lookupHandlerMethod方法中设置断点,观察当请求为/users/search时,MappingRegistry返回的匹配结果列表。可以看到两个方法都在匹配列表中,且无法直接选出最佳匹配。
根因分析
RequestMappingHandlerMapping 负责维护 URL 模式与处理器方法之间的映射。当一个请求到来时,它使用 PathMatcher 进行模式匹配。/users/search 这个路径,既可以精确匹配到 "/users/search",也可以模式匹配到 "/users/{id}"(其中 {id} 被赋值为 search)。Spring 的默认策略通常是精确匹配优先于模式匹配,但在某些路径匹配组合下,如果 Spring 认为两个匹配的“特异性”相同,就无法作出决定,从而抛出 AmbiguousHandlerException。
// AbstractHandlerMethodMapping.lookupHandlerMethod 逻辑简化
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
List<Match> matches = new ArrayList<>();
// 1. 根据路径找到所有可能的handler方法
List<T> directPathMatches = this.mappingRegistry.getMappingsByDirectPath(lookupPath);
if (directPathMatches != null) {
addMatchingMappings(directPathMatches, matches, lookupPath, request);
}
if (matches.isEmpty()) {
// 2. 如果没有直接匹配,遍历所有模式进行匹配
addMatchingMappings(this.mappingRegistry.getRegistrations().keySet(), matches, lookupPath, request);
}
// 3. 对matches进行排序和决策
if (!matches.isEmpty()) {
Match bestMatch = matches.get(0);
if (matches.size() > 1) {
// ... 根据比较器排序 ...
Match secondBestMatch = matches.get(1);
// 如果前两个match特异性相同,则抛出异常
if (bestMatch.compareTo(secondBestMatch) == 0) {
throw new AmbiguousHandlerException(...);
}
}
return bestMatch.handlerMethod;
}
}
修正方案
- 方案一(推荐):让路径模式更加清晰且无法重叠。将搜索路径改为复数形式或增加前缀,使其不可能与
/users/{id}冲突。 - 方案二:调整方法定义的顺序。虽然源码注释显示最早定义的方法有一定优先级,但这并不是可靠的实践,且依赖隐式行为。
- 方案三:使用正则表达式限定
{id}的格式,如"/users/{id:\\d+}"。这样,/users/search就不会再匹配"/users/{id}"模式。
// 修正方案:使用正则限定,或重命名路径
@RestController
public class UserController {
@GetMapping("/users/{id:\\d+}") // id只匹配数字
public User getUserById(@PathVariable Long id) { ... }
@GetMapping("/users/search") // 现在和上面的模式无冲突
public List<User> searchUsers(@RequestParam String name) { ... }
}
最佳实践
- RESTful 设计习惯:将资源查询(如搜索)设计为
/users?name=John,而不是/users/search。这样从根本上避免了路径冲突。 - 路径规范审计:在 Code Review 时,作为一项检查项,自动扫描所有
@RequestMapping注解,识别潜在的模式重叠风险。 - 启动时断言:可在集成测试中,对所有注册的映射进行自动化检查,确保没有模棱两可的映射存在。
案例 3:RouterFunction 与注解映射混合时优先级混乱 (WebFlux)
错误示例
// 函数式路由定义
@Configuration
public class Routes {
@Bean
public RouterFunction<ServerResponse> userRoutes(UserHandler handler) {
return route().GET("/users/{id}", handler::getUser)
.GET("/users", handler::listUsers)
.build();
}
}
// 注解式Controller
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public Mono<User> getUser(@PathVariable Long id) { ... }
}
现象描述
在 WebFlux 应用中,同一个路径 /users/123 同时存在于 RouterFunction 和 @RequestMapping 中。访问该路径时,请求总会由其中一方处理(通常是注解 Controller),函数式路由定义好像“失效”了。
排查思路
- Actuator 端点:访问
/actuator/mappings,在 WebFlux 应用中,该端点会同时列出RouterFunction和HandlerMethod的映射。对比两者的顺序和详情。 - 调试日志:为
org.springframework.web.reactive.function.server包开启 DEBUG 日志,观察请求到来时RouterFunctionMapping和RequestMappingHandlerMapping的处理顺序。 - 条件断点:在
RouterFunctionMapping.getHandlerInternal和RequestMappingHandlerMapping.getHandlerInternal方法上各设置一个断点。观察哪个处理映射先被执行,以及它是否返回了非空的Mono结果。
根因分析
DispatcherHandler(WebFlux 的 DispatcherServlet 等价物)在初始化时,会按 @Order 或默认顺序排序它所有的 HandlerMapping Bean。Spring Boot 自动配置会同时注册 RequestMappingHandlerMapping 和 RouterFunctionMapping。默认情况下,RequestMappingHandlerMapping 的优先级高于 RouterFunctionMapping。
// DispatcherHandler.handle 逻辑简化
public Mono<Void> handle(ServerWebExchange exchange) {
if (this.handlerMappings == null) {
return createNotFoundError();
}
return Flux.fromIterable(this.handlerMappings)
// 按顺序处理,取第一个能匹配上的handler
.concatMap(mapping -> mapping.getHandler(exchange))
.next() // 关键:只取第一个成功的结果
.switchIfEmpty(createNotFoundError())
.flatMap(handler -> invokeHandler(exchange, handler));
}
由于 concatMap 按 HandlerMapping 列表的顺序执行,且 .next() 只取第一个非空结果,一旦较高优先级的 RequestMappingHandlerMapping 匹配并返回了处理器,优先级较低的 RouterFunctionMapping 就不会再有机会。
修正方案
明确设置 RouterFunctionMapping 的 @Order 值,使其优先级高于 RequestMappingHandlerMapping。或者,更根本的做法是,避免在同一路径上混合使用两种路由定义。
@Configuration
public class WebConfig implements WebFluxConfigurer {
@Override
public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
// ...
}
// 方法一:通过定义Bean并指定Order
@Bean
@Order(-1) // 赋予更高优先级
public RouterFunctionMapping routerFunctionMapping(RouterFunction<?> routerFunction) {
RouterFunctionMapping mapping = new RouterFunctionMapping(routerFunction);
mapping.setOrder(-1);
return mapping;
}
// 方法二:更简单的方式
@Bean
public WebFluxConfigurer webFluxConfigurer() {
return new WebFluxConfigurer() {
@Override
public void configureHandlerMapping(HandlerMappingOrder order) {
// 将RouterFunction的优先级提升
order.setRouterFunctionOrder(-1);
order.setRequestMappingOrder(0);
}
};
}
}
最佳实践
- 团队约定:在项目中明确选定一种 Web 层定义风格,是全部使用注解式还是全部使用函数式,或明确划分模块边界(例如,管理 API 用注解,对外高并发 API 用函数式),避免在同一个应用中对相同路径使用混合模型。
- 路径前缀:为函数式路由和注解式路由使用不同的路径前缀,如
/func/users和/api/users,从物理上隔离。
3. 消息转换与序列化反模式
消息转换器是 Spring Web 处理请求体和响应体的核心组件。配置不当会直接导致 400、415 等错误,且排查过程常常令人困惑。
案例 4:缺失 Jackson 依赖导致 415 Unsupported Media Type
错误示例
一个 Spring Boot Web 项目,由于某种原因没有引入 spring-boot-starter-web 而是自己拼凑依赖,或者错误地排除了 spring-boot-starter-json。
<!-- pom.xml 中缺失了 spring-boot-starter-json -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</exclusion>
</exclusions>
</dependency>
现象描述
客户端发送带有 JSON 请求体(Content-Type: application/json)的 POST 请求时,服务端返回 415 Unsupported Media Type。或者,服务端返回 @ResponseBody 对象时,抛出 HttpMessageNotWritableException,客户端收到 500 错误。
排查思路
- 日志检查:查看应用启动日志,搜索
HttpMessageConverter或Jackson。一个缺失了 Jackson 的 Web 上下文通常只注册了StringHttpMessageConverter等少数几个。 - Actuator 端点:如果已经设置了
/actuator/env,可以查看 CLASSPATH 相关的属性,或者间接通过/actuator/beans查找是否有MappingJackson2HttpMessageConverter这个 Bean。 - 条件断点:在
AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters方法中设置断点。进入方法后,查看this.messageConverters列表,确认其中是否存在支持application/json的转换器。RequestResponseBodyMethodProcessor在处理@RequestBody时会调用此方法。
根因分析
WebMvcConfigurationSupport(或自动配置的 WebMvcAutoConfiguration)默认会注册一系列 HttpMessageConverter,其中处理 JSON 的核心是 MappingJackson2HttpMessageConverter。这个转换器的注册条件是类路径下存在 com.fasterxml.jackson.databind.ObjectMapper 和 com.fasterxml.jackson.core.JsonGenerator。如果依赖缺失,该转换器就不会被注册。
当处理 @RequestBody 注解时,AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters 会遍历所有已注册的 HttpMessageConverter,寻找第一个能读取给定 Content-Type 的转换器。
// AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters 核心逻辑
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter = ...
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) : ...) {
if (body != NO_VALUE) {
addAdvice(...);
}
body = genericConverter.read(targetType, contextClass, inputMessage);
break; // 找到即返回
}
}
throw new HttpMediaTypeNotSupportedException(contentType, ...); // 找不到则抛415异常
由于没有可以处理 application/json 的转换器,循环结束后就抛出了 HttpMediaTypeNotSupportedException,最终映射为 415 状态码。
修正方案
引入spring-boot-starter-json 或直接添加 Jackson 依赖。
<!-- 修正:直接或间接引入 spring-boot-starter-json -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
或者,如果项目只使用 FastJson 或 Gson,则需引入相应的 HTTP Message Converter 实现并配置 Spring 使用它。但无论如何,都需要一个能处理目标 Content-Type 的转换器在 CLASSPATH 上并被注册。
最佳实践
- 尽量使用 Starter 父项目:
spring-boot-starter-web和spring-boot-starter-webflux已经为你管理好了核心的 JSON 依赖,避免手动排除,除非有非常明确的理由。 - 依赖分析插件:使用
maven-dependency-plugin或 Gradle 的dependencies任务,检查最终依赖树,确保 Jackson 或你选择的序列化库存在于 Runtime classpath 中。 - 编写一个健康检查接口:在业务上线前,通过一个简单的
POST接口测试 JSON 请求和响应,作为冒烟测试的一部分。
案例 5:@RequestBody 与 @RequestParam 误用导致 Content-Type 错误
错误示例
// 错误示例 1:客户端发来了JSON,但服务端用@RequestParam收
@PostMapping("/user")
public User createUser(@RequestParam String userName, @RequestParam int age) {
// ...
}
// 错误示例 2:客户端发的是Form表单或QueryString,但服务端用@RequestBody收
@GetMapping("/user")
public User getUser(@RequestBody UserSearchCriteria criteria) {
// ...
}
现象描述
- 错误示例 1:客户端 POST
{"userName": "John", "age": 25}到/user,得到400 Bad Request,错误信息为Required request parameter 'userName' for method parameter type String is not present。 - 错误示例 2:客户端 GET
/user?userName=John,得到415 Unsupported Media Type(如果请求未设置 Content-Type)或400 Bad Request(如果请求错误地设置了 Content-Type)。
排查思路
- 基础日志:查看 Spring Web 的日志,
HttpMessageNotReadableException或MissingServletRequestParameterException会指出问题所在。 - 对比 API 契约:对比客户端发送的
Content-Type头和服务端控制器方法的定义。- 如果客户端发送
application/json,服务端方法参数上必须有@RequestBody(或@ModelAttribute,但处理方式不同)。 - 如果客户端发送
application/x-www-form-urlencoded或 Query String,服务端方法参数应是@RequestParam或一个带有相应 getter/setter 的简单对象,不加@RequestBody。
- 如果客户端发送
- 条件断点:在
RequestResponseBodyMethodProcessor.supportsParameter和RequestParamMethodArgumentResolver.supportsParameter上设置断点,观察哪一个参数解析器“认领”了你控制器方法的参数。错误的注解会导致 Spring 选择了错误的解析器。
根因分析
Spring MVC 通过一系列的 HandlerMethodArgumentResolver 来解析控制器方法的参数。@RequestBody 和 @RequestParam 分别由 RequestResponseBodyMethodProcessor 和 RequestParamMethodArgumentResolver 处理,它们在内部委托给不同的基础设施。
@RequestBody:RequestResponseBodyMethodProcessor使用HttpMessageConverter从请求的InputStream中读取整个 body,并根据Content-Type反序列化。@RequestParam:RequestParamMethodArgumentResolver从 Servlet 的request.getParameter()系列方法中获取参数,这些参数来源于 URL Query String 和application/x-www-form-urlencoded格式的请求体。
如果混淆了这两个注解,Spring 会尝试用错误的机制去提取数据。例如,用 @RequestBody 处理 GET 请求,由于 GET 请求没有 Body,readWithMessageConverters 会立即失败,抛出 HttpMessageNotReadableException,"Required request body is missing"。
// RequestResponseBodyMethodProcessor.resolveArgument 内部逻辑
@Override
public Object resolveArgument(...) throws Exception {
// ...
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
// ...
return adaptArgumentIfNecessary(arg, parameter);
}
// readWithMessageConverters 会尝试读取body,如果inputStream为空,则抛出异常
修正方案
严格对齐客户端的请求方式和服务端方法签名。
// 修正示例 1:处理JSON请求
@PostMapping("/user")
public User createUser(@RequestBody UserCreationRequest request) {
return userService.create(request.getUserName(), request.getAge());
}
// 修正示例 2:处理GET查询或Form表单
@GetMapping("/user")
public User getUser(@RequestParam String userName) {
return userService.findByName(userName);
}
最佳实践
- API 设计一致性:如果是 RESTful JSON API,则统一使用
@RequestBody处理请求数据。如果是传统 Form 表单或简单的查询参数,则使用@RequestParam或@ModelAttribute。 - DTO 使用:使用独立的 DTO 对象接收
@RequestBody的 JSON 数据,而不是直接接收零散的@RequestParam,使契约更清晰。 - OpenAPI/Swagger 文档:强制使用 OpenAPI 生成文档,文档会清晰地定义每个接口的
Content-Type和参数形式,开发时便能发现不一致。
案例 6:自定义 ObjectMapper Bean 覆盖 Spring Boot 默认自动配置
错误示例
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// 忘记开启 findAndRegisterModules,导致Java 8时间模块等未注册
return mapper;
}
}
现象描述
声明上述 Bean 后,之前能正常序列化的 LocalDateTime 字段现在抛出 com.fasterxml.jackson.databind.exc.InvalidDefinitionException,全局日期格式变为 yyyy-MM-dd HH:mm:ss,但此前其他地方的自定义格式失效,甚至 null 值的序列化行为也发生了变化。
排查思路
- Actuator 端点:访问
/actuator/beans,搜索objectMapper。会发现存在两个 Bean:一个是jacksonObjectMapper(Spring Boot 自动配置的),另一个是我们自定义的objectMapper。 - 类路径检查:确认是否引入了
jackson-datatype-jsr310等模块。 - 调试代码:在 Controller 中注入
ObjectMapper实例,打印它的 Bean 名称和注册的模块列表。@Autowired ApplicationContext context; // ... String[] beanNames = context.getBeanNamesForType(ObjectMapper.class); // 打印: [jacksonObjectMapper, objectMapper] // 然后对比两个mapper的module列表 - 条件断点:在
JacksonAutoConfiguration的jacksonObjectMapperBean 定义方法上设置断点,观察其@ConditionalOnMissingBean的判断逻辑。因为自定义了objectMapper,@ConditionalOnMissingBean条件不再满足,自动配置的jacksonObjectMapper不会被创建?实际上,在 Spring Boot 2.x 中,JacksonAutoConfiguration注册的 Bean 名字是jacksonObjectMapper,@Primary修饰。自定义的objectMapper仍然会被创建,Spring Boot 的自动配置会感知到已有ObjectMapperBean,并基于它进行进一步定制,而不是替换。
根因分析
Spring Boot 的 JacksonAutoConfiguration 设计得非常巧妙。它定义了一个 jacksonObjectMapper Bean,并通常会将其标记为 @Primary。它的创建过程会应用 Jackson2ObjectMapperBuilder,这个 Builder 会自动发现并注册 Module Bean,应用于 Jackson2ObjectMapperBuilderCustomizer Bean,并根据 spring.jackson.* 配置属性进行设置。
当开发者简单地定义一个 ObjectMapper Bean 时,会发生几件事:
- Spring Boot 的自动配置检测到用户已定义
ObjectMapper类型的 Bean。 - 它不会创建一个全新的、替换掉默认的内部
ObjectMapper。在标准配置下,JacksonAutoConfiguration会查找是否有ObjectMapperBean,如果有,它会用这个 Bean 作为基础,再应用一系列的Jackson2ObjectMapperBuilderCustomizer。 - 问题的核心:如果开发者没有使用
Jackson2ObjectMapperBuilder来创建ObjectMapper,而是直接new ObjectMapper(),那么这个新创建的ObjectMapper实例就缺失了 Builder 在背后自动完成的所有增强——没有注册 JSR-310 等时间模块,没有应用spring.jackson.*的配置。这个“半成品”随后被 Spring 的定制器链修改,可能只应用了部分配置,导致行为不一致。
修正方案
永远不要直接 new ObjectMapper() 并声明为 Bean。使用 Spring Boot 提供的定制器机制。
// 最佳修正方案:使用 Jackson2ObjectMapperBuilderCustomizer
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> {
builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
builder.featuresToEnable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// builder.modules(...); 手动添加模块,但通常会自动发现
};
}
}
// 如果必须创建全新ObjectMapper(比如用于另一个序列化场景),绝不命名为 ‘objectMapper’
@Bean("customObjectMapper")
public ObjectMapper customObjectMapper() {
// 使用 Jackson2ObjectMapperBuilder 创建以获得自动配置的好处
return Jackson2ObjectMapperBuilder.json()
.dateFormat(new SimpleDateFormat("yyyy-MM-dd"))
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
}
最佳实践
- 优先使用定制器:
Jackson2ObjectMapperBuilderCustomizer是修改 Jackson 配置的首选方式,它完全融入 Spring Boot 的自动配置生命周期。 - 使用构建器:无论何时需要创建
ObjectMapper,都使用Jackson2ObjectMapperBuilder,它集成了自动发现模块和属性配置的能力。 - 命名隔离:如果必须声明多个
ObjectMapper,为它们指定明确且唯一的 Bean 名称,并配合@Qualifier使用,避免意外覆盖。
4. 拦截器与过滤器反模式
Filter 和 HandlerInterceptor 是 Servlet 规范与 Spring 框架提供的两种不同层次的 AOP 机制。混淆两者的生命周期和职责边界,是导致逻辑错误和资源泄漏的常见原因。
案例 7:Filter 和 Interceptor 重复实现相同逻辑
错误示例
// Filter: 添加 CORS 头
@Component
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
HttpServletResponse response = (HttpServletResponse) res;
response.setHeader("Access-Control-Allow-Origin", "*");
// ... 其他CORS头 ...
chain.doFilter(req, res);
}
}
// Interceptor: 同样添加 CORS 头
@Component
public class CorsInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
response.setHeader("Access-Control-Allow-Origin", "*");
// ... 其他CORS头 ...
return true;
}
}
现象描述
浏览器发起的 CORS 预检请求(OPTIONS)或实际请求的响应中,Access-Control-Allow-Origin 头部出现了多次相同的值,例如 Access-Control-Allow-Origin: *, *。这违反了 HTTP 规范,可能导致浏览器报错或行为异常。
排查思路
- 浏览器开发者工具:直接查看 Network 面板中具体请求的 Response Headers,可以看到重复的响应头。
- 服务端日志:很难直接从默认日志发现,因为设置响应头是一个简单的操作。
- 条件断点:在
Filter.doFilter和Interceptor.preHandle中设置并添加响应头的代码行上设置断点,观察在一次请求处理过程中,两个断点是否都被命中。在HttpServletResponse.setHeader方法上设置断点(如果是 Tomcat,则在org.apache.catalina.connector.Response.setHeader上),观察调用栈,可以看到两次调用的来源分别是 Filter 和 Interceptor。
根因分析
这是一个典型的架构层次划分不清导致的问题。Filter 工作在 Servlet 容器层,在请求进入 DispatcherServlet 之前和响应返回给客户端之后执行。HandlerInterceptor 工作在 Spring MVC 框架层,在 DispatcherServlet 内部,HandlerMapping 找到 Handler 之后,HandlerAdapter 执行 Handler 之前/之后执行。
开发者在实现横切关注点(如 CORS, 鉴权, 日志)时,如果没有在团队内部清晰地划定职责边界在哪里——是应该在 Servlet 层还是 Spring 层处理——就可能在两处都添加了相同的逻辑。
修正方案
二选一,职责单一。 对于 CORS 这种与 HTTP 协议紧密相关、且应在请求处理最早期介入的横切关注点,官方推荐使用 Filter 或 Spring 内置的 CorsWebFilter / @CrossOrigin。
// 修正方案:只保留一个,比如使用Spring MVC的CORS配置,移除其他。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://your-frontend.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true);
}
}
最佳实践
- 明确团队规范:制定技术规范,明确规定 Filter 和 Interceptor 的适用场景。
- Filter:适用于字符编码 (CharacterEncodingFilter)、请求日志 (CommonsRequestLoggingFilter)、安全 (Spring Security Filter Chain)、CORS、Servlet 级属性设置。
- Interceptor:适用于与 Spring 模型紧密相关的操作,如
ModelAndView的数据填充、基于 Handler 方法的权限控制(例如,检查方法上的自定义注解)、性能监控(利用afterCompletion记录耗时)。
- 代码审查 Checklist:将“是否存在 Filter 和 Interceptor 功能重复”作为代码审查的一个固定检查项。
案例 8:preHandle 返回 false 后未正确释放资源
错误示例
@Component
public class ResourceCleanupInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 假设在这个方法里打开了一个临时文件或获取了一个锁
File tempFile = fileService.createTempFile();
request.setAttribute("tempFile", tempFile);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 清理逻辑
File tempFile = (File) request.getAttribute("tempFile");
if (tempFile != null && tempFile.exists()) {
tempFile.delete();
}
}
}
// 如果另一个拦截器的preHandle返回false,ResourceCleanupInterceptor的afterCompletion不会执行
现象描述
当拦截器链中某个拦截器的 preHandle 返回 false 时,请求被中断。但是,之前已经执行过 preHandle 并返回 true 的拦截器中分配的资源(如临时文件、锁、数据库连接)没有被释放。长时间运行后,磁盘空间耗尽、锁无法释放导致死锁、或者连接池泄漏。
排查思路
- 操作系统监控:监控临时文件目录的大小,或数据库连接池的活跃连接数和等待队列,观察到持续增长。
- 日志分析:在
preHandle中增加资源分配的日志,在afterCompletion中增加资源释放的日志。通过对比日志,可发现大量“分配”日志而没有对应的“释放”日志。 - 条件断点:在所有拦截器的
preHandle方法返回false的代码路径上设置断点。观察此时ResourceCleanupInterceptor的afterCompletion是否被调用。在DispatcherServlet.doDispatch的核心循环中,可以看到当preHandle返回false时,直接执行了triggerAfterCompletion,但只对当前及之前的拦截器有效。关键在于理解“执行”的含义。
根因分析
在 DispatcherServlet.doDispatch 的流程中,拦截器的执行被包裹在了一个 try-catch-finally 样式的循环中。
// DispatcherServlet.doDispatch 中与拦截器相关的核心伪代码
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return; // 重点:如果preHandle返回false,直接返回,不会再继续处理
}
// ... 执行handler ...
// 在finally块中,总会调用这个方法
mappedHandler.triggerAfterCompletion(request, response, null);
关键在于 HandlerExecutionChain.applyPreHandle 方法的实现:
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
for (int i = 0; i < this.interceptorList.size(); i++) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
if (!interceptor.preHandle(request, response, this.handler)) {
// 注意这里:如果某个interceptor返回false,只会为当前索引i之前的interceptor调用afterCompletion
triggerAfterCompletion(request, response, null);
return false;
}
this.interceptorIndex = i;
}
return true;
}
核心问题:triggerAfterCompletion 方法内部有一个计数器 interceptorIndex。当 preHandle 返回 false 时,会为其索引之前的拦截器执行 afterCompletion。但是,代码逻辑有一个细微之处:如果 B 拦截器的 preHandle 返回 false,它会调用 triggerAfterCompletion,这个操作是为 A 拦截器(index=0)调用 afterCompletion。B 拦截器自己的afterCompletion不会被调用,因为它在链中还没有“完成”。这是符合逻辑的,因为 B 的 preHandle 失败了,请求处理被其终止。然而,如果 A 拦截器在它的 preHandle 中分配了资源,并期望在 afterCompletion 中释放,那么资源就会被正确释放。反模式场景常出现在 A 的资源释放逻辑有 Bug,或者开发者错误地认为 B 的 preHandle 返回 false 后,A 的 afterCompletion 仍然不会执行(实际上会执行),但导致了其他并发路径下的资源泄漏。更高级的反模式是资源分配在 afterCompletion 中有着复杂的、依赖于后续 handler 执行状态的释放逻辑,当 handler 未执行时,释放逻辑出错。
修正方案
- 资源分配后移:不要在
preHandle中分配需要在整个请求生命周期结束后才释放的资源。将资源分配逻辑移到 Handler 内部。 - 使用 try-finally 防护:如果必须在
preHandle中分配,应立即使用 try-finally 确保在当前方法内部或在最坏情况下有兜底释放机制。 - 使用 Servlet Filter:对于需要确保 100% 释放的资源,使用 Servlet Filter 更加可靠,因为
Filter.doFilter之后的代码总是在 finally 性质的上下文中执行。
// 修正:在Handler内部使用try-with-resources管理资源
@RequestMapping("/process-file")
public String processFile() {
// 使用try-with-resources,确保资源一定被关闭
try (TempFile tempFile = fileService.createTempFile()) {
// ...
}
return "success";
}
最佳实践
- 资源管理铁律:谁创建,谁释放;在哪里创建,就在哪里释放。
Interceptors的preHandle/afterCompletion对不应被用作长生命周期资源的管理者。 - 使用现代 Java 资源管理:优先使用
try-with-resources管理实现了AutoCloseable的资源,将资源的生命周期绑定到方法调用栈上,而非请求级别属性上。
案例 9:异步请求下 afterCompletion 在 afterConcurrentHandlingStarted 前执行
错误示例
@Component
public class MdcInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
MDC.put("requestId", UUID.randomUUID().toString());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 意图在请求全部处理完后清理MDC
MDC.clear();
}
}
// 当Controller方法返回Callable或DeferredResult时,这个拦截器会出问题
现象描述
在一个异步处理的请求中,主线程已经开始处理下一个请求,但异步线程中的日志却丢失了 requestId。这是因为 afterCompletion 在主线程返回 Callable/DeferredResult 之后立即被调用了,而不是在异步处理真正完成的时刻。
排查思路
- 日志验证:检查包含
requestId的日志。会发现只有主线程(Tomcat 容器线程)的日志有requestId,而处理实际业务的异步线程日志中requestId为 null。 - 线程栈分析:通过
jstack观察异步情况下的线程。你可以看到 Tomcat 容器线程已经回到了线程池,处理另一个请求,而TaskExecutor中的线程正在执行你的业务逻辑。 - 条件断点:
- 在
MdcInterceptor.afterCompletion上设置断点,查看其调用栈。会发现它在DispatcherServlet.doDispatch的 finally 块中被调用。 - 在
MdcInterceptor.preHandle和 Controller 异步方法上设置断点。观察主线程和异步线程的 ID 切换。
- 在
根因分析
这是 Spring MVC 异步模型的核心设计。当 Controller 返回 Callable 或 DeferredResult 时,DispatcherServlet 并不会等待异步结果。它立即释放 Tomcat 容器线程。WebAsyncManager 接管了异步处理的调度。
关键在于 HandlerExecutionChain 中的 applyPreHandle、applyPostHandle 和 triggerAfterCompletion 的调用时机。
// DispatcherServlet.doDispatch 简化逻辑
try {
if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; }
// 执行handler
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
// 如果启动异步处理,直接返回,容器线程被释放
return;
}
// 只有非异步情况下才会执行这里
applyDefaultViewName(processedRequest, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
}
catch (Exception ex) { ... }
finally {
// 无论是否异步,这里都会在容器线程中执行!
mappedHandler.triggerAfterCompletion(request, response, null);
}
从源码可见,finally 块中的 triggerAfterCompletion 会在容器线程中执行,无论 isConcurrentHandlingStarted() 是否为真。这导致 afterCompletion 被过早调用。对于需要在最终业务逻辑完成后才清理的资源(如 MDC),这是一个灾难。Spring 提供了 AsyncHandlerInterceptor 接口来解决此问题。
修正方案
使用AsyncHandlerInterceptor接口代替HandlerInterceptor。
// 修正方案
@Component
public class MdcInterceptor implements AsyncHandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
MDC.put("requestId", UUID.randomUUID().toString());
return true;
}
@Override
public void afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 当异步处理开始后,容器线程被释放,但暂时不清理MDC
// 或者,可以在这里做一些主线程的清理工作,但保留requestId给异步线程
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 这个方法仍然会在容器线程中调用,不适用于异步清理!
// 对于异步请求的最终清理,需要采用其他方式
}
// 最佳的异步清理方式是:在 Callable/deferredResult 的回调中显式清理
}
更完善的方案是,在异步方法内部,使用 DeferredResult 或 CompletableFuture 的回调来执行最终的清理工作。
@RequestMapping("/async")
public DeferredResult<String> handleAsync() {
DeferredResult<String> dr = new DeferredResult<>();
ForkJoinPool.commonPool().submit(() -> {
try {
// 从主线程传递过来的上下文已被Spring自动复制 (需要配置)
MDC.put("requestId", RequestContextHolder...);
// 执行业务...
dr.setResult("OK");
} finally {
MDC.clear(); // 在finally块中确保清理
}
});
return dr;
}
最佳实践
- 理解异步生命周期:必须深刻理解 Tomcat 线程与业务处理线程的分离。
Interceptors的postHandle和afterCompletion在异步场景下会提前于真正的业务处理完成而执行。 - 显式上下文传递:依赖 Spring 的
TaskDecorator或ContextPropagatingTaskDecorator(Spring Boot 2.7.x 后可用)来自动传递RequestContextHolder、MDC 等线程局部变量到异步线程。并明确任务的finally块负责清理。 - 使用
AsyncHandlerInterceptor:必要时实现此接口,并利用afterConcurrentHandlingStarted回调来执行主线程的清理逻辑。
5. 异步处理反模式
Spring MVC 的异步处理机制为吞吐量带来了巨大的提升,但也引入了线程管理、超时和上下文传递等一系列新的复杂性。
案例 10:Callable 未配置线程池导致线程爆炸
错误示例
@RestController
public class AsyncController {
@GetMapping("/async-callable")
public Callable<String> handleCallable() {
return () -> {
// 模拟耗时操作
Thread.sleep(5000);
return "async result";
};
}
}
// 配置中未定义任何AsyncTaskExecutor或TaskExecutor Bean
现象描述
在高并发场景下,应用崩溃,日志中充满 java.lang.OutOfMemoryError: unable to create new native thread。即使没崩溃,线程数也会飙升至一个非常危险的水平,GC 频繁,CPU 上下文切换开销巨大。
排查思路
- JVM 监控:使用
jvisualvm、jconsole或 Prometheus 等工具监控 JVM 的线程数。观察到大量名为task-或SimpleAsyncTaskExecutor-的线程被创建且未复用。 jstack分析:执行jstack <pid>,会发现成百上千个处于TIMED_WAITING(sleeping)或RUNNABLE状态的SimpleAsyncTaskExecutor线程。- Actuator 端点:访问
/actuator/metrics/jvm.threads.live和/actuator/metrics/jvm.threads.peak,可以看到活动线程数和峰值线程数急剧增加。 - 配置检查:检查 Spring 配置,确认是否定义了
TaskExecutorBean。
根因分析
当处理器方法返回 Callable 时,Spring MVC 使用 WebAsyncManager 来处理异步逻辑。WebAsyncManager 需要一个 AsyncTaskExecutor 来执行 Callable 任务。
// WebAsyncManager.startCallableProcessing 核心逻辑
public void startCallableProcessing(Callable<?> callable, Object... processingContext) {
Assert.notNull(callable, "Callable must not be null");
startCallableProcessing(new WebAsyncTask<>(callable), processingContext);
}
public void startCallableProcessing(WebAsyncTask<?> webAsyncTask, Object... processingContext) {
// ...
AsyncTaskExecutor executor = webAsyncTask.getExecutor();
if (executor == null) {
// 如果没有配置executor,使用默认的TaskExecutor
executor = this.defaultExecutor;
}
if (executor == null) {
// 如果连默认的都没有,就使用SimpleAsyncTaskExecutor!
executor = new SimpleAsyncTaskExecutor(TEMPORARY_EXECUTOR_THREAD_NAME);
this.defaultExecutor = executor;
}
// ... 使用executor提交Callable ...
}
SimpleAsyncTaskExecutor 的设计原则是:为每一个任务都创建一个新的线程。它不会复用线程。因此,在高并发下,每一个 Callable 的处理都会产生一个新线程,很快达到操作系统线程数上限,引发 OOM。
修正方案
为异步处理配置一个明确的核心线程池。
@Configuration
@EnableAsync // 开启@Async支持,也建议配置
public class AsyncConfig implements WebMvcConfigurer {
// 1. 为 Spring MVC 的异步处理配置线程池
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
configurer.setTaskExecutor(taskExecutor());
configurer.setDefaultTimeout(60 * 1000L); // 60秒超时
}
// 2. 定义公用的线程池 Bean
@Bean("mvcTaskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 核心线程数
executor.setMaxPoolSize(20); // 最大线程数
executor.setQueueCapacity(100); // 队列容量
executor.setKeepAliveSeconds(60); // 线程空闲时间
executor.setThreadNamePrefix("mvc-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
最佳实践
- 强制显式配置:在生产环境中,绝对不要依赖默认的
SimpleAsyncTaskExecutor。必须显式配置一个ThreadPoolTaskExecutor。 - 线程池监控:使用 Actuator 的
/actuator/metrics/tomcat.threads.*和自定义的ThreadPoolTaskExecutorMetric 来监控核心和异步线程池的健康状况。 - 合理设置队列和拒绝策略:根据业务峰值,仔细调整
queueCapacity和rejectedExecutionHandler。CallerRunsPolicy是一种优雅降级策略,可以让主线程执行任务,从而减缓生产速度。
案例 11:DeferredResult 未在超时或错误时设置结果,请求悬挂
错误示例
@RequestMapping("/deferred")
public DeferredResult<String> handleDeferred() {
DeferredResult<String> dr = new DeferredResult<>(5000L); // 5秒超时
// 模拟一个永远不会完成的操作,或者一个忘记setResult的场景
// someService.onComplete(data -> dr.setResult(data));
// 如果someService的回调永远不来,或者发生异常...
return dr;
}
现象描述
客户端发起请求后一直处于等待状态,直到浏览器或网关超时断开连接。在服务端,Tomcat 的异步处理线程并未释放,长时间占用。随着类似请求的积累,所有处理异步请求的线程都被悬挂,最终导致应用对新请求无响应。
排查思路
- 现象识别:客户端超时,但服务端无任何错误日志。
jstack分析:获取线程栈。找到mvc-async-线程或配置的TaskExecutor线程。这些线程可能在等待某个CountDownLatch或条件。更为关键的是,找到 Tomcat 的请求处理线程(如http-nio-8080-exec-*)。这些线程可能处于WAITING状态,等待着DeferredResult的结果,但由于异步支持,它们可能已经被返回池中。真正的悬挂线程可能是那些等待业务回调的线程。- Actuator 端点:访问
/actuator/httptrace(如果配置) 或自定义的拦截器,记录所有未完成的DeferredResult。编写一个 Health Endpoint 检查这些悬挂的请求。 - 访问日志:Tomcat 的
localhost_access_log可以显示哪些请求的响应时间异常长。 - 条件断点:在
DeferredResult.setResult和DeferredResult.setErrorResult上设置断点,观察业务逻辑中是否永远不会走到这些代码路径。
根因分析
DeferredResult 是 Spring MVC 提供的一个持有异步响应的容器。WebAsyncManager 会启动一个超时任务,但处理超时的行为取决于 DeferredResult 自身的配置。
// WebAsyncManager 处理 DeferredResult 的片段
public void startDeferredResultProcessing(...) throws Exception {
// ...
deferredResult.setResultHandler(result -> {
// 结果设置后的处理逻辑,会调度回容器线程写回响应
...
});
// 处理超时
if (deferredResult.hasTimeout()) {
deferredResult.onTimeout(() -> {
// 默认超时处理是设置一个AsyncRequestTimeoutException到result中
// 如果用户没有定义onTimeout回调,这个异常会被设置为errorResult
});
}
// ...
}
若开发者创建了一个带有超时时间(如 5000ms)的 DeferredResult 对象,但既没有定义 onTimeout 回调,其关联的业务处理在未来也没有调用 setResult 或 setErrorResult。当超时发生时,DeferredResult 内部会触发超时处理,其默认行为是创建一个 AsyncRequestTimeoutException 并通过 setErrorResult 设置。然而,如果异常处理器(@ExceptionHandler 或全局 @ControllerAdvice)没有恰当地处理这个异常,客户端将无法获得预期的错误响应。更糟糕的是,如果 DeferredResult 创建时没有设置超时时间,且业务回调永不抵达,那么该请求将永久悬挂。
修正方案
必须为每个DeferredResult设置超时时间,并提供onTimeout和onError回调,或者确保业务逻辑在所有路径(包括异常路径)上最终都会调用setResult或setErrorResult。
@RequestMapping("/deferred-safe")
public DeferredResult<String> handleDeferredSafe() {
long timeout = 5000L;
DeferredResult<String> dr = new DeferredResult<>(timeout, "timeout-result"); // 超时默认值
dr.onTimeout(() -> {
log.warn("DeferredResult for request timed out.");
// 可以设置自定义超时结果,如果构造函数未提供默认值
// dr.setErrorResult(new MyTimeoutException());
});
dr.onError((Throwable t) -> {
log.error("DeferredResult encountered an error: " + t.getMessage());
// 可以设置一个错误结果给客户端
// dr.setErrorResult(new MyBusinessException());
});
someAsyncService.executeAsync(data -> {
try {
String result = process(data);
dr.setResult(result);
} catch (Exception e) {
dr.setErrorResult(e); // 确保异常路径也能结束请求
}
});
return dr;
}
最佳实践
- 强制超时设置:通过
WebMvcConfigurer.configureAsyncSupport设置全局默认超时时间,作为最后一道防线。 - 封装
DeferredResult创建:创建一个工厂方法,确保每个DeferredResult都包含超时、超时处理和错误处理逻辑。 - 监控未完成请求:利用
DeferredResult的setResultHandler或 AOP,记录请求的开始和完成时间,定期通过jstack或自定义 Endpoint 检查是否有长期未处理的请求。
案例 12:异步方法中抛出异常未捕获,结果无法返回客户端
错误示例
@RequestMapping("/async-exception")
public Callable<String> handleAsyncException() {
return () -> {
if (true) {
throw new RuntimeException("Unexpected error in async processing!");
}
return "OK";
};
}
现象描述
客户端等待并最终超时,或者得到一个空的 200 OK 响应(或默认错误响应),但在服务端日志中,异常信息被淹没,没有被框架正确捕获并转化为错误响应发给客户端。
排查思路
- 日志文件:在应用的日志中搜索
RuntimeException。可能只看到一个简单的栈追踪,但没有与特定的请求关联起来。日志级别为 ERROR,但请求可能没有被正常结束。 - 客户端观察:客户端可能收到一个由 Web 容器返回的超时错误,或者一个不标准的 500 错误。
- 条件断点:在
Callable代码块内的throw语句上设置断点,并查看其调用栈。向上追溯,可以发现它由Callable的call()方法抛出,然后被 Spring 的FutureTask或类似的包装器捕获。进一步在WebAsyncManager的handleError或类似方法上设置断点,观察异常是如何被包装和处理的。WebAsyncManager内部会捕获Callable抛出的异常,并将其作为结果错误设置到DeferredResult的内部机制中。
根因分析
Spring MVC 的异步处理流程对 Callable 中抛出的异常有默认的处理机制。当 Callable.call() 抛出异常时,WebAsyncManager 启动的 Future 任务会捕获该异常。
// WebAsyncManager 内部处理Callable的部分逻辑
// ...提交Callable任务到executor时...
Future<?> future = this.asyncTaskExecutor.submit(() -> {
try {
Object result = webAsyncTask.getCallable().call();
// 如果成功,会通过setResult传递结果,触发DispatcherServlet恢复处理
this.asyncWebRequest.onComplete(event);
this.asyncWebRequest.dispatch();
}
catch (Throwable ex) {
// 如果失败,会用异常来触发错误结果
this.asyncWebRequest.onError(ex);
this.asyncWebRequest.dispatch(); // 再次dispatch回容器,交给异常处理器
}
});
理论上,Spring 的这种设计确保了异常会被重新 dispatch 回 DispatcherServlet,然后由注册的 HandlerExceptionResolver(如 ExceptionHandlerExceptionResolver)处理。反模式出现在以下几个场景:
- 异常处理器不匹配:抛出的异常类型没有匹配到任何
@ExceptionHandler方法,最终由容器(如 Tomcat)的默认错误页面或BasicErrorController处理,返回了非预期的客户端响应。 - 异步异常处理中的二次异常:当
ExceptionHandlerExceptionResolver尝试处理这个异常时,如果在处理过程中(例如,在writeWithMessageConverters写响应体时)再发生错误,可能导致响应被截断或产生一个更难以理解的错误。 DeferredResult.setErrorResult覆盖:如果开发者在代码中也设置了错误结果,可能与框架的异常处理发生冲突。
修正方案
在异步代码内部,用 try-catch 包裹所有代码,并用DeferredResult.setErrorResult()或重新抛出特定领域异常。对于Callable,确保有全局@ExceptionHandler处理异步可能抛出的异常。
// 修正:为异步可能抛出的异常定义全局处理器
@ControllerAdvice
public class GlobalAsyncExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> handleRuntimeException(RuntimeException ex) {
// 记录日志
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Async operation failed: " + ex.getMessage());
}
}
对于 DeferredResult:
dr.onError((Throwable t) -> {
// 总是设置一个客户端能理解的错误结果
dr.setErrorResult(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Business error").build());
});
在 Callable 中:
return () -> {
try {
// ...
} catch (BusinessException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Business error", e); // 抛出Spring能理解的异常
}
};
最佳实践
- 全局异常处理:总是为异步任务可能抛出的未检查异常设置一个顶层的
@ExceptionHandler(Exception.class)作为兜底。 - 异常转义:在异步任务的边界(
Callable的call方法开始处),将未知的异常转义为 Spring 的NestedServletException或ResponseStatusException,以确保它们能被 Servlet 容器和 Spring 异常处理机制正确处理。 - 日志与追踪:在异步异常发生时,确保有完整的日志和请求链路追踪 ID(Trace ID),方便日志关联。
6. 异常处理反模式
异常处理是保证 API 健壮性和一致性的关键。错误的异常配置会导致信息泄露、客户端无法解析错误等严重问题。
案例 13:@ControllerAdvice 与局部 @ExceptionHandler 优先级冲突
错误示例
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleAllExceptions(Exception ex) {
return new ResponseEntity<>("Global: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<String> handleDataExceptions(DataIntegrityViolationException ex) {
return new ResponseEntity<>("Data Integrity Violation globally", HttpStatus.CONFLICT);
}
}
@RestController
public class UserController {
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<String> handleLocalDataException(DataIntegrityViolationException ex) {
return new ResponseEntity<>("Local Data Integrity for User", HttpStatus.CONFLICT);
}
@GetMapping("/user/error")
public String triggerError() {
throw new DataIntegrityViolationException("Duplicate user");
}
}
现象描述
当访问 /user/error 时,UserController 中抛出的 DataIntegrityViolationException 可能被 GlobalExceptionHandler 的 handleDataExceptions 捕获,而不是被局部的 handleLocalDataException 捕获。返回的响应体是 "Data Integrity Violation globally",而不是预期的 "Local Data Integrity for User"。
排查思路
- 日志输出:在两个异常处理方法的开头添加明显的日志输出,观察哪一个被调用。
- Actuator 端点:可以间接地通过查看
/actuator/beans,确认GlobalExceptionHandler和UserController都被成功加载为 Bean。 - 条件断点:
- 在
ExceptionHandlerExceptionResolver.doResolveHandlerMethodException方法入口设置断点。 - 逐步执行,观察它调用
getExceptionHandlerMethod来寻找处理器的过程。 getExceptionHandlerMethod内部会检查@ControllerAdvice和 Controller 自身的@ExceptionHandler方法。Spring 会为找到的处理器方法进行排序,排序的依据通常是异常的继承深度以及两者的“距离”。
- 在
根因分析
ExceptionHandlerExceptionResolver 负责调用 @ExceptionHandler 方法。当它寻找匹配的处理器时,会为同一个异常类型找到多个候选项,然后对它们进行排序,选择“最佳匹配”。排序规则在 ExceptionDepthComparator 中,它主要比较异常的继承深度(越具体越优先)和处理器的来源。在 Spring 5.3.x 中,局部的 @ExceptionHandler 定义在 Controller 内部,而全局的则定义在 @ControllerAdvice 中。通常,局部定义的方法应该具有更高的优先级,因为它的“距离”更近。然而,某些版本或特定组合下,如果全局处理器和局部处理器处理的异常类型完全一样,或者全局处理器使用了 @Priority,就可能导致排序不符合预期。另一个常见问题是:全局处理器捕获的是父类异常,而局部处理器捕获的是子类异常,但异常对象是父类引用。 Spring 在选择时会基于异常的运行时类型(DataIntegrityViolationException)来选择,而不是声明类型。所以通常局部处理器应该优先匹配。真正的反模式是当全局处理器用了 @Order 或实现 Ordered 接口,影响了异常处理的优先级顺序。
修正方案
明确异常处理策略。通常的做法是:局部处理器处理最具体的、与 Controller 紧密相关的异常;全局处理器作为兜底,处理更一般的异常。避免全局处理器“越俎代庖”声明过于具体的异常。
在全局处理器上避免使用 @Order 来与局部处理器竞争优先级。优先让 Spring 的默认规则(局部优先)生效。
// 最佳实践修正:局部处理具体的,全局处理通用的
@ControllerAdvice
public class GlobalExceptionHandler {
// 只处理最通用的Exception作为兜底
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGeneralException(Exception ex) {
return new ResponseEntity<>("Internal Server Error", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
最佳实践
- 约定大于配置:建立团队内部异常处理的优先级约定:局部优先于全局,具体异常优先于通用异常。不在全局处理器中声明与局部处理器可能重叠的具体异常。
- 异常分层设计:设计分层异常体系。例如,
BusinessException(checked) 及其子类通常在业务层处理并转换,而SystemException(unchecked) 及其子类流向 Web 层的全局异常处理器。 - 为全局异常处理器编写集成测试:编写特定的集成测试,断言在 Controller 抛出特定异常时,返回的 HTTP 状态码和响应体符合预期,验证优先级是否正常。
案例 14:BasicErrorController 返回 HTML 而非 JSON,导致 API 客户端解析错误
错误示例
Spring Boot 应用未做任何异常处理配置,全部依赖默认的 BasicErrorController。客户端使用 RestTemplate 或 WebClient 调用该微服务 API。
// 客户端代码
restTemplate.getForObject("http://service/api/data", String.class);
现象描述
服务端发生 500 错误时,客户端抛出一个解析异常,类似 org.springframework.web.client.RestClientException: Could not extract response: no suitable HttpMessageConverter found for response type [class java.lang.String] and content type [text/html]。这是因为 BasicErrorController 根据请求的 Accept 头返回了一个 HTML 的 Whitelabel Error Page,而客户端期望的是 application/json。
排查思路
- 客户端日志:客户端错误日志将直接显示
Content-Type不匹配。 - 浏览器验证:如果 API 也被浏览器调用,浏览器会渲染出白色的错误页面,并显示 500 状态码。
- 服务端访问日志:确认请求的
Accept头。某些客户端(如RestTemplate在不设置消息转换器时)可能不会发送Accept头,或者发送一个非常宽泛的Accept头(如*/*)。 - 条件断点:在
BasicErrorController.errorHtml和BasicErrorController.error方法上设置断点。观察哪个方法被调用。其路由逻辑基于请求Accept头中是否含有text/html。如果客户的请求没有明确指明期望application/json,errorHtml方法可能会被命中。
根因分析
BasicErrorController 是 Spring Boot 自动配置提供的默认错误控制器,定义在 org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController。它有两个主要的请求映射方法:
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) { ... }
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { ... }
Spring MVC 在分发请求时,对于一个发生了异常并转入错误处理的请求,会根据 produces 条件进行内容协商。如果客户端请求的 Accept 头包含了 text/html(浏览器通常如此),errorHtml 方法就会被调用,返回一个 ModelAndView,最终渲染为 HTML。对于许多微服务间的纯 API 调用,客户端 RestTemplate 可能没有显式设置 Accept: application/json,或者设置的是 Accept: */*。在这种情况下,Spring 的内容协商可能会选择 HTML,因为 */* 可以匹配任何类型,而 text/html 在 Spring 的某些版本中可能作为默认或优先级较高的一种候选。
修正方案
对于纯 API 服务,禁用BasicErrorController并实现自定义的、只返回 JSON 的全局异常处理。
// 方案1:使用@ControllerAdvice接管所有异常处理(推荐)
@ControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleAll(Exception ex, WebRequest request) {
ApiError error = new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, ex);
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// 同时设置 property: server.error.whitelabel.enabled=false
// (Spring Boot 2.3+, 否则需要排除ErrorMvcAutoConfiguration)
// 方案2:配置BasicErrorController相关Bean,但完全替换ErrorAttributes
// (不推荐,因为BasicErrorController仍然会根据Accept决定内容格式)
对于必须保留 BasicErrorController 的混合型应用(浏览器页面 + API),可以在 API 路径上加一个 Filter,为缺失 Accept 头的请求强制设置 Accept: application/json。
// 一个强制默认Accept为JSON的Filter
public class ForceJsonAcceptFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
if (request.getHeader("Accept") == null && request.getRequestURI().startsWith("/api/")) {
HeaderMapRequestWrapper wrapper = new HeaderMapRequestWrapper(request);
wrapper.addHeader("Accept", "application/json");
filterChain.doFilter(wrapper, response);
} else {
filterChain.doFilter(request, response);
}
}
}
最佳实践
- API 优先原则:如果应用主要是 API 服务,从一开始就禁用
Whitelabel Error Page,采用基于@ControllerAdvice的全局 JSON 异常处理。 - 统一客户端:在内部的
RestTemplate/WebClientBean 中,通过HttpHeaders统一设置Accept: application/json。 - 异常信息体规范:定义统一的
ApiError对象,包含timestamp,status,error,message,path等字段,与客户端约定好错误格式,参考 Problem Details for HTTP APIs (RFC 7807)。
7. WebFlux 与响应式反模式
WebFlux 的响应式模型移除了传统的阻塞线程池,一切运行在少量的事件循环线程上。任何阻塞调用都会是致命的。
案例 15:响应式管道中调用阻塞 API,引发 BlockHound 告警并卡死事件循环
错误示例
@RestController
public class ReactiveController {
@Autowired
private JdbcTemplate jdbcTemplate; // 阻塞式JDBC
@GetMapping("/users-reactive")
public Mono<List<User>> getUsersReactive() {
return Mono.fromCallable(() -> jdbcTemplate.query("SELECT * FROM users", userRowMapper))
.subscribeOn(Schedulers.boundedElastic()); // 虽然有切换,但直接在主链路上常常被忘记
}
}
// 一个更恶劣、直接在主链路上阻塞的例子:
@GetMapping("/blocking-call")
public Mono<String> blockingCall() {
// 直接在当前线程调用阻塞方法,通常是netty的事件循环线程
String result = someRestTemplate.getForObject("http://slow-service", String.class);
return Mono.just(result);
}
现象描述
WebFlux 应用吞吐量极低,耗时很长。BlockHound(如果引入)会在控制台抛出异常,指出某行代码在不允许的线程上调用了阻塞方法。如果没有引入 BlockHound,现象会更加隐蔽:Netty 事件循环线程被卡住,只有少量的请求能被处理,其他请求均在排队。
排查思路
- 添加 BlockHound:这是排查此类问题的第一步。在
main方法中加载BlockHound。它会精准地报告阻塞调用发生的线程和方法。public static void main(String[] args) { BlockHound.install(); SpringApplication.run(Application.class, args); } - jstack 分析:执行
jstack <pid>。寻找reactor-http-nio-*或nioEventLoopGroup-*线程。如果它们的状态是WAITING或BLOCKED,并且栈信息中包含了 JDBC、HTTP client 或其他经典阻塞库的方法调用,即可确认。 - Reactor 调试:开启
Hooks.onOperatorDebug()来分析 Reactor 操作符的装配栈,这有助于定位是在哪个 Flux/Mono 操作链中引入了阻塞代码。
根因分析
WebFlux 默认使用非阻塞的 Netty 作为底层服务器,它只有数量等于 CPU 核数(或两倍)的事件循环线程(Event Loop threads)。所有请求的处理、资源的读写都在这少数几个线程上通过异步事件驱动的方式完成。如果一个请求处理中,在某一个步骤花费了较长时间去阻塞等待(例如,等待数据库连接、等待 HTTP 响应、等待 Thread.sleep),那么这个事件循环线程就会卡住,无法处理其他请求事件。一个小小的阻塞调用,就能瘫痪整个应用。
// 典型的阻塞调用栈 (jstack)
"reactor-http-nio-3" #39 daemon prio=5 os_prio=0 cpu=... tid=... nid=... waiting on condition [x..]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
... // Netty内部的selector.park
at reactor.netty.transport.ServerTransport$ChildObserver.onStateChange(...)
...
at org.springframework.web.reactive.DispatcherHandler.handle(...)
...
at com.example.ReactiveController.blockingCall(...)
at com.example.ReactiveController$$Lambda$... // Mono.just被调用
// 但如果阻塞发生在Mono内部,栈会是这样的:
at java.net.SocketInputStream.socketRead0(Native Method)
at org.postgresql.core.v3.QueryExecutorImpl.processResults(...)
...
at com.example.dao.JdbcUserDao.findAll() // 阻塞JDBC调用
修正方案
必须将任何阻塞调用隔离到专门的调度器(Scheduler)上。 Reactor 提供了 Schedulers.boundedElastic() 专门用于此类场景。
// 修正:使用subscribeOn隔离阻塞调用
@GetMapping("/users-reactive-fixed")
public Mono<List<User>> getUsersReactive() {
return Mono.fromCallable(() -> jdbcTemplate.query("SELECT * FROM users", userRowMapper))
.subscribeOn(Schedulers.boundedElastic()); // 将阻塞工作调度到弹性线程池
}
// 或者,对于WebClient等非阻塞客户端,则完全不需要切换线程。
@GetMapping("/non-blocking-call")
public Mono<String> nonBlockingCall(WebClient webClient) {
return webClient.get().uri("http://slow-service").retrieve().bodyToMono(String.class);
}
最佳实践
- 依赖
BlockHound作为 CI 检查:将BlockHound集成到测试套件中,任何触发BlockHound告警的测试都应视为失败。 - 代码审查重点:审查所有
Flux/Mono链的创建和执行,确认没有直接或间接调用已知的阻塞 API(JDBC, JPA,RestTemplate,ObjectMapper.readValue(InputStream)等)。 - 使用非阻塞替代方案:积极采用 R2DBC 代替 JDBC,使用 WebClient 代替 RestTemplate,使用响应式 Redis、Kafka 客户端等。对于必须的阻塞代码,用
subscribeOn(Schedulers.boundedElastic())并确保返回到主事件循环的代码路径是非阻塞的。
案例 16:Flux.create 忽略背压导致 OverflowException 或 OOM
错误示例
@GetMapping("/events")
public Flux<ServerSentEvent<String>> streamEvents() {
return Flux.create(emitter -> {
// 模拟一个无限、高速的生产者,例如监听一个消息队列
while (!emitter.isCancelled()) {
String event = fastProducer.next(); // next() 从不阻塞,可能瞬间产生海量数据
emitter.next(event);
}
emitter.complete();
});
}
现象描述
当客户端订阅 /events 时,服务端内存(堆)飙升,很快就抛出 OutOfMemoryError。或者,如果配置了缓冲区限制,Flux.create 会抛出 reactor.core.Exceptions$OverflowException: The receiver is overrun by more signals than expected (bounded queue...)。
排查思路
- JVM 堆转储:使用
jmap获取堆转储,使用 MAT 或 JVisualVM 分析。会看到大量的String或其它被emitter.next()发送的对象无法被 GC 回收,它们都积压在 Reactor 内部的缓冲区中。引用链会指向FluxSink的内部队列。 - Reactor 调试:开启
Hooks.onOperatorDebug(),当OverflowException抛出时,其栈信息会清晰地指向Flux.create的调用点。 - 日志与指标:监控应用内存使用率,并观察
/actuator/metrics/reactor.*(如果暴露)。
根因分析
背压(Backpressure)是 Reactive Streams 的核心概念,它允许消费者(Subscriber)告诉生产者(Publisher)它能够处理多少数据。Flux.create 默认使用一个无界队列(Queues.SMALL_BUFFER_SIZE 应用于某些模式,但 create 默认是 BUFFERED 模式且基本无界)来缓冲生产者推过来的数据,以防消费者处理不及。
当生产者(示例中的 while 循环)速度远快于消费者(网络 IO、客户端)时,无界缓冲区会迅速填满整个 JVM 堆内存,导致 OOM。或者,如果使用了 create 的 OverflowStrategy.LATEST 或 DROP,数据会丢失,但避免了 OOM。而默认策略是 BUFFER,会导致 OOM。
// Flux.create 背后的背压处理
// 默认是 FluxSink.OverflowStrategy.BUFFER,使用SpscLinkedArrayQueue等无界队列
public static <T> Flux<T> create(Consumer<? super FluxSink<T>> emitter) {
return create(emitter, FluxSink.OverflowStrategy.BUFFER);
}
当订阅发生时,Reactor 链会向上游传播一个 request(N),其中 N 通常是 small buffer size。但 Flux.create 默认并不严格遵守这一请求量,特别是在 BUFFER 策略下,它会一直从 emitter.next() 接收数据并放入内部缓冲区,再慢慢向下游发送。
修正方案
使用支持背压的生成方式,或在Flux.create中处理请求量,使用FluxSink.OverflowStrategy在无法处理时进行优雅降级。
// 修正方案1:使用Flux.generate (同步,一对一,支持背压)
@GetMapping("/events-generate")
public Flux<String> streamEventsGenerate() {
return Flux.generate(
() -> fastProducer, // 初始化状态
(producer, sink) -> {
String event = producer.next();
sink.next(event);
if (someCondition) sink.complete();
return producer; // 返回新状态
}
);
}
// 修正方案2:使用Flux.create的背压变体
@GetMapping("/events-create-bp")
public Flux<String> streamEventsCreateWithBP() {
return Flux.create(emitter -> {
// 使用onRequest来根据下游请求量生产数据
emitter.onRequest(n -> {
for (int i = 0; i < n && !emitter.isCancelled(); i++) {
emitter.next(fastProducer.next());
}
});
}, FluxSink.OverflowStrategy.LATEST); // 下游太慢时,只保留最新数据,丢弃旧的
}
最佳实践
- 优先使用高抽象层级操作符:
Flux.generate,Flux.interval,Flux.fromIterable等操作符已经内置了正确的背压处理。 - 仅在必要时使用
create:Flux.create是为桥接遗留的生产者-消费者 API 而设计的。使用前,评估是否可以用其他操作符替代。 - 永远处理背压:在
create的回调中,通过emitter.onRequest(...)来控制生产速度,并选择合适的OverflowStrategy(如DROP,LATEST,ERROR)以应对下游缓慢的情况,绝不依赖默认的无界缓冲。
8. 父子容器与上下文反模式
在 Spring MVC 中,DispatcherServlet 拥有自己的 WebApplicationContext,它是根 ApplicationContext 的子容器。这种设计在大型应用中是一个常见的陷阱。
案例 17:根容器和 Servlet 容器组件扫描重叠,导致 Bean 冲突
错误示例
一个典型的 Spring Boot 应用中,开发者试图显式声明两个容器。
// 根容器配置
@Configuration
@ComponentScan(basePackages = "com.example") // 扫描了整个包
public class RootConfig {
}
// Servlet容器配置
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = "com.example.web") // 又扫描了web子包
public class WebConfig {
@Bean
public MyService myService() {
return new MyService("from-servlet-container");
}
}
尽管 Spring Boot 试图单容器化,但通过这种配置可以强制创建父子容器。
现象描述
@Service或@Component注解的 Bean 被创建了两次。日志中可以看到两次 Bean 初始化的记录。@Autowired注入时,可能返回了不符合预期的 Bean 实例。例如,Controller 中期望注入的MyService是来自根容器(带 AOP 事务代理)的,但实际注入的是 Servlet 容器中未被增强的“原始” Bean。- 应用于根容器 Bean 的事务 (
@Transactional) 可能对 Controller 中调用的 Service 无效,因为 Controller 和 Service 分别位于两个容器,事务切面定义在根容器,而调用链起始于 Servlet 容器。
排查思路
- Actuator 端点:访问
/actuator/beans,搜索同名或同类型的 Bean。你会发现myService或其他 bean 出现了两次,或者它们的scope、resource定义信息不同。 - 启动日志:将日志级别设置为 DEBUG,查找 Bean 的注册信息。
DefaultListableBeanFactory会输出注册 Bean 定义和创建 Bean 的日志。重复的创建日志是直接证据。 ApplicationContext检查:在一个 Controller 中自动注入ApplicationContext,并打印它的 ID 和 parent。然后注入一个MyService,打印它的toString以及它所属的applicationContext。@RestController public class DebugController { @Autowired private ApplicationContext context; @Autowired private MyService myService; @GetMapping("/context") public String getContextInfo() { return "Current context: " + context.getId() + "\nParent context: " + (context.getParent() != null ? context.getParent().getId() : "null") + "\nMyService Bean: " + myService.toString(); } }- 条件断点:在
DefaultListableBeanFactory.doCreateBean上设置断点,条件是beanName.equals("myService")。观察调用栈,会看到doCreateBean被调用两次,分别由根容器和 Servlet 容器触发,验证了重复创建。
根因分析
在标准的 Spring MVC 父子容器模型中,子容器(Servlet 容器)可以访问父容器(根容器)中的 Bean,但反之不行。当两个容器的 @ComponentScan 范围发生重叠时,重叠包下的所有带有 @Component、@Service 等注解的类会在两个容器中都被创建。
这就导致了两个同类型的 Bean 分别存在于父子容器中。@Autowired 默认按照类型查找。DispatcherServlet 在处理请求时,会优先在它的子容器中查找 Bean,找不到再到父容器。因此,Controller 通常会注入子容器中新建的 MyService。而这个子容器中的 Bean 因为没有在父容器的 AOP 事务切面范围内,所以它不会具有事务能力。
// 典型的父子容器结构
Root WebApplicationContext (扫描 "com.example")
|-- MyService (with @Transactional proxy) // 父容器中的
|-- DataSource, TransactionManager, etc.
|
DispatcherServlet WebApplicationContext (扫描 "com.example.web" 重叠 "com.example")
|-- MyController
|-- MyService (plain object, no proxy) // 子容器中的,Controller会注入这个
修正方案
在 Spring Boot 应用中,坚决避免创建父子容器。Spring Boot 的最佳实践是单一容器。 如果必须使用父子容器,必须确保子容器的扫描范围是父容器扫描范围的严格子集(通常只含 Controller),并且不重叠。
// 最佳修正:遵从SpringBoot单容器模型,不显式定义父子容器
@SpringBootApplication // 这自带了 @ComponentScan,它会扫描所有
public class Application {
}
// 如果由于遗留原因必须使用,必须严格隔离扫描范围
// 父容器
@Configuration
@ComponentScan(basePackages = "com.example", excludeFilters = {
@ComponentScan.Filter(Controller.class), // 排除Controller
@ComponentScan.Filter(EnableWebMvc.class)
})
public class RootConfig {
}
// 子容器
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = "com.example.web") // 假设controller都在此包下
public class WebConfig {
}
最佳实践
- 拥抱单容器:在 Spring Boot 中,默认就是单容器模型,要警惕任何试图手动创建父子容器的教程或遗留代码。
- 严谨的包结构:使用
com.example.app作为主程序包,将其他模块放在其下,如com.example.app.service,com.example.app.web.controller。这样默认的@SpringBootApplication扫描就能覆盖,且结构清晰。 - Bean 冲突检测:在 CI/CD 流水线中加入依赖注入相关的集成测试,可以通过端点
/actuator/beans检查特定 Bean 的实例数,或在测试中注入ApplicationContext断言context.getBeanNamesForType(MyService.class).length == 1。
9. 安全与并发反模式
安全和并发是现代 Web 应用的核心挑战。上下文丢失、线程 pinning 等问题在引入异步和虚拟线程后变得尤为突出。
案例 18:SecurityContextHolder 在 @Async 或 DeferredResult 线程中丢失
错误示例
@Service
public class AsyncUserService {
@Async
public CompletableFuture<String> getCurrentUsernameAsync() {
// 尝试在异步线程中获取当前登录用户
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
return CompletableFuture.completedFuture("anonymous");
}
return CompletableFuture.completedFuture(auth.getName());
}
}
@RestController
public class UserController {
@GetMapping("/async-user")
public CompletableFuture<String> asyncUser() {
return asyncUserService.getCurrentUsernameAsync(); // 异步调用
}
}
现象描述
这个异步方法总是返回 "anonymous",即使请求已经通过认证。同样的问题也出现在 DeferredResult 的回调线程中。这可能导致 NullPointerException 或严重的权限绕过问题。
排查思路
- 功能测试:编写一个集成测试,先模拟用户登录,再访问
/async-user,断言返回的是用户名而不是 "anonymous"。这个测试会稳定失败。 - 日志埋点:在主线程和异步线程中分别打印
SecurityContextHolder.getContext().getAuthentication()和当前线程 ID。可以清楚地看到主线程有认证信息,异步线程丢失。 - 源码追踪:了解 Spring Security 的
SecurityContextPersistenceFilter。它默认使用ThreadLocalSecurityContextHolderStrategy,将SecurityContext存储在ThreadLocal中。ThreadLocal在线程切换(@Async或TaskExecutor)时不会自动传递。
根因分析
Spring Security 默认使用 ThreadLocal 来持有 SecurityContext,这种策略被称为 MODE_THREADLOCAL。这是为了确保每个请求的上下文是线程隔离的。然而,当工作被移交给另一个线程(如通过 @Async 或 TaskExecutor 执行 Callable/DeferredResult 回调),新的线程无法访问原来(请求处理线程)的 ThreadLocal 变量。
// SecurityContextHolder 的默认策略
public class SecurityContextHolder {
public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
// ...
private static SecurityContextHolderStrategy strategy = new ThreadLocalSecurityContextHolderStrategy();
// ...
}
修正方案
配置 Spring Security 使用能够传播上下文到子线程的策略。 对于 @Async 方法,可以使用 MODE_INHERITABLETHREADLOCAL。对于线程池,则必须使用 DelegatingSecurityContextAsyncTaskExecutor 或 Spring 提供的其他传播机制。
// 方案1: 全局配置(对new Thread等方式有限制,推荐配合Spring使用)
@Configuration
@EnableAsync
public class SecurityConfig {
// 对于 @Async,使用支持上下文传播的TaskExecutor
@Bean("taskExecutor")
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// ... 标准配置 ...
executor.setTaskDecorator(new DelegatingSecurityContextTaskDecorator());
executor.initialize();
return executor;
}
}
// 方案2: 为@Async方法手动传播(不推荐,过于繁琐)
@Async
public CompletableFuture<String> getUsernameManually() {
SecurityContext ctx = SecurityContextHolder.getContext();
return CompletableFuture.supplyAsync(() -> {
SecurityContextHolder.setContext(ctx); // 手动设置
// ... 业务逻辑 ...
});
}
对于 Web 层异步处理,Spring Security 的 WebAsyncManagerIntegration 会将 SecurityContext 与 Callable 的创建集成。确保 spring.security.filter.dispatcher-types=ASYNC 等配置正确。更现代的方案是使用 spring-security-aspects 或确保 SecurityContextHolderStrategy 设置为 MODE_INHERITABLETHREADLOCAL。
// 在main或配置中,设置上下文传播模式
static {
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
注意,InheritableThreadLocal 不能解决线程池复用线程导致的上下文污染问题。使用 DelegatingSecurityContextTaskExecutor 或 TaskDecorator 是处理线程池的标准方式。
最佳实践
- 使用
DelegatingSecurityContext*包装器:在创建异步执行器(TaskExecutor,Scheduler等)时,总是使用 Spring Security 提供的包装器,它们会在任务执行前设置上下文,并在执行后清理。 - 测试安全性:编写集成测试,模拟用户登录后,明确测试任何
@Async、@EventListener、DeferredResult等异步路径下,安全上下文是否正确传播。 - Micrometer Context Propagation:对于更广泛的上下文传播需求(如 Tracing, MDC),可以考虑引入 Micrometer Context Propagation 库,它提供了一套 API 来在
ThreadLocal、Reactor上下文等之间传递信息。
案例 19:虚拟线程环境下 synchronized 导致平台线程 Pinning (JDK 21+)
错误示例
// 一个在虚拟线程中调用的“常规”同步方法
@Service
public class ExternalService {
// 使用对象锁进行同步
public synchronized String callSlowExternalApi(String data) {
// 模拟慢速网络 IO
return restTemplate.getForObject("http://slow-external-api/data?q=" + data, String.class);
}
}
@RestController
public class VThreadController {
@GetMapping("/vthread-pinning")
Callable<String> handleWithVThread() {
return () -> externalService.callSlowExternalApi("test");
}
}
// 配置spring.thread-executor=virtual (或显式配置AsyncTaskExecutor为VirtualThreadTaskExecutor)
现象描述
应用在流量高峰时并行处理大量虚拟线程。理论上成千上万的虚拟线程可以并发执行,但吞吐量远低于预期。通过 jstack 或 JFR 等工具,可以看到只有少数几个(与平台线程数相等的)callSlowExternalApi 在执行,大量的虚拟线程在等待 synchronized 锁,并且它们被 pin(固定) 在了它们的载体平台线程上。
排查思路
jstackwith-XX:+UnlockDiagnosticVMOptions -XX:+ShowCarrierFrames:使用这些特殊参数抓取线程栈。这将显示虚拟线程及其载体线程的关联信息。你会看到大量虚拟线程被synchronized阻塞,并且它们各自 pin 了一个不同的平台线程。- Java Flight Recorder (JFR):启用 JFR 追踪
jdk.VirtualThreadPinned事件。这个事件会精确记录虚拟线程何时被 pin 住以及 pin 了多久。这是检测该问题最直接的手段。 - 日志与指标:监控
jdk.VirtualThreadPinned的事件计数(可通过 Micrometer 暴露),如果该计数器持续增加,则说明存在 pinning 问题。
根因分析
虚拟线程(Virtual Threads)被设计为由许多虚拟线程复用少量平台线程(Platform Threads / OS Threads)。当一个运行在平台线程上的虚拟线程执行阻塞操作(如 IO)时,虚拟线程会被卸载(unmount),从而将平台线程让给其他虚拟线程使用。这是虚拟线程高吞吐量的关键。
但是,当虚拟线程在一个 synchronized 块或方法内部执行阻塞操作时,由于 synchronized 的实现深度关联到对象头中的锁字,JDK 目前的实现无法将其从载体线程上卸载。这就导致了 pinning(钉住):虚拟线程不仅自己被阻塞,还占据了宝贵的平台线程,导致该平台线程无法为其他虚拟线程服务。如果所有平台线程都被 pin 住,整个应用将彻底停滞,尽管可能还有大量虚拟线程在等待执行。
// synchronized 内阻塞导致pinning的原理示意
// 虚拟线程载体: ForkJoinPool-1-worker-1
// 虚拟线程: virtual-thread-1
// 1. virtual-thread-1被mount到worker-1
// 2. virtual-thread-1执行enter synchronized block (获取对象锁)
// 3. virtual-thread-1发起网络IO (read/write socket)
// 4. 按理说应该unmount,但因为它在synchronized内部,JDK“不敢”unmount
// 5. worker-1被virtual-thread-1牢牢 pin 住,一起阻塞
// 6. ForkJoinPool其他worker可能也在做同样的事情,很快所有平台线程都被pin住
修正方案
将需要高并发的 I/O 或阻塞操作中的synchronized替换为java.util.concurrent.locks.ReentrantLock。
// 修正方案:使用ReentrantLock
@Service
public class ExternalServiceFixed {
private final ReentrantLock lock = new ReentrantLock();
public String callSlowExternalApi(String data) {
lock.lock();
try {
return restTemplate.getForObject("http://slow-external-api/data?q=" + data, String.class);
} finally {
lock.unlock();
}
}
}
ReentrantLock 基于 AbstractQueuedSynchronizer (AQS) 框架,它的阻塞实现不会导致虚拟线程 pinning。当虚拟线程等待 lock.lock() 时,它可以被正确卸载。
最佳实践
- 替换所有关键路径的
synchronized:在对延迟敏感的互联网应用中,尤其是移入虚拟线程环境的代码,逐步将synchronized方法和块替换为ReentrantLock及其条件变量。 - 启用 Pinning 事件监控:在开发和上线初期,启用
jdk.VirtualThreadPinned事件的 JFR 记录和监控,主动发现并消除 pinning 点。 - 性能测试代码审查:在专门针对虚拟线程的代码审查中,将“是否存在
synchronized块内包含阻塞操作”作为关键检查项。 - 注意第三方库:许多流行的 Java 库内部仍然大量使用
synchronized。在采用虚拟线程前,需要评估和测试依赖库。
10. 静态资源与缓存反模式
静态资源的处理和缓存是 Web 性能优化的关键,错误的配置不仅可能导致性能问题,还可能引发 OOM 和数据安全风险。
案例 20:ShallowEtagHeaderFilter 对大文件或流式响应导致 OOM
错误示例
// 配置了 ShallowEtagHeaderFilter
@Bean
public Filter shallowEtagHeaderFilter() {
return new ShallowEtagHeaderFilter();
}
// 一个下载大文件的接口
@GetMapping("/download/large-file")
public ResponseEntity<Resource> downloadFile() {
Resource file = new FileSystemResource("/path/to/huge-file.zip"); // 假设1GB
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(file);
}
现象描述
每当有请求下载大文件时,服务端的 JVM 堆内存瞬间飙升,并且无法被 GC 回收,最终导致 OutOfMemoryError。jmap -histo 显示大量的 byte[] 数组,引用自 ShallowEtagHeaderFilter 的内部缓存。
排查思路
- 堆转储分析:获取 OOM 后的堆转储。
byte[]实例会占据绝大部分内存,它们的 GC Root 路径会清晰地指向org.springframework.web.filter.ShallowEtagHeaderFilter的内部结构。 - 过滤分析:在测试环境中,暂时移除
ShallowEtagHeaderFilter,看大文件下载是否恢复正常。如果恢复,则确认是该 Filter 的问题。 - 条件断点:在
ShallowEtagHeaderFilter.doFilterInternal中,找到它包装HttpServletResponse以捕获响应输出流的代码。在输出流被写入大量数据时,观察内存占用。
根因分析
ShallowEtagHeaderFilter 的工作原理是拦截响应,创建一个包装的 HttpServletResponse,使其写入的内容实际写入一个字节数组输出流(ByteArrayOutputStream 或其他类似机制),而不是直接发送给客户端。在响应完全生成后,Filter 读取整个响应体(字节数组),计算其散列值(如 MD5),作为 ETag 头。然后,它将这个 ETag 与请求中的 If-None-Match 头进行比较。如果匹配,返回 304 Not Modified;否则,将缓存的响应体写到真正的响应流中。
// ShallowEtagHeaderFilter 内部逻辑示意
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
// 创建一个包装的Response,其内部流是ByteArrayOutputStream
ShallowEtagResponseWrapper responseWrapper = new ShallowEtagResponseWrapper(response);
filterChain.doFilter(request, responseWrapper);
byte[] body = responseWrapper.toByteArray(); // 在这里,整个响应体被加载到内存!
String eTag = generateETagHeaderValue(body); // 计算ETag
if (request.getHeader("If-None-Match") != null && ...) { // 比较ETag
response.setStatus(HttpStatus.SC_NOT_MODIFIED);
} else {
response.setHeader("ETag", eTag);
writeBody(response, body); // 输出
}
}
这种“浅层”ETag 的代价极其高昂:它强制在生成 ETag 之前将整个 HTTP 响应缓冲在内存中。对于动态生成的大型响应(如文件下载、视频流、大型 JSON),这在内存和时间上都是灾难性的。
修正方案
禁止对任何非纯动态文本且可能产生大响应体的端点使用ShallowEtagHeaderFilter。改为依赖应用层或专门的静态资源服务器(如 Nginx)来计算 ETag。
- 方案1(针对静态资源):配置 Spring 静态资源处理时,使用
ResourceHttpRequestHandler内置的基于文件内容、长度、最后修改时间的 ETag 生成机制,它是零内存开销的。 - 方案2(针对某些 API):如果有小段且稳定的 JSON API 需要 ETag,可以在 API 代码中手动处理,或者使用 Spring Security 的类似过滤器但排除大文件路径。
- 方案3(最佳方案):在反向代理层(Nginx, CDN)处理 ETag 和 Cache-Control,让 Spring 关注业务逻辑。移除
ShallowEtagHeaderFilter。
// 配置ResourceHandler,它自己的ETag更高效
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/download/**")
.addResourceLocations("file:///path/to/download/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS))
.useLastModified(true) // 使用资源的最后修改时间作为ETag基础
.resourceChain(false); // 禁用额外的资源链
}
}
最佳实践
- 禁用默认的 ShallowETag:在使用
spring-boot-starter-web时,如果不显式声明,ShallowEtagHeaderFilter不会被自动配置。但项目中常被误引入。 - 理解“Shallow”含义:“Shallow”意为其只做浅层校验,不看业务数据,代价就是内存缓冲。在生产中极少有场景是使用
ShallowEtagHeaderFilter的最佳选择。 - 利用基础设施:静态资源 ETag 应属于 Web 服务器或 CDN 的职责。应用程序应专注于为动态内容设计高效的 API,而非包揽 HTTP 缓存职责。
案例 21:Cache-Control: private 与 CDN 结合,导致敏感数据被缓存
错误示例
@GetMapping("/api/me/profile")
public ResponseEntity<Profile> getMyProfile() {
Profile profile = profileService.getCurrentUserProfile(); // 包含手机号、地址等
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate())
.body(profile);
}
现象描述
用户 A 登录后访问 /api/me/profile,看到了自己的个人资料。在一小时内,用户 B 在同一台电脑的同一个浏览器上登录,也访问 /api/me/profile。用户 B 看到了用户 A 的个人资料! 这是一个严重的数据泄露。
排查思路
- 问题复现:用两个不同的账户在同一浏览器上快速切换并访问接口,观察返回数据。
- HTTP 头检查:使用浏览器开发者工具,检查
/api/me/profile的响应头。确认Cache-Control头的值为private, max-age=3600。 - CDN 配置检查:检查 CDN 服务商的控制台,确认该请求路径的缓存规则。某些 CDN 可能会忽略或无法可靠地区分
private指令,尤其是当Authorization头被剥离或标准化后。 - 日志分析:查看服务端的访问日志,当用户 B 获取到 A 的数据时,服务端可能根本没有收到请求,表明响应来自浏览器缓存或 CDN 层。
根因分析
HTTP 规范中,Cache-Control: private 意味着响应是针对单个用户的,并且可以由客户端(例如,浏览器缓存)存储。最重要的是,它严禁由共享缓存(例如,CDN、代理服务器)存储。
然而,现实世界中,并非所有 CDN 或代理都严格遵守 private 指令。一些配置不当的 CDN 可能会忽略它,或者服务器在响应时没有正确包含 Authorization 响应头的缓存规则,导致 CDN 仅根据 URL 进行缓存。一旦敏感 URL 的响应被 CDN 缓存,后续所有用户(无论是否认证)访问同一 URL 时,都可能直接从 CDN 节点获取到该缓存数据,从而绕过应用层的权限认证。
修正方案
对于任何包含用户相关敏感信息的 API,绝对不应该使用max-age这类指令在浏览器或任何缓存中存储。必须使用Cache-Control: no-store。
// 修正:对包含敏感信息的响应禁用缓存
@GetMapping("/api/me/profile")
public ResponseEntity<Profile> getMyProfile() {
Profile profile = profileService.getCurrentUserProfile();
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore()) // 强制不存储
// 或者使用更精确的构建器
// .cacheControl(CacheControl.noCache().headerValue()) // no-cache强制每次都验证,但允许存
.body(profile);
}
最佳实践
- 安全优先的缓存策略:默认情况下,所有需要认证的、返回用户私有数据的 API,都应将
Cache-Control设置为no-store。 - 区分
no-cache和no-store:no-cache:可以缓存,但在重新使用前必须向源服务器进行验证(通过ETag/If-None-Match或Last-Modified/If-Modified-Since)。no-store:严禁任何缓存(浏览器、CDN 等)存储响应或请求的任何部分。- 对于敏感数据,
no-store是唯一安全的选择。
- CDN 策略审查:定期检查 CDN 的缓存策略,确保它与应用设置的
Cache-Control头一致,特别是对于有认证、会话管理的请求路径。
11. 诊断工具集与标准化排查流程
掌握单个反模式只是起点,真正的专家能力在于形成一套通用的、可快速定位问题的标准化诊断流程。本节整合了前文提到的所有工具,构建一套决策树。
通用 Web 层排查流程序列图
sequenceDiagram
participant Dev as 开发者
participant App as 异常应用
participant Tools as 诊断工具箱
participant KB as 知识库(本文反模式)
Dev->>App: 发现线上问题 (404/415/500/超时/OOM)
App-->>Dev: 初步现象 (日志/告警/用户反馈)
Dev->>Tools: 1. 架构级检查 (Actuator)
Tools->>App: GET /actuator/mappings, /actuator/beans, /actuator/env
App-->>Tools: 返回映射, Bean, 配置信息
Tools-->>Dev: 确认路由、容器、消息转换器是否正确加载
alt 问题与路由/容器相关?
Dev->>KB: 参考反模式: 案例1-3, 案例17
KB-->>Dev: 提供修正方案
end
Dev->>Tools: 2. 行为级检查 (日志 & 追踪)
Tools->>App: 查看应用日志 (DEBUG级别,特定Filter/Interceptor日志)
Tools->>App: 检查HTTP访问日志 (Access Log)
App-->>Dev: 确认Filter链、响应时间、状态码、异常栈
alt 问题与过滤器/拦截器相关?
Dev->>KB: 参考反模式: 案例7-9
KB-->>Dev: 提供修正方案
end
Dev->>Tools: 3. 运行时检查 (线程 & 内存)
Tools->>App: jstack <pid> (多次)
Tools->>App: jmap -histo <pid>
App-->>Dev: 线程栈(悬挂/阻塞/pinning), 内存热点(byte[]/大对象)
alt 问题与异步/响应式相关?
Dev->>KB: 参考反模式: 案例10-12, 15-16, 19
Dev->>App: (WebFlux)BlockHound.install()后复现
App-->>Dev: BlockHound 告警,精确到行
KB-->>Dev: 提供修正方案
end
Dev->>Tools: 4. 深度分析
Tools-->>Dev: 条件断点,BTrace/Arthas, 字节码检测
Dev->>App: 附加条件断点到关键源码 (AbstractHandlerMapping, etc.)
App-->>Dev: 在特定条件下暂停,检查局部变量、调用栈
Dev->>KB: 所有现象综合匹配反模式
KB-->>Dev: 定位根因,提供完整解决方案
Dev->>App: 实施修正并验证
标准化排查决策树
面对一个 Web 层未知问题,可按以下决策树自上而下排查:
-
是什么现象?
- 状态码 4xx (404, 400, 405, 415)
a. 404: → 检查
/actuator/mappings→ 检查 Filter 是否拦截 → 检查父子容器 Bean 冲突。 b. 415/400 (媒体类型不匹配): → 检查请求头Content-Type/Accept→ 检查/actuator/beans确认HttpMessageConverterBean 是否加载(案例 4) → 检查@RequestBody/@RequestParam误用(案例 5)。 - 状态码 5xx (500)
a. 偶发或特定场景: → 查看错误日志中的完整异常栈 → 检查
@ControllerAdvice优先级(案例 13) → 检查消息转换器写响应体时的异常(案例 6)。 b. 异步相关: →jstack检查线程池 → 检查@ExceptionHandler是否覆盖了异步异常(案例 12)。 - 请求悬挂 / 无响应
a. 所有请求缓慢/无响应: →
jstack检查http-nio-*和mvc-async-*线程 → (WebFlux)jstack检查reactor-http-nio-*→ 检查是否调用了阻塞 API(案例 15)。 b. 特定异步请求不返回: →jstack检查DeferredResult是否未设置值(案例 11) → 检查Future/Callback是否未正确处理。 - 性能低下 / 吞吐量不达标
a. 同步应用: →
jstack检查线程栈,看是否有频繁的锁竞争。 b. 异步应用: →jstack检查线程池是否配置不当导致线程爆炸(案例 10) → 检查线程池队列长度和拒绝策略。 c. WebFlux 应用: →BlockHound检测(案例 15)。 → 检查背压处理(OOM)(案例 16)。 d. 虚拟线程应用 (JDK 21+): → JFR 监控jdk.VirtualThreadPinned事件 →jstack检查 Pinning(案例 19)。 - OOM (内存溢出)
a. 全堆 OOM: → 获取堆转储 (Heap Dump) →
jmap分析byte[],String, 大对象数组 → 确认是来自ShallowEtagHeaderFilter(案例 20)还是未限流的Flux.create(案例 16)。 b. 本地线程创建 OOM: →jstack确认线程数爆炸 → 检查SimpleAsyncTaskExecutor(案例 10)。 - 数据安全问题
a. 用户数据泄露/混淆: → 检查异步上下文传递(
SecurityContextHolder等)(案例 18)。 → 审查Cache-Control头配置(案例 21)。
- 状态码 4xx (404, 400, 405, 415)
a. 404: → 检查
-
工具选用
- 每一类问题,都首先用
/actuator端点做一次架构级体检。它能快速回答“映射对了吗?”、“Bean 加载了吗?”、“配置生效了吗?”这三个基础问题。 - 所有性能、悬挂、内存问题,
jstack和jmap是必选项。它们是理解运行时状态的权威来源。 - WebFlux 一切诡异问题,先装上
BlockHound再复现。它能将隐蔽的阻塞调用转化为明确的异常,是排错的捷径。
- 每一类问题,都首先用
12. 面试高频专题
熟练处理线上故障是高级工程师的核心竞争力。以下面试题旨在考察候选人对 Spring Web 内部机制的深刻理解和系统化排错能力。
Q1. 线上突然出现大量 404,但服务刚发布,如何排查?
- 标准回答:首先通过自定义的 Controller 或
/actuator/mappings端点检查所有已注册的 URL 映射;其次检查Filter是否正确传递了请求(例如,是否被 Spring Security 或自定义 Filter 拦截);然后检查静态资源路径是否被 Controller 覆盖(/**反模式);如果是 Spring Boot + WebFlux,还会检查RouterFunction的优先级。通过这些步骤定位 404 的具体原因。 - 追问 1:如果
/actuator/mappings显示映射是正确的,但请求还是 404,下一步怎么办?- 回答:开启
DispatcherServlet的 DEBUG 日志,查看请求到达后,getHandler方法内部具体是哪个HandlerMapping返回了null。同时检查请求的DispatcherType,确认它不是被容器转发后丢失了某些信息。也会检查spring.mvc.throw-exception-if-no-handler-found是否开启。
- 回答:开启
- 追问 2:你提到了 Filter,如果过滤器链中某个 Filter 直接返回了 404,如何发现?
- 回答:为
org.springframework.web.filter包开启 DEBUG 日志。为项目中每个自定义 Filter 添加请求追踪日志。在FilterChainProxy(Spring Security) 或OncePerRequestFilter的入口点打条件断点,观察响应状态码何时被设置为 404。
- 回答:为
- 追问 3:如果是父子容器导致的 404,
/actuator/mappings会是什么表现?- 回答:
/actuator/mappings可能显示存在该映射。但此映射可能定义在子容器,而DispatcherServlet使用的是根容器,或反之。真正的原因是DispatcherServlet自身的WebApplicationContext中没有正确的 Handler。通过分析/actuator/mappings返回的详情,检查上下文 ID 可以发现此问题。
- 回答:
- 加分回答:结合 Arhas 或 Btrace 等在线诊断工具,直接动态监控
DispatcherServlet.getHandler的返回,可以做到不改配置、不重启应用就定位到问题HandlerMapping。
Q2. 如何排查一个消费 JSON 的 POST 接口返回 415 错误?
- 标准回答:415 意味着
Unsupported Media Type。首先检查请求的Content-Type头。如果客户端发的是application/json,则检查服务端是否引入了spring-boot-starter-json或类似的包,使得MappingJackson2HttpMessageConverter能被加载。检查是否有自定义的ObjectMapperBean 覆盖了自动配置(案例 6)。最后检查该方法是否错误地使用了@RequestParam而不是@RequestBody(案例 5)。 - 追问 1:如果
Content-Type是text/plain,但方法上是@RequestBody,会怎样?- 回答:会得到 415 错误。因为
@RequestBody背后的RequestResponseBodyMethodProcessor会遍历所有HttpMessageConverter,寻找能够读取text/plain并反序列化为目标对象类型的转换器。默认的StringHttpMessageConverter可以处理text/plain,但目标类型如果是非String的复杂对象,转换器将无法匹配,导致 415。
- 回答:会得到 415 错误。因为
- 追问 2:客户端
Content-Type正确,且 Jackson 依赖也存在,为什么还可能是 415?- 回答:可能是客户端请求体不是合法的 JSON(但不会被当成 415,通常是 400)。或者是 Spring Security 过滤器在请求到达
DispatcherServlet之前就耗尽了InputStream,导致后续无法读取。也可能是有人扩展了HttpMessageConverter,并在canRead方法中返回了false。
- 回答:可能是客户端请求体不是合法的 JSON(但不会被当成 415,通常是 400)。或者是 Spring Security 过滤器在请求到达
- 追问 3:如何从源码层面证明问题出在
canRead或canWrite?- 回答:在
AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters方法的for循环处设置条件断点,逐步执行converter.canRead(...),观察哪个转换器在哪种条件下返回了false。这会直接锁定问题根源。
- 回答:在
- 加分回答:通过
/actuator/beans列出所有HttpMessageConverter的实例,可以直接从 HTTP 端点确认当前环境加载了哪些转换器,及其类型和支持的媒体类型。
Q3. 一个使用了 @Async 的方法,发现里面获取不到 SecurityContextHolder 里的用户信息,怎么解决?
- 标准回答:这是线程上下文传递的经典问题。
SecurityContextHolder默认使用ThreadLocal,异步线程无法直接访问。首先,配置@Async使用的TaskExecutor为一个支持上下文传播的执行器,例如用DelegatingSecurityContextTaskExecutor包装,或者为ThreadPoolTaskExecutor设置DelegatingSecurityContextTaskDecorator。其次,可以通过全局配置将SecurityContextHolder的策略改为MODE_INHERITABLETHREADLOCAL(注意线程池复用的问题)。 - 追问 1:
MODE_INHERITABLETHREADLOCAL有什么潜在问题?为什么更推荐用TaskDecorator?- 回答:
InheritableThreadLocal在线程池模式下会导致上下文污染或泄漏。当任务在线程 A 上执行完毕,如果不清理,线程 A 被回收进线程池,下次执行任务 B 时,可能会意外继承了上次任务残留的、甚至错误的上下文。TaskDecorator机制会在任务执行前主动设置上下文,执行后主动清理,没有泄漏和污染的风险。
- 回答:
- 追问 2:除了 SecurityContext,还有哪些典型的上下文会丢失?
- 回答:MDC (
Mapped Diagnostic Context),RequestContextHolder,LocaleContextHolder,以及自定的ThreadLocal变量。可以通过 Spring 的TaskDecorator或直接使用ContextPropagatingTaskDecorator(Spring Boot 2.7+)一次性传递多个上下文。
- 回答:MDC (
- 追问 3:如果使用 WebFlux,类似的上下文如何传递?
- 回答:WebFlux 使用
Reactor的Context来替代ThreadLocal。通过.contextWrite(ctx -> ctx.put(...))在操作链上填充上下文。Spring Security 的ReactiveSecurityContextHolder就是基于此设计。任何期望在响应式链上传递的信息,都应当放入Reactor Context,而不是依赖ThreadLocal。
- 回答:WebFlux 使用
- 加分回答:介绍 Micrometer Context Propagation 项目,它旨在提供一个跨线程和响应式链的统一 API 来传播各种上下文,是处理这类问题的一个更现代化、标准化的方向。
Q4. WebFlux 应用突然变得非常慢,所有请求的延迟都很高,但是 CPU 和内存使用率都很低,怎么排查?
- 标准回答:这是典型的事件循环线程被阻塞的症状。首先,安装
BlockHound,在测试或预发环境复现问题,BlockHound会精准报告阻塞调用栈。如果在生产环境无法使用BlockHound,则执行jstack,重点关注reactor-http-nio-*线程的状态。如果它们大量处于BLOCKED/WAITING/RUNNABLE(但在做阻塞 IO 系统调用),并且栈顶方法不是 Netty 的异步选择器,而是在 JDBC、RestTemplate、文件 IO 或其他java.net.Socket调用上,就定位了问题。 - 追问 1:如果
jstack显示所有nio线程都不在做阻塞 IO,它们的状态是WAITING (parking),该怎么办?- 回答:这时候瓶颈可能不在事件循环线程本身,而在于有限的业务处理线程或资源上。检查配置的
Schedulers,特别是boundedElastic调度器的线程是否已被耗尽。也可能是下游服务响应极慢,但因为没有阻塞事件循环,所以应用本身 CPU 低。此时需要结合分布式链路追踪(如 Zipkin, SkyWalking)查看请求在哪里花了时间。
- 回答:这时候瓶颈可能不在事件循环线程本身,而在于有限的业务处理线程或资源上。检查配置的
- 追问 2:你之前提到
BlockHound不能上生产,如何在生产环境做类似的实时检查?- 回答:可以使用 JDK 自带的 Java Flight Recorder (JFR) 来收集 I/O 事件和线程停顿事件,并设置阈值。例如,记录任何停顿超过 50ms 的 I/O 操作。虽然不如
BlockHound直达代码行精确,但能有效发现是哪类阻塞 I/O(文件、网络)及调用频率。也可以使用 Arthas 的trace命令来追踪特定方法的调用耗时。
- 回答:可以使用 JDK 自带的 Java Flight Recorder (JFR) 来收集 I/O 事件和线程停顿事件,并设置阈值。例如,记录任何停顿超过 50ms 的 I/O 操作。虽然不如
- 追问 3:如果开发阶段没有引入
BlockHound,现在业务代码庞大,怎么快速找出所有潜在的阻塞点?- 回答:可以使用静态代码分析工具或简单的
grep脚本,在整个项目中搜索已知阻塞 API 的使用:javax.sql.DataSource,java.sql.*,org.springframework.jdbc.*,RestTemplate,java.io.File*,Thread.sleep,Object.wait等。找到后,检查它们是否在 WebFlux Controller 的调用链路中。
- 回答:可以使用静态代码分析工具或简单的
- 加分回答:讨论如何通过自定义 Reactor 的
Hooks.onOperatorDebug与BlockHound集成,或者使用 Netty 的ResourceLeakDetector,来构建更完整的响应式应用监控屏障。
Q5. 系统设计题:设计一个支持 10 万并发的短链接生成服务,要求高吞吐、低延迟。
- 标准回答:
- 技术选型:选择 Spring WebFlux + Netty 作为 Web 框架,因其非阻塞 I/O 模型能更好地支撑高并发。数据库选用 Redis 存储短链映射,因其 O(1) 内存查询极快。短链生成算法使用分布式 ID 生成器(如 Snowflake)或哈希算法 + 62 进制转换。
- 架构设计:API 网关 (如 Nginx/Spring Cloud Gateway) -> 应用层 (WebFlux) -> Redis 集群。生成短链是纯计算和写操作,重定向是纯读操作。为读操作设计基于 Nginx 或 Redis 的二级缓存,以减少对应用层的冲击。
- 核心细节:
- 生成短链:控制器接收长链接,异步调用 ID 生成服务(可以是 Redis 自增或本地 Snowflake),得到唯一 ID,编码为短码,将
短码->长链接及长链接->短码存入 Redis。返回短链接。 - 重定向短链:控制器接收短码,异步查询 Redis。若找到,返回 302 重定向;若未找到,返回 404。对热门短链接,在 API 网关层或应用层用
Caffeine等本地缓存,设置极短的 TTL。 - 背压控制:在所有与 Redis 的交互中使用
ReactiveRedisTemplate;在 API 网关层实施限流(如RequestRateLimiter)。
- 生成短链:控制器接收长链接,异步调用 ID 生成服务(可以是 Redis 自增或本地 Snowflake),得到唯一 ID,编码为短码,将
- 追问 1:如果使用哈希算法生成短码,如何处理哈希冲突?
- 回答:哈希冲突无法避免。常用策略包括:(1)冲突时在原始长链接后追加一个自增序列再重试哈希,直到找到未被占用的短码;(2)使用布隆过滤器(Bloom Filter)快速判断一个短码是否已存在,不存在直接写入,可能存在时再查 Redis 确认,减少 Redis 压力。
- 追问 2:如何保证在极高并发下,短链接的
302重定向响应速度足够快?- 回答:采用多级缓存策略。第一级是 CDN/反向代理层,可以缓存
302响应本身一段时间(如果业务允许)。第二级是应用内的本地缓存(如 Caffeine),存储热点映射。第三级才是 Redis 集群。同时,监控 Redis 的延迟,如果 Redis 成为瓶颈,考虑引入 Redis 只读副本进行读写分离。
- 回答:采用多级缓存策略。第一级是 CDN/反向代理层,可以缓存
- 追问 3:这是一个典型的 IO 密集型服务。如果运行在 JDK 21+,你会选择平台线程还是虚拟线程?为什么?
- 回答:我会评估使用 WebFlux(响应式)和虚拟线程两种方案。对于 IO 密集型,WebFlux 的响应式模型非常契合,能最大化单个核心的吞吐量,但开发心智成本高。虚拟线程同样是此场景的优秀选择,可以使用简单的命令式编码风格,底层通过平台线程池获得极高的 IO 等待并发。如果项目团队对响应式不熟,会优先选择用 Spring MVC + 虚拟线程,并配合
VirtualThreadTaskExecutor来配置 Tomcat 和@Async,能用较低的学习成本获得与 WebFlux 相近的吞吐量。
- 回答:我会评估使用 WebFlux(响应式)和虚拟线程两种方案。对于 IO 密集型,WebFlux 的响应式模型非常契合,能最大化单个核心的吞吐量,但开发心智成本高。虚拟线程同样是此场景的优秀选择,可以使用简单的命令式编码风格,底层通过平台线程池获得极高的 IO 等待并发。如果项目团队对响应式不熟,会优先选择用 Spring MVC + 虚拟线程,并配合
- 加分回答:讨论对短链接服务的监控,包括对每个 API 端点 P99 延迟、QPS 的监控,以及通过
BlockHound确保整个响应式链路的非阻塞性,或通过 JFR 确保虚拟线程未被 pinning。
(鉴于篇幅,其余 15 道面试题将以列表形式呈现,每道题考察一个核心反模式领域,具体回答思路可参照以上 5 题的深度。)
- 如何快速定位 Spring Boot 的某个自动配置没有生效的原因? (考察:
/actuator/conditions) - 如何处理因拦截器 preHandle 返回 false 导致的资源泄漏? (考察:案例 8)
- Controller 中抛出的异常被
@ControllerAdvice处理,但返回的 HTTP 状态码不是你期望的,如何排查? (考察:案例 13 与异常优先级) DeferredResult设置超时后,客户端还是没有收到超时响应,什么原因? (考察:案例 11 与异常处理器兜底)- 解释
@ResponseStatus注解在@ExceptionHandler工作流中的时机和作用。 (考察:异常响应状态码设置机制) - Spring Cloud Gateway 和 Spring WebFlux 的函数式路由定义有什么优先级关系? (考察:WebFlux 模式下的路由)
- 如何排查一个被 CDN 缓存的 API,用户切换后仍能看到之前用户数据的问题? (考察:案例 21,Cache-Control)
- 应用因大量创建线程而 OOM,从日志和堆栈中如何判断是否是由于未配置线程池导致的? (考察:案例 10,SimpleAsyncTaskExecutor)
- 在进行 Spring MVC 异步处理时,你是如何保证请求上下文(如 MDC)在日志中完整传递的? (考察:案例 9 与 TaskDecorator)
- 如何诊断一个
@RequestBody接收 JSON 时总是得到空对象或部分属性为 null 的问题? (考察:案例 6,Jackson 反序列化与字段映射) - 你如何向一个新手解释为什么不应该在 WebFlux 代码里调用
RestTemplate?请从原理上说明。 (考察:案例 15 的根因理解深度) - 如何设计一个优雅的 API 异常处理结构,能同时满足浏览器、移动端和第三方微服务调用者的需求? (考察:案例 14,内容协商与异常处理设计)
- 什么时候应该使用过滤器(Filter),什么时候应该使用拦截器(Interceptor)?用一个认证与授权的场景说明。 (考察:案例 7,Filter/Interceptor 职责边界)
@Async注解的方法返回void、Future<?>和CompletableFuture<?>有何区别?这如何影响异常排查? (考察:异步异常传播机制)- 系统设计题:设计一个支持多人同时在线编辑的文档系统,你会如何选择 Web 层的通信技术栈?请对比几种方案。
- 解释 Spring MVC 处理一个 HTTP 请求,从 Filter 到 Interceptor,再到参数解析、消息转换、视图渲染的完整生命周期的同时,如果过程中抛出不同类型异常,分别会被哪些组件捕获? (考察:全链路与异常处理集的综合理解)
- 在 WebFlux 中,如何处理文件上传并保证整个流程是非阻塞的,同时还能有效控制内存使用? (考察:WebFlux 对
DataBuffer的自定义处理能力,关联背压)
Spring Web 反模式速查表
| 编号 | 现象 | 常见排查点 | 关联反模式/工具 | 快速修正 |
|---|---|---|---|---|
| P1 | 静态资源 404 | /actuator/mappings | 案例 1 (/**) | 避免宽泛映射,隔离API路径 |
| P2 | AmbiguousHandlerException | 启动日志, /actuator/mappings | 案例 2 (路径重叠) | 使用正则限定, 或RESTful查询参数 |
| P3 | WebFlux路由不生效 | /actuator/mappings 顺序 | 案例 3 (混合路由) | 设置 @Order, 或物理隔离路径前缀 |
| P4 | 415 Unsupported Media Type | Content-Type头, /actuator/beans 看HttpMessageConverter | 案例 4 (缺失转换器), 案例 5 | 引入spring-b |
| P5 | POST 请求 400,提示 Required request parameter 缺失 | 对比 Content-Type 与注解 | 案例 5 (注解误用) | 区分 @RequestBody 与 @RequestParam,对齐请求契约 |
| P6 | 序列化后日期格式突变,LocalDateTime 报错 | /actuator/beans 搜索 ObjectMapper | 案例 6 (覆盖自动配置) | 使用 Jackson2ObjectMapperBuilderCustomizer |
| P7 | CORS 头重复,浏览器报错 | 浏览器 Response Headers | 案例 7 (Filter/Interceptor 重复) | 二选一,优先使用 CorsFilter 或 @CrossOrigin |
| P8 | 长时间运行后磁盘/连接泄漏 | 监控资源使用,对比拦截器日志 | 案例 8 (资源未清理) | 在 Handler 内部用 try-with-resources |
| P9 | 异步线程日志缺少 TraceId | 对比主线程和异步线程日志 | 案例 9 (生命周期误判) | 实现 AsyncHandlerInterceptor,显示清理 |
| P10 | 线程数暴增,unable to create native thread | jstack, /actuator/metrics | 案例 10 (线程池缺失) | 配置 ThreadPoolTaskExecutor |
| P11 | 客户端长时间等待,无响应 | jstack 检查 DeferredResult 相关线程 | 案例 11 (悬挂) | 设置超时与 onTimeout 回调 |
| P12 | 异步任务失败,客户端收到空或200 | 日志搜索异常栈 | 案例 12 (异常吞没) | 在 @ExceptionHandler 中兜底,使用 DeferredResult.onError |
| P13 | 局部异常处理器不生效,被全局覆盖 | 在两个 @ExceptionHandler 中加日志 | 案例 13 (优先级冲突) | 不在全局声明过细异常,依赖默认深度优先 |
| P14 | API 调用返回 HTML Whitelabel Error Page | 请求/响应头 Accept vs Content-Type | 案例 14 (内容协商错误) | 全局 JSON 错误处理器,禁用 WhiteLabel |
| P15 | WebFlux 所有请求延迟高,CPU 低 | BlockHound, jstack 看 nio 线程 | 案例 15 (阻塞事件循环) | 用 Schedulers.boundedElastic() 隔离阻塞代码 |
| P16 | 流式接口 OOM,MissingBackpressureException | 堆转储,Hooks.onOperatorDebug | 案例 16 (背压缺失) | 使用 Flux.generate 或处理 onRequest |
| P17 | 事务不生效,Bean 注入行为异常 | /actuator/beans 查看重复 Bean | 案例 17 (扫描重叠) | 使用 Spring Boot 单容器,避免父子扫描重叠 |
| P18 | 异步方法中取不到当前用户 | 测试异步路径,对比上下文 | 案例 18 (上下文丢失) | TaskDecorator/DelegatingSecurityContextTaskExecutor |
| P19 | 虚拟线程吞吐量低,平台线程耗尽 | JFR jdk.VirtualThreadPinned 事件,jstack | 案例 19 (Pinning) | 替换 synchronized 为 ReentrantLock |
| P20 | 下载大文件时 OOM | 堆转储,检查 byte[] 引用链 | 案例 20 (浅层 ETag) | 禁用 ShallowEtagHeaderFilter,使用资源级 ETag |
| P21 | 用户看到他人数据 | 浏览器缓存验证,CDN 日志 | 案例 21 (缓存私有数据) | 敏感接口设置 CacheControl.noStore() |
延伸阅读
- Spring 官方文档:
- 《Spring 实战》第 6 版(Manning):深入讲解 Spring MVC 与 WebFlux 的实战案例。
- 《反应式编程实战:使用 Reactor》(O'Reilly):对背压、操作符调试有深刻剖析。
- BlockHound 官方 GitHub:github.com/reactor/Blo…
- Spring Security 参考文档:安全上下文传播
- JEP 425: Virtual Threads:openjdk.org/jeps/425 – 了解 pinning 机制。
- Arthas 诊断工具:arthas.aliyun.com/
- Spring Boot Actuator 端点大全:docs.spring.io/spring-boot…