臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 一句话理解

核心结论

Spring MVC 的异常处理,本质上是:请求处理过程中抛出异常后,DispatcherServlet 不急着把异常交给服务器,而是依次询问一组 HandlerExceptionResolver:“谁能把这个异常转换成正常的 HTTP 响应?”

业务开发中通常不直接操作解析器,而是使用 @ExceptionHandler 和 @RestControllerAdvice,把异常统一转换成明确的状态码和稳定的错误 JSON。


二、🧭 理论:它是什么

2.1 为什么需要统一异常处理

假设“按编号查询图书”时没有找到数据,最直接的写法是在每个 Controller 里自己判断:

if (book == null) {
    return ResponseEntity.status(404).body(...);
}

这样做能运行,但业务一多就会出现三个问题:

  • 每个接口都重复拼错误响应。
  • 同一种错误可能返回不同的状态码、字段和文案。
  • Controller 同时承担业务流程和错误格式化,职责混在一起。

统一异常处理把这两件事拆开:

  1. 业务代码只负责发现问题并抛出有含义的异常。
  2. 异常处理器负责把异常翻译成 HTTP 状态码和响应体。

这里的“翻译”非常关键:Java 异常是服务端内部语言,HTTP 状态码和 JSON 才是客户端能理解的语言。

2.2 需要区分的三个对象

对象白话理解真实职责
Java 异常业务执行失败的信号携带异常类型、消息和调用栈
异常处理器翻译员决定由谁处理异常,以及如何生成响应
HTTP 错误响应给客户端的结果包含状态码、响应头和响应体

不要把“记录日志”和“返回错误响应”混为一谈

日志给开发、运维人员看;错误响应给客户端看。响应中不应直接暴露堆栈、SQL、服务器路径等内部细节。

2.3 常用注解分别做什么

注解作用范围常见用途
@ExceptionHandler当前 Controller,或所在 Advice 覆盖的 Controller声明某个方法处理哪些异常
@ControllerAdvice默认覆盖所有 Controller全局处理异常;返回值可以是视图
@RestControllerAdvice默认覆盖所有 Controller等于 @ControllerAdvice + @ResponseBody,通常返回 JSON
@ResponseStatus异常类或处理方法声明固定 HTTP 状态码

REST API 项目最常用的组合是:

@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(BookNotFoundException.class)
    public ProblemDetail handleBookNotFound(BookNotFoundException ex) {
        // 把业务异常翻译为 404 响应
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
}

输入是 BookNotFoundException,处理过程是匹配对应方法并构造错误详情,输出是 HTTP 404 和 JSON 响应体。


三、⚙️ 理论:它是怎么工作的

3.1 第一步:异常进入 DispatcherServlet

Spring MVC 使用前端控制器模式。绝大多数 MVC 请求先进入 DispatcherServlet,再找到 Controller 并调用相应方法。

如果请求映射或 Controller 执行期间出现异常,DispatcherServlet 会把异常交给 HandlerExceptionResolver 链处理。

类比中的“总服务台”对应真实组件 DispatcherServlet,“异常处理窗口”对应真实接口 HandlerExceptionResolver。这只是帮助理解,实际运行时是解析器对象按顺序调用。

3.2 第二步:解析器责任链依次尝试处理

HandlerExceptionResolver 是 Spring MVC 异常机制的底层扩展接口。一个解析器处理异常后会返回 ModelAndView;不同返回值表达不同结果:

返回值含义
非空且包含视图的 ModelAndView异常已处理,接下来渲染错误页面
空的 ModelAndView异常已处理,但不需要渲染视图,例如响应已经写完
null当前解析器不处理,继续询问后面的解析器

MVC 配置会提供常用的内置解析器:

解析器主要职责
ExceptionHandlerExceptionResolver查找并调用 @ExceptionHandler 方法
ResponseStatusExceptionResolver处理带 @ResponseStatus 的异常,以及 ResponseStatusException
DefaultHandlerExceptionResolver把 Spring MVC 自身的标准异常映射为对应 HTTP 状态码
SimpleMappingExceptionResolver把异常类名映射到错误视图,传统页面应用更常见

业务代码通常不需要自己实现 HandlerExceptionResolver

@ExceptionHandler 已经建立在这套机制之上。只有需要非常底层、跨处理器的定制行为时,才考虑直接实现解析器。

3.3 第三步:@ExceptionHandler 按异常类型匹配方法

ExceptionHandlerExceptionResolver 会从可用的处理方法中选择最匹配的一个。

推荐直接把目标异常写成方法参数:

@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleBookNotFound(BookNotFoundException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}

Spring 不只检查最外层异常,也能沿着 cause 链查找被包装的异常。多个方法都能匹配时,在同一个 Controller 或 Advice 内,一般优先选择离实际异常类型更近的匹配。

例如:

@ExceptionHandler(RuntimeException.class)
public ProblemDetail handleRuntime(RuntimeException ex) { ... }
 
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleBookNotFound(BookNotFoundException ex) { ... }

如果抛出 BookNotFoundException,第二个方法更具体,因此会被选择。

兜底异常不要写在最前面思考

可以保留 Exception.class 作为最后防线,但已知业务错误应分别映射。否则所有问题都变成 500,客户端无法区分“参数错误”“资源不存在”和“服务内部故障”。

3.4 第四步:先找局部处理器,再找全局处理器

@ExceptionHandler 可以放在 Controller 内,也可以放在 Advice 中。

处理范围和优先关系如下:

  1. 先查找当前 Controller 中的局部 @ExceptionHandler。
  2. 局部没有合适方法时,再查找 @ControllerAdvice 或 @RestControllerAdvice 中的全局处理器。
  3. 如果存在多个 Advice,可以使用 @Order 控制 Advice 的先后顺序。

这意味着:局部规则适合某个 Controller 的特殊需求,全局规则适合整个应用共同遵守的错误协议。

@RestController
public class ReportController {
 
    @ExceptionHandler(ReportBuildingException.class)
    public ProblemDetail handleReport(ReportBuildingException ex) {
        // 只对当前 Controller 生效
        return ProblemDetail.forStatusAndDetail(
                HttpStatus.UNPROCESSABLE_ENTITY,
                "报表暂时无法生成"
        );
    }
}

如果多个 Advice 都可能处理同一种异常,高优先级 Advice 中的原因链匹配,可能先于低优先级 Advice 中的根异常匹配。因此,多 Advice 场景应让主要的精确映射位于高优先级 Advice,避免只凭“异常更具体”猜测最终结果。

3.5 第五步:把异常转换为规范的 HTTP 响应

返回错误响应时至少要回答三个问题:

  • HTTP 状态码是什么?
  • 客户端可以安全看到什么说明?
  • 客户端如何稳定识别错误类型?

Spring Framework 支持 RFC 9457 的 Problem Details。常用对象如下:

类型用途
ProblemDetail表示标准错误响应体,也可以添加扩展字段
ErrorResponse表示完整错误响应,包括状态码、响应头和 ProblemDetail 响应体
ErrorResponseExceptionErrorResponse 的基础实现,可作为自定义异常基类
ResponseEntityExceptionHandler处理 Spring MVC 内置异常的 Advice 基类,并提供自定义钩子

ProblemDetail 的标准字段包括:

字段含义
type标识问题类型的 URI;默认可为 about:blank
title简短、稳定的问题标题
statusHTTP 状态码
detail本次错误的具体说明
instance本次出错请求的 URI

还可以通过 setProperty 添加业务错误码等非标准字段:

ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.NOT_FOUND,
        "图书 42 不存在"
);
problem.setTitle("图书不存在");
problem.setProperty("code", "BOOK_NOT_FOUND");

使用 Jackson 渲染时,properties 中的扩展属性会展开到 JSON 顶层。ProblemDetail.status 决定 HTTP 状态码;如果未主动设置 instance,Spring 会根据当前请求路径补充它。

3.6 哪些异常可能不经过这套机制

@RestControllerAdvice 主要处理进入 Spring MVC 请求处理链后的异常,并不是整个 Web 应用的万能捕获器。

异常位置通常由谁处理
Controller、参数绑定、消息转换、MVC 请求处理Spring MVC 异常解析链
Controller 之前的 Servlet FilterFilter 自己处理,或交给 Servlet 容器
Spring Security 认证与授权AuthenticationEntryPoint、AccessDeniedHandler
已切换到其他线程且未正确回传的异常对应异步机制或任务执行器

因此,“全局异常处理”里的“全局”,通常指所有受其覆盖的 Spring MVC Controller,而不是 JVM 中任何位置抛出的异常。


四、🚀 实践:从准备到验证

4.1 前置准备

本实践构建一个最小 REST API:查询不存在的图书时返回 404,创建图书参数不合法时返回 400,未知异常返回 500。

4.1.1 环境与项目

按 2026-09-22 的官方稳定资料,本例使用以下环境:

  • Java 17 或更高版本。
  • Spring Boot 4.1.1。
  • Maven 3.6.3 或更高版本。
  • 项目依赖:Spring Web、Validation。

可以在 Spring Initializr 创建 Maven / Java 项目,加入 Spring Web 和 Validation。如果你已有 Spring Boot Web 项目,只需确认这两个依赖存在。

4.1.2 Maven 依赖

文件位置:pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
 
    <!-- Spring Boot 管理 Spring Framework、Jackson、Validation 等依赖的兼容版本。 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>
 
    <groupId>com.example</groupId>
    <artifactId>mvc-exception-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>mvc-exception-demo</name>
 
    <properties>
        <!-- Spring Boot 4.x 最低需要 Java 17。 -->
        <java.version>17</java.version>
    </properties>
 
    <dependencies>
        <!-- 提供 Spring MVC、内嵌 Servlet 容器和 JSON 转换能力。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
 
        <!-- 提供 jakarta.validation 注解和参数校验实现。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
 
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

4.2 可以拿来干什么

4.2.1 把业务异常映射为准确状态码

用途:查询不到资源时返回 404,而不是含糊的 500。

前置对象是业务代码抛出的 BookNotFoundException,处理器通过异常类型识别它:

@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleBookNotFound(BookNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            ex.getMessage()
    );
    problem.setTitle("图书不存在");
    problem.setProperty("code", "BOOK_NOT_FOUND");
    return problem;
}
  • 输入:BookNotFoundException。
  • 过程:异常类型匹配处理方法,构造 ProblemDetail。
  • 输出:404、标准问题详情和稳定业务码 BOOK_NOT_FOUND。
  • 适用场景:资源不存在、业务状态冲突、操作不允许等可预期错误。

4.2.2 统一参数校验错误

用途:把 @Valid 产生的字段错误整理成前端容易消费的结构。

下面的方法依赖 ResponseEntityExceptionHandler 基类。当请求体校验失败时,Spring MVC 会抛出 MethodArgumentNotValidException:

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex,
        HttpHeaders headers,
        HttpStatusCode status,
        WebRequest request) {
 
    Map<String, String> errors = new LinkedHashMap<>();
    ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.putIfAbsent(error.getField(), error.getDefaultMessage())
    );
 
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            "请求参数校验失败"
    );
    problem.setTitle("参数错误");
    problem.setProperty("code", "VALIDATION_FAILED");
    problem.setProperty("errors", errors);
 
    return handleExceptionInternal(ex, problem, headers, status, request);
}
  • 输入:Spring MVC 参数绑定阶段产生的校验异常。
  • 过程:提取字段名和校验消息。
  • 输出:400,以及 errors 字段错误集合。
  • 适用场景:创建、修改资源时的 JSON 请求体校验。

4.2.3 为未知故障提供安全兜底

用途:避免未处理异常把内部实现细节直接暴露给客户端。

@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
    // 实际项目应在服务端记录完整异常,响应只返回安全、稳定的描述。
    log.error("未处理的服务端异常", ex);
 
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "服务暂时不可用,请稍后重试"
    );
    problem.setTitle("服务内部错误");
    problem.setProperty("code", "INTERNAL_ERROR");
    return problem;
}
  • 输入:未被更具体规则处理的异常。
  • 过程:服务端记录完整日志,对外隐藏堆栈和内部消息。
  • 输出:500 和稳定的通用错误信息。
  • 适用场景:最后一道防线,不应替代具体业务异常映射。

4.3 完整实践:代码、启动和验证

4.3.1 创建启动类

文件位置:src/main/java/com/example/exceptiondemo/ExceptionDemoApplication.java

package com.example.exceptiondemo;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
 
/**
 * 应用入口。
 * @SpringBootApplication 会启用自动配置和组件扫描,
 * 从而发现 Controller 与全局异常处理器。
 */
@SpringBootApplication
public class ExceptionDemoApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ExceptionDemoApplication.class, args);
    }
}

4.3.2 定义业务异常

文件位置:src/main/java/com/example/exceptiondemo/book/BookNotFoundException.java

package com.example.exceptiondemo.book;
 
/**
 * 表示“请求的图书不存在”。
 * 异常本身只表达业务事实,不负责决定 JSON 格式。
 */
public class BookNotFoundException extends RuntimeException {
 
    public BookNotFoundException(long id) {
        super("图书 " + id + " 不存在");
    }
}

4.3.3 定义请求对象

文件位置:src/main/java/com/example/exceptiondemo/book/CreateBookRequest.java

package com.example.exceptiondemo.book;
 
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
 
/**
 * 创建图书时接收的 JSON 请求体。
 * @NotBlank 和 @Size 会在 Controller 的 @Valid 触发后执行。
 */
public record CreateBookRequest(
        @NotBlank(message = "书名不能为空")
        @Size(max = 100, message = "书名不能超过 100 个字符")
        String title
) {
}

4.3.4 创建 Controller

文件位置:src/main/java/com/example/exceptiondemo/book/BookController.java

package com.example.exceptiondemo.book;
 
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.Map;
 
@RestController
@RequestMapping("/books")
public class BookController {
 
    @GetMapping("/{id}")
    public Map<String, Object> findById(@PathVariable long id) {
        // 为了让示例不依赖数据库,约定只有编号 1 的图书存在。
        if (id != 1L) {
            // Controller 不拼错误 JSON,只抛出能表达业务含义的异常。
            throw new BookNotFoundException(id);
        }
 
        return Map.of("id", 1L, "title", "Spring MVC 入门");
    }
 
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Map<String, Object> create(@Valid @RequestBody CreateBookRequest request) {
        // @Valid 会先校验请求体;校验失败时,此方法体不会继续执行。
        return Map.of("id", 2L, "title", request.title());
    }
 
    @GetMapping("/failure")
    public void failure() {
        // 专门用于验证未知异常的 500 兜底响应。
        throw new IllegalStateException("仅用于演示的内部错误");
    }
}

4.3.5 创建全局异常处理器

文件位置:src/main/java/com/example/exceptiondemo/error/GlobalExceptionHandler.java

package com.example.exceptiondemo.error;
 
import com.example.exceptiondemo.book.BookNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
 
import java.util.LinkedHashMap;
import java.util.Map;
 
/**
 * 全局 REST 异常翻译器。
 *
 * ResponseEntityExceptionHandler 已声明对 Spring MVC 内置异常的处理入口,
 * 子类可以覆盖 protected 方法来定制其中某一种响应。
 */
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
 
    private static final Logger log =
            LoggerFactory.getLogger(GlobalExceptionHandler.class);
 
    /**
     * 把“图书不存在”翻译为 404。
     * ProblemDetail 的 status 同时决定实际 HTTP 状态码。
     */
    @ExceptionHandler(BookNotFoundException.class)
    public ProblemDetail handleBookNotFound(BookNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND,
                ex.getMessage()
        );
        problem.setTitle("图书不存在");
        problem.setProperty("code", "BOOK_NOT_FOUND");
        return problem;
    }
 
    /**
     * 定制 @Valid 请求体校验失败的响应。
     * LinkedHashMap 让字段错误按读取顺序输出,便于观察和测试。
     */
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
 
        Map<String, String> errors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                // 同一字段可能违反多条规则;最小示例只保留第一条消息。
                errors.putIfAbsent(error.getField(), error.getDefaultMessage())
        );
 
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "请求参数校验失败"
        );
        problem.setTitle("参数错误");
        problem.setProperty("code", "VALIDATION_FAILED");
        problem.setProperty("errors", errors);
 
        // 复用基类流程,保留 Spring MVC 为该异常准备的响应头和状态码。
        return handleExceptionInternal(ex, problem, headers, status, request);
    }
 
    /**
     * 最后的未知异常兜底。
     * 完整原因写入服务端日志,对客户端只返回安全文案。
     */
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex) {
        log.error("未处理的服务端异常", ex);
 
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "服务暂时不可用,请稍后重试"
        );
        problem.setTitle("服务内部错误");
        problem.setProperty("code", "INTERNAL_ERROR");
        return problem;
    }
}

4.3.6 启动应用

在项目根目录执行:

./mvnw spring-boot:run

如果项目没有 Maven Wrapper,但本机已经安装 Maven,可以执行:

mvn spring-boot:run

看到应用在 8080 端口启动后,再打开另一个终端进行验证。

4.3.7 验证资源不存在

请求:

curl -i http://localhost:8080/books/42

预期状态码为 404 Not Found,响应体类似:

{
  "type": "about:blank",
  "title": "图书不存在",
  "status": 404,
  "detail": "图书 42 不存在",
  "instance": "/books/42",
  "code": "BOOK_NOT_FOUND"
}

实际过程是:Controller 抛出业务异常 → ExceptionHandlerExceptionResolver 找到全局方法 → 方法返回 ProblemDetail → Spring 写出 404 JSON。

4.3.8 验证参数校验失败

请求中的 title 为空字符串:

curl -i \
  -H 'Content-Type: application/json' \
  -d '{"title":""}' \
  http://localhost:8080/books

预期状态码为 400 Bad Request,响应体类似:

{
  "type": "about:blank",
  "title": "参数错误",
  "status": 400,
  "detail": "请求参数校验失败",
  "instance": "/books",
  "code": "VALIDATION_FAILED",
  "errors": {
    "title": "书名不能为空"
  }
}

4.3.9 验证成功请求

查询存在的图书:

curl -i http://localhost:8080/books/1

预期得到 200 OK:

{
  "id": 1,
  "title": "Spring MVC 入门"
}

创建合法图书:

curl -i \
  -H 'Content-Type: application/json' \
  -d '{"title":"深入理解 Spring MVC"}' \
  http://localhost:8080/books

预期得到 201 Created。这也说明全局异常处理器只接管异常,不会干扰正常响应。

4.3.10 验证未知异常兜底

curl -i http://localhost:8080/books/failure

预期得到 500 Internal Server Error,响应中只有安全文案和 INTERNAL_ERROR;终端日志中则保留完整异常调用栈。

4.4 常见报错与排查

4.4.1 Advice 没有生效

检查以下几点:

  • GlobalExceptionHandler 是否带有 @RestControllerAdvice。
  • Advice 是否位于 @SpringBootApplication 所在包或其子包,能够被组件扫描发现。
  • 是否存在 Controller 内的局部处理方法先匹配了该异常。
  • 是否有更高优先级的其他 Advice 抢先处理。

4.4.2 返回 200 而不是错误状态码

不要只返回自定义错误对象却忘记状态码。优先返回 ProblemDetail、ResponseEntity,或者在处理方法上明确使用 @ResponseStatus。

本例使用 ProblemDetail.forStatusAndDetail,其中的 status 会决定实际 HTTP 状态码。

4.4.3 @Valid 没有触发

检查:

  • 是否引入 spring-boot-starter-validation。
  • Controller 参数前是否有 @Valid。
  • 校验注解是否来自 jakarta.validation.constraints,而不是旧的 javax.validation.constraints。
  • 请求头是否包含 Content-Type: application/json。

4.4.4 500 响应泄露内部信息

不要把 ex.getMessage()、堆栈、SQL、文件路径直接返回给客户端。完整异常写日志,对外返回稳定文案;如果需要追踪,可额外返回由日志链路生成的请求 ID,而不是内部细节。

4.4.5 Filter 中的异常没有被 Advice 捕获

这是作用范围问题,不是 Advice 失效。Filter 通常运行在 DispatcherServlet 之前。可以在 Filter 内捕获并写响应,或把异常转发给 Spring MVC,但要避免响应已经提交后再次写入。


五、📌 总结

  • 快速回顾:DispatcherServlet 把请求处理异常交给 HandlerExceptionResolver 链。
  • 快速回顾:@ExceptionHandler 是常用上层入口,ExceptionHandlerExceptionResolver 负责发现并调用它。
  • 快速回顾:局部处理器优先于全局 Advice;多个 Advice 还要考虑 @Order。
  • 快速回顾:业务异常应映射为准确的 4xx;未知故障才由 500 兜底。
  • 快速回顾:REST API 优先使用 ProblemDetail 统一 status、title、detail、instance 和业务错误码。

记忆句

业务代码负责“抛出什么问题”,全局异常处理器负责“把问题翻译成什么 HTTP 响应”。


六、📚 官方资料

以下资料均为本篇实际参考的一手资料;版本信息以 2026-09-22 页面显示为准: