Spring Boot 全局异常处理怎么写

最后更新:2026-10-08
适用场景:Spring Boot 3.x、Spring MVC、@RestControllerAdvice、@ExceptionHandler、参数校验、JSON 格式错误、统一错误响应

项目刚开始写接口时,查不到用户就在 Controller 里返回 404,参数不对就在另一个 Controller 里写一次 try/catch。接口一多,同一种错误的状态码和返回格式很快就不一致了。

全局异常处理的用处,是把可以预期的错误集中映射为稳定的 HTTP 状态和业务码。业务代码只负责判断“发生了什么”,异常处理层负责决定“向客户端返回什么”。下面沿用前一篇的 ApiResponse<T>,把用户不存在、参数校验失败和 JSON 解析失败这三类常见错误接起来。

一、先看结论

Spring MVC 中,@RestControllerAdvice 可以让 @ExceptionHandler 作用于多个 Controller。典型流程是:

Controller 调用 Service
    ↓
Service 发现用户不存在,抛出 UserNotFoundException
    ↓
@RestControllerAdvice 捕获该异常
    ↓
返回 HTTP 404 + 约定的 JSON 错误码

对外响应可以沿用上一篇 Spring Boot 统一返回结果怎么写 的结构:

{
  "code": "USER_NOT_FOUND",
  "message": "用户不存在",
  "data": null
}

HTTP 状态也要对应实际结果。找不到用户是 404,请求体或参数不合法通常是 400。不要把异常统一包成 HTTP 200,再让前端只读 code。

这里说的“全局”,是针对 Spring MVC 请求处理过程中的 Controller 异常。过滤器、Spring Security、容器错误,以及尚未进入 Controller 的请求,可能走各自的处理链,不能假定一个 Advice 会接住所有错误。

二、先准备示例项目

示例使用 Java 17、Spring Boot 3.x 和 Spring MVC。若要演示 @Valid 与 @NotBlank,项目需要校验实现。在已有 spring-boot-starter-web 的基础上添加:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

使用 Spring Boot 的依赖管理时,不需要给这个 Starter 单独写版本号。

上一篇的 ApiResponse<T> 可以直接复用。如果你还没有这个类,创建 src/main/java/com/example/demo/web/ApiResponse.java:

package com.example.demo.web;

public record ApiResponse<T>(String code, String message, T data) {

    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>("OK", "成功", data);
    }

    public static <T> ApiResponse<T> error(String code, String message) {
        return new ApiResponse<>(code, message, null);
    }
}

ApiResponse<T> 只定义响应体;真正的 HTTP 状态由 ResponseEntity 控制。record 需要较新的 Java 版本,Java 8 或 Java 11 项目可以改成普通 Java 类。

三、先处理一个业务异常

继续使用 Spring Boot 整合 MyBatis 中的用户查询例子。原来的 Service 查不到记录会返回 null,Controller 再判断。现在改成在 Service 层抛出明确的异常。

创建 src/main/java/com/example/demo/web/UserNotFoundException.java:

package com.example.demo.web;

public class UserNotFoundException extends RuntimeException {

    public UserNotFoundException(Long id) {
        super("用户不存在,id=" + id);
    }
}

Service 中查询并判断。下面只展示 findById 相关代码,原有的 findAll、findByStatus 等方法保留:

package com.example.demo.service;

import com.example.demo.domain.User;
import com.example.demo.mapper.UserMapper;
import com.example.demo.web.UserNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    private final UserMapper userMapper;

    public UserService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    public User findById(Long id) {
        User user = userMapper.findById(id);
        if (user == null) {
            throw new UserNotFoundException(id);
        }
        return user;
    }
}

Controller 的单条查询只保留正常路径。下面也只展示该接口,原有的列表接口保留:

package com.example.demo.controller;

import com.example.demo.domain.User;
import com.example.demo.service.UserService;
import com.example.demo.web.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ApiResponse<User> findById(@PathVariable Long id) {
        return ApiResponse.ok(userService.findById(id));
    }
}

查到用户时,@RestController 将返回值序列化为 JSON,默认 HTTP 状态为 200。查不到时,异常继续向上交给异常处理器。

创建 src/main/java/com/example/demo/web/GlobalExceptionHandler.java:

package com.example.demo.web;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleUserNotFound(
            UserNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiResponse.<Void>error(
                        "USER_NOT_FOUND", "用户不存在"));
    }
}

这里的 ex 留给日志或进一步诊断使用。对客户端只返回固定的“用户不存在”,不把内部 ID、SQL 或堆栈直接暴露出去。

请求不存在的用户:

GET /users/9999

结果应为 HTTP 404:

{
  "code": "USER_NOT_FOUND",
  "message": "用户不存在",
  "data": null
}

如果项目只有一两个 Controller,直接在 Controller 中返回 ResponseEntity.notFound() 也完全可以。异常处理层更适合相同错误需要在多个接口里保持一致的项目。

四、处理 @Valid 请求体校验失败

接收新增用户请求时,常见写法是在请求对象上加约束,并在 Controller 参数上加 @Valid。

创建请求对象:

package com.example.demo.web;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record CreateUserRequest(
        @NotBlank(message = "用户名不能为空") String username,
        @NotBlank(message = "邮箱不能为空")
        @Email(message = "邮箱格式不正确") String email
) {
}

Controller 中使用。下面的 import 放在 UserController.java 文件顶部,create 方法放在 UserController 类体内:

import com.example.demo.web.CreateUserRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@PostMapping
public ResponseEntity<ApiResponse<User>> create(
        @Valid @RequestBody CreateUserRequest request) {
    User created = userService.create(request);
    return ResponseEntity.status(HttpStatus.CREATED)
            .body(ApiResponse.ok(created));
}

这段方法添加到上面的 UserController 中。userService.create(request) 代表项目自己的新增逻辑,需要在 Service 中实现;这里只看校验错误如何进入异常处理层。

例如请求:

{
  "username": "",
  "email": "[email protected]"
}

校验失败时,Controller 方法不会执行。对于这种 @Valid @RequestBody 的常见场景,Spring MVC 会抛出 MethodArgumentNotValidException。在 GlobalExceptionHandler 中加入下面的代码。import 放在文件顶部,处理方法放在类体内;后面两个处理器的片段也按这个方式添加:

import org.springframework.web.bind.MethodArgumentNotValidException;

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleInvalidBody(
        MethodArgumentNotValidException ex) {
    String message = "请求参数不合法";
    if (ex.getBindingResult().getFieldError() != null
            && ex.getBindingResult().getFieldError().getDefaultMessage() != null) {
        message = ex.getBindingResult().getFieldError().getDefaultMessage();
    }

    return ResponseEntity.badRequest()
            .body(ApiResponse.<Void>error("INVALID_ARGUMENT", message));
}

这样可以返回 HTTP 400 和:

{
  "code": "INVALID_ARGUMENT",
  "message": "用户名不能为空",
  "data": null
}

当多个字段同时失败时,这段示例只展示第一个错误。实际项目如果需要一次展示全部字段错误,可以专门设计字段错误列表;不要把整个 BindingResult 直接序列化给前端。

五、方法参数校验不一定抛同一个异常

如果约束直接写在方法参数上,例如把下面的 import 加到 UserController.java 文件顶部,把 page 方法加到类体内:

import jakarta.validation.constraints.Min;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/page")
public ApiResponse<String> page(@RequestParam @Min(1) int page) {
    return ApiResponse.ok("第 " + page + " 页");
}

Spring Framework 6.1 及之后的 MVC 方法校验可能抛出 HandlerMethodValidationException。它与上面的 MethodArgumentNotValidException 是两条不同路径。只处理前者或后者,都会留下部分校验错误没有按你的格式返回。

在 GlobalExceptionHandler 中继续添加:

import org.springframework.http.HttpStatusCode;
import org.springframework.web.method.annotation.HandlerMethodValidationException;

@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodValidation(
        HandlerMethodValidationException ex) {
    HttpStatusCode status = ex.getStatusCode();

    if (status.is5xxServerError()) {
        return ResponseEntity.status(status)
                .body(ApiResponse.<Void>error(
                        "INTERNAL_ERROR", "服务暂时不可用"));
    }

    return ResponseEntity.status(status)
            .body(ApiResponse.<Void>error(
                    "INVALID_ARGUMENT", "请求参数不合法"));
}

这里没有把所有方法校验错误强行改成 400。请求参数校验失败通常是客户端错误;返回值校验失败可能属于服务端问题,应保留异常携带的状态。

注意:控制器类上使用 @Validated 可能让方法校验走 AOP 路径,与 Spring MVC 内置方法校验行为不同。遇到“明明有校验注解,却没有进这个处理器”时,先检查实际抛出的异常类型和控制器上的注解。

六、处理 JSON 格式错误

客户端传了不完整的 JSON,例如:

{"username":

这类请求可能在反序列化阶段抛出 HttpMessageNotReadableException,Controller 方法甚至还没拿到 CreateUserRequest。它不属于 @Valid 的字段校验错误。

在 GlobalExceptionHandler 中加入:

import org.springframework.http.converter.HttpMessageNotReadableException;

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleUnreadableBody(
        HttpMessageNotReadableException ex) {
    return ResponseEntity.badRequest()
            .body(ApiResponse.<Void>error(
                    "INVALID_JSON", "请求体不是有效的 JSON"));
}

它返回 HTTP 400。对外不直接使用 ex.getMessage():解析异常里可能包含字段路径、Java 类型和部分原始输入。

也要分清下面几种情况:

情况常见结果主要检查点
JSON 语法写错HttpMessageNotReadableException请求体内容
JSON 合法,但字段违反约束MethodArgumentNotValidException@Valid 和约束注解
方法参数上的直接约束失败HandlerMethodValidationException方法签名与校验路径
Content-Type 不支持通常为 415请求头和接口 consumes

不要把 415 也改成 400。如果要统一它的响应体,应保留 HTTP 415 状态。

七、为什么不建议直接捕获 Exception.class

很多示例会在 Advice 最后补一个:

@ExceptionHandler(Exception.class)

它确实能捕获更多异常,但也可能抢先接住框架本来会按 404、405、415 处理的错误。如果直接统一返回 500,原本准确的状态码和部分响应头就丢了。

对尚未分类的服务端异常,至少做到两件事:

服务端日志保留完整异常,便于定位
对外只给通用提示,不泄露内部细节

Spring Boot 本身提供 /error 作为默认错误处理入口。需要把所有 MVC 内置错误也转成同一个 ApiResponse 格式时,可以基于 ResponseEntityExceptionHandler 扩展,但要逐项保留原状态码和必要的响应头。比如 405 Method Not Allowed 可能带 Allow,直接替换成普通 500 会误导调用方。

项目如果已经使用 Spring 支持的 ProblemDetail(RFC 9457)作为错误响应,也可以继续使用。成功响应与错误响应不必强行套进同一个 Java 类;接口文档说清楚即可。

八、全局处理为什么没生效

1. Advice 没被 Spring 扫描到

启动类在 com.example.demo,Advice 放在 com.example.demo.web 一般能被扫描。若它位于启动类默认扫描范围之外,需要调整包结构或显式配置组件扫描。

先确认启动日志中没有 Bean 创建错误,也没有重复的异常映射方法。

2. Controller 自己也写了 @ExceptionHandler

Spring MVC 会优先考虑 Controller 本地的异常处理方法,然后才是全局 Advice。看到返回格式与预期不同,检查当前 Controller 有没有同类型的 @ExceptionHandler。

3. 异常根本没进入 Spring MVC

过滤器和 Spring Security 可能在请求到达 Controller 之前就结束请求。认证失败、权限不足通常应放到安全框架对应的处理入口里,而不是寄希望于 Controller Advice。

4. 访问一个不存在的 URL,却没有进入业务异常处理器

UserNotFoundException 表示“路由存在,但要查的用户不存在”。一个 URL 连路由都匹配不到,属于另一类 404。在 Spring Boot 中它可能由 MVC 的异常解析器或默认 /error 路径处理,具体还与静态资源映射和配置有关。

不要为了让“未匹配路径”进入业务异常处理器,就把所有资源错误当成 UserNotFoundException。

5. 异常已经被别处捕获

Service 或 Controller 中如果提前 try/catch 住异常并返回了成功对象,Advice 自然不会再执行。定位时沿调用链检查是不是有人把异常吞掉了。

6. 请求是异步或流式响应

响应体一旦开始写出,再发生异常,通常无法把已经发出的内容改成一个完整 JSON 错误体。流式下载、SSE 等接口应单独设计失败方式。

九、怎么验证

先用真实存在的用户 ID 测试成功路径,再用不存在的 ID 测试 404:

curl -i http://localhost:8080/users/1
curl -i http://localhost:8080/users/9999

校验失败和非法 JSON 可以这样测:

curl -i -X POST http://localhost:8080/users \
  -H 'Content-Type: application/json' \
  -d '{"username":"","email":"[email protected]"}'

curl -i -X POST http://localhost:8080/users \
  -H 'Content-Type: application/json' \
  -d '{"username":'

Windows PowerShell 可以用 Apifox、Postman,或用 curl.exe 发送相同请求,重点同时查看 HTTP 状态行和 JSON 响应体。

预期结果:

请求HTTP 状态业务码
查询存在的用户200OK
查询不存在的用户404USER_NOT_FOUND
@Valid 校验失败400INVALID_ARGUMENT
JSON 语法错误400INVALID_JSON

如果请求返回了正确状态,但 JSON 结构与预期不同,先看具体异常类型,再看它走的是自定义 Advice、Spring MVC 内置处理,还是 Spring Boot 的 /error。

常见坑

1. 错误响应一律返回 HTTP 200

这样会让监控、网关和通用 HTTP 客户端把失败当成成功。业务码提供更细的业务含义,HTTP 状态仍应正确。

2. 把数据库异常当成业务异常

查不到用户是一种可预期的业务结果;数据库连接失败属于服务端故障。不要把后者映射成 USER_NOT_FOUND 或 400,否则排查方向会被带偏。

3. 直接把 ex.getMessage() 返回给前端

异常消息可能包含 SQL、文件路径、内部类名或用户输入。对外用固定提示,详细信息留在服务端日志。

4. 只处理 MethodArgumentNotValidException

它覆盖不了所有校验场景。直接写在 Controller 方法参数上的约束,可能触发 HandlerMethodValidationException。先看实际异常,再补处理路径。

5. 把 JSON 解析失败和字段校验失败混在一起

JSON 都没解析成功时,@Valid 还没开始工作。两类错误可以都返回 400,但业务码最好能区分。

6. 同一个异常在多个 Advice 中重复处理

当项目有多个 @RestControllerAdvice 时,优先级和异常匹配规则会影响最终由谁处理。尽量让一个业务异常只有明确的归属,必要时用 @Order 指定顺序。

排查清单

[ ] @RestControllerAdvice 是否被组件扫描到
[ ] Controller 是否有更优先的本地 @ExceptionHandler
[ ] Service 是否真的抛出了预期异常
[ ] 实际异常类型是否与 @ExceptionHandler 匹配
[ ] @Valid 是否写在请求对象参数上
[ ] 是否引入 spring-boot-starter-validation
[ ] 方法参数约束是否走 HandlerMethodValidationException
[ ] HTTP 状态是否与错误类别一致
[ ] 是否有 catch-all 把 404、405、415 改成 500
[ ] 过滤器和安全链的错误是否在进入 MVC 前已经处理
[ ] 对外响应是否隐藏内部异常细节

常见问题 FAQ

1. @RestControllerAdvice 和 @ControllerAdvice 有什么区别?

@RestControllerAdvice 可以理解为带有响应体写出能力的 @ControllerAdvice,适合返回 JSON 的接口项目。@ControllerAdvice 也能处理异常,但返回视图或响应体需要按方法的返回方式处理。

2. @ExceptionHandler 必须写在全局类里吗?

不是。写在 Controller 里只处理该 Controller 的异常;写在 Advice 里可以应用于多个 Controller。

3. 业务异常一定要继承 RuntimeException 吗?

不是强制要求。示例用运行时异常是为了在 Service 中表达可预期的失败,减少层层声明。更重要的是让异常类型和业务码明确,不要用一个 Exception 类型表示所有业务问题。

4. 为什么 @Valid 没生效?

先检查是否引入了校验实现、Controller 参数上有没有 @Valid、约束注解是否来自 jakarta.validation,以及请求是否先被 JSON 解析错误拦住。

5. 参数校验失败一定是 MethodArgumentNotValidException 吗?

不是。直接写在方法参数上的约束可能触发 HandlerMethodValidationException。这也是本文分别处理两者的原因。

6. 全局异常处理能接住 404 吗?

业务代码主动抛出的“资源不存在”异常可以。完全没有路由匹配的请求、静态资源错误,可能走 MVC 内置处理或 Boot 的 /error,需要按实际请求链单独验证。

7. 要不要加一个 Exception.class 兜底?

先确认目的和影响范围。笼统兜底会影响框架自带的异常状态及响应头。若项目决定加兜底,应保留服务端日志、隐藏内部细节,并测试 404、405、415 等情况。

8. Spring Boot 4 能照搬这段代码吗?

文章按 Spring Boot 3.x、Spring Framework 6.2 编写。升级到 Spring Boot 4 时,先核对项目依赖的 Spring Framework 版本和异常处理 API,再迁移示例,不要把旧版本的异常类和方法签名直接复制过去。

最后总结

全局异常处理最有价值的地方,是让可预期错误保持一致:Service 抛出明确异常,Advice 映射为稳定业务码和正确的 HTTP 状态。请求体校验、方法参数校验、JSON 解析失败各有自己的异常类型,排查时先分清来源。

@RestControllerAdvice 负责 MVC 范围内的异常。过滤器、安全链、未匹配路由和默认 /error 仍要按实际请求链验证。把边界想清楚,比写一个包罗万象的 Exception.class 处理器更可靠。

相关文章

Spring Boot 统一返回结果怎么写

Spring Boot 接收 JSON 参数

Spring Boot 整合 MyBatis

Spring Boot 连接 MySQL

Spring Boot 配置文件怎么写

SpringBoot 教程

参考资料

Spring Framework 6.2:Controller Advice

Spring Framework 6.2:ExceptionHandler

Spring Framework 6.2:MVC 参数校验

Spring Framework 6.2:ResponseEntityExceptionHandler API

Spring Boot 3.5:错误处理

更新记录

2026-10-08:创建文章,加入业务异常、请求体验证、方法参数校验、非法 JSON 与 MVC 异常边界的示例。