2.20

View in English

2.20 错误处理与韧性模式

概述与动机

你写的每一个程序都会失败。磁盘会写满,网络会断开,服务会超时,调用方会传入垃圾数据,某个依赖项会返回文档中从未提及的内容。问题从来不在于失败是否会发生,而在于你的代码是带着预案去应对失败,还是被它打个措手不及。错误处理这门手艺,就是逐行、逐函数地决定:当外部世界不配合的时候,你的代码该做什么。这是软件构建中最不光鲜的部分,却也是最能决定人们是否信任你系统的部分,胜过任何一项功能特性。

本章讨论的是代码和组件层面的韧性:函数内部、模块内部或 API 内部的种种选择。它与第 3.5 章相辅相成,后者讨论的是系统层面的韧性(负载均衡、复制、跨服务的故障转移)。第 3.5 章让整个平台在某个区域宕掉时仍能屹立不倒;本章则让单个请求不至于损坏你的数据,或者凭空消失、不留痕迹。二者相互加强:如果架构中的断路器背后的代码会吞掉异常,那这个断路器就没什么意义;而如果周边系统没有冗余,一个防御性的函数也救不了你。本章同样建立在第 2.9 章(软件构建)的基础之上()在那一章中,错误处理只是众多纪律中的一项;而在这一章,它则是全部的主题。

对大型团队而言,一致性才是真正的奖赏。当数百名工程师用数百种不同的方式处理错误时,每一个服务都会变成一道谜题,每一次事件都会变成一场考古发掘。在企业场景中,这种不一致会抬高每一次审计和每一次集成的成本。而在政府部门和其他高风险系统中,赌注更为尖锐:正确性、安全的失败方式,以及清晰的审计轨迹,不是可以事后添加的功能,而是系统从第一次提交起就必须具备的属性。一个默默算错福利金额的系统,或者一个丢失了失败记录、却从未记录日志的档案系统,不仅仅是有缺陷()它是以一种会侵蚀其背后机构信誉的方式变得不可信赖。

关键原则

  • 要区分错误(error)、缺陷(fault)和失败(failure),并在恰当的层次上处理各自的情况。
  • 要有意识地根据具体场景选择快速失败还是安全失败,绝不能随意为之。
  • 让每个函数和每个 API 的错误处理契约明确且诚实。
  • 在边界处进行验证;在边界内保持信任;防御要有度,不要偏执。
  • 绝不要悄悄吞掉一个错误;要么让它显现出来,要么包装它,要么有意识地处理它。
  • 用幂等性、超时、退避和抖动,让重试变得安全。
  • 给错误路径投入与正常路径同等的设计关注。

建议

区分错误、缺陷和失败

含糊的词汇会导致含糊的处理方式,所以要从清晰的术语开始。缺陷(fault)是系统中的一个瑕疵:一个 bug、一项错误的配置、一个宕掉的依赖项。错误(error)是缺陷所导致的不正确的内部状态:本该有值的地方出现了空值,一个不再能够对平的账户余额。失败(failure)是外部观察者所看到的结果:请求返回了错误的答案,或者根本没有返回答案。一个缺陷可能导致许多个错误,而许多个错误可能在变成可见的失败之前就已经被捕获。错误处理的全部意义,就在于打断这条链条()在错误变成用户或审计人员所经历的失败之前,把它捕获住。

这套词汇同时也告诉你该在哪里采取行动。缺陷要在评审、测试和配置环节中加以解决。错误要在运行时通过本章中的各种模式加以解决。失败则要通过第 9.2 章的可观测性和第 3.5 章的系统级韧性来解决。当你的团队共用这套词汇时,事件复盘会变得更加锐利:你可以准确地指出这条链条本该在哪里被打断、而实际上没有被打断,而不是纠结于“这个 bug”到底是什么。

根据具体场景选择快速失败还是安全失败

快速失败意味着一旦发现问题就立即停止,拒绝在错误状态下继续运行,让问题在靠近其成因的地方响亮地暴露出来。安全失败则意味着降级到一个已知的、无害的状态,并继续提供你能够安全提供的服务。二者都不是放之四海而皆准的正确答案,关键的技艺在于依据具体场景做出选择。在开发阶段和内部边界处,快速失败是你的朋友:一个在违反不变量时立即停止的程序,会交给你一段简短的堆栈跟踪,而不是一个漫长的谜团。而在生产环境中,在面向用户的系统边缘处,安全失败往往更胜一筹:一个什么都不返回的推荐面板,好过一个完全加载不出来的结账页面。

要为每一个边界有意识地做出这个决定,并把这个决定写下来。飞行控制或医疗设备中的组件会安全失败到一个预先定义好的状态,因为在损坏的数据上继续运行可能会伤害到人。而账本记账操作会快速失败,因为记下一笔错误的分录比什么都不记还要糟糕。搭配错了,无论朝哪个方向都很危险:在本该快速失败的地方采用了安全失败,会把损坏掩盖起来;而在本该安全失败的地方采用了快速失败,则会把一个无关痛痒的小故障变成一场服务中断。

选定你的错误信号机制,并保持一致地使用它

编程语言提供了两大类信号机制,用来表明出了问题。异常处理会把一个对象向上抛出调用栈,直到某个处理程序捕获它为止,从而把错误路径与主逻辑分离开来。另一种方式是显式的错误值:函数同时返回一个结果和一个错误,调用方必须检查这两者。许多现代语言把后者正式化为一种 Result 类型,通常称为 Result 或 Either,它强制调用方在使用值之前先解出成功或失败的结果。每种方式都有其代价。异常能让正常路径保持干净,但可能隐藏控制流,并诱使开发者写出会抹去信息的“一网打尽”式捕获块。显式的结果让每一种失败都在类型签名中可见,但增加了仪式化的代码,而且如果语言不强制检查,它们也可能被忽略。

真正的答案与其说取决于选择哪种机制,不如说取决于一致性和诚实性。选定你所用语言和生态系统偏好的惯用法,并在你的各个服务中统一应用它,让读者总能明确知道失败是如何传播的。把异常留给真正意外的情况,而不是像“用户未找到”这类应当被建模为普通结果的常规控制流。无论你选择哪种方式,都绝不能让失败变得不可见:一个未经检查的错误值,和一个空的捕获块一样危险。在大型代码库中,一份书面约定加上一个能标记出被忽略错误的代码检查工具,胜过任何个人的偏好。

让错误处理契约变得明确

每一个函数、每一个 API 都有一份错误处理契约,无论有没有人把它写下来。它回答的问题是:这里可能出什么问题,你将如何得知,以及一旦出问题,对状态有什么保证?要把这份契约明确写出来。记录一个函数可能返回或抛出哪些错误,区分可恢复的错误(调用方可以合理地重试或回退)与不可恢复的错误(调用方无法修复这个问题,应当向上传播或中止),并说明该函数在失败时是否让状态保持不变。这最后一项属性,有时被称为强异常保证,意味着一次失败的调用就如同从未发生过一样,而这正是让调用方能够安全重试的关键所在。

对于一个公开的或跨团队使用的 API 而言,这份契约是接口的一部分,与参数类型同样真实。要设计一套小而稳定的错误分类体系:一组有限的类别,比如校验错误、未找到、冲突、未授权、依赖项不可用,以及内部错误。这样调用方就可以依据类别来分支处理,而不必去解析字符串。一套清晰的分类体系,让错误处理能够在众多服务之间组合起来,也让失败变得可审计,因为每一次失败都能映射到一个已知的、具名的种类。

在边界处验证,防御要有度、不要偏执

把跨越信任边界的数据(一次网络请求、一个文件、用户输入、来自另一个服务的消息)当作不可信的,直到经过验证为止,并且要在边界处彻底地验证一次。这就是带着判断力去应用防御性编程。在一个输入已经经过验证的模块内部,每一行都做冗余检查,只会掩盖逻辑,并压制住那些你其实想要看到的失败。这里的纪律是:在边缘处严格防御,在边缘之内彼此信任。在数据进入的地方验证结构、取值范围和不变量,把它转换成让非法状态无法表示的类型,然后让内部代码放心地假设自己处理的是干净的数据。

偏执是有真实代价的。被空值检查和防御性分支层层包裹的代码更难阅读,更糟的是,它常常把一个明确的失败变成一次无声的耸肩,在本该拉响警报的地方返回了一个默认值。那种掩盖 bug 的防御性,不是安全,而是拖延。

让重试变得安全、有边界、且有分寸

许多缺陷都是瞬时性的:一次短暂的网络抖动、一个正在重启的服务、一次短暂的锁竞争。在任何分布式系统(第 3.3 章)中,这类局部失败是常态,而不是例外。重试是自然而然的反应,但一个粗率的重试循环却像一把上了膛的枪。首先,要让你所重试的操作具备幂等性,也就是说执行它两次的效果与执行一次相同。没有幂等性,一次超时之后的重试可能会对一张卡重复扣款两次,或者创建两条记录,因为你无法判断究竟是第一次尝试失败了,还是仅仅是它的确认消息丢失了。为写操作使用幂等键,这样接收方就能识别并去除重复请求。

其次,要为每一次远程调用设置超时,这样一个挂起的依赖项就不会把你也一起拖入挂起状态。第三,用指数退避来间隔重试,每次尝试后把等待时间翻倍,并加入抖动(一段小小的随机延迟),这样成千上万个客户端不会在同一时刻恢复、同步地涌向那个正在恢复的服务,把它再次冲垮。第四,为重试次数和总耗时设置上限,超出后就优雅地放弃。没有上限、退避、抖动和幂等性的重试,是一次小故障演变成一场自作自受的服务中断的最常见方式之一。

在代码中加入断路器、隔离舱,以及优雅降级

当一个依赖项确实已经宕掉时,继续重试只会白费力气,还会把这个坑挖得更深。断路器监视对某个依赖项的调用失败率,一旦失败率超过某个阈值,就会“断开”,在一段冷却期内立即让调用失败,而不是继续等待注定会失败的调用。冷却期结束后,它会放一次试探性调用通过,如果依赖项已经恢复,就重新闭合。这既保护了你的调用方(得到的是快速、可预期的失败,而不是堆积如山的超时),也保护了那个挣扎中的依赖项(获得喘息、恢复的空间)。隔离舱模式,得名于轮船的水密舱室,用来隔离资源,防止某一个饱和的依赖项耗尽所有线程或连接,把整个进程一起拖沉;你为每一个依赖项分配各自有边界的资源池。

这些模式与代码层面的优雅降级相辅相成:当一个非必要的依赖项不可用时,返回一个功能受限但仍然有用的结果,而不是返回一个错误。展示带有陈旧标记的缓存数据,隐藏个性化推荐面板,把写操作排入队列稍后处理。这正是第 3.5 章系统层面韧性在本地层面的补充:架构提供跨机器的冗余,而你的代码在某个部件缺失时提供合理的行为。

用上下文包装错误,绝不吞掉它们

一个在发生地点十层之外读到的“connection refused”几乎毫无用处。随着错误的传播,要用上下文包装它:你当时正在尝试做什么、涉及哪个实体或请求、涉及哪个依赖项,同时保留原始的成因,以免根源丢失。优秀的语言和库都直接支持这种错误链式包装。目标是让值班工程师仅凭一行日志,就能知道是什么失败了、发生在什么操作期间、针对的是什么输入。这正是第 9.2 章可观测性和第 2.15 章调试工作的原始素材。

最不可饶恕的罪过是吞掉一个错误:一个空的捕获块、一个被忽略的返回值、一个只在调试级别记录日志、然后就当什么都没发生一样继续执行的 catch。被吞掉的错误不会凭空消失,它会在之后以损坏的数据或莫名其妙的缺陷形式重新浮现,而此时它已经与最初的成因脱节。每一个错误都必须面对以下三种命运之一:处理它(恢复或降级)、包装并向上传播它,或者在调用栈的最顶层带着完整的上下文记录日志并失败。如果你捕获了一个错误,却以上三种都没做,那你实际上是选择了向未来的自己隐瞒一次即将发生的事件。

权衡:优缺点对比

方法优点缺点
异常正常路径干净;未检查时很难被忽略隐藏控制流;诱使人写出“一网打尽”式的抹除操作
显式错误值/Result 类型失败在类型签名中可见;强制处理仪式化代码更多;若无强制机制可能被忽略
快速失败让 bug 在靠近成因的地方响亮暴露用在边缘处会造成糟糕的用户体验
安全失败持续提供服务;保护用户和数据若用在本该快速失败的地方,可能掩盖损坏
带退避的重试自动扛过瞬时性故障若无幂等性,会放大负载并造成重复写入
断路器快速失败;让依赖项得以恢复增加了状态和调优工作;可能掩盖一个持续存在的问题
边界处的防御性验证及早、一次性、响亮地捕获坏数据用力过猛会让逻辑变得杂乱,掩盖真正的失败

其中核心的张力在于可见性与噪声之间的取舍。处理错误太过安静,问题就会一直藏着,直到代价高昂时才被发现;处理错误太过响亮、处处如此,则会用仪式化的代码淹没真正的信号,掩盖住真正重要的失败。解决之道在于位置和意图。在边界处()坏数据和依赖项故障进入的地方()要响亮而严格。在内部()输入已经是干净的地方()要安静而信任。为每一个边界决定快速失败还是安全失败,并把它写下来。目标是让每一次失败都恰好有一个明确的归属人和一个明确的归宿,不让任何东西无声无息地从缝隙中溜走。

与团队讨论的问题

  1. 我们的各个服务是否共用同一套错误分类体系和错误处理约定,还是每个团队各自即兴发挥? 在大型团队中,这正是能够组合起来的失败与令人困惑的失败之间的分野。当一个服务对校验问题返回 HTTP 500,另一个服务抛出一个带类型的异常,第三个服务返回一个空值时,每一次集成都会变成一场谈判,每一次事件都会变成一场翻译练习。找出同一种逻辑失败()比如“记录未找到”()在你三个服务中分别是如何呈现的,看看它们的信号方式有多大差异。答案应当变成一份书面标准:一组有限的错误类别、一种一致的信号表示方式,以及一个强制执行它的代码检查工具或评审清单。这里的一致性会在未来的每一次集成、每一次审计和每一次值班中带来回报。

  2. 对每一个关键边界,我们是否已经有意识地选择了快速失败还是安全失败?代码是否与这个选择一致? 大多数团队从未明确做出过这个决定,这意味着它是由最先写代码的那个人替他们决定的,而且做法不一致。这里存在真实的相互竞争的考虑:安全失败能让用户持续得到服务,却可能让损坏扩散;而快速失败能保护数据,却可能把一次轻微的依赖项中断变成一次可见的故障。找出你的事件历史,对其中最严重的几起问一问,代码的失败方式是否与你事先被问及时本会选择的方式一致。你想要的证据,是一张标注了每个边界、且每个标注都是有意为之的地图()尤其是任何涉及资金、安全或公民记录的地方。凡是标注与代码不一致的地方,就是你下一个要修复的问题。

  3. 我们上一次有意演练某条错误路径是什么时候?它的表现是否符合设计预期? 错误路径通常是你所拥有的代码中测试最少的部分,然而信任正是在这里赢得或失去的,如果你从未亲眼见过它的表现,“我们能够安全失败”就是一句无法验证的空话。一个没有幂等性的重试循环、一个阈值设错了的断路器、一个隐藏在很少触发的分支中的被吞掉的异常:这些都会一直潜伏,直到一次真实的事件替你把它们找出来。找出你有意向一个真实环境中注入故障(杀掉一个依赖项、人为制造一次超时、构造一个格式错误的负载)的结果。接下来要采取的行动,是把故障注入变成常规做法,让恢复、降级和安全失败的行为得到持续验证,而不是只能寄希望于此。任何你从未触发过的错误路径,都是一个从未经过测试的承诺。

  4. 我们的哪些写操作是幂等的?在哪些地方,一次确认消息丢失后的重试,会重复产生一次真实世界中的效果,比如一笔付款或一条记录? 重试是最常见的韧性本能反应,如果处理不当,也是一次瞬时性小故障演变成重复扣款或重复数据的最常见方式。在大型团队中,重试逻辑常常同时存在于共享客户端、中间件和各个服务之中,因此一次写操作可能在多个层次上被重试,却没有任何人对整体行为负责。相互竞争的拉力在于:幂等键、去重机制和已存储的请求结果,会增加存储和代码量,而处在交付压力下的团队常常会对那些他们错误地以为是安全的写操作跳过这些机制。找出你所有对外可见的写操作清单,逐一标注它是否携带幂等键,以及接收方如何识别并去除重复请求。在企业和政府场景中,要优先标出那些会转移资金或变更公民记录的写操作,因为一次重复付款或重复发放的福利,不仅仅是一个缺陷,而是一项审计发现,有时甚至是一项法律风险。

  5. 我们的超时、断路器和隔离舱,是来自同一个共享、经过测试的库,还是每个团队各自手工实现? 这些模式说起来容易,做起来却容易出现微妙的错误:一个缺失的超时、一个永远不会触发的断路器阈值、一个连接池的大小设置不当,导致一个缓慢的依赖项就能耗尽整个进程的资源。当每个团队都各自重新实现这些模式时,你就积累了许多略有瑕疵的副本,而一旦发现某个缺陷,也没有一个单一的地方可以去修复它。与之相互竞争的考虑是,一个共享库会强加一套统一的接口和升级节奏,而那些运行时环境特殊或延迟需求特殊的团队,可能会感到束手束脚,或者干脆绕开它。找出生产环境中实际运行着多少种不同的重试和断路器实现,以及哪些服务的出站调用完全没有设置超时。对大型企业或政府机构而言,一个经过审核的共享库,还能让安全评审人员和审计人员只需认证一个组件,而不是几十个,从而降低每一次评审的成本。

  6. 如果昨晚发生了一次事件,任何一位值班工程师能否仅凭一行日志就追溯到问题所在?审计人员日后能否看到系统记录下的每一次失败? 一个被包装、分类、记录良好的错误,是十分钟内完成诊断,和一场午夜考古发掘之间的区别,而一个被吞掉的错误则是你自己隐瞒给自己的一次未来事件。在大型团队中,失败会跨越许多次服务跳转,因此价值来自于能够在这些跳转中存活下来的一致上下文和关联标识符,而不是来自任何一个团队的自律。相互竞争的张力在于成本和噪声:记录一切会淹没信号,并且要为存储付出代价;记录太少又无法重建当时发生了什么。找出一次真实的近期故障,把它的轨迹端到端地走一遍,记下每一处上下文被丢失、或错误被捕获后又被丢弃的跳转点。在受监管的系统和政府系统中,要把这当作一项合规属性来对待,因为一次无法审计的失败,或者一项数年后仍无法解释的决策,是一项法律风险,而不仅仅是一个运维上的缺口。

行业视角

初创企业。 在只有少数几名工程师、又没有多少余裕的情况下,要把错误处理的预算花在一次失败会让你失去客户或数据的地方:为每一次出站调用设置超时,让涉及资金流转的写操作具备幂等性,并添加一条针对被忽略错误的代码检查规则。跳过精心设计的框架;为核心函数使用一个 Result 类型,并对非关键依赖项做优雅降级,只需几天的工作量就能买到大部分的安全性。在开发环境中快速失败,让 bug 响亮地暴露出来,并且在你真正拥有一个值得使用断路器的依赖项之前,不要急于手工实现一个断路器。

小型企业。 在没有韧性专家、预算又紧张的情况下,要依靠你所用的语言、框架和云服务商已经提供的能力,而不是从零开始构建这些模式:托管队列、服务商侧的重试机制,以及库自带的超时功能,覆盖的范围往往比大多数团队预期的要多。把决策框定为“购买还是自建”,只要有一个成熟的依赖项能替你处理重试、退避和幂等性,就选择购买。把你稀缺的关注力集中在那一两个边界上()在那里,一笔错误或丢失的交易真的会造成实质性伤害()确保它们能够安全失败,并留下痕迹。

企业。 在众多团队之间,真正的奖赏是一致性:一套共享的错误分类体系,一个提供超时、重试、断路器和隔离舱的通用库,以及一个在流水线中强制执行这些机制的代码检查工具和评审清单。把每一个错误都接入一个统一的可观测性平台,并带上关联标识符,让一次失败可以跨服务跳转被追溯;并为每个边界统一快速失败与安全失败的决策,这样审计时找到的会是一种有文档记录、经得起推敲的模式,而不是一堆各自为政的局部习惯。要把这个共享库当作一款真正的产品来治理,因为在那里修复一次缺陷,就是在所有地方都修复了这个缺陷。

政府。 正确性、安全的失败方式,以及经久可靠的审计轨迹,是义务,而不是可有可无的偏好。对任何涉及资金或资格认定的、被违反的不变量要快速失败,在边界处验证每一项面向公民的输入,并把每一次失败写入一份不可变的日志,附带足够的上下文,使一项决策能够在数年后仍可被解释和审查。采购流程和漫长的系统生命周期,意味着错误契约必须被记录下来,这样公务员才能在原作者早已离开之后,依然安全地维护这些代码,而任何供应商提供的组件,都必须公开其失败行为,而不能把它隐藏在一个不透明的接口背后。

示例

初创企业。 一家四人组成的初创公司发布了一款应用,调用第三方支付提供商和一项邮件服务。早期,他们添加了一个简单粗糙的重试循环,很快就因为一次超时掩盖了一次实际已成功的扣款,而对某位客户重复扣款。这次修复给他们上了一课:他们为每一次写操作添加了幂等键,为每一次出站调用设置了超时,并改用带抖动的指数退避。他们为核心服务函数采用了 Result 类型,让失败在签名中显现出来,并用一条代码检查规则标记出任何被忽略的错误。当邮件发送失败时,结账流程会通过把消息排入队列来优雅地降级,而不是阻塞整个销售流程。这份纪律花费了几天时间,却让他们避免了一整类原本会在退款和信任方面付出远为高昂代价的事件。

企业。 一家全球物流公司运行着数百个服务,并在所有服务中统一了错误处理方式。每个服务都把失败映射到一套共享的分类体系(校验错误、未找到、冲突、依赖项不可用、内部错误),因此调用方依据类别进行分支处理,而不必解析消息文本。一个通用库提供了断路器、带退避和抖动的有边界重试,以及带隔离舱的连接池,因此没有人会手工实现出错误的版本。每一个错误都带着关联上下文被记录下来,接入第 9.2 章所述的可观测性平台,因此值班工程师仅凭一行日志,就能跨服务跳转追溯一次失败。由于这套标准是统一的、并在流水线中被强制执行,工程师们可以自信地在陌生的服务之间穿梭工作,审计人员也能看到每一次失败都被记录、分类,并且可以追溯。

政府。 某国家级福利机构正在构建一套资格认定和支付系统,在这套系统中,一个错误的答案可能导致某人拿不到房租补助,或者从公共资金中被多支付。正确性和安全的失败方式是不可协商的,因此代码会在任何被违反的财务不变量上快速失败:一项无法对平的计算会拒绝入账,而不是记下一个错误的数字。每一项面向公民的输入都在边界处经过验证,非法状态在领域类型中被设计为无法表示。每一次失败都会带着完整的上下文被写入一份不可变的审计日志,满足决策必须在数年后仍可解释、可审查的法律要求。当一个非关键依赖项(比如文档预览功能)宕机时,系统会优雅降级,让理赔专员依然能够处理申请。新入职的公务员所接手的代码,其错误契约都有文档记录,因此即使在原作者早已离职多年之后,他们依然能够安全地维护这些代码。

商业论证:动机、投资回报率与总拥有成本

有纪律的错误处理带来的回报,体现为更少的事件、更短的事件持续时间,以及更低的事件成本。大多数生产环境的服务中断都不是什么离奇的事情,它们通常可以追溯到一个被吞掉的异常、一个缺失的超时、一场重试风暴,或者一个信任了本该验证的数据的边界。每一种都可以通过本章的模式加以预防,而每一次被预防的事件,节省下来的不仅是宕机的直接成本,还有应急响应、客户流失和调查工作所带来的复合成本。因为一个被包装、记录良好的错误可以在几分钟而不是几小时内被诊断出来,平均恢复时间会随之下降,变更失败率也会随之下降()因为工程师们不再害怕面对错误路径。

采用它的成本不高,而且大多是一次性的。你需要写下一套错误分类体系,提供一个用于重试和断路器的共享库,让团队不必各自重新发明一个有缺陷的版本,添加针对被忽略错误的代码检查规则,并培养起故障注入的习惯。而疏忽这一切的代价,则会悄悄地累积:被吞掉的错误会逐渐积累成难以理清的损坏数据,不一致的处理方式则会让每一次集成、每一次审计的成本成倍增加。在受监管的和政府场景中,一次无法审计的失败不仅仅是一个工程问题,还是一项合规和法律风险。要向领导层说明这一点,就把错误处理的纪律,与他们已经在关注的各项指标联系起来:事件发生频率、平均恢复时间、变更失败率,以及审计发现。

反模式与常见陷阱

  • 无声吞噬: 空的捕获块和被忽略的返回值,把一次失败变成一个延迟出现、且已脱节的谜团。
  • 一网打尽式抹除: 一个宽泛的 catch,只记录一条通用消息,并丢弃了原始的错误及其上下文。
  • 没有幂等性的重试: 在超时之后重新执行非幂等的写操作,导致重复扣款或重复记录。
  • 重试风暴: 没有退避、没有抖动、也没有上限,导致客户端同步涌入,把正在恢复的依赖项再次砸垮。
  • 没有超时: 不设边界的远程调用,让一个挂起的依赖项耗尽所有线程,冻结整个进程。
  • 把异常当作控制流: 为“未找到”这类普通结果去抛出和捕获异常,掩盖了逻辑,也拖慢了代码。
  • 防御性偏执: 每一行都加上检查,把逻辑埋没起来,把真正的失败变成无声的默认值。
  • 字符串式类型的错误: 调用方靠解析错误消息文本来判断,因为没有一套稳定、分类的体系可供分支判断。
  • 在本该快速失败的地方采用了安全失败: 在一个错误答案比没有答案更糟糕的系统中,继续在损坏的状态上运行。

成熟度模型

  • 第 1 级,启动: 错误处理是临时性的、被动式的,由每个开发者自行决定。空的捕获块和被忽略的返回值很常见,重试做法粗糙,超时缺失,失败以损坏的数据或莫名其妙的缺陷形式显现出来,没有一致的日志记录。
  • 第 2 级,发展: 团队开始采用一些基本实践,但并不一致。错误被记录时带有一定的上下文,明显的吞噬行为在评审中会被劝阻,超时和简单的重试机制已经存在,但各服务之间的约定不一,幂等性时有时无,错误路径也很少被测试。
  • 第 3 级,标准化: 一套共享的错误分类体系和处理约定被记录下来,并在整个组织中得到强制执行。边界验证、带退避和抖动的幂等重试、断路器、隔离舱和错误包装,都成为标准做法,由通用库提供支持,每一个错误都接入一条统一的可观测性流水线。
  • 第 4 级,管理: 错误处理行为依据基线进行度量,并依靠数据加以管控。重试率、断路器触发次数、超时次数、静态分析发现的被吞噬错误、平均恢复时间和变更失败率,都按服务进行追踪;断路器的阈值和超时设置依据观测到的延迟和失败数据进行调优,而不是靠猜测;故障注入按计划定期运行;团队会审视这些指标,以捕捉回退,并要求每一个快速失败或安全失败的选择都有证据支撑。
  • 第 5 级,协同: 韧性与交付和风险规划相互集成,并持续改进。这套分类体系、共享库和标准,从每一次事件中不断演进;混沌实验和故障注入实验成为常规做法;组织随着流量、依赖项和风险状况的变化,不断调整超时时长、断路器阈值、降级策略和边界决策。

讨论思路

  1. 在你的代码库中,哪里的错误目前正在被吞掉?如果你的判断是错的(其实并没有被吞掉),你要怎样才能知道?
  2. 你的哪些写操作是幂等的?如果一次确认消息丢失后触发了重试,哪些操作会被重复执行?
  3. “用户未找到”应该是一个异常、一个错误值,还是一个正常结果?你的团队对这个问题的回答是否一致?
  4. 你关于验证应该发生在哪里的实际规则是什么?你能否指出一个信任了本不该信任的数据的边界?
  5. 你如何决定一个断路器的阈值和冷却时间?你要怎样才能知道当前的设置是错的?
  6. 如果一位审计人员要求查看你的系统上个月经历的每一次失败,你能否拿出来,并且是分类清楚、带有上下文的?

要点总结

  • 区分缺陷、错误和失败,并在一个内部错误变成可见的失败之前,打断这条链条。
  • 为每一个边界有意识地选择快速失败还是安全失败,并让每个函数的错误处理契约变得明确。
  • 在信任边界处严格验证,在边界内彼此信任;掩盖失败的防御性不是安全,而是拖延。
  • 用幂等性、超时、指数退避和抖动,让重试变得安全,并在代码中加入断路器和优雅降级。
  • 用上下文包装错误,把它们输送给可观测性系统,绝不吞掉它们;每一个错误都必须被处理、传播,或者被记录并暴露出来。

参考文献与延伸阅读

  • Michael T. Nygard 著,Release It! Design and Deploy Production-Ready Software
  • Andrew Hunt 和 David Thomas 合著,The Pragmatic Programmer
  • Steve McConnell 著,Code Complete: A Practical Handbook of Software Construction
  • Betsy Beyer、Chris Jones、Jennifer Petoff 和 Niall Richard Murphy(编),Site Reliability Engineering: How Google Runs Production Systems
  • Marc Brooker,“Timeouts, Retries, and Backoff with Jitter”,Amazon Builders’ Library
  • Martin Fowler,“CircuitBreaker”,martinfowler.com
  • Nassim Nicholas Taleb 著,Antifragile: Things That Gain from Disorder