反模式与排查宝典:Spring Web 常见陷阱与排错指南

0 阅读1小时+

概述

通过前面十余篇文章的深入剖析,Spring Web 层从启动、请求处理、参数绑定、消息转换到异步模型、响应式引擎的完整知识图谱已经呈现。本文将前面的正向设计知识转化为逆向排错能力,集中曝光那些反复出现、容易导致生产事故的 Web 层反模式,并提炼出一套以 Actuator 端点、日志、线程堆栈和条件断点为核心的标准化诊断工具箱。

Spring Web 框架以其强大的抽象能力和高度的可扩展性著称,但正是这些复杂的调度链——从 FilterDispatcherServlet,从 HandlerMappingHandlerAdapter,从 HttpMessageConverterViewResolver——使得问题排查变得极具挑战性。一个 404 错误可能源自路由冲突、过滤器拦截,甚至是父子容器配置失误;一个 415 错误可能是消息转换器缺失、Content-Type 头错误或者参数注解误用。本文将 Spring Web 开发中常见的 21 个反模式归纳为九大领域,每个反模式都结合前文讲过的核心链路进行深度剖析,并提炼出以 Actuator 端点、日志、线程堆栈和条件断点为核心的通用排查方法,帮助开发者在面对复杂的 Web 问题时快速定位根因。

核心要点

  • 反模式九大领域:请求路由与映射、消息转换与序列化、拦截器与过滤器、异步处理、异常处理、WebFlux 响应式、父子容器与上下文、安全与并发、静态资源与缓存。
  • 统一剖析结构:每个反模式都按照“错误示例→现象描述→排查思路→根因分析→修正方案→最佳实践”的固定结构展开,确保诊断路径清晰可复现。
  • 诊断工具箱:整合 Actuator(/mappings/httptrace/metrics)、容器访问日志、jstack 线程栈、Reactor Hooks.onOperatorDebugBlockHound、条件断点等工具,形成一套从现象到根因的标准化决策树。
  • 根因溯源:所有反模式的根因都直接回溯到前文讲解过的 DispatcherServlet.doDispatchAbstractMessageConverterMethodProcessor.writeWithMessageConvertersWebAsyncManager.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 错误?”要求面试者能系统地阐述从路由、过滤器到容器上下文的完整排查路径。
  • 关键结论掌握 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拦截器与过滤器FilterInterceptor 重复执行相同逻辑CORS 响应头被重复添加、性能监控指标被重复记录,逻辑正确但行为异常
8拦截器与过滤器HandlerInterceptor.preHandle 返回 false 后未正确清理资源请求被拦截后,临时文件未删除、锁未释放,长时间运行导致资源泄漏
9拦截器与过滤器异步请求下 afterCompletionafterConcurrentHandlingStarted 前执行拦截器的清理逻辑在异步线程完成前执行,导致上下文(如 MDC)被意外清空
10异步处理@AsyncCallable 使用 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) 解析失败
15WebFlux 响应式在响应式事件循环线程中调用阻塞 API (如 JDBC)应用吞吐量极低,BlockHound 报警,整个事件循环被卡死,所有请求延迟增加
16WebFlux 响应式Flux.create 忽略背压,导致 MissingBackpressureException 或 OOM生产者速度远快于消费者,下游被淹没,内存飙升,应用崩溃
17父子容器与上下文根容器与 Servlet 容器组件扫描重叠,导致 Bean 重复创建@Service 层事务失效,@Autowired 注入 Bean 版本不一致,应用行为诡异
18安全与并发SecurityContextHolder@AsyncDeferredResult 线程中丢失异步执行的方法中无法获取当前登录用户信息,导致 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 方法未匹配具体路径)。应用的页面完全无法加载样式和脚本。

排查思路

  1. 路径确认:访问具体的静态资源 URL,确认返回状态码(200 非预期,或 404)。
  2. Actuator 端点:访问 /actuator/mappings,查看所有请求映射。可以清晰地看到 /** 的映射条目覆盖了所有路径层级,并且优先级可能高于静态资源处理器 ResourceHttpRequestHandler 的映射。
  3. 条件断点:在 AbstractHandlerMapping.getHandler(HttpServletRequest request) 方法上设置条件断点,条件是 request.getRequestURI().contains(".css")。观察返回的 HandlerExecutionChainCatchAllController 还是 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-patternspring.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' 的异常。

排查思路

  1. Actuator 端点:访问 /actuator/mappings,展开 /users/search/users/{id} 的映射详情,确认它们映射到不同的方法。
  2. 启动日志:更仔细地查看应用启动日志。RequestMappingHandlerMapping 在注册映射时,如果检测到模棱两可的情况,可能会输出 WARN 级别日志。
  3. 条件断点:在 AbstractHandlerMethodMappinglookupHandlerMethod 方法中设置断点,观察当请求为 /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;
    }
}

修正方案

  1. 方案一(推荐):让路径模式更加清晰且无法重叠。将搜索路径改为复数形式或增加前缀,使其不可能与 /users/{id} 冲突。
  2. 方案二:调整方法定义的顺序。虽然源码注释显示最早定义的方法有一定优先级,但这并不是可靠的实践,且依赖隐式行为。
  3. 方案三:使用正则表达式限定 {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),函数式路由定义好像“失效”了。

排查思路

  1. Actuator 端点:访问 /actuator/mappings,在 WebFlux 应用中,该端点会同时列出 RouterFunctionHandlerMethod 的映射。对比两者的顺序和详情。
  2. 调试日志:为 org.springframework.web.reactive.function.server 包开启 DEBUG 日志,观察请求到来时 RouterFunctionMappingRequestMappingHandlerMapping 的处理顺序。
  3. 条件断点:在 RouterFunctionMapping.getHandlerInternalRequestMappingHandlerMapping.getHandlerInternal 方法上各设置一个断点。观察哪个处理映射先被执行,以及它是否返回了非空的 Mono 结果。

根因分析

DispatcherHandler(WebFlux 的 DispatcherServlet 等价物)在初始化时,会按 @Order 或默认顺序排序它所有的 HandlerMapping Bean。Spring Boot 自动配置会同时注册 RequestMappingHandlerMappingRouterFunctionMapping。默认情况下,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));
}

由于 concatMapHandlerMapping 列表的顺序执行,且 .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 错误。

排查思路

  1. 日志检查:查看应用启动日志,搜索 HttpMessageConverterJackson。一个缺失了 Jackson 的 Web 上下文通常只注册了 StringHttpMessageConverter 等少数几个。
  2. Actuator 端点:如果已经设置了 /actuator/env,可以查看 CLASSPATH 相关的属性,或者间接通过 /actuator/beans 查找是否有 MappingJackson2HttpMessageConverter 这个 Bean。
  3. 条件断点:在 AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters 方法中设置断点。进入方法后,查看 this.messageConverters 列表,确认其中是否存在支持 application/json 的转换器。RequestResponseBodyMethodProcessor 在处理 @RequestBody 时会调用此方法。

根因分析

WebMvcConfigurationSupport(或自动配置的 WebMvcAutoConfiguration)默认会注册一系列 HttpMessageConverter,其中处理 JSON 的核心是 MappingJackson2HttpMessageConverter。这个转换器的注册条件是类路径下存在 com.fasterxml.jackson.databind.ObjectMappercom.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-webspring-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)。

排查思路

  1. 基础日志:查看 Spring Web 的日志,HttpMessageNotReadableExceptionMissingServletRequestParameterException 会指出问题所在。
  2. 对比 API 契约:对比客户端发送的 Content-Type 头和服务端控制器方法的定义。
    • 如果客户端发送 application/json,服务端方法参数上必须有 @RequestBody(或 @ModelAttribute,但处理方式不同)。
    • 如果客户端发送 application/x-www-form-urlencoded 或 Query String,服务端方法参数应是 @RequestParam 或一个带有相应 getter/setter 的简单对象,不加 @RequestBody
  3. 条件断点:在 RequestResponseBodyMethodProcessor.supportsParameterRequestParamMethodArgumentResolver.supportsParameter 上设置断点,观察哪一个参数解析器“认领”了你控制器方法的参数。错误的注解会导致 Spring 选择了错误的解析器。

根因分析

Spring MVC 通过一系列的 HandlerMethodArgumentResolver 来解析控制器方法的参数。@RequestBody@RequestParam 分别由 RequestResponseBodyMethodProcessorRequestParamMethodArgumentResolver 处理,它们在内部委托给不同的基础设施。

  • @RequestBodyRequestResponseBodyMethodProcessor 使用 HttpMessageConverter 从请求的 InputStream 中读取整个 body,并根据 Content-Type 反序列化。
  • @RequestParamRequestParamMethodArgumentResolver 从 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 值的序列化行为也发生了变化。

排查思路

  1. Actuator 端点:访问 /actuator/beans,搜索 objectMapper。会发现存在两个 Bean:一个是 jacksonObjectMapper(Spring Boot 自动配置的),另一个是我们自定义的 objectMapper
  2. 类路径检查:确认是否引入了 jackson-datatype-jsr310 等模块。
  3. 调试代码:在 Controller 中注入 ObjectMapper 实例,打印它的 Bean 名称和注册的模块列表。
    @Autowired
    ApplicationContext context;
    // ...
    String[] beanNames = context.getBeanNamesForType(ObjectMapper.class);
    // 打印: [jacksonObjectMapper, objectMapper]
    // 然后对比两个mapper的module列表
    
  4. 条件断点:在 JacksonAutoConfigurationjacksonObjectMapper Bean 定义方法上设置断点,观察其 @ConditionalOnMissingBean 的判断逻辑。因为自定义了 objectMapper@ConditionalOnMissingBean 条件不再满足,自动配置的 jacksonObjectMapper 不会被创建?实际上,在 Spring Boot 2.x 中,JacksonAutoConfiguration 注册的 Bean 名字是 jacksonObjectMapper@Primary 修饰。自定义的 objectMapper 仍然会被创建,Spring Boot 的自动配置会感知到已有 ObjectMapper Bean,并基于它进行进一步定制,而不是替换。

根因分析

Spring Boot 的 JacksonAutoConfiguration 设计得非常巧妙。它定义了一个 jacksonObjectMapper Bean,并通常会将其标记为 @Primary。它的创建过程会应用 Jackson2ObjectMapperBuilder,这个 Builder 会自动发现并注册 Module Bean,应用于 Jackson2ObjectMapperBuilderCustomizer Bean,并根据 spring.jackson.* 配置属性进行设置。 当开发者简单地定义一个 ObjectMapper Bean 时,会发生几件事:

  1. Spring Boot 的自动配置检测到用户已定义 ObjectMapper 类型的 Bean。
  2. 它不会创建一个全新的、替换掉默认的内部 ObjectMapper。在标准配置下,JacksonAutoConfiguration 会查找是否有 ObjectMapper Bean,如果有,它会用这个 Bean 作为基础,再应用一系列的 Jackson2ObjectMapperBuilderCustomizer
  3. 问题的核心:如果开发者没有使用 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. 拦截器与过滤器反模式

FilterHandlerInterceptor 是 Servlet 规范与 Spring 框架提供的两种不同层次的 AOP 机制。混淆两者的生命周期和职责边界,是导致逻辑错误和资源泄漏的常见原因。

案例 7:FilterInterceptor 重复实现相同逻辑

错误示例

// 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 规范,可能导致浏览器报错或行为异常。

排查思路

  1. 浏览器开发者工具:直接查看 Network 面板中具体请求的 Response Headers,可以看到重复的响应头。
  2. 服务端日志:很难直接从默认日志发现,因为设置响应头是一个简单的操作。
  3. 条件断点:在 Filter.doFilterInterceptor.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 的拦截器中分配的资源(如临时文件、锁、数据库连接)没有被释放。长时间运行后,磁盘空间耗尽、锁无法释放导致死锁、或者连接池泄漏。

排查思路

  1. 操作系统监控:监控临时文件目录的大小,或数据库连接池的活跃连接数和等待队列,观察到持续增长。
  2. 日志分析:在 preHandle 中增加资源分配的日志,在 afterCompletion 中增加资源释放的日志。通过对比日志,可发现大量“分配”日志而没有对应的“释放”日志。
  3. 条件断点:在所有拦截器的 preHandle 方法返回 false 的代码路径上设置断点。观察此时 ResourceCleanupInterceptorafterCompletion 是否被调用。在 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)调用 afterCompletionB 拦截器自己的afterCompletion不会被调用,因为它在链中还没有“完成”。这是符合逻辑的,因为 B 的 preHandle 失败了,请求处理被其终止。然而,如果 A 拦截器在它的 preHandle 中分配了资源,并期望在 afterCompletion 中释放,那么资源就会被正确释放。反模式场景常出现在 A 的资源释放逻辑有 Bug,或者开发者错误地认为 B 的 preHandle 返回 false 后,A 的 afterCompletion 仍然不会执行(实际上会执行),但导致了其他并发路径下的资源泄漏。更高级的反模式是资源分配在 afterCompletion 中有着复杂的、依赖于后续 handler 执行状态的释放逻辑,当 handler 未执行时,释放逻辑出错。

修正方案

  1. 资源分配后移:不要在 preHandle 中分配需要在整个请求生命周期结束后才释放的资源。将资源分配逻辑移到 Handler 内部。
  2. 使用 try-finally 防护:如果必须在 preHandle 中分配,应立即使用 try-finally 确保在当前方法内部或在最坏情况下有兜底释放机制。
  3. 使用 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";
}

最佳实践

  • 资源管理铁律:谁创建,谁释放;在哪里创建,就在哪里释放。InterceptorspreHandle/afterCompletion 对不应被用作长生命周期资源的管理者。
  • 使用现代 Java 资源管理:优先使用 try-with-resources 管理实现了 AutoCloseable 的资源,将资源的生命周期绑定到方法调用栈上,而非请求级别属性上。

案例 9:异步请求下 afterCompletionafterConcurrentHandlingStarted 前执行

错误示例

@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 之后立即被调用了,而不是在异步处理真正完成的时刻。

排查思路

  1. 日志验证:检查包含 requestId 的日志。会发现只有主线程(Tomcat 容器线程)的日志有 requestId,而处理实际业务的异步线程日志中 requestId 为 null。
  2. 线程栈分析:通过 jstack 观察异步情况下的线程。你可以看到 Tomcat 容器线程已经回到了线程池,处理另一个请求,而 TaskExecutor 中的线程正在执行你的业务逻辑。
  3. 条件断点
    • MdcInterceptor.afterCompletion 上设置断点,查看其调用栈。会发现它在 DispatcherServlet.doDispatch 的 finally 块中被调用。
    • MdcInterceptor.preHandle 和 Controller 异步方法上设置断点。观察主线程和异步线程的 ID 切换。

根因分析

这是 Spring MVC 异步模型的核心设计。当 Controller 返回 CallableDeferredResult 时,DispatcherServlet 并不会等待异步结果。它立即释放 Tomcat 容器线程。WebAsyncManager 接管了异步处理的调度。 关键在于 HandlerExecutionChain 中的 applyPreHandleapplyPostHandletriggerAfterCompletion 的调用时机。

// 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 的回调中显式清理
}

更完善的方案是,在异步方法内部,使用 DeferredResultCompletableFuture 的回调来执行最终的清理工作。

@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 线程与业务处理线程的分离。InterceptorspostHandleafterCompletion 在异步场景下会提前于真正的业务处理完成而执行。
  • 显式上下文传递:依赖 Spring 的 TaskDecoratorContextPropagatingTaskDecorator(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 上下文切换开销巨大。

排查思路

  1. JVM 监控:使用 jvisualvmjconsole 或 Prometheus 等工具监控 JVM 的线程数。观察到大量名为 task-SimpleAsyncTaskExecutor- 的线程被创建且未复用。
  2. jstack 分析:执行 jstack <pid>,会发现成百上千个处于 TIMED_WAITING(sleeping)RUNNABLE 状态的 SimpleAsyncTaskExecutor 线程。
  3. Actuator 端点:访问 /actuator/metrics/jvm.threads.live/actuator/metrics/jvm.threads.peak,可以看到活动线程数和峰值线程数急剧增加。
  4. 配置检查:检查 Spring 配置,确认是否定义了 TaskExecutor Bean。

根因分析

当处理器方法返回 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.* 和自定义的 ThreadPoolTaskExecutor Metric 来监控核心和异步线程池的健康状况。
  • 合理设置队列和拒绝策略:根据业务峰值,仔细调整 queueCapacityrejectedExecutionHandlerCallerRunsPolicy 是一种优雅降级策略,可以让主线程执行任务,从而减缓生产速度。

案例 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 的异步处理线程并未释放,长时间占用。随着类似请求的积累,所有处理异步请求的线程都被悬挂,最终导致应用对新请求无响应。

排查思路

  1. 现象识别:客户端超时,但服务端无任何错误日志。
  2. jstack 分析:获取线程栈。找到 mvc-async- 线程或配置的 TaskExecutor 线程。这些线程可能在等待某个 CountDownLatch 或条件。更为关键的是,找到 Tomcat 的请求处理线程(如 http-nio-8080-exec-*)。这些线程可能处于 WAITING 状态,等待着 DeferredResult 的结果,但由于异步支持,它们可能已经被返回池中。真正的悬挂线程可能是那些等待业务回调的线程。
  3. Actuator 端点:访问 /actuator/httptrace (如果配置) 或自定义的拦截器,记录所有未完成的 DeferredResult。编写一个 Health Endpoint 检查这些悬挂的请求。
  4. 访问日志:Tomcat 的 localhost_access_log 可以显示哪些请求的响应时间异常长。
  5. 条件断点:在 DeferredResult.setResultDeferredResult.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 回调,其关联的业务处理在未来也没有调用 setResultsetErrorResult。当超时发生时,DeferredResult 内部会触发超时处理,其默认行为是创建一个 AsyncRequestTimeoutException 并通过 setErrorResult 设置。然而,如果异常处理器(@ExceptionHandler 或全局 @ControllerAdvice)没有恰当地处理这个异常,客户端将无法获得预期的错误响应。更糟糕的是,如果 DeferredResult 创建时没有设置超时时间,且业务回调永不抵达,那么该请求将永久悬挂。

修正方案

必须为每个DeferredResult设置超时时间,并提供onTimeoutonError回调,或者确保业务逻辑在所有路径(包括异常路径)上最终都会调用setResultsetErrorResult

@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 都包含超时、超时处理和错误处理逻辑。
  • 监控未完成请求:利用 DeferredResultsetResultHandler 或 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 响应(或默认错误响应),但在服务端日志中,异常信息被淹没,没有被框架正确捕获并转化为错误响应发给客户端。

排查思路

  1. 日志文件:在应用的日志中搜索 RuntimeException。可能只看到一个简单的栈追踪,但没有与特定的请求关联起来。日志级别为 ERROR,但请求可能没有被正常结束。
  2. 客户端观察:客户端可能收到一个由 Web 容器返回的超时错误,或者一个不标准的 500 错误。
  3. 条件断点:在 Callable 代码块内的 throw 语句上设置断点,并查看其调用栈。向上追溯,可以发现它由 Callablecall() 方法抛出,然后被 Spring 的 FutureTask 或类似的包装器捕获。进一步在 WebAsyncManagerhandleError 或类似方法上设置断点,观察异常是如何被包装和处理的。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)处理。反模式出现在以下几个场景

  1. 异常处理器不匹配:抛出的异常类型没有匹配到任何 @ExceptionHandler 方法,最终由容器(如 Tomcat)的默认错误页面或 BasicErrorController 处理,返回了非预期的客户端响应。
  2. 异步异常处理中的二次异常:当 ExceptionHandlerExceptionResolver 尝试处理这个异常时,如果在处理过程中(例如,在 writeWithMessageConverters 写响应体时)再发生错误,可能导致响应被截断或产生一个更难以理解的错误。
  3. 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) 作为兜底。
  • 异常转义:在异步任务的边界(Callablecall 方法开始处),将未知的异常转义为 Spring 的 NestedServletExceptionResponseStatusException,以确保它们能被 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 可能被 GlobalExceptionHandlerhandleDataExceptions 捕获,而不是被局部的 handleLocalDataException 捕获。返回的响应体是 "Data Integrity Violation globally",而不是预期的 "Local Data Integrity for User"。

排查思路

  1. 日志输出:在两个异常处理方法的开头添加明显的日志输出,观察哪一个被调用。
  2. Actuator 端点:可以间接地通过查看 /actuator/beans,确认 GlobalExceptionHandlerUserController 都被成功加载为 Bean。
  3. 条件断点
    • 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。客户端使用 RestTemplateWebClient 调用该微服务 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

排查思路

  1. 客户端日志:客户端错误日志将直接显示 Content-Type 不匹配。
  2. 浏览器验证:如果 API 也被浏览器调用,浏览器会渲染出白色的错误页面,并显示 500 状态码。
  3. 服务端访问日志:确认请求的 Accept 头。某些客户端(如 RestTemplate 在不设置消息转换器时)可能不会发送 Accept 头,或者发送一个非常宽泛的 Accept 头(如 */*)。
  4. 条件断点:在 BasicErrorController.errorHtmlBasicErrorController.error 方法上设置断点。观察哪个方法被调用。其路由逻辑基于请求 Accept 头中是否含有 text/html。如果客户的请求没有明确指明期望 application/jsonerrorHtml 方法可能会被命中。

根因分析

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/WebClient Bean 中,通过 HttpHeaders 统一设置 Accept: application/json
  • 异常信息体规范:定义统一的 ApiError 对象,包含 timestampstatuserrormessagepath 等字段,与客户端约定好错误格式,参考 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 事件循环线程被卡住,只有少量的请求能被处理,其他请求均在排队。

排查思路

  1. 添加 BlockHound:这是排查此类问题的第一步。在 main 方法中加载 BlockHound
    public static void main(String[] args) {
        BlockHound.install();
        SpringApplication.run(Application.class, args);
    }
    
    它会精准地报告阻塞调用发生的线程和方法。
  2. jstack 分析:执行 jstack <pid>。寻找 reactor-http-nio-*nioEventLoopGroup-* 线程。如果它们的状态是 WAITINGBLOCKED,并且栈信息中包含了 JDBC、HTTP client 或其他经典阻塞库的方法调用,即可确认。
  3. 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, RestTemplateObjectMapper.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...)。

排查思路

  1. JVM 堆转储:使用 jmap 获取堆转储,使用 MAT 或 JVisualVM 分析。会看到大量的 String 或其它被 emitter.next() 发送的对象无法被 GC 回收,它们都积压在 Reactor 内部的缓冲区中。引用链会指向 FluxSink 的内部队列。
  2. Reactor 调试:开启 Hooks.onOperatorDebug(),当 OverflowException 抛出时,其栈信息会清晰地指向 Flux.create 的调用点。
  3. 日志与指标:监控应用内存使用率,并观察 /actuator/metrics/reactor.*(如果暴露)。

根因分析

背压(Backpressure)是 Reactive Streams 的核心概念,它允许消费者(Subscriber)告诉生产者(Publisher)它能够处理多少数据。Flux.create 默认使用一个无界队列(Queues.SMALL_BUFFER_SIZE 应用于某些模式,但 create 默认是 BUFFERED 模式且基本无界)来缓冲生产者推过来的数据,以防消费者处理不及。 当生产者(示例中的 while 循环)速度远快于消费者(网络 IO、客户端)时,无界缓冲区会迅速填满整个 JVM 堆内存,导致 OOM。或者,如果使用了 createOverflowStrategy.LATESTDROP,数据会丢失,但避免了 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.generateFlux.intervalFlux.fromIterable 等操作符已经内置了正确的背压处理。
  • 仅在必要时使用createFlux.create 是为桥接遗留的生产者-消费者 API 而设计的。使用前,评估是否可以用其他操作符替代。
  • 永远处理背压:在 create 的回调中,通过 emitter.onRequest(...) 来控制生产速度,并选择合适的 OverflowStrategy(如 DROPLATEST, 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 容器。

排查思路

  1. Actuator 端点:访问 /actuator/beans,搜索同名或同类型的 Bean。你会发现 myService 或其他 bean 出现了两次,或者它们的 scope resource 定义信息不同。
  2. 启动日志:将日志级别设置为 DEBUG,查找 Bean 的注册信息。DefaultListableBeanFactory 会输出注册 Bean 定义和创建 Bean 的日志。重复的创建日志是直接证据。
  3. 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();
        }
    }
    
  4. 条件断点:在 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.servicecom.example.app.web.controller。这样默认的 @SpringBootApplication 扫描就能覆盖,且结构清晰。
  • Bean 冲突检测:在 CI/CD 流水线中加入依赖注入相关的集成测试,可以通过端点 /actuator/beans 检查特定 Bean 的实例数,或在测试中注入 ApplicationContext 断言 context.getBeanNamesForType(MyService.class).length == 1

9. 安全与并发反模式

安全和并发是现代 Web 应用的核心挑战。上下文丢失、线程 pinning 等问题在引入异步和虚拟线程后变得尤为突出。

案例 18:SecurityContextHolder@AsyncDeferredResult 线程中丢失

错误示例

@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 或严重的权限绕过问题。

排查思路

  1. 功能测试:编写一个集成测试,先模拟用户登录,再访问 /async-user,断言返回的是用户名而不是 "anonymous"。这个测试会稳定失败。
  2. 日志埋点:在主线程和异步线程中分别打印 SecurityContextHolder.getContext().getAuthentication() 和当前线程 ID。可以清楚地看到主线程有认证信息,异步线程丢失。
  3. 源码追踪:了解 Spring Security 的 SecurityContextPersistenceFilter。它默认使用 ThreadLocalSecurityContextHolderStrategy,将 SecurityContext 存储在 ThreadLocal 中。 ThreadLocal 在线程切换(@AsyncTaskExecutor)时不会自动传递。

根因分析

Spring Security 默认使用 ThreadLocal 来持有 SecurityContext,这种策略被称为 MODE_THREADLOCAL。这是为了确保每个请求的上下文是线程隔离的。然而,当工作被移交给另一个线程(如通过 @AsyncTaskExecutor 执行 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 会将 SecurityContextCallable 的创建集成。确保 spring.security.filter.dispatcher-types=ASYNC 等配置正确。更现代的方案是使用 spring-security-aspects 或确保 SecurityContextHolderStrategy 设置为 MODE_INHERITABLETHREADLOCAL

// 在main或配置中,设置上下文传播模式
static {
    SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}

注意,InheritableThreadLocal 不能解决线程池复用线程导致的上下文污染问题。使用 DelegatingSecurityContextTaskExecutorTaskDecorator 是处理线程池的标准方式。

最佳实践

  • 使用DelegatingSecurityContext*包装器:在创建异步执行器(TaskExecutorScheduler 等)时,总是使用 Spring Security 提供的包装器,它们会在任务执行前设置上下文,并在执行后清理。
  • 测试安全性:编写集成测试,模拟用户登录后,明确测试任何 @Async@EventListenerDeferredResult 等异步路径下,安全上下文是否正确传播。
  • Micrometer Context Propagation:对于更广泛的上下文传播需求(如 Tracing, MDC),可以考虑引入 Micrometer Context Propagation 库,它提供了一套 API 来在 ThreadLocalReactor 上下文等之间传递信息。

案例 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(固定) 在了它们的载体平台线程上。

排查思路

  1. jstack with -XX:+UnlockDiagnosticVMOptions -XX:+ShowCarrierFrames:使用这些特殊参数抓取线程栈。这将显示虚拟线程及其载体线程的关联信息。你会看到大量虚拟线程被 synchronized 阻塞,并且它们各自 pin 了一个不同的平台线程。
  2. Java Flight Recorder (JFR):启用 JFR 追踪 jdk.VirtualThreadPinned 事件。这个事件会精确记录虚拟线程何时被 pin 住以及 pin 了多久。这是检测该问题最直接的手段。
  3. 日志与指标:监控 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 回收,最终导致 OutOfMemoryErrorjmap -histo 显示大量的 byte[] 数组,引用自 ShallowEtagHeaderFilter 的内部缓存。

排查思路

  1. 堆转储分析:获取 OOM 后的堆转储。byte[] 实例会占据绝大部分内存,它们的 GC Root 路径会清晰地指向 org.springframework.web.filter.ShallowEtagHeaderFilter 的内部结构。
  2. 过滤分析:在测试环境中,暂时移除 ShallowEtagHeaderFilter,看大文件下载是否恢复正常。如果恢复,则确认是该 Filter 的问题。
  3. 条件断点:在 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 的个人资料! 这是一个严重的数据泄露。

排查思路

  1. 问题复现:用两个不同的账户在同一浏览器上快速切换并访问接口,观察返回数据。
  2. HTTP 头检查:使用浏览器开发者工具,检查 /api/me/profile 的响应头。确认 Cache-Control 头的值为 private, max-age=3600
  3. CDN 配置检查:检查 CDN 服务商的控制台,确认该请求路径的缓存规则。某些 CDN 可能会忽略或无法可靠地区分 private 指令,尤其是当 Authorization 头被剥离或标准化后。
  4. 日志分析:查看服务端的访问日志,当用户 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-cacheno-store
    • no-cache:可以缓存,但在重新使用前必须向源服务器进行验证(通过 ETag/If-None-MatchLast-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 层未知问题,可按以下决策树自上而下排查:

  1. 是什么现象?

    • 状态码 4xx (404, 400, 405, 415) a. 404: → 检查 /actuator/mappings → 检查 Filter 是否拦截 → 检查父子容器 Bean 冲突。 b. 415/400 (媒体类型不匹配): → 检查请求头 Content-Type/Accept → 检查 /actuator/beans 确认 HttpMessageConverter Bean 是否加载(案例 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)。
  2. 工具选用

    • 每一类问题,都首先用/actuator端点做一次架构级体检。它能快速回答“映射对了吗?”、“Bean 加载了吗?”、“配置生效了吗?”这三个基础问题。
    • 所有性能、悬挂、内存问题,jstackjmap是必选项。它们是理解运行时状态的权威来源。
    • 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 能被加载。检查是否有自定义的 ObjectMapper Bean 覆盖了自动配置(案例 6)。最后检查该方法是否错误地使用了 @RequestParam 而不是 @RequestBody(案例 5)。
  • 追问 1:如果 Content-Typetext/plain,但方法上是 @RequestBody,会怎样?
    • 回答:会得到 415 错误。因为 @RequestBody 背后的 RequestResponseBodyMethodProcessor 会遍历所有 HttpMessageConverter,寻找能够读取 text/plain 并反序列化为目标对象类型的转换器。默认的 StringHttpMessageConverter 可以处理 text/plain,但目标类型如果是非 String 的复杂对象,转换器将无法匹配,导致 415。
  • 追问 2:客户端 Content-Type 正确,且 Jackson 依赖也存在,为什么还可能是 415?
    • 回答:可能是客户端请求体不是合法的 JSON(但不会被当成 415,通常是 400)。或者是 Spring Security 过滤器在请求到达 DispatcherServlet 之前就耗尽了 InputStream,导致后续无法读取。也可能是有人扩展了 HttpMessageConverter,并在 canRead 方法中返回了 false
  • 追问 3:如何从源码层面证明问题出在 canReadcanWrite
    • 回答:在 AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters 方法的 for 循环处设置条件断点,逐步执行 converter.canRead(...),观察哪个转换器在哪种条件下返回了 false。这会直接锁定问题根源。
  • 加分回答:通过 /actuator/beans 列出所有 HttpMessageConverter 的实例,可以直接从 HTTP 端点确认当前环境加载了哪些转换器,及其类型和支持的媒体类型。

Q3. 一个使用了 @Async 的方法,发现里面获取不到 SecurityContextHolder 里的用户信息,怎么解决?

  • 标准回答:这是线程上下文传递的经典问题。SecurityContextHolder 默认使用 ThreadLocal,异步线程无法直接访问。首先,配置 @Async 使用的 TaskExecutor 为一个支持上下文传播的执行器,例如用 DelegatingSecurityContextTaskExecutor 包装,或者为 ThreadPoolTaskExecutor 设置 DelegatingSecurityContextTaskDecorator。其次,可以通过全局配置将 SecurityContextHolder 的策略改为 MODE_INHERITABLETHREADLOCAL(注意线程池复用的问题)。
  • 追问 1MODE_INHERITABLETHREADLOCAL 有什么潜在问题?为什么更推荐用 TaskDecorator
    • 回答InheritableThreadLocal 在线程池模式下会导致上下文污染或泄漏。当任务在线程 A 上执行完毕,如果不清理,线程 A 被回收进线程池,下次执行任务 B 时,可能会意外继承了上次任务残留的、甚至错误的上下文。TaskDecorator 机制会在任务执行前主动设置上下文,执行后主动清理,没有泄漏和污染的风险。
  • 追问 2:除了 SecurityContext,还有哪些典型的上下文会丢失?
    • 回答:MDC (Mapped Diagnostic Context),RequestContextHolderLocaleContextHolder,以及自定的 ThreadLocal 变量。可以通过 Spring 的 TaskDecorator 或直接使用 ContextPropagatingTaskDecorator(Spring Boot 2.7+)一次性传递多个上下文。
  • 追问 3:如果使用 WebFlux,类似的上下文如何传递?
    • 回答:WebFlux 使用 ReactorContext 来替代 ThreadLocal。通过 .contextWrite(ctx -> ctx.put(...)) 在操作链上填充上下文。Spring Security 的 ReactiveSecurityContextHolder 就是基于此设计。任何期望在响应式链上传递的信息,都应当放入 Reactor Context,而不是依赖 ThreadLocal
  • 加分回答:介绍 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 命令来追踪特定方法的调用耗时。
  • 追问 3:如果开发阶段没有引入 BlockHound,现在业务代码庞大,怎么快速找出所有潜在的阻塞点?
    • 回答:可以使用静态代码分析工具或简单的 grep 脚本,在整个项目中搜索已知阻塞 API 的使用:javax.sql.DataSourcejava.sql.*org.springframework.jdbc.*RestTemplatejava.io.File*Thread.sleepObject.wait 等。找到后,检查它们是否在 WebFlux Controller 的调用链路中。
  • 加分回答:讨论如何通过自定义 Reactor 的 Hooks.onOperatorDebugBlockHound 集成,或者使用 Netty 的 ResourceLeakDetector,来构建更完整的响应式应用监控屏障。

Q5. 系统设计题:设计一个支持 10 万并发的短链接生成服务,要求高吞吐、低延迟。

  • 标准回答
    1. 技术选型:选择 Spring WebFlux + Netty 作为 Web 框架,因其非阻塞 I/O 模型能更好地支撑高并发。数据库选用 Redis 存储短链映射,因其 O(1) 内存查询极快。短链生成算法使用分布式 ID 生成器(如 Snowflake)或哈希算法 + 62 进制转换。
    2. 架构设计:API 网关 (如 Nginx/Spring Cloud Gateway) -> 应用层 (WebFlux) -> Redis 集群。生成短链是纯计算和写操作,重定向是纯读操作。为读操作设计基于 Nginx 或 Redis 的二级缓存,以减少对应用层的冲击。
    3. 核心细节
      • 生成短链:控制器接收长链接,异步调用 ID 生成服务(可以是 Redis 自增或本地 Snowflake),得到唯一 ID,编码为短码,将 短码->长链接长链接->短码 存入 Redis。返回短链接。
      • 重定向短链:控制器接收短码,异步查询 Redis。若找到,返回 302 重定向;若未找到,返回 404。对热门短链接,在 API 网关层或应用层用 Caffeine 等本地缓存,设置极短的 TTL。
      • 背压控制:在所有与 Redis 的交互中使用 ReactiveRedisTemplate;在 API 网关层实施限流(如 RequestRateLimiter)。
  • 追问 1:如果使用哈希算法生成短码,如何处理哈希冲突?
    • 回答:哈希冲突无法避免。常用策略包括:(1)冲突时在原始长链接后追加一个自增序列再重试哈希,直到找到未被占用的短码;(2)使用布隆过滤器(Bloom Filter)快速判断一个短码是否已存在,不存在直接写入,可能存在时再查 Redis 确认,减少 Redis 压力。
  • 追问 2:如何保证在极高并发下,短链接的 302 重定向响应速度足够快?
    • 回答:采用多级缓存策略。第一级是 CDN/反向代理层,可以缓存 302 响应本身一段时间(如果业务允许)。第二级是应用内的本地缓存(如 Caffeine),存储热点映射。第三级才是 Redis 集群。同时,监控 Redis 的延迟,如果 Redis 成为瓶颈,考虑引入 Redis 只读副本进行读写分离。
  • 追问 3:这是一个典型的 IO 密集型服务。如果运行在 JDK 21+,你会选择平台线程还是虚拟线程?为什么?
    • 回答:我会评估使用 WebFlux(响应式)和虚拟线程两种方案。对于 IO 密集型,WebFlux 的响应式模型非常契合,能最大化单个核心的吞吐量,但开发心智成本高。虚拟线程同样是此场景的优秀选择,可以使用简单的命令式编码风格,底层通过平台线程池获得极高的 IO 等待并发。如果项目团队对响应式不熟,会优先选择用 Spring MVC + 虚拟线程,并配合 VirtualThreadTaskExecutor 来配置 Tomcat 和 @Async,能用较低的学习成本获得与 WebFlux 相近的吞吐量。
  • 加分回答:讨论对短链接服务的监控,包括对每个 API 端点 P99 延迟、QPS 的监控,以及通过 BlockHound 确保整个响应式链路的非阻塞性,或通过 JFR 确保虚拟线程未被 pinning。

(鉴于篇幅,其余 15 道面试题将以列表形式呈现,每道题考察一个核心反模式领域,具体回答思路可参照以上 5 题的深度。)

  1. 如何快速定位 Spring Boot 的某个自动配置没有生效的原因? (考察:/actuator/conditions)
  2. 如何处理因拦截器 preHandle 返回 false 导致的资源泄漏? (考察:案例 8)
  3. Controller 中抛出的异常被 @ControllerAdvice 处理,但返回的 HTTP 状态码不是你期望的,如何排查? (考察:案例 13 与异常优先级)
  4. DeferredResult 设置超时后,客户端还是没有收到超时响应,什么原因? (考察:案例 11 与异常处理器兜底)
  5. 解释 @ResponseStatus 注解在 @ExceptionHandler 工作流中的时机和作用。 (考察:异常响应状态码设置机制)
  6. Spring Cloud Gateway 和 Spring WebFlux 的函数式路由定义有什么优先级关系? (考察:WebFlux 模式下的路由)
  7. 如何排查一个被 CDN 缓存的 API,用户切换后仍能看到之前用户数据的问题? (考察:案例 21,Cache-Control)
  8. 应用因大量创建线程而 OOM,从日志和堆栈中如何判断是否是由于未配置线程池导致的? (考察:案例 10,SimpleAsyncTaskExecutor)
  9. 在进行 Spring MVC 异步处理时,你是如何保证请求上下文(如 MDC)在日志中完整传递的? (考察:案例 9 与 TaskDecorator)
  10. 如何诊断一个 @RequestBody 接收 JSON 时总是得到空对象或部分属性为 null 的问题? (考察:案例 6,Jackson 反序列化与字段映射)
  11. 你如何向一个新手解释为什么不应该在 WebFlux 代码里调用 RestTemplate?请从原理上说明。 (考察:案例 15 的根因理解深度)
  12. 如何设计一个优雅的 API 异常处理结构,能同时满足浏览器、移动端和第三方微服务调用者的需求? (考察:案例 14,内容协商与异常处理设计)
  13. 什么时候应该使用过滤器(Filter),什么时候应该使用拦截器(Interceptor)?用一个认证与授权的场景说明。 (考察:案例 7,Filter/Interceptor 职责边界)
  14. @Async 注解的方法返回 voidFuture<?>CompletableFuture<?>有何区别?这如何影响异常排查? (考察:异步异常传播机制)
  15. 系统设计题:设计一个支持多人同时在线编辑的文档系统,你会如何选择 Web 层的通信技术栈?请对比几种方案。
  16. 解释 Spring MVC 处理一个 HTTP 请求,从 Filter 到 Interceptor,再到参数解析、消息转换、视图渲染的完整生命周期的同时,如果过程中抛出不同类型异常,分别会被哪些组件捕获? (考察:全链路与异常处理集的综合理解)
  17. 在 WebFlux 中,如何处理文件上传并保证整个流程是非阻塞的,同时还能有效控制内存使用? (考察:WebFlux 对 DataBuffer 的自定义处理能力,关联背压)

Spring Web 反模式速查表

编号现象常见排查点关联反模式/工具快速修正
P1静态资源 404/actuator/mappings案例 1 (/**)避免宽泛映射,隔离API路径
P2AmbiguousHandlerException启动日志, /actuator/mappings案例 2 (路径重叠)使用正则限定, 或RESTful查询参数
P3WebFlux路由不生效/actuator/mappings 顺序案例 3 (混合路由)设置 @Order, 或物理隔离路径前缀
P4415 Unsupported Media TypeContent-Type头, /actuator/beansHttpMessageConverter案例 4 (缺失转换器), 案例 5引入spring-b
P5POST 请求 400,提示 Required request parameter 缺失对比 Content-Type 与注解案例 5 (注解误用)区分 @RequestBody@RequestParam,对齐请求契约
P6序列化后日期格式突变,LocalDateTime 报错/actuator/beans 搜索 ObjectMapper案例 6 (覆盖自动配置)使用 Jackson2ObjectMapperBuilderCustomizer
P7CORS 头重复,浏览器报错浏览器 Response Headers案例 7 (Filter/Interceptor 重复)二选一,优先使用 CorsFilter@CrossOrigin
P8长时间运行后磁盘/连接泄漏监控资源使用,对比拦截器日志案例 8 (资源未清理)在 Handler 内部用 try-with-resources
P9异步线程日志缺少 TraceId对比主线程和异步线程日志案例 9 (生命周期误判)实现 AsyncHandlerInterceptor,显示清理
P10线程数暴增,unable to create native threadjstack/actuator/metrics案例 10 (线程池缺失)配置 ThreadPoolTaskExecutor
P11客户端长时间等待,无响应jstack 检查 DeferredResult 相关线程案例 11 (悬挂)设置超时与 onTimeout 回调
P12异步任务失败,客户端收到空或200日志搜索异常栈案例 12 (异常吞没)@ExceptionHandler 中兜底,使用 DeferredResult.onError
P13局部异常处理器不生效,被全局覆盖在两个 @ExceptionHandler 中加日志案例 13 (优先级冲突)不在全局声明过细异常,依赖默认深度优先
P14API 调用返回 HTML Whitelabel Error Page请求/响应头 Accept vs Content-Type案例 14 (内容协商错误)全局 JSON 错误处理器,禁用 WhiteLabel
P15WebFlux 所有请求延迟高,CPU 低BlockHoundjstacknio 线程案例 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)替换 synchronizedReentrantLock
P20下载大文件时 OOM堆转储,检查 byte[] 引用链案例 20 (浅层 ETag)禁用 ShallowEtagHeaderFilter,使用资源级 ETag
P21用户看到他人数据浏览器缓存验证,CDN 日志案例 21 (缓存私有数据)敏感接口设置 CacheControl.noStore()

延伸阅读

  1. Spring 官方文档
  2. 《Spring 实战》第 6 版(Manning):深入讲解 Spring MVC 与 WebFlux 的实战案例。
  3. 《反应式编程实战:使用 Reactor》(O'Reilly):对背压、操作符调试有深刻剖析。
  4. BlockHound 官方 GitHubgithub.com/reactor/Blo…
  5. Spring Security 参考文档安全上下文传播
  6. JEP 425: Virtual Threadsopenjdk.org/jeps/425 – 了解 pinning 机制。
  7. Arthas 诊断工具arthas.aliyun.com/
  8. Spring Boot Actuator 端点大全docs.spring.io/spring-boot…