SpringBoot全局异常处理怎么设计
Controller 抛出的异常如果直接返回给用户,既难看又可能泄露堆栈、SQL 和内部路径。SpringBoot 可以通过 @RestControllerAdvice 统一捕获业务异常、参数校验异常和兜底异常。这篇从异常分层、响应结构、日志追踪和安全边界讲起,给出一套可落地的全局异常处理方案。
接口开发久了,总会遇到这种场景:
用户点击一个按钮,页面弹出一大串英文异常;开发人员问用户怎么回事,用户只能回答:我也不知道,反正页面报错了。
更麻烦的是,如果我们直接把异常消息返回给前端,里面可能包含 SQL、表名、文件路径,甚至内部服务地址。
这既影响体验,也可能造成信息泄露。
SpringBoot 可以通过 @RestControllerAdvice 和 @ExceptionHandler 把 Controller 层的异常集中处理。
但全局异常处理不只是写一个 catch-all 方法。
我们还要考虑异常分层、响应结构、日志、TraceId、参数校验和未知异常的安全返回。
全局异常处理解决什么
没有统一处理时,异常可能沿着调用链直接冒到 Web 层:
数据库异常 / 业务异常 / 参数异常
│
▼
Controller 没有处理
│
▼
框架默认错误响应
│
▼
前端看到不稳定、难理解甚至敏感的信息
有了全局异常处理器:
Controller 或 Service 抛出异常
│
▼
@RestControllerAdvice 统一接收
│
├── 记录完整日志和堆栈
├── 转成稳定的业务错误码
└── 只向客户端返回安全消息
它主要解决四件事:
- 统一接口错误响应结构
- 区分业务异常、参数异常和系统异常
- 记录服务端完整排查信息
- 避免把内部实现细节返回给用户
注意,异常处理器不是为了把所有异常吞掉。
核心异常要让监控和发布系统感知,客户端只拿到适合展示的内容。
Advice 和 ExceptionHandler
@ControllerAdvice 是一个全局控制器增强器。
如果方法要直接返回 JSON,通常还要配合 @ResponseBody。
@RestControllerAdvice 可以理解成 @ControllerAdvice 和 @ResponseBody 的组合,更适合 REST 接口:
@RestControllerAdvice
public class GlobalExceptionHandler {
// 在这里集中声明各类异常处理方法
}
@ExceptionHandler 用来声明某个方法负责处理哪些异常类型:
@ExceptionHandler(BusinessException.class)
public ApiError handleBusiness(BusinessException exception) {
return ApiError.from(exception);
}
相比在每个 Controller 中手写 try-catch,Advice 的优点是规则集中、响应一致,也不会漏掉某个接口。
不要只写顶级 Exception
下面这种写法可以兜底,但不适合承担所有业务逻辑:
@ExceptionHandler(Exception.class)
public ApiError handle(Exception exception) {
return ApiError.internalError();
}
如果所有异常都进入这里,业务错误码、参数错误和系统故障就混成一团了。
更好的方式是:
- 具体异常优先单独处理
- 通用 Exception 只做最后兜底
- 兜底时记录完整堆栈,但返回固定安全消息
Spring 会优先选择更匹配的异常处理方法。
先设计统一响应
异常处理器返回什么,应该先和前端约定。
一个常见的错误响应可以包含:
错误码:用于前端分支和问题定位
消息:适合用户或调用方看到的说明
traceId:关联服务端日志
时间:便于问题定位
示例结构:
public record ApiError(
String code,
String message,
String traceId
) {
public static ApiError from(BusinessException exception) {
return new ApiError(
exception.code(),
exception.safeMessage(),
TraceId.current()
);
}
public static ApiError internalError() {
return new ApiError(
ErrorCode.INTERNAL_ERROR.value(),
ErrorMessage.INTERNAL_ERROR,
TraceId.current()
);
}
}
具体是使用 record、普通 Java Bean 还是项目已有响应类,可以按工程版本决定。
关键不是数据结构长什么样,而是成功响应和失败响应的契约稳定。
不要把异常消息直接返回
下面这种写法风险很大:
return new ApiError(
ErrorCode.INTERNAL_ERROR.value(),
exception.getMessage(),
TraceId.current()
);
getMessage() 可能包含:
- SQL 片段
- 数据库表名和列名
- 本地文件路径
- 远程服务地址
- 内部类名和堆栈信息
业务异常可以返回经过设计的安全消息。
未知系统异常统一返回固定的“系统繁忙,请稍后重试”类消息,详细原因只写服务端日志。
自定义业务异常
业务异常和系统异常最好分开。
例如库存不足、订单状态不允许修改、重复提交,这些是业务流程的一部分,不应该被记录成系统故障。
public class BusinessException extends RuntimeException {
private final String code;
private final String safeMessage;
public BusinessException(String code, String safeMessage) {
this.code = code;
this.safeMessage = safeMessage;
}
public String code() {
return code;
}
public String safeMessage() {
return safeMessage;
}
}
处理器可以单独捕获:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiError> handleBusiness(
BusinessException exception) {
ApiError body = ApiError.from(exception);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(body);
}
}
HTTP 状态码和业务错误码不要互相替代。
HTTP 状态码表达请求在协议层面的结果,业务错误码表达系统自己的业务原因。
具体是统一返回 200 还是使用 4xx/5xx,要遵循现有接口契约,不要在一个项目里各写一套。
参数校验异常
前端传参不合法属于非常常见的一类异常。
例如请求对象使用 Bean Validation:
public record CreateUserRequest(
@NotBlank String username,
@Email String email
) {
}
Controller:
@PostMapping
public UserView create(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
校验失败时,Spring 常见会抛出 MethodArgumentNotValidException。
可以把字段错误提取成统一结构:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(
MethodArgumentNotValidException exception) {
String message = exception.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(FieldError::getDefaultMessage)
.orElse(ErrorMessage.INVALID_ARGUMENT);
ApiError body = new ApiError(
ErrorCode.INVALID_ARGUMENT.value(),
message,
TraceId.current()
);
return ResponseEntity
.badRequest()
.body(body);
}
原始示例里的 BindingResult result = me.getBindingResult() 存在变量错误,实际应该从当前异常对象读取 BindingResult。
还要根据项目情况处理这些异常:
ConstraintViolationException:方法参数或路径参数校验失败MethodArgumentTypeMismatchException:字符串无法转换成目标类型HttpMessageNotReadableException:请求体 JSON 格式错误MissingServletRequestParameterException:缺少必要参数
参数错误应该返回清晰但安全的提示,不要把 Jackson 或 Bean Validation 的完整内部堆栈返回出去。
系统异常怎么兜底
最后保留一个顶级异常处理器:
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnknown(
Exception exception,
HttpServletRequest request) {
String traceId = TraceId.current();
// 记录 traceId、请求路径、用户信息摘要和完整堆栈
log.error(
"Unhandled controller exception, traceId={}, path={}",
traceId,
request.getRequestURI(),
exception
);
ApiError body = ApiError.internalError();
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(body);
}
这里的日志字符串是服务端代码示例,不是返回给用户的内容。
生产环境至少要避免:
- 只记录
exception.getMessage(),不记录堆栈 - 把完整堆栈塞进接口响应
- 把请求体中的密码、Token 直接写入日志
- 对同一异常重复打印多次,造成日志噪声
日志里有 TraceId,用户反馈错误码或 TraceId 后,开发人员才能快速定位对应请求。
异常处理器的优先级
如果项目里有多个 Advice,可以用 @Order 控制处理器顺序。
但不要靠很多个全局处理器互相抢异常。
更容易维护的方式是:
一个主异常处理器
├── 业务异常
├── 参数校验异常
├── 认证和权限异常
├── 数据访问异常
└── 未知异常兜底
如果认证、权限或模块确实需要独立处理,可以明确优先级和职责边界。
不要让同一个异常在多个 Advice 之间来回猜。
异常分类建议
可以按下面的方向分层:
- 4xx:客户端请求不合法、参数错误、没有权限
- 业务错误码:库存不足、状态不允许、重复操作
- 5xx:数据库不可用、第三方服务异常、未知系统故障
真正的错误码命名和状态码要结合项目已有协议。
文章示例的 ApiError 只是表达结构,不应该无脑复制到所有项目。
不要用 AOP 替代所有异常处理
原文结尾提到可以使用 AOP。
AOP 确实可以拦截 Service 或 DAO 层,但它和 Controller 全局异常处理解决的问题不完全一样。
@RestControllerAdvice:负责 Web 层异常到 HTTP 响应的转换
AOP:负责横切逻辑,例如日志、审计、耗时和统一事务边界
如果在 AOP 里把所有异常提前吞掉,Controller Advice 可能收不到异常,事务回滚和监控也可能受到影响。
更合理的方式是:
- Service 继续抛出业务异常或系统异常
- AOP 负责记录必要的横切信息,不随意吞异常
- Controller Advice 负责统一转换 HTTP 错误响应
异常处理链路越清晰,排查越容易。
测试全局异常处理
不要只测试“抛异常后返回 500”。
至少覆盖:
- 正常请求不被异常处理器影响
- 业务异常返回正确业务码
- 参数校验异常能返回字段提示
- JSON 格式错误有稳定响应
- 未知异常不泄露堆栈、SQL 和路径
- 日志包含 TraceId 和完整服务端堆栈
- 不同环境的日志脱敏规则有效
- 异常发生时事务按预期回滚
可以用 MockMvc 或接口测试验证 HTTP 状态码、响应结构和敏感信息是否泄露。
安全测试时,搜索响应中是否出现 at com.、SQL 片段、数据库地址和本地路径,往往比单纯看状态码更有效。
小结
SpringBoot 全局异常处理的核心,不是把所有异常捕获后返回一段文字,而是建立一条清晰的错误边界:
@RestControllerAdvice统一接收 Controller 层异常@ExceptionHandler按异常类型拆分处理逻辑- 业务异常返回稳定的业务码和安全消息
- 参数校验异常提取字段级提示
- 未知异常记录完整服务端日志,但不向客户端泄露细节
- TraceId 连接用户反馈和服务端日志
- AOP 负责横切能力,不要随意吞掉异常
一句话总结:异常可以详细记录在服务端,但返回给用户的内容必须稳定、克制、可理解。
把错误处理做好,用户看到的是清楚的提示,开发人员拿到的是完整的排查线索,系统也不会因为一段堆栈信息把内部结构暴露出去。
