SpringBoot异常处理-到底该转换还是继续抛-入口层与调用层不能一刀切

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);
}

异常发生后做了三件事:

  1. 为日志准备错误结果;
  2. finally 中继续执行后置处理和上下文恢复;
  3. 使用 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
其他未知异常 服务器内部错误

业务异常是系统预期的一部分,未知异常则表示实现或基础设施出现了非预期失败。

日志也应区别处理。当前 BaseAspectLoggerServiceException 记录错误消息但不附带 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

相关推荐
Liora_Yvonne1 小时前
不懂后端,只会 TypeScript,想独立做完整项目?这套全栈底座就是给前端准备的
前端·后端·全栈
前端Hardy1 小时前
GitHub 爆火!236K+ Star!一套 Skills 让 AI 按工程师方式写代码
前端·后端
名字还没想好☜1 小时前
Go 用 slices/maps 标准库泛型函数:告别手写 Contains、Sort、去重(Go 1.21)
开发语言·后端·算法·golang·go
赵广陆1 小时前
Spring AI的聊天模型
java·人工智能·spring
IT 小阿姨(数据库)1 小时前
K8s v1.24.17 完整搭建文档(CentOS7 + containerd1.6.33 + Calico)
java·容器·kubernetes
程序大爆炸2 小时前
gcsfuse与中断FUSE_INTERRUPT
后端
lhldsg2 小时前
全民健身解决方案:从共享球场到智能运营的技术实践
java·大数据·开发语言·需求分析
vipxieliang2 小时前
ValidX时间注解完全指南:10种时间验证注解详解
java·后端
七牛开发者2 小时前
解读 Cordis:dsh 一切皆插件背后的运行时设计
前端·javascript·后端