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 不是 ERROR | BizException 是“预期内失败”(库存不足、重复提交),打 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 官方文档:Error Handling(ErrorAttributes/ErrorController)
- Spring Framework 文档:@ExceptionHandler 与 @ControllerAdvice
- Spring Framework 文档:MessageSource 与国际化
- Spring Framework 文档:LocaleResolver(Accept Header/Cookie/Session)
- Jakarta Bean Validation 官方文档
- RFC 9110:HTTP 状态码语义
- Google JSON 风格指南(错误响应参考)
标题:Spring Boot 全局异常处理进阶:自定义异常体系+错误码规范+国际化
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/12/1788597845609.html
公众号:服务端技术精选
- 引言
- 一、先立契约:统一响应体与错误返回原则
- 1.1 统一响应结构
- 1.2 HTTP 状态码怎么分
- 二、错误码规范:模块前缀 + 序号
- 2.1 设计规则
- 2.2 三条治理红线
- 2.3 代码实现:错误码枚举 + 国际化消息 key
- 三、BizException:一套异常家族,而不是一个类
- 3.1 基类与两个子类
- 3.2 为什么继承 RuntimeException 而不是 Exception
- 3.3 使用姿势
- 四、@ControllerAdvice:全局拦截的完整装配
- 4.1 处理器全集
- 4.2 四个容易漏的细节
- 五、错误消息国际化:MessageSource + Locale
- 5.1 配置与资源文件
- 5.2 消息工具类
- 5.3 Locale 是怎么来的(以及动态切换)
- 5.4 国际化的三条纪律
- 六、组装验收:一个请求的完整旅程
- 七、常见问题
- 7.1 业务失败到底返回 HTTP 200 还是 4xx?
- 7.2 错误码用数字还是字符串?"0"表示成功会冲突吗?
- 7.3 @ControllerAdvice 能拦截到过滤器/拦截器里的异常吗?
- 7.4 国际化文件太多语言,key 漏配了怎么办?
- 7.5 异常处理和重试、告警怎么衔接?
- 7.6 这套体系怎么最小成本落地到存量项目?
- 八、总结
- 体系速查卡
- 一句话
- 给团队的建议
- 参考资料
评论