没有全局处理会怎样
不写全局异常处理时,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 关联。
排查步骤
- 用 curl 触发一次异常,看返回结构是不是统一的 JSON;
- 在
@ExceptionHandler方法里打断点,确认异常有没有进到处理器; - 没进来就往上找:是不是被 catch 吞了,或者异常类型不匹配;
- 404、405 单独确认,它们不一定走全局异常;
- 检查日志里同一个异常是否被打印了两遍,避免重复告警。
评论