一个项目用 @ControllerAdvice 统一异常处理,把所有异常转成 {code, message}。上线后发现两类问题:过滤器里抛的异常没被捕获(返回了容器的默认错误页),以及异步请求的异常直接丢了(客户端一直等)。
@ControllerAdvice 是"Spring MVC 层"的机制——它只覆盖"进入 DispatcherServlet 之后、在 Controller 方法之内"的异常。这个"边界"很容易被"统一"这个词掩盖:"统一异常处理"听上去是全局的,而实际它只是"DispatcherServlet 内部的统一"——于是"更外层"的东西依然各自为政。
而"之前的"与"之后的"都不在它的管辖内。
1. 一张"异常在哪一层"的地图
| 层 | @ControllerAdvice 是否覆盖 |
|---|---|
| 过滤器(Filter) | 否 |
拦截器(HandlerInterceptor)preHandle |
否(部分) |
| 参数解析/校验 | 是(MethodArgumentNotValidException) |
| Controller 方法体 | 是 |
| 消息转换(序列化) | 是(部分) |
| 异步线程内 | 否(需专用处理) |
第一项是最常见的"漏":Filter 在 DispatcherServlet 之前,所以它的异常到不了 advice。第一项的"典型场景"是"鉴权过滤器":它在业务之前抛"未授权",而客户端收到的是容器的 HTML 错误页——于是"API 返回了 HTML",前端解析失败。
2. 三种"漏掉"的异常与修法
| 类型 | 修法 |
|---|---|
| Filter 异常 | 在 Filter 内 try-catch 并写响应 |
异步(@Async/CompletableFuture) |
AsyncUncaughtExceptionHandler |
Servlet 异步(DeferredResult) |
setErrorHandler / 超时回调 |
第一项必须"自己写响应":Filter 里没法"交给 advice",只能"手动序列化错误体"。
第二项的"危险"在于"它看起来成功":调用方拿到了 Future,而 get() 之前没人知道失败了——如果"忘了 get 或 get 超时",异常就彻底丢了。
第二项是"静默失败"的来源:@Async 方法抛异常时,调用方拿不到——它被交给"默认处理器"打日志,而"日志里没人看"。
// 异步异常处理器(否则异常只被打日志)
@Configuration
public class AsyncCfg implements AsyncConfigurer {
@Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("async failed: {}", method.getName(), ex); // 至少可观测
}
}
// 更好:把"异步结果"落成"任务状态",而不是"只有日志"
3. "统一异常体"的三个坑
| 坑 | 表现 |
|---|---|
| 状态码全是 200 | 客户端/网关无法区分 |
| 错误码与 HTTP 语义冲突 | 重试逻辑错 |
| 不区分"可重试" | 客户端盲目重试 |
第一项在"前端只判断 body.code"时"能用",而"网关/监控"看不出错误率第一项的"后果"会扩散到"运维":监控按 HTTP 5xx 算错误率 → 错误率恒为 0 → 告警不触发——于是"事故期间一切绿灯"。
——因为 HTTP 层面全是成功。
第二项的"约定"要写进文档:哪些 code 可重试、哪些不可——而"可重试性"不能由客户端"猜",否则"重试放大故障"。
第二项很实际:用 200 表示"业务错误"、用 500 表示"系统错误" 是常见约定;而"一律 200"会让"重试策略"失效(客户端不知道"该不该重试")。
// 一个"可判读"的错误响应(四要素)
{"code": "ORDER_NOT_FOUND", "message": "订单不存在",
"retryable": false, "traceId": "..."}
// 配套:HTTP 状态用 4xx/5xx 表达"类别",body 表达"具体原因"
4. @ControllerAdvice 的三种"范围"
| 写法 | 范围 |
|---|---|
| 无参数 | 全局 |
basePackages |
指定包 |
annotations |
指定注解的 Controller |
第二项在"多模块"里有用:内部接口与管理接口"错误体"可能不同第二项的"落地细节"是"每个 advice 都显式写 basePackages"——因为"无参数的 advice 是全局的",两个全局 advice 就会"互相覆盖"。
——于是"两个 advice + 不同包"更清楚。
"过滤器里的错误响应"要自己写,而写法上有三个要点:
// Filter 内统一错误响应(三要点:状态码、内容类型、可序列化的体)
catch (Exception e) {
resp.setStatus(401);
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"未授权\"}");
// 注意:不要 rethrow(rethrow 会继续走到容器错误页)
}
// 反例:catch 后只 log 不写响应 → 容器返回 HTML 默认页
5. "该捕获什么"的三条原则
| 原则 | 说明 |
|---|---|
| 业务异常 → 明确的 code | 可预期 |
| 校验异常 → 字段级信息 | 前端能定位 |
| 未知异常 → 记录 + 通用 code | 不泄漏内部信息 |
第三项的"泄漏"常常是"顺手加的":为了"方便联调"把 e.getMessage() 放进响应——而它可能包含"表名、SQL、文件路径"。
第三项是"安全"要求:不要把"堆栈/内部路径/SQL"返回给客户端——而它常出现在"为了方便排查"的临时改动里,然后忘了删。
"统一错误体"应当有一个"契约测试",跑一遍所有错误码的响应形状:
// 契约测试(每个错误码一行)
@Test void notFoundShape() throws Exception {
mockMvc.perform(get("/api/orders/O-none"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"))
.andExpect(jsonPath("$.retryable").value(false));
}
// 收益:改 advice 时"形状被破坏"立刻红;它比"前端联调时发现"便宜
6. 排错:五种"异常处理问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 客户端收到 HTML 错误页 | Filter 异常 | Filter 内写响应 |
| 异步请求一直等 | 异步异常没交付 | 状态回调 |
| 所有错误都是 200 | 统一包了一层 | 保留 HTTP 语义 |
| 前端拿不到"哪个字段错" | 校验异常被压成通用 | 保留字段信息 |
| 同一异常两种响应格式 | 两个 advice 冲突 | 明确范围 |
第五项在"加了第二个 advice"后出现:两个 advice 都处理 Exception,谁生效取决于顺序第五项的"识别"是"同一异常在不同接口上格式不同"——于是"前端要为每个接口写不同的解析",而它看起来像"前端问题"。
——于是"同一个异常在不同接口上有不同格式"。
最后一个"追踪"视角:错误响应里应带 traceId,而它需要在"最外层"生成、在"每一层"透传。
// traceId 的传递链(缺一环就断)
1 Filter:生成/接收 traceId → 放入 MDC 与响应头
2 advice:错误体里带上 traceId
3 异步:手动把 MDC 复制到子线程(否则子线程里没有)
4 日志格式:包含 traceId
// 判据:给定一个 traceId,能否"一条命令捞出整条链路"
7. 边界:什么情况不必用 advice
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单个 Controller(小服务) | 无统一需求 | 就地 try-catch |
| 错误体由网关统一包装 | 上层已处理 | 不必重复 |
| 纯函数式端点(WebFlux) | 机制不同 | 用 onErrorResume |
| 内部工具(错误可见) | 不需美化 | 不必 |
最后一条与"可观测"有关的边界:统一异常处理应当同时"产出指标"(按 code 计数)。否则"哪类错误最多"只能从日志里挖,而"日志里挖"比"指标"慢一个量级。
判断口径一句话:"错误响应需不需要"跨接口一致"。需要(对外 API),用 advice;不需要(内部小工具),就地处理更简单。
8. 自测
@ControllerAdvice覆盖哪一层?为什么 Filter 里的异常到不了它?- 三种"漏掉"的异常里,哪一种会造成"客户端一直等"?修法是什么?
- "状态码全是 200"为什么会破坏"重试逻辑"?
@ControllerAdvice的三种范围分别适合什么?多模块为什么需要"按包"?- "未知异常"为什么要"记录 + 通用 code",而不是"返回堆栈"?
- 判断"是否需要 advice"的那个问题是什么?