ElIauk

Spring Boot 全局异常处理

没有全局处理会怎样 不写全局异常处理时,Spring Boot 会把异常交给默认的 /error 处理,浏览器里看到 Whitelabel 错误页,接口调用方拿到一段结构不固定的 JSON,堆栈信息还可能直接暴露给前端。更麻烦的是,每个 Controller 都写一遍 try-catch,代码重复不

评论
Spring Boot 全局异常处理

没有全局处理会怎样

不写全局异常处理时,Spring Boot 会把异常交给默认的 /error 处理,浏览器里看到 Whitelabel 错误页,接口调用方拿到一段结构不固定的 JSON,堆栈信息还可能直接暴露给前端。更麻烦的是,每个 Controller 都写一遍 try-catch,代码重复不说,错误码和提示语还经常对不齐。

统一的做法是:业务代码只管抛异常,由一个全局处理器负责把异常翻译成统一的响应结构。这也是 Spring Boot 项目最基本的一条规范。

一个统一的异常处理器

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.validation.FieldError;

import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusiness(BusinessException e) {
        log.warn("业务异常, code={}, message={}", e.getCode(), e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValid(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        return Result.fail(400, message);
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.fail(500, "系统繁忙,请稍后重试");
    }
}

@RestControllerAdvice 是 @ControllerAdvice 和 @ResponseBody 的组合,返回值直接序列化成 JSON。三个方法分别处理业务异常、参数校验异常和兜底异常,顺序上 Spring 会优先匹配最具体的异常类型。

统一返回体可以很简单:

public record Result<T>(int code, String message, T data) {
    public static <T> Result<T> fail(int code, String message) {
        return new Result<>(code, message, null);
    }
}

record 需要 JDK 16 及以上,项目还在 JDK 8 的话换成普通类,补上 getter 即可,字段和方法签名都不用变。

业务异常要带错误码

不要到处 throw new RuntimeException("用户不存在")。定义一个业务异常,把错误码和提示语分开:

public class BusinessException extends RuntimeException {
    private final int code;

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}

业务异常用 WARN 级别记录,因为它是预期内的分支(比如余额不足、库存不够);只有非预期异常才用 ERROR 并打印堆栈。日志级别用对了,告警才不会天天响。

错误码怎么设计

错误码不是随便编的数字。常见的做法是按模块分段:用户模块 10xxx,订单模块 20xxx,支付模块 30xxx,每段内部再细分。这样看到 20001 就知道大概哪个业务出的问题,比统一用 500 强得多。

错误码和提示语要分开维护,可以用枚举集中管理:

public enum ErrorCode {
    USER_NOT_FOUND(10001, "用户不存在"),
    STOCK_NOT_ENOUGH(20001, "库存不足"),
    SYSTEM_ERROR(50000, "系统繁忙,请稍后重试");

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

提示语是给用户看的,不要带技术细节;日志里则要记录足够的上下文,比如订单号、用户 ID、请求参数。同一个错误码在不同语言环境下可能需要不同文案,所以前端按 code 做映射也是一种常见做法。

对外暴露的错误码要稳定。一旦发布,就不能随意改数字,否则前端和调用方全要跟着改。新增错误码往对应段里追加,废弃的保留但标记出来。

HTTP 状态码和业务码是两层:HTTP 500 表示服务端故障,HTTP 200 里面 code=10001 表示业务失败。两者不要混着用,否则网关、监控、重试策略都不好配。

常见坑

1. 异常被 catch 吞掉

Controller 或 Service 里 try-catch 之后既不处理也不抛出,全局处理器永远收不到。没有特殊逻辑就别 catch,让它自然往上抛。

2. 404 不走全局异常

默认情况下,找不到处理器会交给 /error,不会进入 @ExceptionHandler。想统一处理 404,需要开启 spring.mvc.throw-exception-if-no-handler-found=true,并注意静态资源映射可能把请求兜住。这个开关在不同版本行为有差异,建议实际请求一次确认。

3. 参数校验异常分好几种

@RequestBody 校验失败抛 MethodArgumentNotValidException,表单对象抛 BindException,@RequestParam 上的约束抛 ConstraintViolationException。只处理第一种,另外两种还是会返回默认错误页。

4. 响应已经提交还去写异常

方法返回 ResponseEntity 之后又手动往 HttpServletResponse 写数据,再抛异常时容器已经提交响应,全局处理器写不进去,日志里会出现 “Cannot call sendError() after the response has been committed”。统一用返回值,不要混着手写响应。

5. 把异常堆栈返回给前端

Result.fail(500, e.getMessage()) 可能把 SQL、表名、内网地址暴露出去。对外只给统一提示,详细信息写日志,用 traceId 关联。

排查步骤

  1. 用 curl 触发一次异常,看返回结构是不是统一的 JSON;
  2. 在 @ExceptionHandler 方法里打断点,确认异常有没有进到处理器;
  3. 没进来就往上找:是不是被 catch 吞了,或者异常类型不匹配;
  4. 404、405 单独确认,它们不一定走全局异常;
  5. 检查日志里同一个异常是否被打印了两遍,避免重复告警。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者