2064 字
约 6 分钟
16
S07-A22 · `@ControllerAdvice` 的边界:统一异常处理里漏掉的三类

一个项目用 @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. 自测

  1. @ControllerAdvice 覆盖哪一层?为什么 Filter 里的异常到不了它?
  2. 三种"漏掉"的异常里,哪一种会造成"客户端一直等"?修法是什么?
  3. "状态码全是 200"为什么会破坏"重试逻辑"?
  4. @ControllerAdvice 的三种范围分别适合什么?多模块为什么需要"按包"?
  5. "未知异常"为什么要"记录 + 通用 code",而不是"返回堆栈"?
  6. 判断"是否需要 advice"的那个问题是什么?
S07-A22 · `@ControllerAdvice` 的边界:统一异常处理里漏掉的三类
https://www.clxhxhhr.top/posts/6611/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。