异常处理与错误设计实战:让报错成为有用的信息
「系统错误,请稍后重试」是生产事故里最气人的一句话:用户不知道怎么办,开发也查不到。这篇讲清错误三分类、上下文包装、双层信息设计、重试与降级,以及五个最常见的事故模式。
一、先分清三类错误:处理策略完全不同
错误处理做不好的第一大原因,是没有区分错误的类型——把 bug、业务失败和网络超时当成同一回事来处理。三类错误的正确姿势完全不同:
| 类型 | 例子 | 策略 |
|---|---|---|
| 编程错误(Bug) | 空指针、数组越界、状态机不合法转换 | 不重试、不吞掉——暴露出来,修复它 |
| 操作错误(可预期) | 参数非法、余额不足、无权限 | 返回明确原因,指导用户如何修正 |
| 环境错误(暂时性) | 网络超时、依赖限流、下游不可用 | 可重试、可降级、兜底要提前设计 |
判断标准很简单:重试有没有意义?有 = 环境错误;没有但用户能修正 = 操作错误;用户修不了、重试也没用 = Bug,交给监控与修复流程。
错误处理的目标:用户拿到可行动的下一步,开发者拿到可定位的上下文。
二、异常还是返回值:别走极端
- 异常留给「非常规路径」:流程上的正常分支(比如"查不到记录")用返回值表达,别用异常当控制流——可读性与性能都差
- 语言生态有差异,团队约定要统一:Go 用错误值、Java 有受检异常、JS 用 rejected Promise——关键是同一代码库风格一致,评审能对标
- 边界处转换:对外接口把内部异常翻译成"错误码 + 用户话术",内部保留技术细节——两层信息在边界汇合(见第四节)
三、错误传播:加一层上下文,别丢现场
最常见的翻车模式:底层抛出「connection refused」,中间层 catch 后重新抛一个「操作失败」,原始信息全丢。正确做法是包装而不是替换——每一层补上"在做什么、关键参数是什么",错误链一路带到日志里:
# 每层补上下文,保留原始错误链(示意)
try:
charge(order_id, amount)
except PaymentError as e:
raise OrderSubmitError(
f"submit order {order_id} failed: charge {amount}",
cause=e, # 保留原始错误,别丢
)
同时把所有环节串起来的关键字段是追踪 ID:请求进来生成一个 request_id,日志、错误响应、下游调用全带着它——用户报障时报这个 ID,你能直接跳到那一次请求的完整现场(呼应可观测性一篇的链路设计)。
四、两层信息:给用户的和给开发者的
同一次失败,要产出两份表述:
- 用户层:人话 + 下一步动作——「验证码已过期,请重新获取」「余额不足,请更换支付方式」,而不是「错误码 50012」
- 开发者层:稳定错误码 + 上下文 + 追踪 ID——可粘贴进工单,可过滤搜索,可写进文档
错误码的设计纪律:稳定(发布后不改语义)、可枚举(不随意新增)、可文档化(每个码对应处理建议)。另外永远记住:堆栈、SQL、内部主机名不进用户可见的响应——那是攻击者的礼物(呼应 API 设计篇的错误体设计)。
五、可重试与降级:把「失败后怎么办」写进设计
- 重试前提是幂等:只重试可重试的错误(超时、5xx、限流),用指数退避加抖动;非幂等操作先上幂等键(呼应 MQ 与 API 两篇)
- 降级要有兜底内容:推荐服务挂了返回热门榜单、搜索超时返回缓存结果——"降级"必须定义"降到什么"
- 超时预算:每层都设超时,避免级联等待把线程池拖干;上游的超时应当小于下游之和
- 对用户的话术:不确定时宁可承诺"稍后自动重试",也别让用户手动反复提交——重复提交是事故放大器
六、边界与氛围:panic 留给「不可恢复」
- 顶层统一兜底:全局异常处理器负责——记录完整上下文 → 返回友好错误 → 触发告警,三件事一次做完
- 后台任务单独处理:定时任务、消费者没有用户会"看到"错误,必须主动告警(呼应 MQ 篇的死信设计)
- 别吞异常:空 catch 是事故温床——至少记录;"先 catch 住以后再说"的代码,永远不会有人回来说
七、错误与告警、复盘的联动
错误日志的级别就是它的用途(呼应可观测性篇):ERROR 必须是"需要人行动"的事件,用户输错验证码是 WARN 甚至 INFO;同类错误要聚合去重,否则告警风暴会把值班同学练成"不看党"。最后,把「这次事故的错误信息够不够定位问题」写进复盘检查项——错误质量本身也需要迭代。
八、五个常见坑
- 空 catch / 吞错误:出了问题零线索,等于把 bug 埋进时间胶囊
- 错误信息没有上下文:「操作失败」「系统错误」——开发者看了也不知道从哪查
- 把技术细节暴露给用户:堆栈、SQL、内网地址进了响应——既吓用户又送情报
- 用异常做正常流程:把"未找到"当异常抛——流程分支混乱,性能也受影响
- 所有错误都重试:不可恢复的错误反复重试,把一次故障放大成雪崩
速查卡
| 场景 | 做法 |
|---|---|
| 分不清错误类型 | 先问:重试有意义吗?用户能修正吗? |
| 向上传播错误 | 包装加上下文,保留原始错误链 |
| 设计错误响应 | 用户话术 + 稳定错误码 + 追踪 ID |
| 外部依赖失败 | 超时 + 回退重试 + 降级兜底内容 |
| 后台任务出错 | 主动告警 + 死信处理,别等用户发现 |
| 日志与告警 | ERROR 只留需要行动的;同类聚合去重 |
写在最后
错误设计的标准只有一个:这条报错,能不能让下一个人更快地解决问题——用户看到它可以决定下一步动作,开发者看到它可以定位到代码与请求。把"报错"当成一个产品来设计,事故的修复时间会以肉眼可见的速度缩短。
评论(0)