2.15

查看英文版

2.15 调试与故障排查

概述与动机

调试是查明一个系统为何做出不该做的事情的严谨工作,而故障排查则是把同样的技能用在时间压力下的一个正在运行的生产系统上。两者都是把科学方法应用于缺陷:你观察到一个出人意料的行为,就其成因形成一个假设,设计一个能够证实或证伪它的实验,然后让证据而不是你的直觉来告诉你该改什么。以这种方式来做,调试就是一项可学习、可传授的工程技能。若是当作民间传说来做,它就会沦为迷信:随机改几行代码、重启服务器,然后祈祷。

对于一个大型团队而言,这种差异代价高昂。一个棘手的缺陷可能会牵扯多个服务的工程师、消耗大量待命时间,并拖慢一次发布。当每个人都凭直觉调试时,这些努力不会累积,因为没有人能复现或解释别人尝试过什么。而当团队共享一套方法(先复现、以搜索方式定位、用一个失败的测试捕获缺陷、再修复)时,同样的努力就会变成一个可重复的过程和一个不断壮大的回归测试套件。调试与测试策略(第 2.4 章)、软件质量(第 2.11 章),以及让代码从一开始就可诊断的构建习惯(第 2.9 章)紧密相连。

在企业和政府场景中,风险更高。企业中的缺陷跨越服务和团队边界,因此看到症状的人往往并不是造成原因的人。政府系统还增加了大多数工程师从未遇到过的约束:无法在生产环境中挂接调试器的隔离或受限环境、必须从制品出发进行诊断的可复现构建,以及必须记录你改了什么、为何这样改的审计轨迹。在这三种场景中,目标是一样的:用证据取代猜测。

关键原则

  • 先复现,再理论化。 一个你无法按需触发的缺陷是谣言,不是缺陷。
  • 调试就是假设检验。 先说清楚你相信什么,再设计能以最低成本证明你错了的实验。
  • 先读错误信息和堆栈跟踪。 系统通常在你改动任何一行代码之前,就已经告诉了你它是在哪里出问题的。
  • 对问题空间进行搜索,而不是扫描。 每一步都把嫌疑区域减半,而不是从头到尾逐行阅读。
  • 归约到最小。 不断精简用例,直到只剩下触发缺陷所必需的部分。
  • 一次只改一处。 一次性大范围乱改会摧毁本可以告诉你哪个改动真正起作用的证据。
  • 在修复之前,先用一个失败的测试捕获这个缺陷。 只有当这个测试变绿并保持绿色时,修复才算被证明有效。
  • 找到根本原因,而不是最近的症状。 一个只掩盖症状的补丁,会让缺陷卷土重来。

建议

在改动任何东西之前,先可靠地复现缺陷

你的第一项工作是找到一个可靠的复现方式:一组步骤或一个能够按需触发缺陷的自动化用例。没有它,你就无法把真正的修复与巧合区分开来,因为症状可能会因为你从未掌控的原因而时隐时现。锁定输入、环境、版本和时序。如果缺陷是间歇性的,就去寻找让它出现的那个隐藏变量(某条特定的数据记录、某个时钟边界、某个并发请求),直到复现变得可靠为止。一个可靠的复现方式是调试过程中最有价值的单一产出物,因为在它之后的一切都变得可以度量。

在动代码之前,先读错误信息、日志和堆栈跟踪

在形成任何一个理论之前,先读一读系统已经告诉你的东西。堆栈跟踪(失败发生那一刻调用链的记录)通常会指明出错的文件、行号和调用顺序。异常消息、周围的日志行,以及作用域内的变量值,在你还没做任何改动之前就能缩小搜索范围。工程师们常常在回溯信息第一行就已排除的原因上浪费数小时去做理论推测。把错误输出当作第一位目击证人,仔细完整地读完它,然后再决定要调查什么。

用二分查找的方式对问题空间进行定位

不要从头到尾扫描代码,而要搜索它。使用二分查找:找到一个状态仍然正常的点和一个已经出错的点,然后检查中间点,如此反复,每次都把嫌疑区域减半。这能把一次上千行代码的搜索变成十个问题。当回归问题出现在一段提交历史范围内时,把同样的思路应用到历史记录上,也就是二分定位:git bisect 会遍历提交范围,你只需标记每个版本是好是坏,它就会找出引入该缺陷的那个确切改动。把“好/坏”这个判定测试自动化,二分定位就能自行运行。

归约为一个最小可复现示例

一旦你能触发这个缺陷,就把它缩小。最小可复现示例是仍然会失败的最小输入和代码路径:不断移除数据、功能和步骤,直到再移除任何一点缺陷就会消失为止。归约不是无谓的苦工;你排除的每一个元素,都是一个你已经排除掉的原因,因此最小用例往往会直接指向缺陷所在。当输入很大或结构复杂时,用增量调试来自动化这个精简过程,这是一种系统性地移除失败输入中若干片段、以找出最小失败子集的算法。一个小巧、自包含的复现用例,同时也是交给另一个团队的最佳缺陷报告。

先用日志加以观测,再使用交互式调试器

要让工具与缺陷相匹配。当你需要观察一段时间内、跨进程、或在一个无法暂停的环境中的行为时,日志和针对性的观测手段是最合适的。当你能在本地运行代码,并需要密切观察单次执行过程时,交互式调试器(可以设置断点、逐行单步执行、检查实时状态)是最合适的。要把埋点当作一个与假设相绑定的有意实验来添加,而不是随手散落的打印语句,并在缺陷解决后将其移除,或者把它提升为永久性的结构化日志。在生产环境中,要依靠可观测性驱动的调试方式:高基数事件和分布式追踪(第 9.2 章)能让你跨越多个服务追踪单个请求,这往往是调试一个你无法挂接调试器的分布式系统的唯一方式。

在修复之前,先写一个能捕获该缺陷的失败测试

在写修复代码之前,先写一个因为这个缺陷而失败的测试。这一举三得:它证明你确实理解了原因,它精确地定义了“修复好了”意味着什么,并且它会成为一道永久的守护。然后再做修复,看着测试变绿。这个测试此后作为一道回归测试守护加入你的测试套件,这样同一个缺陷就不会在无人察觉的情况下卷土重来。这项实践把调试直接与你的测试策略(第 2.4 章)联系起来:你解决的每一个棘手缺陷,都会让测试套件比之前更强健,而对不稳定的测试也应采取同样的处理方式(复现不确定性根源,然后加以防护),而不是简单地打上重试标注了事。

找到根本原因,并保持分析的无责性

修复症状不等于修复了缺陷。把故障一路回溯到它真正的源头,在每一层都问一句“为什么”,直到找到一个可以彻底消除而非掩盖的原因。对于已经进入生产环境的缺陷,作为事件管理(第 9.3 章)的一部分,开展一次无责的根本原因分析:把关注点放在让这个缺陷得以发布并存活下来的系统和流程条件上,而绝不针对写下那行代码的个人。追责会把信息逼入地下,而调试依赖的正是信息的畅通。产出既包括一个修复,也包括对这一类缺陷如何在下一次更早被发现的流程改进。

权衡:利弊

方式优点缺点
日志与埋点在生产环境和分布式系统中都可用;能捕获一段时间内的行为噪声、成本和日志膨胀;可能干扰时序类缺陷
交互式调试器精确、可检查实时状态;对本地缺陷速度快在受限或隔离的生产环境中毫无用处;可能掩盖并发缺陷
先复现的纪律把猜测变成度量,为编写失败测试提供基础前期较慢;有些缺陷确实很难触发
二分查找与二分定位即使在不熟悉的代码中也能快速定位需要一个可靠的好/坏判定测试;缺陷相互交织时较难
增量调试式归约能自动把巨大的输入缩小到触发点有搭建成本;假定失败是确定性的
先修复症状在压力下能快速恢复服务让根本原因得以卷土重来;累积技术债

核心张力在于速度与确定性之间的取舍。在一次生产事故中,你可能需要先止血(回滚或打一个症状补丁)以恢复服务,这是合理的。错误在于就此止步。解决这一张力的办法是把两项工作分开:先快速缓解以保护用户,然后再复现、找到根本原因,并在认定该缺陷已解决之前加上回归防护。一个没有后续跟进的症状修复,只是一个你已经同意日后再次相遇的缺陷。

与团队讨论的问题

  1. 当有人遇到一个棘手的缺陷时,他们做的第一件事是什么,是复现还是猜测? 诚实的答案会揭示你的团队是拥有一套共享方法,还是一屋子各自为政的民间传说。请人们大声讲述他们最近一次处理棘手缺陷的经过:他们是先拿到了一个可靠的复现方式,还是直接开始改代码、重启服务?一个先复现的团队可以把缺陷在人与人之间传递,因为复现方式本身是可携带的;一个靠猜测的团队做不到这一点,因为每一次尝试都不可重复。随着团队规模扩大,这一点变得越来越重要,因为看到症状的人越来越不太可能是能修复它的人。如果默认做法是猜测,那就把“先复现”确立为一项规范,并把一次干净的复现设为受理一个缺陷工单的门槛。

  2. 我们修复的缺陷会卷土重来吗?如果它们真的回来了,我们会知道吗? 一个卷土重来的缺陷,就是一个根本原因从未被真正消除、修复也从未被测试守护住的缺陷。翻出上个季度的事故记录和被重新打开的工单,数一数有多少是早先缺陷的重复或近亲。每一次重复都表明团队修补了症状、跳过了失败测试,或者过早地终止了根本原因分析。解决办法是定一条规矩:只要没有一个在旧行为下失败、在新行为下通过并加入测试套件的测试,就不能关闭这个缺陷。带上一个最近反复出现的缺陷,问一问什么样的防护本可以拦住它,因为那个防护正是你所缺失的东西。

  3. 考虑到我们被允许接触生产系统的方式,我们到底能不能调试它们? 在企业、尤其是政府环境中,你往往无法挂接调试器,无法用真实数据复现问题,也无法在没有审计轨迹的情况下改动一个正在运行的系统。如果你唯一的调试手段是本地交互式调试器,那么恰恰在最棘手缺陷所在的地方,你会是两眼一抹黑。问一问一次生产故障实际会留下什么证据:结构化日志、分布式追踪(第 9.2 章)、核心转储,还是可复现的构建制品。现在就决定默认要采集什么,这样未来的事故才是可诊断的,因为你无法为一个已经发生过的故障补加埋点。在受监管场景中,要确认同一份记录轨迹也能满足你的审计义务。

  4. 当一次生产事故迫使我们迅速止血时,我们如何确保事后根本原因仍然能被找到? 在一次事故中,回滚或打一个症状补丁是保护用户的正确第一步,但危险在于,服务一旦恢复,工单就会立刻关闭,而潜在的缺陷从未被诊断出来。对于一个大型团队而言,这正是技术债悄悄累积的地方,因为同一类故障会在几个月后于另一个服务、由另一位值班工程师再次浮现。带上你最近几次一级严重事故,逐一检查:缓解措施之后,是否跟进了复现、根本原因分析和回归防护,还是故事在“服务已恢复”这里就结束了?就此约定一条明确规则:一次被缓解的事故要保持开放状态,直到根本原因被理解并被防护住,并指定谁负责这项跟进工作。在企业和政府场景中,要把这一点与你的事件管理流程(第 9.3 章)挂钩,使事后复盘成为一个必须完成、可审计的步骤,而不是在下一场火情出现时就会溜走的一项礼节。

  5. 事后我们实际能重建出多少故障场景,又是谁决定了我们默认采集什么? 你无法为一个已经发生的故障补加埋点,因此任何一次事故的可诊断程度,都由你事先选择采集的日志、追踪、指标和转储所预先决定。相竞争的考量是成本和噪声:高基数事件和全量追踪并非免费,过度记录日志会淹没信号,同时推高存储成本,在受监管场景中还会加大你的数据留存暴露面。带上一次最近的真实事故,问问它留下了什么证据,然后倒推你希望自己当初采集了什么、保留它又需要什么成本。要有意识地决定哪些信号默认开启,哪些采样或按需开启,并把这个决定记录下来,使其成为一项政策,而不是一次意外。对企业或政府系统而言,还要补充谁对这项可观测性预算负责,以及所采集的记录轨迹是否也满足审计、隐私和数据驻留方面的义务。

  6. 我们是把调试当作一项被教授、可度量的技能,还是让新工程师靠耳濡目染去吸收? 调试是可以学习的,然而大多数团队从未系统地教授它,因此初级工程师只能继承离他们最近的那套民间传说,先复现的方法要么传播得参差不齐,要么根本没有传开。这里的张力在于,有意识的教学(在棘手缺陷上结对、撰写事后复盘、追踪相关指标)会占用总感觉别处更需要的资深人员时间。带两个数字到讨论现场:你的缺陷重复率和诊断耗时,因为如果你无法度量它们,你就无法判断自己的方法是在改善还是在退化。想一想新人入职培训是否包含一次真正的调试练习,以及根本原因分析的发现是否真的反馈到了更早的检测环节中。在一个大型或公共组织中,一套有据可查、可度量的调试实践,也会成为审计人员、监管机构和监督机构日益期望看到的工程严谨性的证据。

行业视角

创业公司。 只有寥寥几位工程师、没有任何余量,你的目标是让缺陷的复现成本低廉、让人不可能遗忘,而不是构建繁重的流程。多依靠 git bisect、快速的本地复现,以及每修复一个缺陷就配一个失败测试,因为这个习惯只花几分钟,却能在你争分夺秒交付时避免为同一个缺陷反复买单。可以跳过正式的事后复盘,但永远不要跳过回归测试:它是那种小到你永远负担得起、又有价值到你永远该保留的产出物。

小型企业。 你很可能没有专职的可靠性或可观测性专家,工具预算也很紧张,因此要善用你的技术栈已经提供的东西:可读的堆栈跟踪、结构化日志,以及你已购买的框架和托管服务内置的追踪能力。在评估一个新平台时,要权衡它能让故障可诊断到什么程度,因为一个便宜却掩盖了问题所在的工具,在猜测耗费的时间上,远比省下的许可证费用要昂贵得多。先复现和一次只改一处是免费的纪律,在没有人有富余时间的时候,它们的回报来得最快。

企业。 你的棘手缺陷会跨越服务和团队边界,因此看到症状的人往往不是造成原因的人,共享的方法比任何个人的技能都更重要。在各团队之间把先复现、二分查找定位、修复前先写失败测试和无责事后复盘标准化,并投资于分布式追踪(第 9.2 章),使单个请求可以跨服务被追踪。把调试作为一项被度量的能力来管理:跟踪缺陷重复率和诊断耗时,并把根本原因分析的发现反馈到更早的检测环节中,避免同一类故障在你的服务地图上巡回演出。

政府。 采购规则、受限环境和公共问责制塑造着你究竟能如何调试。你往往无法在生产环境中挂接调试器,也无法把公民数据复制到笔记本电脑上,因此要基于被允许的手段来设计诊断能力:可复现的构建、在隔离环境中生成的合成记录,以及默认采集的结构化日志和追踪信息。把每一个诊断步骤和每一次改动都记录在审计轨迹中,并要求供应商暴露足够的遥测数据、并保证构建的可复现性,使你能够独立调查故障,而不是只能依赖供应商的一面之词。

示例

创业公司。 一个四人工程团队一直看到部分用户的结账流程失败,但在测试中却从未复现。一位工程师没有靠猜测,而是通过重放那个确切失败的请求负载得到了一个可靠的复现方式,然后读了他们此前一直忽略的堆栈跟踪,发现它指向了一次日期解析调用。一次快速的 git bisect,在本周的提交记录中定位到了那个更换日期库的改动。他们用引发问题的时间戳写了一个失败测试,修复了解析器,看着测试变绿,并把它保留在测试套件中。整个调查只花了一个下午,因为他们先复现,再理论化,而这个缺陷再也没有出现过。

企业。 一个支付平台出现了间歇性超时,没有任何一个团队能单独解释清楚,因为症状出现在结账环节,而原因却在三个服务之外。值班工程师使用分布式追踪(第 9.2 章)跨服务边界追踪一个失败请求,发现下游一个调用偶尔会在并发负载下发生死锁,这是一种典型的竞态条件,结果取决于线程之间不走运的时序。他们用负载测试复现了它,将其捕获在一个失败的集成测试中,修复了锁的问题,并运行了一次无责事后复盘(第 9.3 章),增加了一个追踪跨度和一条告警,使下一次发生时能在几分钟内被捕捉到,而不是几天。

政府。 一个福利机构在一个隔离环境中运行其案件系统,工程师无法在生产环境中挂接调试器,也无法把公民数据复制到自己的笔记本电脑上。一个计算缺陷在对账过程中浮现出来。团队基于该环境所允许的手段进行调试:结构化日志、一个可以在隔离测试环境中搭建起来的可复现构建,以及重现失败用例的合成记录。每一个诊断步骤都被记录在审计轨迹中,修复随附一个从失败到通过的测试作为证据,根本原因分析的结果被纳入一项新的发布前检查。由于复现使用的是合成数据,没有任何一条公民记录离开过边界。

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

严谨调试带来的回报,体现在没有被浪费在猜测上的工程师工时,以及不再复发的缺陷上。一个未被诊断出来的间歇性缺陷,可能消耗数天的资深工程师时间,并引发反复的值班升级;先复现的方法把它变成一项有边界、可委派的任务,而“先写失败测试”的习惯则能阻止同一个缺陷在下个季度再次向你收费。在一个大型组织中,永不为同一个缺陷重复买单所带来的累积效应相当可观,并且能直接改善领导层早已在跟踪的变更失败率和平均恢复时间。

总拥有成本主要是培训和工具方面的投入,而且规模适中。你需要共享的约定(先复现、一次只改一处、修复前先写失败测试)、工具链中已经常见的调试器和追踪能力,以及第 9.2 章所述的可观测性投资。更大、也更隐蔽的成本是替代方案:一种迷信文化,在这种文化里,工程师随意乱改,症状被打上补丁后又卷土重来,值班负担无止境地增长。仅仅是减轻值班负担这一项,往往就足以证明这项投资的合理性,而向领导层论证时,最简洁的说法就是:以一次性的习惯和埋点投入,换来更少的重复事故和更快的恢复速度。

反模式与陷阱

  • 散弹式调试: 一次性改动许多东西,以至于即便修复成功,也教不会你任何关于原因的东西。
  • 不复现就修复: 对一个你从未能按需触发的缺陷宣布胜利。
  • 忽视错误输出: 就堆栈跟踪早已排除的原因进行理论推测。
  • 症状打补丁: 让症状消音,而根本原因却存活下来,日后卷土重来。
  • 打印语句泛滥: 代码中留下散落的调试输出,增添的是噪声,而不是一个与假设绑定的实验。
  • 跳过回归测试: 修复了缺陷却不留任何防护,使其可以悄悄归来。
  • 对不稳定测试反复重试: 用重试来掩盖不确定性,而不是去调试底层的竞态条件或 heisenbug(一种你一试图观察它,它就会改变或消失的缺陷)。
  • 追责式事后复盘: 惩罚代码作者,把调试所依赖的信息逼入地下。

成熟度模型

  • 第 1 级,启动: 调试是个人化的民间传说和临场反应。工程师靠猜测、进行散弹式改动、反复重启服务。缺陷只在症状层面被修复,复现难得一见,同样的缺陷反复出现。生产环境几乎无法诊断,没有人能把一个缺陷交接给别人,因为没有一次尝试是可重复的。
  • 第 2 级,发展: 一些工程师能够可靠地复现问题、阅读堆栈跟踪、使用调试器,但这种做法并不一致,因人而异、因团队而异。日志存在,但噪声大且缺乏结构。修复有时随附一个失败测试,但常常没有,根本原因分析只在有人坚持时才会发生。
  • 第 3 级,标准化: 先复现、二分查找定位、一次只改一处,以及修复前先写失败测试,已成为在整个组织范围内被记录并被强制执行的团队规范。二分定位和增量调试式归约已成为常见做法。生产环境具备结构化日志和追踪(第 9.2 章),无责事后复盘(第 9.3 章)是每一个流入生产的缺陷的标准应对方式。
  • 第 4 级,管理: 调试实践被对照基线加以度量和控制。缺陷重复率、诊断耗时、被重新打开的工单数量,以及随附回归测试发布的修复占比,都按团队被跟踪并按固定节奏被审查。复现和根本原因分析的完成被当作硬性关卡,而不是良好意愿,相对基线的趋势变化,会驱动你把资源投向工具、培训和可观测性。
  • 第 5 级,编排: 调试是一项被教授的技能,与质量(第 2.11 章)和事件管理(第 9.3 章)相整合,整个闭环持续自我调整。可观测性被内建在设计之中,使大多数生产缺陷无需调试器即可诊断,每一个被解决的缺陷都会让回归测试套件更强健,根本原因的发现会反馈到更早的检测环节,使某一类缺陷得以被预防,而不是被反复重新诊断。组织会随着系统和故障模式的演变而重新平衡投入,缺陷重复率持续下降。

讨论话题

  1. 在你们最近的缺陷中,有多大比例是在任何人改动代码之前就被可靠地复现的,这个比例说明了你们方法的什么问题?
  2. 当出现一次回归时,你的团队是选择二分定位,还是逐行手动阅读代码直到有人发现问题?
  3. 你的生产系统今天有多大的可诊断性,对于一次已经发生的故障,你愿意付出什么代价来获得当初本该采集的信息?
  4. 你们的修复是否始终随附一个从失败到通过的测试,如果不是,这项纪律在哪个环节被打破了?
  5. 你们如何处理不稳定的测试:是去调试其背后的不确定性根源,还是用重试来敷衍了事?
  6. 调试是被有意识地教给新工程师的,还是任由他们靠耳濡目染去吸收民间传说?

关键要点

  • 调试就是假设检验:先可靠地复现,阅读错误信息和堆栈跟踪,再通过二分查找和二分定位来定位,而不是逐行扫描。
  • 把故障归约到一个最小可复现示例,对大型输入使用增量调试,因为每移除一个元素,就排除了一个原因。
  • 让工具与缺陷相匹配:对生产环境和分布式系统使用埋点与追踪(第 9.2 章),对本地调查使用交互式调试器。
  • 在修复之前先写一个能捕获该缺陷的失败测试,使修复得到证明,并让该缺陷从此被永久防护住(第 2.4 章)。
  • 找到并消除根本原因,运行无责事后复盘(第 9.3 章),把调试当作一项可以学习的技能,而不是民间传说。
  • 一次只改一处;散弹式改动和症状补丁会摧毁证据,并引来缺陷卷土重来。

参考文献与延伸阅读

  • David J. Agans, Debugging: The 9 Indispensable Rules for Finding Even the Most Elusive Software and Hardware Problems
  • Andreas Zeller, Why Programs Fail: A Guide to Systematic Debugging
  • Andreas Zeller and Ralf Hildebrandt, “Simplifying and Isolating Failure-Inducing Input” (the delta debugging algorithm)
  • Brian W. Kernighan and Rob Pike, The Practice of Programming (chapter on debugging)
  • Andrew Hunt and David Thomas, The Pragmatic Programmer (the chapters on debugging and assertions)
  • Steve McConnell, Code Complete: A Practical Handbook of Software Construction (the debugging chapter)
  • John Regehr, “Reducers Are Fuzzers” and related writing on test-case reduction
  • Charity Majors, Liz Fong-Jones, and George Miranda, Observability Engineering (debugging production with high-cardinality telemetry and tracing)
  • Betsy Beyer, Chris Jones, Jennifer Petoff, and Niall Richard Murphy, eds., Site Reliability Engineering (blameless postmortems and production debugging)