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 状态 | 业务码 |
|---|---|---|
| 查询存在的用户 | 200 | OK |
| 查询不存在的用户 | 404 | USER_NOT_FOUND |
@Valid 校验失败 | 400 | INVALID_ARGUMENT |
| JSON 语法错误 | 400 | INVALID_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 Framework 6.2:Controller Advice
Spring Framework 6.2:ExceptionHandler
Spring Framework 6.2:ResponseEntityExceptionHandler API
更新记录
2026-10-08:创建文章,加入业务异常、请求体验证、方法参数校验、非法 JSON 与 MVC 异常边界的示例。
Spring Boot 全局异常处理怎么写
https://java.li/archives/spring-boot-global-exception-handler
评论