尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用SpringBoot开发微服务时如何优雅处理异常

用SpringBoot开发微服务时如何优雅处理异常 凌晨三点报警群弹出一条红色消息支付服务接口超时率飙升至80%。你打开日志看到满屏的NullPointerException而调用端收到的却是堆栈信息——那堆栈信息里甚至暴露了服务器内部的绝对路径。这不是技术问题是异常处理的问题。如果你用SpringBoot开发微服务却还在让异常裸奔那么每次故障都是一次事故每次异常都是一次信任崩塌。优雅处理异常不是防御性编程的锦上添花而是微服务生存的底线。先承认一个事实SpringBoot默认的异常处理只配在单体时代陪你练手SpringBoot确实提供了自动配置的BasicErrorController以及默认的error页面。当未捕获异常出现时它会返回一个包含timestamp、status、error、message、path的JSON体。看起来够用我们拆解一下默认错误响应没有任何业务语义它把HTTP状态码和业务错误码混为一谈。当验证失败时返回400你没法区分是参数格式错误、字段缺失、还是业务规则不允许。更危险的是默认错误处理会把异常堆栈直接暴露给客户端如果配置了server.error.include-messagealways数据库连接密码都可能出现在响应里。微服务场景下异常处理的目标变了要把错误转化为可协商的契约而不是让客户端去解析一串堆栈。你需要一个统一拦截器把所有未处理异常收敛到一个出口这个出口有能力判断这个异常该转化成什么状态码该携带什么错误码该记录多少日志。任何绕过全局处理器的异常都是埋给下一次事故的雷。建立异常分类学不是所有异常都配得上500很多团队的异常处理是一锅粥业务校验失败扔一个IllegalArgumentException数据不存在扔一个RuntimeException外部服务超时直接向上抛TimeoutException。前端拿到500只能对着用户说“系统开小差了”。优雅处理的第一步是给异常建立清晰的家谱。我建议至少分三层系统异常网络超时、数据库连接不可用、磁盘满、代码bug导致的NPE。这类异常你无法在请求处理时修复应当返回500或503并记录完整的堆栈。业务异常用户余额不足、订单状态不允许取消、重复提交。这类异常是流程的一部分应当返回200或合适的4xx携带业务错误码让前端展示友好提示。参数校验异常格式错误、范围越界、必填缺失。这类异常应当返回400并给出精确的字段错误信息。分类的目的是让客户端能够根据错误码自动决策而不是去解析人类可读的句子。一个常见实践是自定义BizException业务异常和SysException系统异常都继承自RuntimeException。业务异常里持有ErrorCode枚举系统异常里持有Throwable cause。这样全局处理器就能根据异常类型分流避免用instanceof链式判断几十种异常。全局异常处理的正确姿势RestControllerAdvice的进阶用法SpringBoot的RestControllerAdvice是优雅处理异常的核心阵地。但很多人只是写了一个ExceptionHandler(Exception.class)然后返回一个通用错误体。这不叫优雅这叫兜底。正确姿势是为每一种异常类别设计专属的处理器方法并让它们共享一个响应结构。比如RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResponseEntityErrorResponse handleBiz(BizException e) { return ResponseEntity.status(HttpStatus.OK) .body(ErrorResponse.of(e.getErrorCode())); } ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntityErrorResponse handleValid(MethodArgumentNotValidException e) { String fieldMsg e.getBindingResult().getFieldErrors().stream() .map(f - f.getField() : f.getDefaultMessage()) .collect(Collectors.joining(, )); return ResponseEntity.badRequest() .body(ErrorResponse.of(ErrorCode.PARAM_ERROR, fieldMsg)); } ExceptionHandler(Exception.class) public ResponseEntityErrorResponse handleOther(Exception e) { // 记日志、上报监控、发送告警 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ErrorResponse.of(ErrorCode.SYSTEM_ERROR)); } }这里的关键是所有处理器的返回类型统一为ErrorResponse但HTTP状态码可以不同。业务异常返回200是因为业务上操作已经完成只是状态不对前端需要展示错误码而不是跳转错误页。系统异常返回500是让网关感知到服务不可用从而触发熔断或重试。不要让HTTP状态码承载过多业务语义也不要把所有错误都压到200上。ErrorResponse的设计哲学错误码是给机器看的消息是给人看的一个优秀的错误响应体至少包含四个字段code机器可读的字符串或数字、message人可读的摘要、traceId关联日志、timestamp。更进一步可以加details用于参数校验错误时携带字段级别的信息。错误码体系必须全局唯一、永不复用。一旦线上客户端已经依赖了某个错误码你就不能改它的含义。建议用三段式服务名-模块-编号比如PAY-USER-1001表示支付服务的用户模块余额不足。永远不要在响应中暴露异常堆栈、类名、文件路径。这些信息对调用方毫无用处还可能成为攻击者的情报。正确做法是把堆栈打到服务端日志并在响应里放一个traceId让排障人员通过日志平台检索。对你来说是日志对客户端来说是凭证。没有traceId的错误响应等于留下了一张没有案件编号的报警单。参数校验的优雅处理让Spring帮你把脏活干完在Controller层手动写if (param null) throw new IllegalArgumentException这是石器时代的写法。Spring Validation让你用注解声明规则然后统一处理校验结果。优雅的关键在于让校验失败的消息能够精准定位到字段。比如PostMapping(/transfer) public Result transfer(Valid RequestBody TransferRequest req) { // 业务逻辑 }当NotBlank、Min等注解触发时MethodArgumentNotValidException会携带BindingResult。你的全局处理器要把它解析成可读的字段错误集合。不要只是返回“参数错误”四个字要告诉客户端“amount不能小于0来自账户不能为空”。这才是“优雅”的实质——把混乱的失误变成清晰的信息。更进阶的用法是自定义校验注解比如CheckOrderStatus校验逻辑放在一个ConstraintValidator里。这样异常处理统一走全局拦截器业务代码完全无感。校验逻辑与业务逻辑分离异常处理与请求处理分离这是分层思想的极致体现。微服务间的异常传递别让Feign吃掉你的业务错误在微服务架构里A服务调用B服务B业务异常已经被B的全局处理器转换成了ErrorResponse返回。但A服务通过Feign调用时Feign会把非200的响应直接抛成FeignException。如果你不做处理B精心设计的错误码就变成了A里一个包含JSON字符串的异常对象。优雅的跨服务异常传递应当这样做Feign的Decoder里如果响应体是ErrorResponse格式直接反序列化并包装成RemoteBizException抛出。全局处理器捕获RemoteBizException提取其中的code和message并决定是透传给更上层还是转换为本服务的错误码。为每个Feign接口配置fallbackFactory当远程调用超时或熔断时返回一个降级的业务结果而不是让调用方直接面对连接超时。如果你把服务间的异常当作跨网络的数据来解析而不是当作异常堆栈来传播那么你的系统会稳得多。当然远程异常需要携带traceId让整个链路日志能够串联起来——这需要你在RequestHeader中传递traceId并在全局处理器中把它回写到响应体里。异步与线程池异常会在你意想不到的地方逃逸Async方法、消息消费者、定时任务、CompletableFuture里的异常不经过Controller层你的RestControllerAdvice根本拦不到。这是异常处理最容易遗漏的角落。一个优雅的系统必须为异步任务设定独立的异常处理策略对于Async方法自定义AsyncUncaughtExceptionHandler把异常记录到日志并发送告警。对于消息消费者捕获异常后决定是重试、丢弃还是进入死信队列。对于CompletableFuture使用exceptionally或handle方法处理返还一个默认值或包装成业务异常。异步异常的处理原则是不能吞但也不能裸抛。吞掉会让系统带病运转裸抛会直接杀线程。正确做法是捕获、记录、计数并触发相应的补偿机制。记住写在Service里的try-catch不是异常处理是业务逻辑的一部分写在回调里的try-catch才是真正的系统防护。兜底与降级优雅的极致是让用户感知不到异常即使你做了所有上述工作仍然会有预料之外的异常。比如数据库连接池耗尽、第三方网关超时。这时候全局处理器捕获后返回500已经是最后的体面。但更优雅的是在每个关键业务入口设置兜底策略——当系统异常时返回一个缓存的历史结果或者一个“稍后重试”的提示而不是让用户面对白屏。这在读服务中尤其重要。比如商品详情服务数据库挂了但Redis里有最近一小时的缓存那就返回缓存并标记degradedtrue。降级是一场预谋的异常处理你提前决定好“当异常发生时用户看到什么”。还有一类是重试。对于临时性的网络抖动在网关或Feign层做1-2次重试利用指数退避成功率会大幅提升。但重试必须控制次数且只在幂等操作上使用。你不希望支付接口因为重试导致重复扣款。如果要重试最好让下游接收一个幂等键Idempotency-Key保证同一次请求的多次重试只生效一次。优雅的异常处理不是消灭异常而是把异常的影响范围控制在预设的边界内。实战检验你的异常处理真的优雅吗写完代码做几个测试。第一个测试故意抛出一个业务异常看响应体里有没有traceId日志里有没有对应关联。第二个测试用curl发送一个缺少必填字段的请求看返回的message是否明确指出了是哪个字段。第三个测试模拟下游服务超时看你的Feign fallback是否真的生效返回值是否合理。第四个测试关掉数据库看你的接口返回的是500还是降级数据。如果任何一道测试让你去改代码别急这说明你的异常处理还有上升空间。最后给你一个自查清单是否所有外部暴露的接口都经过了同一个全局处理器是否所有线程池里的异常都没有被吞掉是否所有远程调用的错误码都能互相翻译是否所有错误响应的格式都一致如果答案是肯定的那么当凌晨三点报警再一次亮起时你打开的将不是惊恐的堆栈而是一条带着traceId的清晰错误信息——它能告诉你问题出在哪个服务、哪个业务、哪个字段以及用户看到的是一句怎样的友好文案。这才是微服务时代异常处理该有的样子。它不是事后补救的卫生纸而是系统设计时就要亲手铸就的铠甲。
返回列表