公司动态

用SpringBoot写接口时,如何把异常处理得干干净净

📅 2026/9/2 3:38:56
用SpringBoot写接口时,如何把异常处理得干干净净
凌晨两点线上订单模块突然告警。你打开日志看到一堆NullPointerException堆栈却找不到对应业务上下文前端同事甩来截图说“接口报错了”但只显示一行“服务器内部错误”。这种场景是不是熟悉得让人窒息我们天天用SpringBoot写接口却很少有人把异常处理当成一等公民来设计。异常处理不是把try-catch写满Service而是建立一套让错误可见、可追踪、可反馈的完整机制。今天我们不谈那些花哨的中间件就把“如何把异常处理得干干净净”这件事拆开揉碎。别在Controller里做“人肉断路器”最常见的错误写法是在每个接口方法内部用try-catch包裹全部逻辑然后catch里自己拼一段JSON返回。这种代码初看“安全”实则埋下三颗雷第一每个接口的异常响应格式都靠程序员心情前端解析要写七八个兼容分支第二业务异常被catch后直接吞掉或转为空数据排查问题时只能靠猜第三一旦遗漏某个异常类型又会让默认的500错误页面裸奔给用户。Controller层的职责是接收参数、调用服务、返回结果它不该成为异常逻辑的聚集地。SpringBoot早为我们准备了RestControllerAdvice这个利器但很多人只是简单用一下根本没有发挥出它的架构价值。真正干净的异常处理应该遵循“异常向上抛处理聚一处”的原则。Service层遇到业务问题直接抛出你自定义的业务异常Controller层不做任何catch由全局异常处理器统一兜底。这样一来Controller代码会瘦成一道闪电业务方法里不再混入一堆降级逻辑所有异常流向清晰可见。你的代码质量往往取决于你愿意把脏活累活集中到哪一层。自定义异常要带上“身份信息”Java自带的RuntimeException虽然开箱即用但抛出来就像写了一张没有落款的便条——你知道出事了但不知道是谁、在哪、为什么。很多人自定义异常时只加了一个code字段其实远远不够。一个合格的业务异常至少要包含三方面信息错误码给程序看的机器标识、错误消息给用户看的人话、上下文给排查者看的线索字段。举个例子假设你在做一个订单系统。库存不足时如果直接抛new RuntimeException(库存不足)那么日志里只有一行干巴巴的话你根本不知道是哪个商品、哪个仓库、当时请求里带了什么参数。更糟糕的是同一条消息会原样返回给前端在敏感场景下可能泄露内部数据。我见过最优雅的做法是定义一个BizException内部持有ErrorCode枚举、message模板和MapString, Object context然后在关键节点把订单号、商品ID、当前库存等塞进context里。全局异常处理器拿到这个异常后把错误码和消息返回给前端同时把整个context完整打印到日志——让错误既“说得清”也“记得下”。全局异常处理器别只写一个空壳RestControllerAdvice几乎人人都会写但很多人只放了一个ExceptionHandler(Exception.class)的方法就完事了。这种做法相当于把所有可能被捕获的异常全塞进了一个黑箱子里打印出来的堆栈毫无层次更别提返回给前端统一友好的应答了。真正的全局异常处理器应该像一张“分诊台”根据异常类型精确分流到不同的处理通道。我推荐的处理器结构至少包含以下几类方法处理你自定义的BizException返回明确的业务错误码和提示语处理MethodArgumentNotValidException和BindException专门解析Bean Validation的字段校验错误把“哪个字段、为什么失败”拼成可读的集合处理ConstraintViolationException解决路径变量和请求参数上的校验失败处理MissingServletRequestParameterException、HttpMessageNotReadableException等框架异常把它们翻译成参数错误的400响应最后才是兜底的Exception返回统一的“系统繁忙”占位消息并记录完整堆栈。不同异常对应不同场景处理粒度越细调用方体验就越顺滑。这里有个细节常被忽略ExceptionHandler方法上一定要带上ResponseStatus吗答案是不必。如果你手动设置ResponseEntity或者直接往response里写入状态码那么ResponseStatus就是多余的。更优雅的做法是让全局异常处理器返回一个ResponseEntityErrorResult其中状态码由业务错误码映射而来。比如库存不足返回409参数非法返回400未认证返回401无权限返回403系统内部错误统一500。状态码是HTTP层给客户端的第一信号别滥用200来包裹所有错误。校验异常把“不合格输入”挡在门外请求参数的校验是异常处理中最容易“将就”的部分。很多人习惯在Controller里手写if (param null) return ...或者只在Service层抛个异常了事。但你有没有想过SpringBoot已经内置了JSR-380规范Hibernate Validator实现你只需要在DTO字段上标注NotNull、Size、Pattern等注解再在Controller参数前加一个Valid框架就会自动完成校验并抛出MethodArgumentNotValidException。用注解代替手写判断不是炫技而是把重复的校验逻辑从业务代码中彻底剥离。可悲的是很多人即使用了Valid却在全局异常处理器里把该异常当作普通异常处理只返回一个笼统的“参数错误”。这完全浪费了校验框架提供的丰富信息。正确的做法是从BindingResult中遍历每个FieldError取出field和defaultMessage组装成类似ListFieldErrorItem的结构响应给前端。这样前端可以直接把“username不能为空”“password长度必须在6到20之间”这样的提示渲染到表单项下方而不是弹一个模糊的提示框。校验错误是对接口使用者的友好对话别让一次错误变成一次谜面。还有一个坑如果你在Service层的参数对象上使用Validated和NotNull这类注解那么抛出的会是ConstraintViolationException而不是MethodArgumentNotValidException。所以在全局异常处理器中这两个异常都必须单独处理缺一个就会导致校验失败时被兜底逻辑吞成500。永远不要假设框架只会抛一种异常错误类型雷达要全覆盖。业务异常与系统异常要分而治之我见过太多团队把业务失败和系统故障混为一谈。用户余额不够、商品已下架、操作太频繁这些是业务异常属于“预期内的分支”通常可以用明确的错误码和提示语告诉用户而数据库连不上、第三方接口超时、线程池拒绝任务这些是系统异常属于“非预期的故障”你需要的是告警、兜底和重试机制而不是让用户看到一串技术术语。业务异常是流程分支系统异常是bug警报把两者装在同一个响应结构里是对运维和前端同事的双重折磨。在实际编码中我建议业务异常使用自定义的BizException并带有明确的ErrorCode系统异常则让它们自由冒泡或者在最外层包装成SystemException带有一个底层异常源cause。全局异常处理器在处理BizException时返回的HTTP状态码应该是4xx通常是200或422具体看团队约定同时在响应体里给到清晰的业务错误码处理SystemException时返回500并且响应体里的message永远是“系统开小差了请稍后重试”绝不透露具体堆栈信息。给用户看的是安慰给开发者看的是细节这条线必须划得清清楚楚。把日志当作异常的第一道“案发现场”异常处理干净与否很大程度取决于日志能否还原案发现场。很多团队在全局异常处理器里用logger.error(error, e)一笔带过看起来没毛病但实际排查时你会发现日志里全是“error”字样根本没法快速定位到是哪个接口、哪个用户、哪个幂等ID。异常日志的价值不在于堆栈本身而在于把堆栈放置到足够的上下文中。我强烈建议在全局异常处理器中调试输出时至少带上以下信息请求方法GET/POST、请求路径、查询参数脱敏后、请求体脱敏后、用户ID如果上下文中取得到、异常对应的业务码和业务消息、堆栈。组合方式可以直接用MDCMapped Diagnostic Context或自定义日志格式。更进阶的做法是为每个请求生成一个traceId在过滤器入口写入MDC然后在异常日志中打印出来。这样前端报错时只要附带这个traceId运维就能在日志系统里秒级检索到完整调用链。没有traceId的异常日志就像没有坐标的求救信号。另外需要注意不要重复打印异常。如果你在Service层catch异常打了一条日志又往上抛全局处理器又打一条那么一条异常会在日志系统里产生多条记录误导排查。原则是“谁捕获谁负责最终抛出时只打一次”。在全局异常处理器里对于系统异常记录完整堆栈对于业务异常通常只记录警告级别日志因为这类异常本身是预期分支不需要打印一大段堆栈吓人。日志级别选择也是异常处理的一部分错误被当成警告记录会降低告警疲劳。统一响应结构别让“接口结果”随意生长异常处理离不开响应体规范。假如你的接口正常返回是{code: 0, data: ...}但异常返回却是{status: 500, message: ...}这就搞得前端要判断两种完全不同的结构。干净的做法是所有接口无论成功还是失败都返回同一个外壳。你可以定义一个ApiResponseT包含code、message、data三个字段。成功时code为0或200data为业务数据失败时code为业务错误码data为null或空对象。这样设计还有一个好处全局异常处理器返回的也是ApiResponse?前端只需要写一个统一拦截器检查code值就能决定走成功分支还是错误分支不用再捕获HTTP非2xx状态。有人担心这样做会掩盖HTTP语义其实完全可以两者并存——HTTP状态码保持4xx/5xx同时响应体里也有兼容的业务code。规范不在于选哪种而在于前后端用同一套语言沟通。不过统一响应结构也有反模式有人把data字段强行塞进一个Map让成功和失败的数据结构彻底变成“黑盒”。比如错误时往data里塞一个Map字段名叫errorDetail这就破坏了契约。更合理的做法是错误响应不返回data字段或者data为null额外的上下文信息放在独立的errorDetails字段中且该字段隶属于错误对象本身而不是data的变体。接口契约越稳定异常处理越能成为一个你不用再担心的暗礁。事务边界上的异常别让回滚变得失控Spring声明式事务是无数开发者的舒适区但异常处理的误差很容易在涉及Transactional时爆炸。默认情况下RuntimeException和Error会触发回滚而受检异常如Exception的子类不会。如果你在Service里抛出一个自定义的受检异常比如OrderExportException事务会提交——数据已经写进去了但界面提示失败这会造成严重的数据不一致。记住默认回滚策略只针对运行时异常受检异常需要显式设置rollbackFor。另一个常见坑是在Transactional方法内部捕获了异常却没有重新抛出导致事务判定为“成功提交”但实际业务步骤并未完成。比如你在一个事务里先扣库存然后调远程服务远程调用失败后你catch了异常并返回了null此时事务会正常提交库存扣减就会生效而用户看到的却是操作失败。异常处理必须在事务边界内外保持清醒要么彻底处理并提交要么抛出并回滚。更进阶的建议是合理使用Transactional(rollbackFor Exception.class)并且避免在事务方法中调用同类内部方法自调用导致代理失效。异常从Service层抛向Controller层的全过程事务注解不会改变异常的类型但会影响数据库的最终状态。所以在写业务代码前先想清楚每一个被捕获的异常是否会破坏数据一致性。异常处理的终极目标不是让代码不崩溃而是让崩溃后的状态依然正确。防御性编程异常处理的最高境界是“没有异常”最后谈一个看似矛盾的观点真正干净的异常处理实际上是尽可能减少需要处理的异常。如果接口的入参都是合法的——通过Validated校验如果依赖的服务都有超时和熔断——通过CircuitBreaker或手动降级如果Redis操作都判空了——通过Optional封装如果外部HTTP调用都设定了重试上限——那么真正走到全局异常处理器的系统异常就会少一个量级。异常处理的最高境界不是把每个catch写得完美而是让大部分异常在发生前就失去发生的土壤。但这并不意味着你可以不写全局异常处理器。它是最后一道防洪堤即便前面的水库、闸门、水渠都失效它也得保住下游城市。所以一个面向未来的做法是为你的系统定义一个错误码手册把常见的业务错误码、系统错误码、第三方错误码都编号登记写一个自动化测试专门测试全局异常处理器对各种异常类型的响应在CI/CD流程中强制检查Controller方法是否包含try-catch——不允许有除非有极其特殊的理由。把异常处理制度化、自动化才谈得上“干干净净”。写到这里我想起一个同事说过的话判断一个后端工程师的功力不用看他的业务代码多华丽只需要翻一翻他的全局异常处理器和日志配置。异常处理看似是细枝末节它决定了这个系统的可观测性、可靠性以及——说句实在的决定了下一次凌晨三点被电话叫醒时你是能按图索骥轻松定位还是只能对着日志大海捞针。今晚就去把你的全局异常处理器重新审视一遍加上足够多的ExceptionHandler分支给每个自定义异常配上上下文把traceId打印出来再用统一响应结构武装到牙齿。做完这些你写接口时的底气会完全不一样。