2501 字
约 8 分钟
14
S07-A17 · 统一异常响应:三种实现与它们的代价

一个项目的错误响应最初到处不一样:有的返回 {"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. 自测

  1. 三种实现位置里,哪一种能覆盖"路由不存在"?为什么 Advice 覆盖不到?
  2. 响应体四个字段中,哪一个必须"稳定且不含文案"?为什么?
  3. "全用 200 + 业务码"会带来什么后果?为什么监控看不到错误率?
  4. "HTTP 码表达类别、业务码表达细节"的分工是什么?举一个例子。
  5. 为什么监控要按 code 聚合而不是按 message?4xx 与 5xx 的告警策略差别是什么?
  6. 判断"是否需要统一异常响应"的那个问题是什么?
S07-A17 · 统一异常响应:三种实现与它们的代价
https://www.clxhxhhr.top/posts/6515/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。