调了一个支付接口,返回 "timeout"。

到底成功了没有?重试一次?还是先查一下?查的接口也超时了。现在怎么办?

代码里没有告诉你怎么办。因为错误处理就写了 log.Error(err); return err。日志里多了一行,调用方拿到的也是一个 error——至于这个 error 意味着什么、下一步该干什么,没人知道。日志是写给三个月后排查问题的人看的,但三个月后那个人对着日志也判断不出这笔钱到底扣了没有。

很多系统不是因为 happy path 写得差出问题。是因为失败路径像临时搭的棚子——该重试的没重试,该补偿的没补偿,该告警的静默,该停下来的继续往前冲。每个错误处理决策单独看都合理——"打一行日志,把 error 返回去"——但十个这样的决策拼在一起,系统的失败行为是涌现出来的,不是设计出来的。

错误处理不是 if err != nil 后面写什么。它是一个系统在面对失败时,如何保持可预测、可恢复、可解释的能力。这是一个架构决策。

错误不只有两种

大多数编程语言把错误建模成一个值——Go 里是一个 error interface,Java 里是一个 Exception 对象,Python 里是一个 raise。这种建模方式让人产生一种错觉:错误就是"成功了"和"失败了"两种状态,失败就是失败,没区别。

但分布式系统里的失败不是二元的。

"超时"不是失败——是不知道成功没成功。下游可能已经处理完了,只是响应没回来;也可能根本没收到请求。重试?可能重复扣款。不重试?可能钱没付出去。"不知道"是第三种状态,但我们的错误模型把它塞进了和"连接被拒绝"同一个 error 里。

"部分成功"也不是失败。批量处理了 100 条,23 条写成功了,77 条因为主键冲突跳过了,0 条真的失败了。这个结果到底算成功还是失败?caller 拿到一个 error 还是 nil?

还有"降级可用"——主链路挂了,备链路扛着,功能降了但系统还活着。给调用方返回 error 吗?如果返回 nil,调用方以为一切正常;如果返回 error,调用方可能触发不必要的告警和回滚。

一个 error 字符串承载了所有这些可能性,但没有结构化地表达它们。开发者在每个 catch 块或 if err != nil 后面做的判断——重试?降级?告警?忽略?——依赖的不是系统层面的设计,是个人经验和当时的心情。

错误处理策略不应该是开发者临时拍脑袋的结果,而应该是系统层面有意识的设计。当你写 return err 的时候,你其实是在做一个架构决策——你决定了这个错误的传播范围、恢复策略和可观测性。只是你没有意识到自己在做这个决策。

四个必须回答的问题

一个系统里每个关键操作,在错误处理层面需要回答四个问题。不是代码层面的问题——是设计层面的。

第一,可重试性:这个操作失败后,重试安全吗?

不是所有失败都适合重试。超时可能适合——你不知道是否成功,但下游接口声称幂等,重试是安全的。连接拒绝可能适合——请求根本没发出去,重试不会产生副作用。但"余额不足"重试没有意义,除非用户在这期间充了钱。"订单不存在"重试也没有意义,数据不会自己长出来。

区分 transient 和 permanent 失败,不是在每个调用点做的判断。是接口设计者在定义错误码或错误类型时就应该分类的。调用方不需要理解业务细节就知道该不该重试——这是错误契约的一部分。

第二,可暴露性:谁应该看到这个错误?看到什么?

给用户看"网络超时,请稍后重试",给日志写完整的请求参数、trace id、下游响应体。给调用方只暴露错误码和错误信息,不暴露内部调用链——一个第三方接口挂了,你的 API 返回的应该是"服务暂时不可用",不是"调用 XX 第三方接口返回 connection refused"。后者不仅没用,还泄露了你的系统拓扑。

错误信息的逐层翻译和脱敏,不是安全团队事后审计的 checklist。一个 API 的 error response schema 和它的 success response schema 是同一份契约的两半——只定义成功返回的结构,不定义失败返回的结构,这份契约是不完整的。这是每个服务边界在设计对外接口时就应该回答的问题。

第三,数据一致性:失败后,数据处于什么状态?

写 DB 成功了,发消息失败了——订单在 DB 里是"已支付",但通知没发出去。扣积分成功了,写积分流水失败了——积分已经扣了,但流水里没有这条记录,审计发现问题的时候说不清楚。

这不是"要不要用事务"的问题。跨了资源边界的事务本来就不存在。要回答的是:在这种部分失败的情况下,系统能不能通过重试、补偿或异步对账恢复到一致状态?如果不能,这个失败就不是"错误处理没写好"——是操作本身就不具备容错能力,需要重新设计操作边界。

很多数据不一致的 bug,根因不是代码写错了。是操作拆得太细或太粗,失败时没有恢复路径。这个问题在设计阶段就应该被看到。

第四,可观测性:这个错误应该触发告警吗?什么级别的告警?

不是所有 error 都值得把人叫醒。"用户输入了一个无效参数"不应该告警——返回 400 就完了。"下游超时率从 0.1% 跳到 5%"应该告警,但这靠单个 error log 看不到——需要聚合。"死信队列堆积超过 1000 条"应该告警,但如果你没有死信队列,这个指标就不存在。

告警策略需要在系统层面设计:哪些错误计数、哪些计数需要配阈值、哪些阈值触发什么级别的通知。靠开发者在代码里随手写 log.Error 然后指望运维能在日志里发现异常——这不是策略,是赌运气。

重试不是"再试一次"

把重试想象成"失败了就再调一次",是做不好重试的。重试是一个分布式系统里最容易放大故障的操作。

第一个问题:退避策略。失败后立刻重试,下游大概率还在抖。连续重试 3 次、每次间隔 0 秒,等于在 100ms 内对下游打了 4 个请求。下游如果是因为过载超时,这 4 个请求会让它更慢。指数退避加随机抖动——第一次等 100ms,第二次等 200ms,第三次等 400ms,每次加一个随机偏移——不是为了优雅,是为了不给已经出问题的下游再踹一脚。

第二个问题:重试风暴。上游重试了,上游的上游也重试了,用户的浏览器也重试了。一个下游短暂抖动,每一层都在重试,请求量放大几倍,抖动变成雪崩。这也是为什么重试需要做在整个链路的某几层,而不是每一层。网关可以重试,你的 service 层就别再试了——再试就是叠加。

第三个问题:重试的前提是幂等。一个没有幂等保证的操作加上重试,等于生产 bug 的配方。你重试了支付接口,用户被扣了两次钱——"我以为它第一次没成功"不是理由。幂等不是在重试的时候临时实现的——它必须在操作定义的时候就设计进去。幂等键从哪来?有效期多长?下游是否接受同一个幂等键?这些是接口契约的一部分,不是代码细节。

超时和熔断

有一种 bug 叫"慢了,但没坏"。

下游不返回 error,只是在慢慢地返回。你的服务在等,连接池里的连接被慢慢耗光。上游也在等你,也在慢慢耗光它的连接池。一整条链路都在等那个最慢的下游,没人报错,但系统已经不可用了。

跨进程调用应该有明确的超时。 你调下游要设超时,你的上游调你也要设超时。每一层都应该有一个明确的答案:"我等多久,再等就不值得了"。这个时间不应该由开发者在代码里随便填——它应该基于这个接口的 P99 延迟、业务可接受的响应时间、以及这个失败对整个链路的影响来决定。一个查询历史订单列表的接口超时可以设 2 秒,一个支付回调的接口超时可以设 5 秒——但要有一个理由,不是拍一个数字。

超时设好了,还有另一个问题:如果下游已经确定不可用了(超时率持续高),还要继续发请求吗?

这就是熔断器的逻辑。当失败率超过阈值,熔断器打开,直接快速失败——不浪费下游的资源,也不浪费上游的等待时间。熔断不是惩罚下游。熔断是一种自我保护:"我知道你现在不行了,我不打你了,你缓一缓,我用自己的 fallback。"等一段时间后,熔断器以探测请求的方式试探恢复——半开状态。如果探测成功,恢复全量;如果还失败,继续断开。

这些东西听起来像中间件的配置——加一个 library、配几个参数。但哪个接口需要熔断?阈值设多少?熔断后的 fallback 是什么——返回缓存数据?降级到备链路?直接报错?这些不是单纯的运维配置,也是业务上的架构选择。一个支付接口的熔断策略和一个商品推荐接口的熔断策略可以是完全不同的——前者可能根本不想用同样的快速失败策略,因为用户付不了钱比系统慢更严重。

补偿和 Saga

不是所有操作都能回滚。

发了邮件收不回来。扣了积分没法"撤销"——你只能补回一条新的积分记录。调了第三方 API 没有回滚接口——对方的系统不是你设计的。

这些场景下,"事务回滚"这个直觉不成立。需要的是补偿:一个独立的业务操作,它的效果在业务上抵消了原操作的效果。退款是支付的补偿,不是支付的"回滚"。退货是发货的补偿。补回积分是扣积分的补偿。

补偿有三个容易被忽略的属性。

补偿本身可能失败。扣积分成功了,补回积分的时候积分服务挂了——补偿也需要自己的重试和幂等。补偿操作不是一个简单的反向函数,它有自己独立的失败模式、自己的超时设置、自己的告警条件。

补偿可能是"最终"的,不是"立即"的。发出去的邮件没法收回,只能再发一封更正邮件。用户可能已经看到了第一封——这就是补偿的代价。设计一个操作的时候,如果你知道它的补偿不是立刻生效的,你就知道在用户可见的层面需要额外的处理:状态文案、通知节奏、客服口径。

补偿可能触发新的业务规则。退货不是物理上的"反向发货",它是一个独立的业务流程——验货、退款、入库、财务处理。它的复杂度和正向流程是同级别的。把它当成"出错时调用一下"是严重低估了它的设计成本。

Saga 是把这些补偿操作编排起来的模式:一个长事务拆成多个本地事务,每个本地事务配一个补偿事务。如果中间某一步失败了,按顺序执行之前所有成功步骤的补偿。

但 Saga 引入了一个新的问题:在补偿执行期间,系统的状态对外部可见。用户可能看到订单"已支付"然后又被"退款中"——中间态是暴露的。这是 Saga 的本质代价,不是实现缺陷。选择 Saga 意味着你接受中间态对外可见,并且你要为这个可见性做设计:用户看到什么、什么时候看到、看到之后能做什么。

死信队列的价值

重试了 N 次还是失败,怎么办?

直接扔掉?不行——你不知道这条消息重不重要。人工排查?也可以——但这依赖人及时看到了日志,且日志还没被滚动掉。给调用方返回 error?如果对方是异步的,它可能已经把请求扔掉了。

死信队列承认了一件事:有些失败需要人工判断。 不是所有问题都能在代码里处理完。设计良好的死信队列把"我们处理不了"变成"我们把它放在一边,等人来处理"——这是一个架构决策,不是承认失败。

死信队列的价值不止于"存着"。好的死信队列应该能回答:这条消息失败了多少次、每次失败的 error 是什么、这条消息现在是什么状态(待处理 / 已手动重放 / 已确认丢弃)。然后围绕这些能力建立运维流程:谁关注死信队列?什么级别的堆积需要告警?手动重放的 SOP 是什么?

我见过的系统里,很多没有死信队列不是因为不需要,是没人觉得"失败消息的处理"是需要被设计的。消息失败了就失败了,重试几轮拉倒。当你问"这些失败消息去哪了"的时候,答案是"不知道"——这本身就是问题。

怎么设计一个系统的错误处理

聊了这么多,回到一个最实际的问题:如果你现在开始设计一个新系统,或者接手一个没有系统化错误处理的系统,从哪里开始?

起点不是写代码。起点是在设计文档里为每个关键接口回答几个问题:

  1. 这个接口如果下游超时了怎么办?重试还是快速失败?重试几次?幂等键是什么?
  2. 如果下游返回了业务错误(不是系统错误)怎么办?哪些业务错误应该原样返回给调用方,哪些应该翻译?
  3. 如果写 DB 成功了但发消息失败了怎么办?有没有补偿路径?没有的话,数据不一致被观测到的窗口是多大?
  4. 这个接口的错误需要告警吗?什么级别的告警?单次失败告警还是成功率低于阈值告警?

不是每个接口都需要回答全部问题。一个查配置的接口和一个扣款接口,回答的粒度完全不同。你的单体应用内部函数调用也不需要熔断器——这些策略的引入门槛是跨进程、跨服务边界。但关键接口——支付、下单、退款、发货、积分变动——如果这些操作在设计阶段没有人问过"失败了怎么办",那就不是设计没有完成,是设计根本没开始。

我在看到一个新系统的设计文档时,如果里面只有几张画着成功路径的时序图,会觉得不安。不是因为时序图不好——是因为时序图只画了系统正确工作时的样子。而一个系统的可靠性,不取决于它在 happy path 上跑得多快多顺,取决于失败时它的行为是否仍然在控制和理解范围之内。

错误处理不是代码质量,是设计质量

我刚入行那几年,觉得错误处理是代码细节——属于"代码规范"那一类。变量命名、函数长度、错误处理——都是 code review 检查项。后来变得不那么想了。

不是因为规范不重要。是因为如果你在设计阶段没有把失败当一等公民,code review 救不了你。reviewer 最多能告诉你"这里应该判断 nil"、"这里 error 不应该被吞掉"。他不可能在 review 一个 PR 的时间里,把十几处分散的错误处理决策串联成一个有设计的整体。这不现实。

好的错误处理不是你写了多少 if err != nil。是你走进设计评审的时候,能在白板上画出:这个链路上每一步超时设多长、哪些地方重试、哪些地方熔断、哪些地方走补偿、失败消息去哪、什么情况叫人——而且能说出每一个决策的理由。

Happy path 是入门的考试题,failure path 才是架构的设计题。把错误处理当成架构决策来设计,不只当成代码注释来写。