调试的根本哲学
作者:Max Kanat-Alexander
发布日期:2017年7月17日
有时人们在调试时会遇到很大困难。通常,这些人认为调试系统需要靠思考而不是观察。
让我举个例子。假设你有一个Web服务器,有5%的时间会静默地无法向用户提供页面。你对“为什么?”这个问题的反应是什么?你会立即尝试想出答案吗?你会开始猜测吗?如果是这样,那你做错了。
对这个问题的正确答案是:“我不知道。”
这为我们提供了成功调试的第一步:开始调试时,要意识到你并不知道答案。
你可能会觉得自己已经知道答案。有时你猜对了。这种情况并不常见,但足以让人误以为猜测是调试的好方法。然而,大多数时候,你会花费数小时、数天甚至数周的时间猜测答案并尝试不同的修复方法,结果只是让代码变得更复杂。实际上,一些代码库中充满了对“bug”的“解决方案”,这些方案实际上只是猜测——而这些“解决方案”是代码库复杂性的重要来源。
顺便说一下,我要告诉你一个有趣的原则。通常,如果你很好地修复了一个bug,你实际上会让系统的某部分消失、变得更简单、设计更好等。我可能以后会详细讨论这一点,但这里先提一下。很多时候,修复bug的最佳方法实际上是删除代码或简化系统。
回到调试过程本身,你应该做什么?猜测是浪费时间,想象问题的原因是浪费时间——基本上,当你第一次遇到问题时,脑海中发生的大部分活动都是浪费时间。你唯一需要做的事情是:记住正常系统的行为方式;找出需要查看什么以获取更多数据。
因为,这引出了调试的最重要原则:调试是通过收集数据直到你理解问题原因来完成的。
收集数据的方式几乎总是通过观察。对于无法提供页面的Web服务器,你可能会查看其日志。或者你可以尝试重现问题,以便在问题发生时观察服务器的行为。这就是为什么人们经常想要一个“重现案例”(一系列步骤,可以重现确切问题)——这样他们就可以在bug发生时观察发生了什么。
有时你需要收集的第一条数据是bug实际上是什么。用户提交的bug报告通常数据不足。例如,假设用户提交了一个bug:“当我加载页面时,Web服务器没有返回任何内容。”这信息不足。他们尝试加载了什么页面?“没有返回任何内容”是什么意思?只是一个空白页面?你可能会假设用户的意思,但很多时候你的假设是错误的。用户作为程序员或计算机技术人员的经验越少,他们就越难以在没有你询问的情况下具体表达发生了什么。在这些情况下,除非是紧急情况,我做的第一件事就是向用户发送具体请求以澄清他们的bug报告,并等待他们回复。在他们澄清之前,我根本不会查看。如果我在完全理解问题之前就去尝试解决,我可能会浪费时间查看系统中与问题完全无关的随机角落。更好的做法是,在等待用户回复时,将时间花在更有成效的事情上,然后当我有完整的bug报告时,再去研究现在已知bug的原因。
不过,请注意,不要因为用户提交了不完整的bug报告而对他们粗鲁或不友好。你比他们更了解系统,但这并不使你成为高人一等的存在,可以从“比你聪明山”的闪烁顶峰上俯视所有用户。相反,以友好或直接的方式提问,获取信息。提交bug的人很少是故意愚蠢的——他们只是不知道,而帮助他们提供正确信息是你工作的一部分。如果人们经常不提供正确信息,你甚至可以在bug提交页面上包含一个小问卷或表单,让他们填写正确信息。关键是要帮助他们,这样他们才能帮助你,让你轻松解决出现的问题。
一旦你澄清了bug,你就需要去查看系统的各个部分。查看系统的哪些部分取决于你对系统的了解。通常是日志、监控、错误消息、核心转储或系统的其他输出。如果你没有这些东西,你可能需要启动或发布一个新版本的系统来提供信息,然后才能完全调试系统。虽然这看起来只是为了修复一个bug而做很多工作,但实际上,发布一个提供足够信息的新版本通常比花时间在系统中四处寻找和猜测而没有信息更快。这也是支持快速、频繁发布的另一个好论点——这样你可以快速发布一个提供新调试信息的新版本。有时你也可以只向遇到问题的用户发布一个新版本的系统构建,作为获取所需信息的捷径。
现在,还记得我上面提到的你必须记住正常系统的样子吗?这是因为调试还有另一个原则:调试是通过将你拥有的数据与你知道的正常系统数据应该是什么样子进行比较来完成的。
当你看到日志中的一条消息时,那是正常消息还是实际上是错误?也许日志说:“警告:所有用户数据都缺失。”这看起来像错误,但实际上你的Web服务器每次启动时都会打印这条消息。你必须知道正常工作的Web服务器会这样做。你要寻找正常系统不显示的行为或输出。此外,你必须理解这些消息的含义。也许Web服务器可选地有一些你没有使用的用户数据库,这就是你得到警告的原因——因为你希望所有“用户数据”都缺失。
最终你会找到正常系统不做的事情。不过,当你看到这个时,你不应该立即假设你找到了问题的原因。例如,也许它记录了一条消息说:“错误:昆虫正在吃掉所有cookie。”你可以“修复”这种行为的一种方法是删除日志消息。现在行为正常了,对吧?不,错了——实际的bug仍然在发生。这是一个相当愚蠢的例子,但人们会做不那么愚蠢的版本,这些版本并不能修复bug。他们没有深入问题的根本原因,而是用一些变通方法掩盖bug,这些方法永远存在于代码库中,并给以后在该代码区域工作的每个人带来复杂性。甚至说“你会知道你找到了真正的原因,因为修复它就能修复bug”也不够。这接近真相,但更准确的说法是:“当你确信修复它会让问题永远不再出现时,你会知道你找到了一个真正的原因。”这不是一个绝对的陈述——bug的“修复”程度有一个尺度。一个bug可以更修复或更不修复,通常取决于你希望解决方案“深入”多少,以及你愿意花多少时间。通常你会知道你找到了问题的体面原因,现在可以宣布bug已修复——这很明显。但我想警告你不要通过消除症状但不处理原因来掩盖bug。
当然,一旦你找到了原因,你就修复它。如果你做对了其他所有事情,这实际上是最简单的步骤。
所以基本上,这为我们提供了调试的四个主要步骤:
- 熟悉正常系统的行为。
- 理解你并不知道问题的原因。
- 查看数据直到你知道问题的原因。
- 修复原因而不是症状。
这听起来很简单,但我看到人们一直违反这个公式。根据我的经验,大多数程序员在面对bug时,都想坐着思考或讨论可能的原因——这两种形式都是猜测。与可能拥有系统信息或建议在哪里寻找有助于调试数据的人交谈是可以的。但坐着集体猜测bug可能的原因并不比你自己坐着做更好,除非你可以和同事聊天,如果你喜欢他们的话,这可能很好。但大多数情况下,你这样做是在浪费一群人的时间,而不仅仅是浪费你自己的时间。
所以不要浪费人们的时间,也不要在代码库中创建不必要的复杂性。这种调试方法是有效的。它在每个代码库、每个系统上都有效。有时“数据收集”步骤相当困难,特别是对于无法重现的bug。但在最坏的情况下,你可以通过查看代码并尝试查看是否能发现bug,或绘制系统行为图并查看是否能察觉到问题来收集数据。我只建议将其作为最后的手段,但如果你必须这样做,它仍然比猜测错误或假设你已经知道要好。
有时,通过查看正确数据直到你知道,bug的解决几乎是神奇的。自己试试看。它实际上可以很有趣。
-Max
评论
Steven Gordon 说:
2017年7月17日晚上9:03
想到两点:
- 无法估计修复一个bug需要多长时间,因为大部分工作是诊断。你无法提前知道诊断需要什么,因为你无法知道你还不知道什么。当你通过观察得到支持的诊断后,你应该能够给出修复所需时间的合理估计,但到那时你已经完成了大部分工作。
- 在完成本文中描述良好的诊断步骤之前,我建议编写覆盖由于bug而失败的案例的自动化测试。这不仅帮助你知道何时修复了bug,而且累积的先前bug的自动化测试验证了你没有撤销其他bug的修复。bug众所周知会表现出再犯。
Max Kanat-Alexander 说:
2017年7月17日晚上9:35
嘿Steven!两点都同意。我唯一要提醒的是,当你编写测试时,你不应该只是响应bug而被动地编写“回归测试”,而是编写良好覆盖和设计良好的单元测试,通过通常设计良好的测试的性质来暴露bug。这并不意味着有时你不需要在发现新bug时向单元测试添加另一个案例,但意味着基本测试应该围绕测试的基本原则设计。
-Max
Steven Gordon 说:
2017年7月18日凌晨12:40
单元测试在解决方案实施期间似乎更合适,因为你可能为了修复bug而重构或重新处理代码。
Max Kanat-Alexander 说:
2017年7月26日晚上8:54
嗯,如果它们是好的单元测试,它们应该覆盖系统的功能。如果该功能被适当地表达,那么你遇到的bug就是对该功能的违反,应该可以通过改进单元测试来捕获。
进行纯回归测试的问题是,你开发了一个大型的有些随机的测试套件,这些测试可能或可能不像设计良好的单元测试那样在未来具有同样多的价值。在许多代码库中,你可以看到这种后果。测试也是必须维护的代码。
-Max
Simon 说:
2017年7月18日凌晨4:08
嘿……我最近一直在尝试向一些新员工教授这些东西,聪明的小伙子,但相当新手。这是一个挑战,因为一半的时间,我知道bug是什么,只要他们描述它……这不是真的猜测,只是多年的经验指引我走向正确的方向。
但这种直觉对新人没有帮助,因为他们少了十五年的经验。所以相反,我需要抵制直接修复bug的冲动,而是带他们通过系统地收集信息以自己找到bug的过程。
Jack 说:
2017年7月18日上午11:52
同样在这里我读了你的帖子,Max,我认为可以(至少对我而言)很难区分“思考”和“查看”。
主要是因为很多时候,我已经有很多关于特定系统的数据。因此,当问题出现时,并不总是有一个“查看阶段”:数据已经被收集,只留下“思考”部分。从我知道的所有数据中,我“思考”问题“在那里”。
这并不妨碍我在修复前检查我的猜测 🙂
Max Kanat-Alexander 说:
2017年7月26日晚上8:57
是的,我绝对理解你们在说什么。这也是我经历过的事情。但我还是尝试通过正确的过程,因为平均而言,它往往更快(考虑到你猜错并必须重试的时间)。不过,对系统的经验确实有助于知道首先查看哪里。
-Max
Warren 说:
2017年7月18日下午12:38
几年前,我将Simon Tatham关于提交bug报告的文章改编成了一本小电子书——http://cnx.org/contents/qofD1eNQ@2.4:31arWXa5@4/Submitting-Bug-Reports
这听起来很相似……并回到了我多年来对故障排除的观察:相对而言,很少有人能有效调试;只有稍多的人能可重复地进行故障排除。
Max Kanat-Alexander 说:
2017年7月26日晚上8:59
那很酷。 🙂
是的,令人惊讶的是有多少人在故障排除方面有困难。它需要一定程度的纪律(有时是耐心)来保持所需的简单性水平——简单地说,“好吧,我要获取这些数据,当我获取数据时,我会做出决定。”
-Max
jsc42 说:
2017年7月18日晚上11:50
我不同意你不应该先“猜测”的前提。也许这只是术语问题。我认为你的意思是“基于几乎只是直觉决定问题在哪里,希望它会奏效”。对我来说,“猜测”意味着你使用对系统的知识来优先考虑哪些可能是最有用的调查领域,接受它可能导致死胡同。否则,你对解决方案的搜索并不比漫无目的地徘徊希望到达目的地更好;你真正想做的是获取地图,决定路线但知道可能有路障和绕行。
使用你的Web服务器不提供页面的例子,在从问题发现者那里获得更多线索后,我可能从查看发送页面的部分开始,回溯到创建页面的部分。从已知(页面不出现)开始,向后工作到原因。我不会从查看(比如)计算所得税的应用程序部分开始。
Warren 说:
2017年7月19日凌晨4:30
正如我多年前的一位讲师所说,这都是关于应用斧头来削减问题规模。
如果你无法ssh到服务器,你从查看是否连接到网络开始,而不是查看硬盘是否已满,例如(尽管后者可能是问题)。
David 说:
2017年7月19日凌晨4:33
我也质疑了关于不“猜测”的部分。对我来说,调试开始时的“猜测”工作/思考是一种描述和分类报告行为的方式。这种思考/对话可以消除系统中不相关的部分,让程序员更好地专注于最可能引起bug的原因。
我认为总体情绪是不要浪费时间胡乱编造,而是使用科学方法,我们观察行为并相应调整我们的理论。
George 说:
2017年7月19日上午8:32
阿门。
经过多年,我发现调试主要是假设检验。你获取所有合理的信息,然后提出一个理论。然后设计一种方法来测试该理论。通过获取信息,好的调试工具是无价的。这个过程听起来有点像猜测,但它是有效的。
作为说明,我发现大约50%的时间解决方案是删除代码。
Mike W. 说:
2017年7月20日下午6:03
我不得不不同意。但也许是术语问题。 🙂
你描述的不是“猜测”。这是在正确的地方查看。
你不是在猜测根本原因是什么。你只是在问题区域查看,并使用所有信息渠道来调查(观察)那个区域。
当然,查看所得税计算来调试页面不加载是愚蠢的,但这个错误不是由未能猜测引起的。它是由没有意识到你所有的错误(所有看起来不像正常工作服务器的东西)与代码的页面加载部分有关,而不是税收计算部分引起的。
这在文章中被称为你唯一需要做的事情之一:
- 找出你需要查看什么以获取更多数据。
但猜测会是:“也许如果我增加数据库缓存,页面加载问题就会消失。”没有逻辑连接两者;根本没有理论。因为它基于完全没有观察的纯粹猜测。人们真的经常这样做;我见过。那是猜测。
智能地在合理的地方查看,以收集更多信息,而不假设你已经知道解决方案,不是猜测。 🙂
Max Kanat-Alexander 说:
2017年7月26日晚上9:01
是的,基本上就是Mike说的。 🙂
-Max
Michael S. Meyers-Jouan 说:
2017年7月19日上午9:10
在我看来,任何问题解决过程中都应该应用另一个原则。我们将其表达为“Mah nishtanah halailah hazeh mikol haleilot?”(英语中,“这个夜晚与所有其他夜晚有何不同?”)。换句话说,关于bug出现的上下文,我们能区分出什么与bug不出现的“正常”上下文不同。这些信息通常帮助我们隔离最可能引起问题的系统部分——然后我们可以进行你的第三步,有信心我们在查看最有用的数据。
Warren 说:
2017年7月19日上午9:43
我听到这样描述:“每个看过《芝麻街》的孩子都知道如何开始故障排除,‘……这些东西中有一个与众不同……’”
Max Kanat-Alexander 说:
2017年7月26日晚上9:02
当然,这是一种看待方式!
另外,作为犹太人长大,我喜欢你表达问题的方式。带回许多逾越节的美好回忆! 🙂
-Max
Kurt Guntheroth 说:
2017年7月19日上午9:59
我也不同意先猜测,尽管我同意在不知道bug是什么之前尝试修复可以礼貌地描述为疯狂。
我最尊敬的调试者是方法ical科学家。他们对出问题的地方做出假设。他们写下假设。他们寻找证据来确认或反驳假设。他们进行实验并记录这些实验的结果。
做出假设是关键的第一步。它告诉你在大型Web服务器中查看哪些数据。它告诉你在百万行代码库中检查哪些部分。如果第一个假设未被确认,他们做出一个新的并查看更多数据。如果他们需要做出代码更改来确认假设,他们使用版本管理系统记录这些更改,以便在假设未被确认时可以回退。
文章的其余部分是正确的。
John Hilberts 说:
2017年7月19日上午11:33
上下文为王。我见过一个特别了解系统的人从酒吧调试一个人们已经工作了半天的重大问题,手里拿着啤酒,只用一行信息就第一次击中了要害。但他对系统了如指掌,可以利用他的直觉(集体过去经验)基于非常有限的信息做出假设。
Mike W. 说:
2017年7月20日下午6:27
在你做出假设之前,你必须观察。一旦你做出假设,你必须观察更多。
任何人都可以做出假设。但如果你没有先查看,并且没有仔细、密切地查看,并在查看之后进行更多查看,你的假设就只是空想,不会与任何东西相关。你甚至无法用它来修复问题。“也许有网络问题,我应该稍后再试。”“也许我用了错误的编译器。”
你如何区分好假设和坏假设?实际上不是通过实验。一个好假设基于观察,并适用于观察到最多差异的数据区域。一个坏假设甚至没有考虑哪些区域有最多偏离“正常工作服务器样子”的地方。所以你追逐蝴蝶而不是查看错误最多的区域。
但真的,假设只是告诉你更具体地查看哪里。当你真正拥有bug的原因时,你不需要做任何猜测。“这行缺少分号。”或者,“编写这个shell片段的程序员在他的函数中使用shell变量,好像它们是局部作用域而不是全局的。”
Kurt Guntheroth 说:
2017年7月20日晚上7:02
我发现,如果你必须在做出假设之前查看所有代码,软件看起来相当大。你有一些关于bug的行为信息来指导你的初始假设,并从那里逐步细化。
我们在争论措辞,而不是基本概念。
Max Kanat-Alexander 说:
2017年7月26日晚上9:08
嗯,我确实认为Mike和我所说的与你所说的有轻微区别。
根据定义,假设是一个提议的解释,但我在查看某物之前甚至不提议解释。而查看事物通常本身很容易生成解释,而不需要做任何猜测。也就是说,通常当你查看某物足够时,你不是在假设——你知道。
-Max
John Hilberts 说:
2017年7月19日上午11:30
我创造了一个名为ULTRA调试的术语:
- 理解问题:无论对你如何有效。我个人喜欢白板,映射出问题和各种序列或系统在玩。
- 日志:获取它们。阅读它们。
- 跟踪:将问题跟踪回代码或配置等。也可以称这一步为理论化,因为此时你正在形成假设。
- 重现:理想情况下