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”。

至少覆盖:

  1. 正常请求不被异常处理器影响
  2. 业务异常返回正确业务码
  3. 参数校验异常能返回字段提示
  4. JSON 格式错误有稳定响应
  5. 未知异常不泄露堆栈、SQL 和路径
  6. 日志包含 TraceId 和完整服务端堆栈
  7. 不同环境的日志脱敏规则有效
  8. 异常发生时事务按预期回滚

可以用 MockMvc 或接口测试验证 HTTP 状态码、响应结构和敏感信息是否泄露。

安全测试时,搜索响应中是否出现 at com.、SQL 片段、数据库地址和本地路径,往往比单纯看状态码更有效。

小结

SpringBoot 全局异常处理的核心,不是把所有异常捕获后返回一段文字,而是建立一条清晰的错误边界:

  • @RestControllerAdvice 统一接收 Controller 层异常
  • @ExceptionHandler 按异常类型拆分处理逻辑
  • 业务异常返回稳定的业务码和安全消息
  • 参数校验异常提取字段级提示
  • 未知异常记录完整服务端日志,但不向客户端泄露细节
  • TraceId 连接用户反馈和服务端日志
  • AOP 负责横切能力,不要随意吞掉异常

一句话总结:异常可以详细记录在服务端,但返回给用户的内容必须稳定、克制、可理解。

把错误处理做好,用户看到的是清楚的提示,开发人员拿到的是完整的排查线索,系统也不会因为一段堆栈信息把内部结构暴露出去。

更多推荐

章节目录