Spring Boot 异常处理:到底该转换还是继续抛?入口层与调用层不能一刀切
摘要: Spring Boot 的"统一异常处理"不等于每一层都 catch 后返回统一对象。本文结合 MetaLite 的入口切面、调用切面、
ServiceException与 HTTP 状态处理,说明内部异常为何必须继续传播,而 API、Job、MQ 等入口为何需要完成最终语义转换。
Spring Boot 项目做统一异常处理时,经常出现两个极端。
一种是每层都写 try/catch:DAO 捕获一次,Service 包装一次,Controller 再转换一次。最终日志里充满重复堆栈,最初的异常类型和位置反而越来越难找。
另一种是"所有异常都由全局处理器解决",内部调用没有明确约束,业务错误、基础设施错误和 HTTP 状态混在一起。
真正需要统一的不是 catch 的位置,而是异常跨越不同边界时应该保留什么语义。
MetaLite 将切面分成入口层和调用层,两类采用不同策略:
- DAO、RPC、Redis 等内部调用失败,异常继续向上抛;
- API、Job、MQ 消费等入口捕获异常,完成日志、响应和上下文清理。
一、为什么每一层都转换异常会破坏语义
假设数据库连接超时,DAO 立即将异常转换成一个普通 Resp.error():
java
public Resp<UserEntity> findUser(String id) {
try {
return Resp.ok(jdbc.queryForObject(...));
} catch (Exception e) {
return Resp.error("查询失败");
}
}
上层看到的是一次"正常方法返回",而不是一次异常退出。
这会产生几个问题:
- 事务拦截器可能无法按异常触发回滚;
- Service 必须逐层检查
resp.isOk(); - 原始异常类型、SQLState 和调用栈容易丢失;
- 不同 DAO 对同一错误可能转换成不同消息;
- 上层无法决定是重试、降级还是直接失败。
内部调用层最重要的职责,是保留真实失败,而不是过早决定外部应该看到什么。
二、调用层如何保留原始异常
MetaLite 的非入口环绕模板用于 DAO、内部 API、Redis、MQ 生产和 Caffeine 等调用。
核心结构可以简化为:
java
try {
Resp preResp = chain.applyPreHandle(aspectInfo);
if (preResp.isOk()) {
result = joinPoint.proceed();
} else {
result = preResp;
}
} catch (Throwable ex) {
result = "error: " + message(ex);
throw ex;
} finally {
chain.applyPostHandle(aspectInfo, result);
recoverContext(aspectInfo);
}
异常发生后做了三件事:
- 为日志准备错误结果;
- 在
finally中继续执行后置处理和上下文恢复; - 使用
throw ex原样继续传播。
它没有在 DAO 或 RPC 边界把异常改造成接口响应,也没有调用入口层的 errorHandle。
这样,最外层事务和业务入口仍能看到真实异常。
三、入口层为什么必须完成最终转换
异常不能无限向外传播。
当执行到 API、定时任务或消息消费入口时,框架已经到达一次业务执行的最外层边界,需要给触发方一个稳定结果,并保证上下文被清理。
入口层模板如下:
java
try {
Resp preResp = chain.applyPreHandle(aspectInfo);
if (preResp.isOk()) {
result = joinPoint.proceed();
} else {
result = preResp;
}
} catch (Throwable ex) {
chain.applyErrorHandle(aspectInfo, ex);
result = Resp.error(ex);
} finally {
chain.applyPostHandle(aspectInfo, result);
recoverContext(aspectInfo);
}
入口层负责:
- 调用异常处理器记录错误;
- 将异常转换成统一业务响应;
- 无论成功失败都执行后置处理;
- 恢复或清理本次调用上下文。
内部保留异常,边界处改变语义,这比"所有层都统一返回 Resp"更准确。
四、可预期失败不一定要抛异常
并非所有失败都应该进入 catch。
参数校验、权限校验、限流和重复提交属于可预期的前置决策。处理器可以直接返回失败 Resp:
java
Resp resp = handler.preHandle(aspectInfo);
if (!resp.isOk()) {
return resp;
}
这是一种 fail-fast:目标业务方法不再执行,后续普通前置处理器也被短路。
与异常相比,这类结果具有明确业务语义,不需要用堆栈表达控制流。
但失败请求仍需记录日志,所以处理器链会额外补执行日志类 Handler。业务逻辑停止,观测链路不能一起消失。
五、ServiceException 与未知异常应该区别对待
ServiceException 用于业务代码主动表达可识别错误码:
java
throw new ServiceException(
ErrorCode.INVALID_CALLER,
"调用方已被禁用"
);
入口调用 Resp.error(Throwable) 时按异常类型转换:
| 异常类型 | 当前转换结果 |
|---|---|
IllegalArgumentException |
参数错误 |
IllegalStateException |
操作失败 |
ServiceException |
保留指定业务 code 与 message |
| 其他未知异常 | 服务器内部错误 |
业务异常是系统预期的一部分,未知异常则表示实现或基础设施出现了非预期失败。
日志也应区别处理。当前 BaseAspectLogger 对 ServiceException 记录错误消息但不附带 Throwable 堆栈;对其他异常记录完整堆栈。
需要以源码为准:ServiceException 当前使用的是无堆栈的 error 日志调用,并不是类注释所说的 warn 级别。
六、业务错误与 HTTP 状态码不是同一个维度
统一响应中有业务 code,HTTP 协议本身也有状态码。二者不应简单混为一谈。
MetaLite 网关当前采用下面的规则:
- 参数、权限、限流等业务错误保持默认 HTTP 200,通过
Resp.code表达; - 只有
ErrorCode.SERVER这类服务器内部错误,将 HTTP 状态设置为 500。
对应的后置处理器逻辑是:
java
if (resp.getCode() == ErrorCode.SERVER.getCode()) {
WebUtil.setResponseStatusCode(500);
}
这种设计让客户端可以区分:
- HTTP 请求已正常到达并得到业务拒绝;
- 服务内部出现未知故障。
它不是 REST API 的唯一正确规则。有些团队会把参数错误映射为 400、未授权映射为 401/403、限流映射为 429。关键在于协议必须稳定,客户端、监控和网关对同一错误有一致理解。
七、finally 中的后置处理为什么不能省
无论成功、前置失败还是目标方法抛异常,入口最终都会进入 finally。
后置阶段可能承担:
- 输出调用结果和耗时;
- 设置 HTTP 状态;
- 响应加密或数据清理;
- 释放重复提交锁;
- 恢复 ThreadContext 和 MDC。
如果异常路径直接 return 或 throw,跳过这些动作,就可能出现锁未释放、上下文串请求或失败日志不完整。
因此,异常策略不只决定"返回什么",还要决定哪些清理动作必须拥有 finally 语义。
八、统一异常处理最容易出现的四个误区
误区一:Service 层全部返回 Resp
如果每个内部方法都返回 Resp,上层会不断重复判断,异常传播和事务回滚也更难保持自然。
误区二:所有业务失败都抛异常
参数、权限和限流等前置判断可以直接返回明确结果,没有必要让异常承担普通分支控制。
误区三:catch 后重新 new 一个 RuntimeException
无意义包装会拉长调用栈、改变异常类型,还可能丢失原始上下文。内部层应尽量原样传播或保留 cause。
误区四:HTTP 200 就代表成功
当前协议中业务结果由 Resp.code 判断;服务器未知错误同时映射 HTTP 500。调用方必须同时理解传输状态和业务状态。
九、判断异常在哪里转换,只需问一个问题
可以用一个简单原则判断:
当前层是否已经到达这次业务执行对外承诺结果的边界?
如果还在 DAO、RPC、缓存等内部调用中,应保留异常,让事务和上层策略继续工作。
如果已经到达 API、Job 或 MQ 消费入口,就需要完成错误记录、稳定结果转换和上下文清理。
统一异常处理的目标不是让所有方法长得一样,而是让异常在内部保持真实,在边界处变得可理解。
框架简介
MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。
源码基线
JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。
作者简介
15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。
持续更新
MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。
在线演示
演示地址: https://admin.metalite.top/
演示账号: guess
演示密码: admin@2026