Spring Boot 异常处理:Spring MVC 局部与全局异常处理全解析

欢迎你来读这篇博客,这篇博客主要介绍 Spring Boot 项目中的异常处理机制。

本文将从 Spring MVC 的异常处理链路出发,分析局部异常处理、全局异常处理、异常处理器优先级、参数校验异常、Spring Security 异常,以及企业级项目中的异常体系设计。

序言

在一个简单的 Spring Boot 项目中,我们可能会直接在 Controller 中使用 try-catch

1
2
3
4
5
6
7
8
@GetMapping("/{id}")
public Result<UserVO> findById(@PathVariable Long id) {
try {
return Result.success(userService.findById(id));
} catch (Exception exception) {
return Result.fail(exception.getMessage());
}
}

这种写法虽然能够返回错误信息,但存在明显问题:

  1. Controller 中充斥大量重复的 try-catch
  2. 参数异常、业务异常和系统异常无法区分;
  3. HTTP 状态码无法准确表达请求结果;
  4. 可能将数据库、文件路径或内部类名暴露给前端;
  5. 异常日志容易重复打印;
  6. 错误响应结构难以统一。

Spring MVC 已经提供了一套完整的异常解析机制。我们真正需要做的,不是在每一个接口中手动捕获异常,而是建立一套统一、清晰且可维护的异常处理体系。

正文

一、Spring Boot 异常处理整体链路

Spring Boot Web 项目的异常处理主要由 Spring MVC 完成。

一次请求的大致执行流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
HTTP Request


Servlet Filter / Spring Security Filter


DispatcherServlet

├── HandlerMapping 查找 Controller

├── HandlerAdapter 执行 Controller

└── Controller / Service 抛出异常


HandlerExceptionResolverComposite

├── ExceptionHandlerExceptionResolver
├── ResponseStatusExceptionResolver
└── DefaultHandlerExceptionResolver


异常仍未被处理


Spring Boot /error


BasicErrorController

这里需要记住一个关键结论:

@ExceptionHandler@ControllerAdvice 并不是简单依赖 Spring AOP 捕获异常,而是由 DispatcherServlet 的异常解析器链负责调用。

Spring MVC 默认提供了多个 HandlerExceptionResolver

1. ExceptionHandlerExceptionResolver

负责解析:

1
@ExceptionHandler

它既能处理 Controller 内部的局部异常处理方法,也能处理 @ControllerAdvice 中定义的全局异常处理方法。

2. ResponseStatusExceptionResolver

负责处理:

1
@ResponseStatus

以及:

1
ResponseStatusException

3. DefaultHandlerExceptionResolver

负责处理 Spring MVC 自身定义的标准异常,例如:

  • HttpRequestMethodNotSupportedException
  • HttpMediaTypeNotSupportedException
  • MissingServletRequestParameterException
  • MethodArgumentTypeMismatchException
  • HttpMessageNotReadableException

它会将这些异常转换为对应的 HTTP 状态码。

4. Spring Boot 的 /error 兜底

如果异常没有被 Spring MVC 的异常解析器成功处理,Servlet 容器会进行错误分派,最终可能进入:

1
/error

Spring Boot 默认使用 BasicErrorController 生成错误响应。

因此,BasicErrorController 更像是最后一道防线,而不应该作为业务项目的主要异常处理方式。


二、Spring MVC 局部异常处理

局部异常处理是在某个 Controller 内部定义 @ExceptionHandler 方法。

它通常只处理当前 Controller 或其继承体系中抛出的异常。

1. 基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@RestController
@RequestMapping("/users")
public class UserController {

@GetMapping("/{id}")
public UserVO findById(@PathVariable Long id) {
if (id == 1L) {
throw new UserNotFoundException(id);
}

return new UserVO(id, "SuperMario");
}

@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ApiError> handleUserNotFound(
UserNotFoundException exception,
HttpServletRequest request) {

ApiError error = new ApiError(
"USER_NOT_FOUND",
exception.getMessage(),
HttpStatus.NOT_FOUND.value(),
request.getRequestURI(),
Instant.now()
);

return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(error);
}
}

对应的错误响应可能是:

1
2
3
4
5
6
7
{
"code": "USER_NOT_FOUND",
"message": "用户不存在,userId=1",
"status": 404,
"path": "/users/1",
"timestamp": "2026-07-14T06:00:00Z"
}

2. 根据方法参数推断异常类型

除了在注解中明确指定异常类型:

1
2
3
4
5
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ApiError> handle(
UserNotFoundException exception) {
// ...
}

也可以让 Spring 根据方法参数推断:

1
2
3
4
5
@ExceptionHandler
public ResponseEntity<ApiError> handle(
UserNotFoundException exception) {
// ...
}

为了提高代码可读性,通常建议在 @ExceptionHandler 中明确写出需要处理的异常类型。

3. 多个异常处理方法的匹配

假设 Controller 中存在以下两个处理器:

1
2
3
4
5
6
7
8
9
10
11
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiError> handleBusiness(
BusinessException exception) {
// ...
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiError> handleRuntime(
RuntimeException exception) {
// ...
}

当实际抛出 BusinessException 时,Spring 会优先匹配更加具体的 BusinessException 处理器。

因此,异常处理方法应该尽量声明准确的异常类型,不要所有方法都使用:

1
@ExceptionHandler(Exception.class)

4. 局部异常处理适用场景

局部异常处理适合以下情况:

  • 某个异常只属于一个 Controller;
  • 同一种异常在不同 Controller 中需要返回不同结果;
  • 传统 MVC 页面需要返回特定错误视图;
  • 某个接口需要兼容特殊响应协议;
  • 文件、图片或流式接口需要特殊的异常输出方式。

例如,同样是文件不存在:

  • 下载接口可能需要返回 JSON;
  • 页面接口可能需要跳转到错误页;
  • 图片接口可能需要返回默认占位图。

如果多个 Controller 都在重复编写相同的异常处理逻辑,就应该考虑全局异常处理。


三、Spring MVC 全局异常处理

Spring MVC 提供了两个常用注解:

1
@ControllerAdvice

和:

1
@RestControllerAdvice

1. ControllerAdvice

@ControllerAdvice 可以统一为多个 Controller 提供:

  • 异常处理;
  • 数据绑定;
  • Model 公共属性。

它适合传统 Spring MVC 项目,可以返回页面视图。

2. RestControllerAdvice

@RestControllerAdvice 相当于:

1
2
@ControllerAdvice
@ResponseBody

它更适合前后端分离项目,因为异常处理方法的返回值会直接写入 HTTP 响应体。

3. 基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiError> handleBusinessException(
BusinessException exception,
HttpServletRequest request) {

ApiError error = new ApiError(
exception.getCode(),
exception.getMessage(),
exception.getHttpStatus().value(),
request.getRequestURI(),
Instant.now()
);

return ResponseEntity
.status(exception.getHttpStatus())
.body(error);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnknownException(
Exception exception,
HttpServletRequest request) {

ApiError error = new ApiError(
"SYSTEM_ERROR",
"系统繁忙,请稍后重试",
HttpStatus.INTERNAL_SERVER_ERROR.value(),
request.getRequestURI(),
Instant.now()
);

return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(error);
}
}

4. 限制 Advice 的生效范围

默认情况下,@RestControllerAdvice 会作用于所有 Controller。

可以按照包名限制:

1
2
3
4
5
@RestControllerAdvice(
basePackages = "com.example.user.adapter.web"
)
public class UserExceptionHandler {
}

也可以使用类型安全的 basePackageClasses

1
2
3
4
5
@RestControllerAdvice(
basePackageClasses = UserController.class
)
public class UserExceptionHandler {
}

还可以按照 Controller 类型限制:

1
2
3
4
5
6
7
8
@RestControllerAdvice(
assignableTypes = {
UserController.class,
OrderController.class
}
)
public class BusinessExceptionHandler {
}

或者按照注解限制:

1
2
3
4
5
@RestControllerAdvice(
annotations = RestController.class
)
public class RestApiExceptionHandler {
}

在大型项目中,可以按照不同模块拆分全局异常处理器,而不必把所有异常都塞进一个几百行的类中。


四、局部异常处理和全局异常处理的优先级

局部异常处理器优先于全局异常处理器。

例如,Controller 中定义:

1
2
3
4
5
6
7
8
9
@RestController
public class UserController {

@ExceptionHandler(BusinessException.class)
public String handleLocal(
BusinessException exception) {
return "local";
}
}

全局处理器中定义:

1
2
3
4
5
6
7
8
9
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public String handleGlobal(
BusinessException exception) {
return "global";
}
}

UserController 抛出 BusinessException 时,会优先调用:

1
handleLocal()

整体优先级可以简单理解为:

1
2
3
4
5
6
7
8
9
10
11
当前 Controller 中的 @ExceptionHandler

高优先级 @ControllerAdvice

低优先级 @ControllerAdvice

@ResponseStatus / ResponseStatusException

Spring MVC 默认异常处理

Spring Boot /error

多个 Advice 的优先级

可以使用 @Order

1
2
3
4
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ValidationExceptionHandler {
}
1
2
3
4
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class FallbackExceptionHandler {
}

建议按照职责拆分:

1
2
3
4
ValidationExceptionHandler
BusinessExceptionHandler
SecurityExceptionHandler
FallbackExceptionHandler

不要把所有 Advice 都设置成最高优先级。大家都抢第一,最后通常只剩一团迷雾。


五、统一错误响应结构

企业项目应该定义统一的异常响应结构。

Spring Boot 3 和 JDK 17 以上可以使用 record

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ApiError(
String code,
String message,
int status,
String path,
String traceId,
Instant timestamp,
Map<String, String> fieldErrors
) {

public static ApiError of(
String code,
String message,
HttpStatusCode status,
String path,
String traceId,
Map<String, String> fieldErrors) {

return new ApiError(
code,
message,
status.value(),
path,
traceId,
Instant.now(),
fieldErrors
);
}
}

Spring Boot 2 和 JDK 11 可以使用普通 Java 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class ApiError implements Serializable {

@Serial
private static final long serialVersionUID = 1L;

private final String code;
private final String message;
private final int status;
private final String path;
private final String traceId;
private final Instant timestamp;
private final Map<String, String> fieldErrors;
}

建议包含以下字段:

字段 作用
code 稳定的业务错误码
message 可以返回给调用方的错误信息
status HTTP 状态码
path 发生错误的请求路径
traceId 关联日志和链路追踪
timestamp 错误发生时间
fieldErrors 参数校验错误详情

未知异常不应该直接向前端返回:

1
exception.getMessage()

因为它可能包含:

  • 数据库表名;
  • SQL 语句;
  • 文件绝对路径;
  • Java 类名;
  • 上游服务地址;
  • 数据库约束名称;
  • 其他内部实现细节。

未知异常建议统一返回:

1
系统繁忙,请稍后重试

完整异常堆栈只记录在服务端日志中。


六、业务错误码设计

可以先定义统一的错误码接口:

1
2
3
4
5
6
7
8
public interface ErrorCode {

String code();

HttpStatus status();

String defaultMessage();
}

定义公共错误码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Getter
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode {

INVALID_PARAMETER(
"COMMON_400_001",
HttpStatus.BAD_REQUEST,
"请求参数不合法"
),

RESOURCE_NOT_FOUND(
"COMMON_404_001",
HttpStatus.NOT_FOUND,
"请求资源不存在"
),

DATA_CONFLICT(
"COMMON_409_001",
HttpStatus.CONFLICT,
"数据状态冲突"
),

INTERNAL_ERROR(
"COMMON_500_001",
HttpStatus.INTERNAL_SERVER_ERROR,
"系统繁忙,请稍后重试"
);

private final String code;
private final HttpStatus status;
private final String defaultMessage;
}

定义业务异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Getter
public class BusinessException extends RuntimeException {

private final ErrorCode errorCode;

public BusinessException(ErrorCode errorCode) {
super(errorCode.defaultMessage());
this.errorCode = errorCode;
}

public BusinessException(
ErrorCode errorCode,
String message) {
super(message);
this.errorCode = errorCode;
}

public BusinessException(
ErrorCode errorCode,
String message,
Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
}

业务代码中使用:

1
2
3
4
5
6
7
public User findById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(
CommonErrorCode.RESOURCE_NOT_FOUND,
"用户不存在,userId=" + userId
));
}

在包装底层异常时,需要保留原始异常:

1
2
3
4
5
6
7
8
9
try {
return remoteUserService.findById(userId);
} catch (RemoteServiceException exception) {
throw new BusinessException(
CommonErrorCode.INTERNAL_ERROR,
"调用用户服务失败",
exception
);
}

不要只保留错误信息而丢失异常原因:

1
2
3
4
throw new BusinessException(
CommonErrorCode.INTERNAL_ERROR,
exception.getMessage()
);

否则排查问题时会失去完整的异常调用链。


七、参数校验异常处理

Spring MVC 中常见的参数校验异常不止一种。

1. MethodArgumentNotValidException

通常由以下写法触发:

1
2
3
4
@PostMapping("/users")
public void create(
@Valid @RequestBody CreateUserRequest request) {
}

DTO 示例:

1
2
3
4
5
6
7
8
9
public record CreateUserRequest(

@NotBlank(message = "用户名不能为空")
String name,

@Min(value = 1, message = "年龄不能小于 1")
Integer age
) {
}

异常处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleMethodArgumentNotValid(
MethodArgumentNotValidException exception,
HttpServletRequest request) {

Map<String, String> fieldErrors =
exception.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
error -> Optional
.ofNullable(error.getDefaultMessage())
.orElse("参数不合法"),
(first, ignored) -> first,
LinkedHashMap::new
));

ApiError error = ApiError.of(
"VALIDATION_ERROR",
"请求参数校验失败",
HttpStatus.BAD_REQUEST,
request.getRequestURI(),
MDC.get("traceId"),
fieldErrors
);

return ResponseEntity.badRequest().body(error);
}

2. ConstraintViolationException

常见于 Spring Boot 2 或方法级参数校验:

1
2
3
4
5
6
7
8
9
10
11
12
@Validated
@RestController
@RequestMapping("/users")
public class UserController {

@GetMapping
public void query(
@RequestParam
@Min(value = 1, message = "页码不能小于 1")
Integer page) {
}
}

处理方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiError> handleConstraintViolation(
ConstraintViolationException exception,
HttpServletRequest request) {

Map<String, String> fieldErrors =
exception.getConstraintViolations()
.stream()
.collect(Collectors.toMap(
violation -> violation
.getPropertyPath()
.toString(),
ConstraintViolation::getMessage,
(first, ignored) -> first,
LinkedHashMap::new
));

ApiError error = ApiError.of(
"VALIDATION_ERROR",
"请求参数校验失败",
HttpStatus.BAD_REQUEST,
request.getRequestURI(),
MDC.get("traceId"),
fieldErrors
);

return ResponseEntity.badRequest().body(error);
}

3. HandlerMethodValidationException

在 Spring Framework 6.1 以后,Controller 方法参数校验可能抛出:

1
HandlerMethodValidationException

如果项目基于较新的 Spring Boot 3,应同时处理:

  • MethodArgumentNotValidException
  • HandlerMethodValidationException

Spring Boot 2 项目则重点关注:

  • MethodArgumentNotValidException
  • BindException
  • ConstraintViolationException

4. HttpMessageNotReadableException

当前端发送的 JSON 格式错误,或者字段类型无法转换时,可能抛出:

1
HttpMessageNotReadableException

例如,后端要求数字:

1
private Integer age;

前端却发送:

1
2
3
{
"age": "不是数字"
}

可以统一处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiError> handleHttpMessageNotReadable(
HttpMessageNotReadableException exception,
HttpServletRequest request) {

ApiError error = ApiError.of(
"MESSAGE_NOT_READABLE",
"请求体格式错误或字段类型不匹配",
HttpStatus.BAD_REQUEST,
request.getRequestURI(),
MDC.get("traceId"),
null
);

return ResponseEntity.badRequest().body(error);
}

八、继承 ResponseEntityExceptionHandler

Spring MVC 提供了:

1
ResponseEntityExceptionHandler

它已经为许多 Spring MVC 内置异常提供了统一处理入口。

在 Spring Boot 3 项目中,可以继承它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler
extends ResponseEntityExceptionHandler {

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiError> handleBusinessException(
BusinessException exception,
HttpServletRequest request) {

ErrorCode errorCode = exception.getErrorCode();

log.warn(
"Business exception, code={}, path={}, message={}",
errorCode.code(),
request.getRequestURI(),
exception.getMessage()
);

ApiError body = ApiError.of(
errorCode.code(),
exception.getMessage(),
errorCode.status(),
request.getRequestURI(),
MDC.get("traceId"),
null
);

return ResponseEntity
.status(errorCode.status())
.body(body);
}

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException exception,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {

Map<String, String> fieldErrors =
exception.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
error -> Optional
.ofNullable(error.getDefaultMessage())
.orElse("参数不合法"),
(first, ignored) -> first,
LinkedHashMap::new
));

ApiError body = ApiError.of(
"VALIDATION_ERROR",
"请求参数校验失败",
status,
getPath(request),
MDC.get("traceId"),
fieldErrors
);

return handleExceptionInternal(
exception,
body,
headers,
status,
request
);
}

@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException exception,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {

ApiError body = ApiError.of(
"MESSAGE_NOT_READABLE",
"请求体格式错误或字段类型不匹配",
status,
getPath(request),
MDC.get("traceId"),
null
);

return handleExceptionInternal(
exception,
body,
headers,
status,
request
);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnknownException(
Exception exception,
HttpServletRequest request) {

log.error(
"Unhandled exception, method={}, path={}, traceId={}",
request.getMethod(),
request.getRequestURI(),
MDC.get("traceId"),
exception
);

ApiError body = ApiError.of(
CommonErrorCode.INTERNAL_ERROR.code(),
CommonErrorCode.INTERNAL_ERROR.defaultMessage(),
CommonErrorCode.INTERNAL_ERROR.status(),
request.getRequestURI(),
MDC.get("traceId"),
null
);

return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(body);
}

private String getPath(WebRequest request) {
if (request instanceof ServletWebRequest servletWebRequest) {
return servletWebRequest
.getRequest()
.getRequestURI();
}

return "";
}
}

需要注意,Spring Boot 2 中 ResponseEntityExceptionHandler 的方法签名使用:

1
HttpStatus

Spring Boot 3 中则使用:

1
HttpStatusCode

升级时不能直接复制旧版本方法签名。


九、ResponseStatus 和 ResponseStatusException

除了 @ExceptionHandler,Spring MVC 还支持直接为异常绑定 HTTP 状态码。

1. 使用 ResponseStatus

1
2
3
4
5
6
7
@ResponseStatus(
value = HttpStatus.NOT_FOUND,
reason = "用户不存在"
)
public class UserNotFoundException
extends RuntimeException {
}

业务中直接抛出:

1
throw new UserNotFoundException();

这种方式简单,但异常类会直接依赖 HTTP 协议。

如果异常位于 Domain 或 Application 层,就会导致业务层与 Web 层耦合。

2. 使用 ResponseStatusException

1
2
3
4
throw new ResponseStatusException(
HttpStatus.NOT_FOUND,
"用户不存在"
);

它适合一些简单接口或临时场景。

但是,如果业务代码中大量出现 ResponseStatusException,同样会导致业务逻辑与 HTTP 强耦合。

企业项目更推荐:

1
2
3
4
5
Domain / Application 抛出业务异常

Adapter-Web 全局异常处理器

转换为 HTTP 状态码和 JSON

十、哪些异常无法直接被 RestControllerAdvice 捕获

@RestControllerAdvice 并不是整个 JVM 的万能异常捕获器。

它主要处理进入 DispatcherServlet 之后,在 Spring MVC 请求执行链中产生的异常。

1. Filter 中的异常

Filter 在 DispatcherServlet 之前执行:

1
2
3
4
5
Filter

DispatcherServlet

Controller

因此,Filter 自身抛出的异常通常不会自然进入 Controller 的 @ExceptionHandler

如果确实需要统一处理,可以注入 Spring MVC 的异常解析器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
public class ExceptionBridgeFilter
extends OncePerRequestFilter {

private final HandlerExceptionResolver exceptionResolver;

public ExceptionBridgeFilter(
@Qualifier("handlerExceptionResolver")
HandlerExceptionResolver exceptionResolver) {
this.exceptionResolver = exceptionResolver;
}

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {

try {
filterChain.doFilter(request, response);
} catch (Exception exception) {
exceptionResolver.resolveException(
request,
response,
null,
exception
);
}
}
}

不过,不应该把普通业务校验放在 Filter 中。

Filter 更适合:

  • TraceId;
  • 编码设置;
  • 请求日志;
  • 请求包装;
  • 安全认证;
  • 跨域处理。

2. Spring Security 异常

Spring Security 工作在 Filter 链中。

认证和授权异常通常由以下组件处理:

1
AuthenticationEntryPoint

和:

1
AccessDeniedHandler
  • 未登录或认证失败:返回 401 Unauthorized
  • 已登录但无权限:返回 403 Forbidden

配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http,
ObjectMapper objectMapper) throws Exception {

http.exceptionHandling(exceptionHandling ->
exceptionHandling
.authenticationEntryPoint(
(request, response, exception) -> {
response.setStatus(
HttpStatus.UNAUTHORIZED.value()
);
response.setContentType(
MediaType.APPLICATION_JSON_VALUE
);

ApiError body = ApiError.of(
"UNAUTHORIZED",
"用户未登录或登录已失效",
HttpStatus.UNAUTHORIZED,
request.getRequestURI(),
MDC.get("traceId"),
null
);

objectMapper.writeValue(
response.getOutputStream(),
body
);
}
)
.accessDeniedHandler(
(request, response, exception) -> {
response.setStatus(
HttpStatus.FORBIDDEN.value()
);
response.setContentType(
MediaType.APPLICATION_JSON_VALUE
);

ApiError body = ApiError.of(
"ACCESS_DENIED",
"无权访问当前资源",
HttpStatus.FORBIDDEN,
request.getRequestURI(),
MDC.get("traceId"),
null
);

objectMapper.writeValue(
response.getOutputStream(),
body
);
}
)
);

return http.build();
}

3. Async 异步异常

@Async 方法在另一个线程中运行。

如果方法返回:

1
CompletableFuture<T>

异常会保存在异步结果中。

如果方法返回:

1
void

则需要通过:

1
AsyncUncaughtExceptionHandler

处理未捕获异常。

因此,异步线程中的异常不会自动回到最初的 HTTP 请求线程,也不会天然进入 @RestControllerAdvice


十一、HTTP 状态码与业务错误码

不推荐所有请求都返回:

1
HTTP/1.1 200 OK

然后在响应体中写:

1
2
3
4
{
"code": 500,
"message": "系统错误"
}

这种做法会导致:

  • 网关无法准确统计失败率;
  • 监控系统无法区分 4xx 和 5xx;
  • HTTP 客户端的重试策略失效;
  • OpenAPI 文档无法准确描述响应;
  • 浏览器、代理和缓存无法理解请求结果。

推荐同时使用:

1
2
HTTP 状态码:表达协议层结果
业务错误码:表达具体业务原因

例如:

场景 HTTP 状态码 业务错误码
参数错误 400 COMMON_400_001
未登录 401 AUTH_401_001
无权限 403 AUTH_403_001
用户不存在 404 USER_404_001
数据状态冲突 409 ORDER_409_001
请求过于频繁 429 COMMON_429_001
上游服务不可用 503 REMOTE_503_001
未知系统异常 500 COMMON_500_001

业务错误码应该稳定,不能直接使用异常类名作为业务码。


十二、异常日志应该如何记录

异常日志不应该全部使用:

1
log.error("error", exception);

应该按照异常类型分类处理。

1. 可预期业务异常

例如:

  • 余额不足;
  • 状态不允许;
  • 数据不存在;
  • 重复提交。

可以记录为 WARN

1
2
3
4
5
6
log.warn(
"Business exception, code={}, message={}, traceId={}",
exception.getErrorCode().code(),
exception.getMessage(),
MDC.get("traceId")
);

通常不需要每次都打印完整堆栈。

2. 参数校验异常

可以记录为 INFOWARN

1
2
3
4
5
6
log.info(
"Validation failed, path={}, errors={}, traceId={}",
request.getRequestURI(),
fieldErrors,
MDC.get("traceId")
);

3. 未知系统异常

应该记录为 ERROR,并保留完整堆栈:

1
2
3
4
5
6
log.error(
"Unhandled exception, path={}, traceId={}",
request.getRequestURI(),
MDC.get("traceId"),
exception
);

4. 避免重复打印异常

不推荐在 Service 层打印一次:

1
2
log.error("查询失败", exception);
throw exception;

然后在全局异常处理器中再次打印:

1
log.error("系统异常", exception);

这会导致一条异常出现多份相同堆栈。

推荐原则:

能够真正处理异常的位置负责记录;只是继续向上抛出的代码通常不重复记录。


十三、Spring Boot 2 与 Spring Boot 3 的差异

Spring Boot 2 通常基于:

1
2
3
Spring Framework 5
javax.servlet
javax.validation

Spring Boot 3 通常基于:

1
2
3
Spring Framework 6
jakarta.servlet
jakarta.validation

主要区别包括:

对比项 Spring Boot 2 Spring Boot 3
Servlet 包名 javax.servlet jakarta.servlet
Validation 包名 javax.validation jakarta.validation
HTTP 状态抽象 HttpStatus HttpStatusCode
标准错误响应 自定义 DTO 为主 支持 ProblemDetail
方法级校验 ConstraintViolationException 较常见 增加 HandlerMethodValidationException
JDK 要求 常见 JDK 8/11 最低 JDK 17

ProblemDetail

Spring Framework 6 提供了:

1
ProblemDetail

可以返回标准化错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleUserNotFound(
UserNotFoundException exception) {

ProblemDetail detail = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
exception.getMessage()
);

detail.setTitle("User Not Found");
detail.setProperty("code", "USER_NOT_FOUND");

return detail;
}

Spring Boot 3 可以启用 Problem Details:

1
2
3
4
spring:
mvc:
problemdetails:
enabled: true

项目需要在两种方式中做出统一选择:

  1. 使用自定义 ApiError
  2. 使用标准 ProblemDetail

不建议一部分接口返回 ApiError,另一部分接口返回完全不同结构的 ProblemDetail


十四、常见错误写法

错误一:Controller 中到处写 try-catch

1
2
3
4
5
6
7
8
@GetMapping("/{id}")
public Result<UserVO> findById(@PathVariable Long id) {
try {
return Result.success(userService.findById(id));
} catch (Exception exception) {
return Result.fail(exception.getMessage());
}
}

问题:

  • Controller 代码重复;
  • 异常类型无法区分;
  • HTTP 状态码失真;
  • 可能泄漏内部信息;
  • 无法统一 TraceId 和日志。

应该让异常自然向上抛,由异常处理器统一转换。

错误二:所有异常都返回 500

1
2
3
4
5
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiError handle(Exception exception) {
// ...
}

参数错误、资源不存在、权限不足和数据冲突都不应该返回 500。

错误三:未知异常直接返回异常信息

1
2
3
4
5
6
7
8
return ApiError.of(
"SYSTEM_ERROR",
exception.getMessage(),
HttpStatus.INTERNAL_SERVER_ERROR,
request.getRequestURI(),
MDC.get("traceId"),
null
);

未知异常应该返回固定、安全的提示,真实异常只写入日志。

错误四:业务层直接抛 ResponseStatusException

1
2
3
4
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"订单状态不允许"
);

如果业务层还需要被 Dubbo、gRPC、MQ 或定时任务调用,就会产生不必要的 HTTP 耦合。

错误五:使用异常代替正常流程

1
2
3
4
5
try {
return cache.get(key);
} catch (CacheMissException exception) {
return database.get(key);
}

高频正常分支不应该依赖异常控制流程。


十五、企业级异常分层建议

在整洁架构或分层架构中,可以按照以下方式组织:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
common
└── error
├── ErrorCode
├── ApiError
└── CommonErrorCode

domain
└── exception
├── DomainException
├── OrderStatusException
└── AccountBalanceException

application
└── exception
├── ApplicationException
└── UseCaseExecutionException

adapter-web
└── exception
├── GlobalExceptionHandler
├── ValidationExceptionHandler
└── SecurityErrorWriter

各层职责如下:

1
2
3
4
5
6
7
8
9
10
11
Domain 层
只描述业务规则错误,不关心 HTTP

Application 层
描述用例执行错误,不关心 JSON

Adapter-Web 层
将业务异常转换为 HTTP 状态码和 JSON

Infrastructure 层
处理数据库、RPC、MQ 等技术异常,并转换为业务语义

例如:

1
2
3
4
5
DuplicateKeyException
↓ Infrastructure 转换
UserAlreadyExistsException
↓ Adapter-Web 转换
HTTP 409 + USER_409_001

这样可以避免 Controller 或 Domain 层直接依赖数据库异常类型。


十六、异常处理测试

可以使用 MockMvc 测试异常响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@WebMvcTest(UserController.class)
@Import(GlobalExceptionHandler.class)
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

@Test
void shouldReturn404WhenUserNotFound() throws Exception {
mockMvc.perform(
get("/users/{id}", 999)
)
.andExpect(status().isNotFound())
.andExpect(
jsonPath("$.code")
.value("USER_404_001")
)
.andExpect(
jsonPath("$.message")
.value("用户不存在")
);
}

@Test
void shouldReturn400WhenRequestInvalid() throws Exception {
mockMvc.perform(
post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "",
"age": -1
}
""")
)
.andExpect(status().isBadRequest())
.andExpect(
jsonPath("$.code")
.value("VALIDATION_ERROR")
)
.andExpect(
jsonPath("$.fieldErrors")
.isMap()
);
}
}

建议至少覆盖:

  1. 业务异常;
  2. 参数校验异常;
  3. JSON 解析异常;
  4. 请求方法不支持;
  5. 媒体类型不支持;
  6. 未知系统异常;
  7. 局部处理器优先于全局处理器;
  8. 401 和 403;
  9. TraceId 是否正确返回。

十七、完整处理链路总结

Spring Boot 异常处理流程可以总结为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Controller 或请求处理链抛出异常

DispatcherServlet 捕获异常

HandlerExceptionResolverComposite

当前 Controller 的 @ExceptionHandler

全局 @ControllerAdvice

@ResponseStatus / ResponseStatusException

Spring MVC 默认异常解析

Spring Boot /error 兜底

企业级项目需要重点做好以下内容:

  1. 区分参数异常、业务异常、安全异常和系统异常;
  2. 使用准确的 HTTP 状态码;
  3. 建立稳定的业务错误码;
  4. 未知异常不向客户端泄漏内部信息;
  5. 在错误响应中返回 TraceId;
  6. Filter、Spring Security、异步线程分别处理;
  7. Domain 和 Application 层不要直接依赖 HTTP;
  8. 使用 MockMvc 对异常响应进行自动化测试;
  9. 避免在多个层级重复打印相同异常堆栈;
  10. 保证整个项目的错误响应格式统一。

参考资料

启示录

异常处理的价值,不只是让接口在报错时能够返回一段 JSON。

一套真正完善的异常体系,应当让:

  • 用户看到清晰且安全的提示;
  • 前端拿到稳定且可判断的错误协议;
  • 网关和监控识别准确的 HTTP 状态;
  • 开发人员通过 TraceId 快速定位问题;
  • 业务层保持对 HTTP、数据库和框架实现的独立。

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


Spring Boot 异常处理:Spring MVC 局部与全局异常处理全解析
https://allendericdalexander.github.io/2026/07/14/java/spring/springmvc-exception-handle/
作者
AtLuoFu
发布于
2026年7月14日
许可协议