Spring Boot 项目里 90% 的人都写错了全局异常处理

你的 @RestControllerAdvice 真的兜住了所有异常吗?线上告警满天飞,日志里全是堆栈,接口返回时而 JSON 时而 HTML——这些问题,说到底都是异常处理没做对。


1. 先看你现在的代码是不是这样

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.fail("系统繁忙");
    }
}

这代码初看没毛病,上线就跑偏——JSON 解析异常根本进不来

Spring 在处理请求体反序列化时,
HttpMessageNotReadableException 抛出时机早于 Controller 方法调用,默认的 /error 路径会给你返回一个 HTML 页面。前端直接裂开。


2. 先堵死默认的 /error 路径

Spring Boot 的 BasicErrorController 是万恶之源。你定义了 @RestControllerAdvice,但某些异常绕过它走了 /error,返回的就是 HTML。

@RestController
public class NotFoundController implements ErrorController {

    private static final String ERROR_PATH = "/error";

    @RequestMapping(ERROR_PATH)
    public Result<?> handleError(HttpServletRequest request) {
        Integer statusCode = (Integer) request
                .getAttribute("javax.servlet.error.status_code");
        if (statusCode == 404) {
            return Result.fail(404, "接口不存在");
        }
        return Result.fail(500, "服务器内部错误");
    }
}

这个 Controller 要放在能被扫描到的包里,不然还是不生效。


3. 统一响应体——别再用 Map 了

Map 拼 JSON 是典型的"能跑就行"思维。字段名全靠手敲,拼错一个线上排查半小时。

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private int code;
    private String message;
    private T data;

    public static  Result ok(T data) {
        return new Result<>(200, "success", data);
    }

    public static Result fail(String message) {
        return new Result<>(500, message, null);
    }

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

泛型加上的好处:Swagger 能自动推断返回数据结构,接口文档不再是一团 Object。


4. 参数校验异常——被忽略的重灾区

这个坑踩过的人最多:参数上加了 @Valid,校验失败抛了
MethodArgumentNotValidException,但你的全局异常处理里根本没接。

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

别返回 e.getMessage(),那玩意儿是给开发者看的,不是给前端看的。上面的代码取的是校验注解里的 message 属性:

@Data
public class UserDTO {
    @NotBlank(message = "用户名不能为空")
    private String username;

    @Min(value = 1, message = "年龄必须大于0")
    private Integer age;
}

5. 自定义业务异常——层级清晰才能精准兜底

异常体系不分层,到头来只能 catch (Exception e) 一把梭,日志里啥有效信息都没有。

public class BusinessException extends RuntimeException {
    private final int code;

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

    public BusinessException(String message) {
        this(500, message);
    }

    public int getCode() { return code; }
}

全局处理器里单独接一下:

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

关键点:这里用 log.warn 不是 log.error。业务异常是预期内的,不应该触发告警。


6. 第三方调用异常——不兜底就等着雪崩

调外部接口超时、熔断、返回异常报文,这些不兜底直接往上抛,调用方收到的就是一堆不可读的异常栈。

@ExceptionHandler(HttpClientErrorException.class)
public Result<?> handleHttpClientError(HttpClientErrorException e) {
    log.error("HTTP调用异常: status={}, body={}",
            e.getStatusCode(), e.getResponseBodyAsString());
    return Result.fail("服务暂时不可用,请稍后重试");
}

如果是 Feign 调用,脱了壳再接一层:

@ExceptionHandler(FeignException.class)
public Result<?> handleFeignException(FeignException e) {
    log.error("Feign调用失败: status={}, url={}",
            e.status(), e.request().url());
    return Result.fail("远程服务调用异常");
}

7. 兜底——接住所有漏网之鱼

上面都接完了,最后还是得有个兜底的:

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

注意顺序:Spring 会找最匹配的 @ExceptionHandler,把 Exception.class 放在最后,具体异常的 handler 放在前面。


8. 日志脱敏——最容易忽略的安全问题

异常堆栈里如果有手机号、身份证、密码,日志打到 ELK 里就是安全事故。加个脱敏工具类:

@ExceptionHandler(Exception.class)
public Result<?> handleUnknownException(Exception e) {
    String safeMsg = SensitiveUtil.mask(e.getMessage());
    log.error("未捕获异常: {}", safeMsg, e);
    return Result.fail(500, "系统繁忙");
}

SensitiveUtil 用正则把手机号中间四位打 *、身份证中间八位打 *,这里不详写,网上轮子很多。


完整类一览

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 1. 参数校验
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<?> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors()
                .stream().map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        return Result.fail(400, msg);
    }

    // 2. 业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<?> handleBusiness(BusinessException e) {
        log.warn("业务异常: {}", e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    // 3. Feign 调用
    @ExceptionHandler(FeignException.class)
    public Result<?> handleFeign(FeignException e) {
        log.error("Feign异常: status={}", e.status());
        return Result.fail("远程服务调用异常");
    }

    // 4. 兜底
    @ExceptionHandler(Exception.class)
    public Result<?> handleUnknown(Exception e) {
        log.error("未捕获异常", e);
        return Result.fail(500, "系统繁忙,请稍后重试");
    }
}

写在最后

全局异常处理不是什么高大上的东西,但能做到位的项目真不多。总结三个要点:

照这个模板改完,Ctrl+S 一把,至少能少接一半的线上告警电话。

下一篇聊聊 @Transactional 的 5 个致命坑,关注不走丢。

展开阅读全文

更新时间:2026-06-01

标签:科技   全局   异常   项目   业务   系统   繁忙   接口   日志   路径   堆栈   参数

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight All Rights Reserved.
Powered By 61893.com 闽ICP备11008920号
闽公网安备35020302034844号

Top