一个项目的错误响应最初到处不一样:有的返回 {"error": "..."},有的返回 {"code": 4001, "msg": "..."},还有的直接 500 加 HTML 错误页。上线后前端做了三套解析,运维做两套告警规则——而这全是因为"没有统一"。
这三个后果里,第三个最容易被忽略:没有统一的错误体,就没有地方放 traceId;而没有 traceId,用户说"我刚才报错了"就只能靠时间猜。
统一异常响应看起来是件小事,但它决定了三件事:前端能不能用一套逻辑处理错误、运维能不能按码告警、以及排障能不能把用户报障对上日志。实现方式有三种,代价不同。
1. 三种实现位置
| 位置 | 优点 | 缺点 |
|---|---|---|
| Filter / 中间件 | 覆盖所有响应(含 404、框架异常) | 改响应体需包装器 |
@ControllerAdvice |
能拿到异常类型 | 覆盖不到框架层异常 |
| 每个 Controller 自己包 | 无(不推荐) | 一致性无法保证 |
第一项最彻底:**它能覆盖"路由不存在""参数绑定失败""框架内部异常"**第一项的代价是"改响应体很难":要包装 HttpServletResponse 才能改已写出的内容,而包装器本身有坑(流未 flush、编码、大响应缓冲)。所以它更适合"生成新响应",而不是"改写旧响应"。
,而这些恰恰是用户最常遇到的。
第二项最常用:它能看到异常对象,能区分业务异常与系统异常,但覆盖不到"进 Controller 之前"的错误。
两层分层还有"责任清晰"的好处:Advice 管"我的代码出错"(业务异常、未捕获异常),Filter 管"请求没到我的代码"(404、绑定失败、过滤器自身异常)。这样出问题能立刻知道去哪层查。
实践中的正解通常是"两层都做":Advice 处理业务异常,Filter 兜住框架层异常并保证响应格式一致。
// Advice:业务异常 → 统一格式
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiError> handle(BusinessException e) {
return ResponseEntity.status(e.httpStatus())
.body(new ApiError(e.code(), e.publicMessage(), traceId()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleAll(Exception e) {
log.error("unhandled", e);
return ResponseEntity.status(500)
.body(new ApiError("INTERNAL", "服务暂时不可用", traceId()));
}
}
2. 响应体的四个字段
| 字段 | 作用 |
|---|---|
code |
机器判断(稳定、不随文案变) |
message |
人读(可展示给用户) |
traceId |
关联日志 |
details |
可选,字段级错误 |
第一项必须稳定且不含文案:它是契约,文案改了它不能改。用 INVALID_PARAM 这类枚举,而不是"参数不合法"。
第三项是排障的关键:用户报障时给出的 traceId 能直接定位到日志第三项还应当"出现在错误页面上":很多系统的 traceId 只在响应体里,而用户看到的是浏览器默认错误页。所以要么前端把它显示出来,要么承诺"截图里能看到"。
,比"大概几点"精确得多。
// 统一格式(四个字段,稳定)
{
"code": "INVALID_PARAM",
"message": "手机号格式不正确",
"traceId": "9f2c1a7e",
"details": [{ "field": "phone", "reason": "format" }]
}
3. 状态码与业务码的关系
| 做法 | 问题 |
|---|---|
| 全用 200,业务码表达一切 | 网关/监控看不到错误 |
| 全用 HTTP 码 | 业务细分不够 |
| HTTP 码 + 业务码 | 推荐 |
第一项还有个"客户端"后果:HTTP 客户端库的自动重试只对 5xx 生效,全用 200 时重试逻辑完全失效。于是"临时抖动"会变成"用户手动刷新"。
第一项在早期项目里常见:所有响应都 200,靠 code != 0 判断失败。代价是"监控、网关限流、重试策略"全都看不到错误率——因为它们只看 HTTP 状态。
第二项的问题是"细粒度不够":10 种业务错误都得用 400,前端无法区分。第二项的另一个问题在"国际化":HTTP 码没有语义细分,而业务码可以带文案映射(INVALID_PARAM → 各语言)。所以业务码不只是给机器看,也是文案的键。
第三项的分工是"HTTP 码表达类别,业务码表达细节":400 表示"请求有问题",INVALID_PARAM/DUPLICATE 表示具体哪种。
4. 错误码的编号方式
| 方式 | 例子 |
|---|---|
| 字符串枚举 | INVALID_PARAM |
| 数字分段 | 40001(4 = 客户端,0001 = 序号) |
| HTTP 码 + 子码 | 400-01 |
| 纯数字无结构 | 10086 |
第一项最易读,也最不怕"分类变更";第二项便于"按段告警"(如 4xxxx 都算客户端错误)。
第一项(字符串枚举)的维护成本在"命名"上:INVALID_PARAM 与 INVALID_ARGUMENT 的区别没人记得住,于是同一种错误有两个码。所以错误码应当有"登记表",新增前先查。
第三项折中:能一眼看出类别,又不丢细节。而第四项是"看起来专业但没人记得住"的典型。
还有一类"框架层"错误必须显式处理,否则会绕过统一格式:
// 三类最常漏掉的框架异常
@ExceptionHandler(MethodArgumentNotValidException.class) // @Valid 校验失败
ResponseEntity<ApiError> onValid(MethodArgumentNotValidException e) { ... }
@ExceptionHandler(MethodArgumentTypeMismatchException.class) // 类型转换失败
ResponseEntity<ApiError> onType(MethodArgumentTypeMismatchException e) { ... }
@ExceptionHandler(HttpMessageNotReadableException.class) // 请求体解析失败
ResponseEntity<ApiError> onBody(HttpMessageNotReadableException e) { ... }
// 三类不处理时,返回的是框架默认格式(甚至 HTML)
5. 与日志、监控的联动
| 联动 | 做法 |
|---|---|
| 日志带 traceId | 与响应一致 |
| 监控按 code 聚合 | 而不是按 message |
| 客户端错误不告警 | 只统计 |
| 5xx 告警 | 阈值 + 分位 |
第二项很关键:按 message 聚合会因为文案微调而分裂成多个指标,而按 code 聚合是稳定的。
第三项常被做错:把 4xx 也纳入告警,于是"用户输错密码"也会半夜叫人。第二项(按 code 聚合)还有一个"跨版本"的好处:新旧接口的文案不同、码相同,于是同一张看板能把它们合起来看。
正确做法是"4xx 只看趋势,5xx 才告警"。
// 告警规则:区分客户端与服务端错误
alerts:
- name: server_error_rate
expr: rate(http_5xx_total[5m]) / rate(http_total[5m]) > 0.01
severity: P1
- name: client_error_spike
expr: rate(http_4xx_total[5m]) > 3 * rate(http_4xx_total[1h] offset 1d)
severity: P3 # 只看突增,不单独告警
响应格式的"契约测试"值得单独做:对每个错误码各发一次请求,断言响应体结构与状态码。这类测试能防住"某次重构把格式改了"这类回归。
// 契约测试的三个断言(每个错误场景)
1 状态码 == 期望(如 400 / 404 / 500)
2 响应体含 code/message/traceId 三个字段
3 message 不含内部细节(如 IP、SQL、堆栈)
// 第 3 项最重要:它是"泄漏"的守门员
6. 排错:五种"统一了还是乱"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 404 返回 HTML | Filter 未兜住 | 加 Filter 层 |
| 参数绑定错误格式不同 | 框架异常未被 Advice 处理 | 显式处理绑定异常 |
| 前端仍要三套解析 | 老接口未迁移 | 分批迁移 + 版本 |
| 告警按 message 分裂 | 监控口径错 | 改按 code |
| 用户报障对不上日志 | 无 traceId | 响应里加 traceId |
第二项最常见的具体表现是:@Valid 校验失败抛的是 MethodArgumentNotValidException,而它不在业务异常体系里,于是返回了框架默认格式。
错误体还应有一个"可重试"的提示字段,这对客户端行为影响很大:没有它,客户端只能"全部重试"或"全部不重试",而前者会把限流转成雪崩。
// 带可重试性的错误体
{"code":"UPSTREAM_TIMEOUT","message":"服务繁忙,请稍后重试",
"traceId":"9f2c1a7e","retryable":true,"retryAfterMs":2000}
// 客户端据此决定:等待后重试 / 直接报错给用户
7. 边界:什么情况不必统一
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单接口的内部工具 | 只有自己用 | 不统一 |
| 已对外承诺的旧接口 | 改动即破坏 | 冻结,只在新接口统一 |
| 文件下载/流式响应 | 不是 JSON | 单独约定 |
| 网关层已统一 | 平台负责 | 跟随平台 |
最后一条与"迁移"有关的边界:统一格式不能一次改完老接口,因为老调用方会崩。正确做法是"新接口用新格式 + 给老接口加并行字段",等调用方迁移完再下线——这与接口版本化是同一个问题。
判断口径一句话:前端(或调用方)是否需要"一套逻辑处理所有错误"。需要,就必须统一;只有一两个接口的内部工具,统一是过度设计。
8. 自测
- 三种实现位置里,哪一种能覆盖"路由不存在"?为什么 Advice 覆盖不到?
- 响应体四个字段中,哪一个必须"稳定且不含文案"?为什么?
- "全用 200 + 业务码"会带来什么后果?为什么监控看不到错误率?
- "HTTP 码表达类别、业务码表达细节"的分工是什么?举一个例子。
- 为什么监控要按 code 聚合而不是按 message?4xx 与 5xx 的告警策略差别是什么?
- 判断"是否需要统一异常响应"的那个问题是什么?