Spring Boot 全局异常处理进阶:自定义异常体系+错误码规范+国际化

引言

看一眼我们线上早期接口的报错,很容易理解为什么前端同学会崩溃:

{"timestamp": "2025-08-22T10:23:01.882", "status": 500, "error": "Internal Server Error", "path": "/order/create"}

{"timestamp": "2025-08-22T10:23:03.114", "status": 500, "error": "Internal Server Error", "message": "订单服务异常: java.lang.NullPointerException", "path": "/order/create"}

{"code": "A0001", "msg": "系统繁忙"}

{"code": "500", "message": "下单失败:库存不足!"}

{"errCode": 10010, "errMsg": "param error"}

同一个团队,五种返回结构、三套错误码风格、有的暴露堆栈有的吞掉。前端只能靠 if (res.code === 'A0001' || res.code === '500' || res.errCode === 10010) 这种防御式编程活着,监控告警更是没法配——连“失败”都没有统一定义,谈何治理

这篇文章从零搭建一套规范的异常处理体系,五个组成部分一次配齐:BizException 异常基类 → @ControllerAdvice 统一拦截 → 错误码枚举规范(模块前缀+序号)→ 参数校验异常统一返回 → 错误消息国际化(MessageSource + Locale)。所有代码基于 Spring Boot 3.x + Jakarta EE,可以直接抄进项目。


一、先立契约:统一响应体与错误返回原则

1.1 统一响应结构

整套体系的地基是一个所有接口共享的响应壳:

/**
 * 统一响应体:成功包 data,失败包 code+message
 * 字段只有三个——每多一个字段,前端的 if 分支就多一种
 */
public record ApiResponse<T>(String code, String message, T data) {

    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>("0", "ok", data);
    }

    public static <T> ApiResponse<T> fail(ErrorCode errorCode, Locale locale) {
        return new ApiResponse<>(errorCode.getCode(), errorCode.getMessage(locale), null);
    }

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

三条契约先定死:

契约内容为什么
code = "0" 即成功成功判定只看一个值前端/网关的判断逻辑恒定
HTTP 状态码分两层4xx/5xx 表达“机器可读的失败类别”,业务 code 表达“人类可读的业务原因”网关、监控、重试中间件都依赖 HTTP 码;只在 body 里放错误码会让它们全瞎
message 是给用户看的禁止出现堆栈、类名、SQL 片段既防信息泄漏,也保证国际化可翻译

1.2 HTTP 状态码怎么分

业务接口推荐这套映射(后文所有异常处理器都遵循它):

200 → 业务成功(含“业务性失败但请求本身合法”,如库存不足→按团队约定也可 200+业务码)
400 → 参数/请求体不合法(校验异常)
401 / 403 → 未认证 / 无权限
404 → 资源不存在(含 ErrorController 兜底)
409 → 业务冲突(幂等拒绝、重复提交、状态冲突)
429 → 限流
500 → 未预期异常(服务器自己的锅,message 一律脱敏)

争议最大的是“业务失败到底 200 还是 4xx/5xx”,结论写在 7.1,先给原则:错误码体系统里,HTTP 码表达“这次请求处理得对不对”,业务 code 表达“业务上发生了什么”——两层各司其职,不要合并。


二、错误码规范:模块前缀 + 序号

2.1 设计规则

错误码是“跨团队协议”,规范比实现重要。我们采用五段式:

字符位:  A    0     0    0    1
含义:   模块  层级  子域  序号  (A=字母+4位数字,共5位)

第一位:模块前缀(大写字母)
  A = 通用/系统      U = 用户域      O = 订单域
  S = 库存域        P = 支付域       M = 营销域

第二位:错误层级
  0 = 成功/正常
  1 = 客户端错误(参数、权限、状态冲突)
  2 = 服务端错误
  3 = 第三方依赖错误

后三位:模块内序号(递增分配,不复用、不改含义)
示例码含义层级
0成功
U1001用户不存在(客户端错误)400
O1002订单状态不允许支付409
O2001订单服务内部异常500
P3001支付渠道超时502

2.2 三条治理红线

红线原因
码一经发布只增不改错误码会被前端、监控、日志、上游服务长期依赖,改含义=撕协议
新增码走统一登记错误码表是文档不是废墟——用一张 wiki 表或枚举类集中维护,新增需查重
禁止跨模块借用前缀订单域代码里抛 U 开头的码,等于破坏了“按码定位模块”的能力

2.3 代码实现:错误码枚举 + 国际化消息 key

/**
 * 错误码枚举:码 + 国际化消息 key
 * 消息文案不硬编码在枚举里,指向 messages 资源文件(第五章)
 */
public enum ErrorCode {

    // ── 通用 A ──────────────────────────────
    PARAM_INVALID("A1001", "error.param.invalid"),
    UNAUTHORIZED("A1002", "error.unauthorized"),
    FORBIDDEN("A1003", "error.forbidden"),
    RATE_LIMITED("A1004", "error.rate.limited"),
    SYSTEM_ERROR("A2001", "error.system"),

    // ── 订单域 O ────────────────────────────
    ORDER_NOT_FOUND("O1001", "error.order.notfound"),
    ORDER_STATUS_CONFLICT("O1002", "error.order.status.conflict"),
    ORDER_DUPLICATE("O1003", "error.order.duplicate"),
    ORDER_CREATE_FAIL("O2001", "error.order.create.fail");

    private final String code;
    private final String messageKey;      // 国际化消息 key

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

    public String getCode() { return code; }

    /** 按请求语言取消息文案 */
    public String getMessage(Locale locale) {
        return MessageUtils.get(messageKey, locale);
    }
}

设计要点:枚举持有“码 + 消息 key”而不是“码 + 中文文案”——文案全部外置到资源文件,国际化才有抓手(第五章展开)。


三、BizException:一套异常家族,而不是一个类

3.1 基类与两个子类

/**
 * 业务异常基类:携带错误码,是全局处理的"主拦截对象"
 */
public class BizException extends RuntimeException {

    private final ErrorCode errorCode;

    public BizException(ErrorCode errorCode) {
        super(errorCode.getCode());
        this.errorCode = errorCode;
    }

    /** 带动态参数的构造:模板填充用,如"库存不足:仅剩 {0} 件" */
    public BizException(ErrorCode errorCode, Object... args) {
        super(errorCode.getCode());
        this.errorCode = errorCode;
        this.args = args;
    }

    public ErrorCode getErrorCode() { return errorCode; }

    private transient Object[] args;
    public Object[] getArgs() { return args == null ? new Object[0] : args; }
}

/** 客户端错误(4xx 档):参数、状态冲突、幂等拒绝 */
public class ClientBizException extends BizException { ... }

/** 服务端错误(5xx 档):依赖失败、内部异常的"有码包装" */
public class ServerBizException extends BizException { ... }

3.2 为什么继承 RuntimeException 而不是 Exception

两条理由:① 不受检设计——业务代码里到处 throws Exception 会污染签名、劝退异常抛出,而“失败就抛 BizException”的写法才会被团队坚持;② Spring 事务默认回滚 RuntimeException(@Transactional 无需配 rollbackFor 也不会漏),异常体系与事务语义天然对齐。

3.3 使用姿势

// ✅ 推荐:码 + 动态参数,文案由资源文件模板渲染
if (!product.hasStock(req.getQuantity())) {
    throw new BizException(ErrorCode.STOCK_NOT_ENOUGH, product.getRemain());
}

// ❌ 反模式一:new RuntimeException("库存不足")——没码,全局处理器只能归到 500
// ❌ 反模式二:BizException 手写字符串 message——绕过了国际化与集中治理

四、@ControllerAdvice:全局拦截的完整装配

4.1 处理器全集

/**
 * 全局异常处理器:按"从精确到兜底"的顺序编排
 * @Order 越小越先匹配,BizException 挡在 Exception 前面
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /** ① 业务异常:体系内的"正常失败"——WARN 级日志,返回业务码 */
    @ExceptionHandler(BizException.class)
    public ResponseEntity<ApiResponse<Void>> handleBiz(BizException e, Locale locale) {
        log.warn("[BizException] code={}, msgKey={}, args={}",
                e.getErrorCode().getCode(), e.getErrorCode().name(), e.getArgs());
        return ResponseEntity
                .status(resolveStatus(e.getErrorCode()))          // 按 1.2 映射 4xx/5xx
                .body(ApiResponse.fail(e.getErrorCode(), locale));
    }

    /** ② 参数校验异常:@Valid 触发的 Bean Validation 失败 */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<List<FieldErrorVO>>> handleValid(
            MethodArgumentNotValidException e, Locale locale) {
        List<FieldErrorVO> errors = e.getBindingResult().getFieldErrors().stream()
                .map(fe -> new FieldErrorVO(
                        fe.getField(),
                        MessageUtils.get(fe.getDefaultMessage(), locale),  // 校验消息也国际化
                        fe.getRejectedValue()))
                .toList();
        log.warn("[ParamInvalid] {}", errors);
        return ResponseEntity.badRequest()
                .body(new ApiResponse<>(ErrorCode.PARAM_INVALID.getCode(),
                        MessageUtils.get("error.param.invalid", locale), errors));
    }

    /** ③ 约束注解直接标在 @RequestParam/@PathVariable 上的校验失败 */
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ApiResponse<Void>> handleConstraint(
            ConstraintViolationException e, Locale locale) {
        String detail = e.getConstraintViolations().iterator().next().getMessage();
        return ResponseEntity.badRequest()
                .body(ApiResponse.fail(ErrorCode.PARAM_INVALID, locale));
    }

    /** ④ 未认证/无权限:交给框架异常归类,避免落进 500 */
    @ExceptionHandler({AccessDeniedException.class, AuthenticationException.class})
    public ResponseEntity<ApiResponse<Void>> handleAuth(Exception e, Locale locale) {
        ErrorCode code = e instanceof AccessDeniedException
                ? ErrorCode.FORBIDDEN : ErrorCode.UNAUTHORIZED;
        return ResponseEntity.status(resolveStatus(code)).body(ApiResponse.fail(code, locale));
    }

    /** ⑤ 兜底:所有未预期异常——ERROR 级 + 带堆栈日志,对外脱敏 */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleUnknown(Exception e, HttpServletRequest req) {
        log.error("[SystemError] uri={}, method={}", req.getRequestURI(), req.getMethod(), e);
        return ResponseEntity.internalServerError()
                .body(ApiResponse.fail(ErrorCode.SYSTEM_ERROR, LocaleContextHolder.getLocale()));
    }

    private HttpStatus resolveStatus(ErrorCode code) {
        return switch (code.getCode().charAt(1)) {      // 错误码第二位 → HTTP 档位
            case '1' -> HttpStatus.BAD_REQUEST;
            case '2' -> HttpStatus.INTERNAL_SERVER_ERROR;
            case '3' -> HttpStatus.BAD_GATEWAY;
            default -> HttpStatus.INTERNAL_SERVER_ERROR;
        };
    }
}

4.2 四个容易漏的细节

细节说明
业务异常打 WARN 不是 ERRORBizException 是“预期内失败”(库存不足、重复提交),打 ERROR 会污染告警信噪比;只有兜底 ⑤ 才配 ERROR + 完整堆栈
兜底处理器禁止 e.getMessage() 直接下发NPE 的 message、连接池的 URL 都藏在里面——兜底只回“系统繁忙”,真相留日志
处理器里的 Locale 来自注入参数Spring MVC 自动解析请求语言(Accept-Language / cookie),不用手动取 Header(第五章)
404 也要统一格式@RestControllerAdvice 拦不到 404,需要再配一个继承 DefaultErrorAttributes 的 ErrorAttributes Bean 或 ErrorController,把 404 也渲染成 ApiResponse 结构

五、错误消息国际化:MessageSource + Locale

5.1 配置与资源文件

# application.yaml
spring:
  messages:
    basename: i18n/messages        # classpath:i18n/messages*.properties
    encoding: UTF-8
    fallback-to-system-locale: false   # 找不到回退到默认文件,而不是系统语言
# src/main/resources/i18n/messages.properties(默认/中文)
error.system=系统繁忙,请稍后再试
error.param.invalid=请求参数不合法
error.unauthorized=请先登录
error.forbidden=没有操作权限
error.rate.limited=请求过于频繁,请稍后再试
error.order.notfound=订单不存在
error.order.status.conflict=订单状态不允许当前操作
error.order.duplicate=请勿重复下单
error.order.create.fail=下单失败,请稍后再试
error.stock.notenough=库存不足,仅剩 {0} 件
# src/main/resources/i18n/messages_en.properties(英文)
error.system=System busy, please try again later
error.param.invalid=Invalid request parameters
error.unauthorized=Authentication required
error.forbidden=Access denied
error.rate.limited=Too many requests, please try later
error.order.notfound=Order not found
error.order.status.conflict=Order status does not allow this operation
error.order.duplicate=Duplicate submission
error.order.create.fail=Failed to create order
error.stock.notenough=Insufficient stock, only {0} left

5.2 消息工具类

/**
 * MessageSource 封装:key 缺失时返回 key 本身并告警——
 * 绝不抛异常打断响应链,也绝不静默吞配置错误
 */
@Component
@RequiredArgsConstructor
public class MessageUtils {

    private static MessageSource messageSource;

    public MessageUtils(MessageSource ms) { MessageUtils.messageSource = ms; }

    public static String get(String key, Locale locale) {
        return get(key, locale, new Object[0]);
    }

    public static String get(String key, Locale locale, Object... args) {
        try {
            return messageSource.getMessage(key, args, key, locale);
        } catch (Exception e) {
            return key;     // 缺配置时的兜底:前端至少能看到稳定的 key
        }
    }
}

5.3 Locale 是怎么来的(以及动态切换)

Spring 自动从请求解析 Locale(LocaleResolver),默认策略是 Accept-Language 头优先

请求带 Accept-Language: en-US  → messages_en.properties
请求带 Accept-Language: zh-CN  → messages_zh_CN.properties
没带                           → messages.properties(默认)

多语言站点常见需求是“语言存在用户偏好里”,用 Cookie/Session 版 LocaleResolver 替换默认配置:

@Bean
public LocaleResolver localeResolver() {
    CookieLocaleResolver resolver = new CookieLocaleResolver("LOCALE");
    resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
    resolver.setCookieMaxAge(3600 * 24 * 30);
    return resolver;
    // 前端切换语言:写 cookie LOCALE=en-US,或在请求参数 ?lang=en-US
    // (配合 LocaleChangeInterceptor 激活参数切换)
}

5.4 国际化的三条纪律

纪律原因
key 粒度 = 错误码粒度一个错误码对应一个 key,不要复用文案 key——英语里“订单不存在”和“用户不存在”措辞习惯不同,复用会锁死翻译质量
动态参数用 {0} 占位{0} 由 MessageFormat 渲染,注意多语言语序差异(德语可能需要 {1} 在前)——参数顺序不可假设
枚举/日志/报错信息不国际化的三分离国际化只针对返回给用户的 message;日志(给研发)、错误码(给机器)保持英文原文——三者受众不同,语言不同

六、组装验收:一个请求的完整旅程

把五件套装在一起,看“库存不足的下单请求”在体系里的完整链路:

① 请求进入:POST /order(Body 缺 productId)
   → 校验失败 → @ExceptionHandler(MethodArgumentNotValidException)
   → 400 + A1001 + "请求参数不合法"(中文)/"Invalid request parameters"(英文)

② 修复参数重试,但库存只剩 2 件要 5 件:
   → service 抛 BizException(ErrorCode.STOCK_NOT_ENOUGH, 2)
   → 处理器:WARN 日志 [BizException] code=S1003
   → 400 + S1003 + "库存不足,仅剩 2 件"({0}=2 已填充)

③ 假如这时 DB 连接池被打爆(未预期):
   → 兜底 Exception 处理器
   → ERROR 日志(完整堆栈 + URI)
   → 500 + A2001 + "系统繁忙"(无任何内部信息泄漏)

三类失败、三种路径、统一的壳、分级的日志、可翻译的文案——这就是“体系”和“到处 try-catch”的区别。


七、常见问题

7.1 业务失败到底返回 HTTP 200 还是 4xx?

给出决策规则而不是站队:“请求本身处理正确,只是业务规则不允许” → 200 + 业务码(如“库存不足但你可以买别的”);“请求在语义上不成立” → 4xx(参数错、状态冲突、幂等拒绝)。判断标准是调用方视角:“这个错误我改改请求还能再来吗”——能再来且请求没错 → 200 + 业务码;请求本身要修 → 4xx。最忌讳的是一半接口 200+码、一半接口 4xx+码混着来,前端只会两种都写。

7.2 错误码用数字还是字符串?"0"表示成功会冲突吗?

字符串前缀式(A1001)的优势:按码定位模块、人眼可读、可扩展层级;纯数字的优势:存储小、老系统兼容。新建系统选前缀式;"0"表成功没有冲突风险(字母前缀体系里 0 独占),如果团队坚持全数字,用 00000 占住成功位即可。

7.3 @ControllerAdvice 能拦截到过滤器/拦截器里的异常吗?

拦截器(HandlerInterceptor)的异常拦到(它们在 DispatcherServlet 内);Filter 和 Filter 之前(Security 过滤链、字符编码 Filter)的异常拦不到——它们发生在 DispatcherServlet 之前。解法:Filter 内自行 try-catch 转 HandlerExceptionResolver,或者用 spring.security.filter.dispatcher-types 调整。部署前用一个故意在 Filter 里抛异常的测试请求验证一遍。

7.4 国际化文件太多语言,key 漏配了怎么办?

三层保险:① fallback-to-system-locale: false + 默认文件兜底(漏译时至少回退中文/英文);② MessageUtils 缺 key 时返回 key 本身——稳定可见的异常好过静默乱码;③ CI 加一个校验任务:对比各语言 properties 的 key 集合,diff 出缺失项阻断合并(十行脚本的活,收益极高)。

7.5 异常处理和重试、告警怎么衔接?

分层定责:BizException(预期内失败)→ 不告警、不自动重试(用户或调用方按码处理);未预期异常(兜底 500)→ ERROR 日志接告警 + 网关层幂等重试(配合上期讲的幂等体系,重试才是安全的)。日志里带上 traceId(响应头也返回给前端),客服工单一来就能按码+traceId 双键定位。

7.6 这套体系怎么最小成本落地到存量项目?

三步渐进:① 先上 ApiResponse + GlobalExceptionHandler(BizException + 兜底两个处理器),存量接口的错误返回立刻统一,一天工作量;② 错误码枚举按本文规范建表,存量硬编码错误信息逐步替换成码+key(跟着改哪个模块换哪个);③ 国际化最后上,且只对“用户可见”的接口做(C 端接口全量、管理后台可不做)。先统一结构,再统一码,最后统一语言——反过来做会卡死在翻译上。


八、总结

体系速查卡

┌───────────────────┬────────────────────────────────────────────────┐
│ 组件               │ 关键设计                                        │
├───────────────────┼────────────────────────────────────────────────┤
│ ApiResponse        │ code+message+data 三字段;code="0" 即成功        │
│ HTTP 码分层        │ 4xx/5xx 给机器,业务 code 给人                    │
│ ErrorCode 枚举     │ 模块前缀+层级+序号五段式;码只增不改              │
│ BizException       │ RuntimeException;码+消息key+动态参数             │
│ @ControllerAdvice  │ Biz(WARN)→校验→权限→兜底(ERROR脱敏) 四级编排       │
│ 国际化             │ 枚举存 key 不存文案;MessageSource+LocaleResolver │
└───────────────────┴────────────────────────────────────────────────┘

一句话

全局异常处理的本质是定义“失败的协议”:统一响应壳让前端只写一种判断,五段式错误码让一个码能定位到模块和层级,@ControllerAdvice 按预期内 WARN/预期外 ERROR 分级让告警有信噪比,国际化把文案外置成资源文件让多语言只是加文件而不是改代码。先定契约再写代码,四步落地(结构→码→语言→渐进替换),任何一个 30 人的团队都能在一周内拥有一套十年不用重构的异常体系。

给团队的建议

建议
本周ApiResponse + 两个处理器(Biz/兜底)上线,存量错误返回先统一壳
规范错误码表集中登记(wiki 或枚举单文件),码只增不改写进 Code Review
日志BizException=WARN 无堆栈,兜底=ERROR 全堆栈,前端报障按码+traceId 定位
国际化C 端接口全量 key 化;CI 校验各语言 key 一致性
验收上线前跑一遍"三类失败"验收链(参数错/业务失败/未预期)

互动话题:你们项目的错误码长什么样?踩过“前端五种 if 判断”的坑吗?评论区聊聊你们的错误码进化史。


参考资料


标题:Spring Boot 全局异常处理进阶:自定义异常体系+错误码规范+国际化
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/12/1788597845609.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消