Spring Boot 异常处理到底该转换还是继续抛入口层与调用层不能一刀切摘要Spring Boot 的“统一异常处理”不等于每一层都 catch 后返回统一对象。本文结合 MetaLite 的入口切面、调用切面、ServiceException与 HTTP 状态处理说明内部异常为何必须继续传播而 API、Job、MQ 等入口为何需要完成最终语义转换。Spring Boot 项目做统一异常处理时经常出现两个极端。一种是每层都写 try/catchDAO 捕获一次Service 包装一次Controller 再转换一次。最终日志里充满重复堆栈最初的异常类型和位置反而越来越难找。另一种是“所有异常都由全局处理器解决”内部调用没有明确约束业务错误、基础设施错误和 HTTP 状态混在一起。真正需要统一的不是 catch 的位置而是异常跨越不同边界时应该保留什么语义。MetaLite 将切面分成入口层和调用层两类采用不同策略DAO、RPC、Redis 等内部调用失败异常继续向上抛API、Job、MQ 消费等入口捕获异常完成日志、响应和上下文清理。一、为什么每一层都转换异常会破坏语义假设数据库连接超时DAO 立即将异常转换成一个普通Resp.error()publicRespUserEntityfindUser(Stringid){try{returnResp.ok(jdbc.queryForObject(...));}catch(Exceptione){returnResp.error(查询失败);}}上层看到的是一次“正常方法返回”而不是一次异常退出。这会产生几个问题事务拦截器可能无法按异常触发回滚Service 必须逐层检查resp.isOk()原始异常类型、SQLState 和调用栈容易丢失不同 DAO 对同一错误可能转换成不同消息上层无法决定是重试、降级还是直接失败。内部调用层最重要的职责是保留真实失败而不是过早决定外部应该看到什么。二、调用层如何保留原始异常MetaLite 的非入口环绕模板用于 DAO、内部 API、Redis、MQ 生产和 Caffeine 等调用。核心结构可以简化为try{ResppreRespchain.applyPreHandle(aspectInfo);if(preResp.isOk()){resultjoinPoint.proceed();}else{resultpreResp;}}catch(Throwableex){resulterror: message(ex);throwex;}finally{chain.applyPostHandle(aspectInfo,result);recoverContext(aspectInfo);}异常发生后做了三件事为日志准备错误结果在finally中继续执行后置处理和上下文恢复使用throw ex原样继续传播。它没有在 DAO 或 RPC 边界把异常改造成接口响应也没有调用入口层的errorHandle。这样最外层事务和业务入口仍能看到真实异常。三、入口层为什么必须完成最终转换异常不能无限向外传播。当执行到 API、定时任务或消息消费入口时框架已经到达一次业务执行的最外层边界需要给触发方一个稳定结果并保证上下文被清理。入口层模板如下try{ResppreRespchain.applyPreHandle(aspectInfo);if(preResp.isOk()){resultjoinPoint.proceed();}else{resultpreResp;}}catch(Throwableex){chain.applyErrorHandle(aspectInfo,ex);resultResp.error(ex);}finally{chain.applyPostHandle(aspectInfo,result);recoverContext(aspectInfo);}入口层负责调用异常处理器记录错误将异常转换成统一业务响应无论成功失败都执行后置处理恢复或清理本次调用上下文。内部保留异常边界处改变语义这比“所有层都统一返回 Resp”更准确。四、可预期失败不一定要抛异常并非所有失败都应该进入 catch。参数校验、权限校验、限流和重复提交属于可预期的前置决策。处理器可以直接返回失败RespRespresphandler.preHandle(aspectInfo);if(!resp.isOk()){returnresp;}这是一种 fail-fast目标业务方法不再执行后续普通前置处理器也被短路。与异常相比这类结果具有明确业务语义不需要用堆栈表达控制流。但失败请求仍需记录日志所以处理器链会额外补执行日志类 Handler。业务逻辑停止观测链路不能一起消失。五、ServiceException 与未知异常应该区别对待ServiceException用于业务代码主动表达可识别错误码thrownewServiceException(ErrorCode.INVALID_CALLER,调用方已被禁用);入口调用Resp.error(Throwable)时按异常类型转换异常类型当前转换结果IllegalArgumentException参数错误IllegalStateException操作失败ServiceException保留指定业务 code 与 message其他未知异常服务器内部错误业务异常是系统预期的一部分未知异常则表示实现或基础设施出现了非预期失败。日志也应区别处理。当前BaseAspectLogger对ServiceException记录错误消息但不附带 Throwable 堆栈对其他异常记录完整堆栈。需要以源码为准ServiceException当前使用的是无堆栈的 error 日志调用并不是类注释所说的 warn 级别。六、业务错误与 HTTP 状态码不是同一个维度统一响应中有业务codeHTTP 协议本身也有状态码。二者不应简单混为一谈。MetaLite 网关当前采用下面的规则参数、权限、限流等业务错误保持默认 HTTP 200通过Resp.code表达只有ErrorCode.SERVER这类服务器内部错误将 HTTP 状态设置为 500。对应的后置处理器逻辑是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演示密码: admin2026