异常治理:错误码、熔断与降级的统一设计 — editorial cover photo

异常没治理好,故障就成了黑盒

异常处理散乱、错误码各搞各的——异常治理让故障可见、可控、可处理。

2025年10月17日·返回文章列表
文章大纲

联易融蜂控平台有一次线上告警风暴,一晚上几百条 ERROR。值班同学通宵排查,结果发现绝大部分是用户输错密码触发的。密码错误本该是正常的业务流程,因为异常处理没分类,统一抛 RuntimeException 统一记 ERROR 日志,业务异常和系统故障混在一起,告警系统被业务异常淹没,真正的系统故障反而看不出来。那晚之后我们做了一轮异常治理,从错误码结构、异常分类到日志级别全面规范,告警量降了一个数量级,值班终于能从噪音里解脱出来关注真正的故障。

异常处理散乱是很多系统的通病。服务 A 抛 RuntimeException,服务 B 自定义异常体系,错误码有的用字符串有的用数字,前端收到错误码不知道怎么展示。表面看是代码规范问题,深层是故障可观测性的问题。异常处理不规范,故障就不可见,不可见就不可控,不可控就只能等用户投诉。

错误码要有结构,不能各拍各的

错误码治理的第一步是统一结构。散落的错误码(有的 1001 有的 ORDER_NOT_FOUND 有的 "param_error")没法跨服务识别,前端拿到也不知道怎么处理。统一的错误码结构要满足三个要求,全局唯一、可定位、可文档化。

我倾向用 业务域_错误类型_序号 的格式,比如 ORDER_PARAM_INVALID_001PAY_TIMEOUT_002。业务域让人一眼知道是哪个服务出的错,错误类型说明错的性质,序号保证同域同类下唯一。这种结构比纯数字码(1001)可读性好,比纯字符串码("error")信息量大。

全局唯一靠命名规范和序号管理保证,跨服务不冲突。可定位是核心价值,运维或前端看到 ORDER_PARAM_INVALID_001 就知道是订单服务的参数校验问题,不用查文档就能初步判断方向。文档化要求每个错误码有一份说明,包含错误含义、触发场景、处理建议,放在团队的错误码字典里维护。

配合错误码的是统一的响应结构。所有接口返回相同的外壳,业务码、用户友好消息、技术细节、traceId、时间戳、数据体。前端根据 code 决定怎么展示和处理,根据 traceId 串联整条链路用于排查。

{
"code": "ORDER_001_NOT_FOUND",
"message": "订单不存在",
"detail": "订单号 ORD123 不存在",
"traceId": "abc-123-def",
"timestamp": 1697123456789,
"data": null
}

异常分类,是治理的真正起点

PlantUML
正在加载图表…
PlantUML diagram

图:异常三分法——业务异常、系统异常、第三方异常,处理方式截然不同,混在一起是万恶之源

错误码统一只是表层,异常治理的内核是分类。异常分三类,业务异常、系统异常、第三方异常,它们的处理方式完全不同,混在一起是万恶之源。

业务异常是用户操作引起的,参数错、状态不允许、权限不足。比如用户用一个不存在的订单号查询,或者重复提交表单。这类异常是正常业务流程的一部分,用户可以纠正,对应 HTTP 的 4xx。它不该告警,不该记 ERROR 日志,记个 INFO 就够。

系统异常是系统自身故障,DB 连不上、OOM、空指针。用户无能为力,对应 HTTP 的 5xx。这类异常才是真故障,要告警,要记 ERROR,要人工介入。

第三方异常是外部依赖故障,调用的风控接口超时、短信网关返回错误。这类异常介于前两者之间,可以重试或降级,处理策略要看依赖的重要性。

业务异常和系统异常必须分开

蜂控平台那晚的告警风暴就是没分开的典型。业务异常被当系统异常处理,记 ERROR 触发告警,几百条密码错误的告警淹没了真正的故障。分开之后,业务异常记 INFO/WARN 不告警,系统异常记 ERROR 才告警,告警的信噪比立刻上来。判断标准很简单,这个异常是用户能纠正的吗,能就是业务异常,不能就是系统异常。

全局异常处理器,让业务代码只管抛

异常分类清楚后,怎么落地。答案是全局异常处理器,Spring 里就是 @ControllerAdvice@ExceptionHandler。业务代码里该抛什么异常就抛什么,不用每个接口写一堆 try-catch,异常冒泡到全局处理器统一拦截,分类处理后转成统一响应结构返回。

这套机制的好处是异常处理逻辑集中,业务代码干净。新增一种异常类型,只需要在全局处理器里加一个 handler,不用改所有接口。但要注意一点,全局处理器不是万能的,它只能拦截到 Controller 层的异常。如果异常发生在异步线程里、或发生在定时任务里,全局处理器接不到,这类场景得单独处理,要么在异步逻辑里自己 catch 并记录,要么用专门的异步异常处理机制。

异常处理器的另一个职责是翻译。把底层的技术异常翻译成对调用方友好的业务错误码。比如 DB 唯一键冲突,底层抛 DuplicateKeyException,处理器翻译成 ORDER_ALREADY_EXISTS 这样的业务码返回,前端能直接展示有意义的提示,而不是把 SQL 异常信息透出去。

异常是熔断和降级的输入

PlantUML
正在加载图表…
PlantUML diagram

图:异常是熔断降级的输入信号——系统/第三方异常计入熔断,业务异常不计入,避免误熔断

异常不是抛了就完事,它是熔断器和降级逻辑的输入信号。这正是异常治理和稳定性治理交汇的地方。

调用下游时,异常被熔断器统计。每次调用成功或失败都计入统计窗口,失败率(或慢调用比例)超阈值就触发熔断。这里的关键是哪些异常算失败。系统异常和第三方异常算失败,业务异常不算。用户传错参数导致下游返回业务错误,这不是下游故障,不该计入熔断统计。如果把业务异常也算失败,正常的参数校验错误就能把下游熔断掉,这是误熔断。

降级逻辑也依赖异常分类。非核心依赖抛异常时,应该走降级而不是直接抛给用户。比如调用推荐服务失败,降级返回默认推荐列表,用户照样能浏览,只是少了个性化。这种异常到降级的转换,让故障对用户无感。判断标准是依赖的优先级,核心依赖(支付、下单)的异常必须抛出让用户知道,非核心依赖的异常应该被降级吞掉。

日志级别要和异常类型对应

PlantUML
正在加载图表…
PlantUML diagram

图:日志级别与异常类型对应关系——乱用 ERROR 会制造告警风暴,淹没真正故障

异常记什么级别的日志,不能随手 log.error。日志级别和异常类型有对应关系,乱了就会出蜂控平台那种告警风暴。

业务异常记 INFO 或 WARN。这是正常流程的一部分,用户传错密码、订单状态不允许操作,记下来用于业务分析就够,不需要人工介入。用 ERROR 记业务异常是最常见的错误,它会让日志全是噪音,真正的系统故障被淹没。

系统异常记 ERROR。DB 挂了、空指针、OOM,这些是需要人工介入的真故障,记 ERROR 触发告警。

可恢复的异常记 WARN。重试成功的网络抖动、降级生效的第三方故障,故障发生了但系统自动处理了,记 WARN 留个痕迹,不触发高级别告警但可观测。

调试细节记 DEBUG,生产环境关闭。这条最简单但也最常被忽略,把调试信息用 INFO 打到生产日志里,日志膨胀还查不出问题。

重试要区分可重试和不可重试

不是所有异常都该重试,重试策略也要按异常类型区分。

网络超时、临时不可用这类瞬时故障,可以重试。重试用指数退避,第一次等 100ms,第二次 200ms,第三次 400ms,避免重试风暴压垮刚恢复的下游。重试次数要有上限,超过就放弃走降级或抛出。

参数错误、权限拒绝这类确定性故障,不该重试。重试一万次结果还是一样,纯粹浪费资源。幂等校验失败、业务规则不满足,都属于不可重试。

第三方异常要看具体情况。对方明确返回失败(比如余额不足)不重试,对方超时或连接失败可以重试。判断标准是这个故障是暂时的还是确定的。

异常治理的最终目标,是让故障可见、可控、可处理。错误码统一让故障可识别,异常分类让处理准确,全局处理器让代码干净,与熔断降级联动让故障可自愈,日志级别对应让故障可观测。这套体系搭起来,系统的可维护性会有质的变化,值班同学的睡眠质量也会跟着改善。

Continue Reading

关联文档推荐

查看全部
技术债治理:识别、量化与有序偿还 — editorial cover photo配置治理:消灭散落的硬编码与魔法值 — editorial cover photo限流降级治理:统一规则中心与执行链路 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。