调试的根本哲学
作者: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
评论
1 – The Fundamental Philosophy of Debugging 说:
2017年7月17日下午6:17
[…] 来源:http://www.codesimplicity.com/post/the-fundamental-philosophy-of-debugging/ […]
回复
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
是的,我 definitely 理解你们在说什么。这也是我经历过的事情。但我还是尝试走正确的过程,因为平均而言,它往往更快(考虑到你猜错并不得不重试的时间)。不过,对系统的经验确实有助于知道首先查看哪里。
-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是什么之前尝试修复可以礼貌地描述为疯狂。
我最尊敬的调试者是方法科学的科学家。他们提出关于哪里出错的假设。他们写下假设。他们寻找证据来确认或反驳假设。他们进行实验并记录这些实验的结果。
提出假设是关键的第一步。它告诉你在庞大的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的行为信息来指导你的初始假设,并从那里逐步完善。
我们在争论措辞,而不是基本概念。
回复
**