17-调试代码的基本哲学

61 阅读11分钟

  有时候人们在调试代码时会感受到强烈的挫败感。因为绝大部分人在调试系统代码时,倾向于将时间花费在思索而不是追溯代码的调用上。

  让我举个例子来说明我想表达的观点,假设你的服务器在运行的过程中平均有5%的时间无法响应用户请求的页面。对于这个问题你的第一个反应肯定是:“为什么?”

  你是不是会企图在第一时间思考问题出在哪?又或者你会开始猜测引起问题的原因是什么?如果你真的这么做了,也就意味着你正在以错误的方式解决问题。正确处理问题的方式应该是告诉自己:“我不知道为什么会发生这样的问题。”这才是成功调试代码的第一步:

当你开始调试代码时,请意识到其实你对答案一无所知。

  人们倾向于相信冥冥中自己已经悟到了问题的答案。有时你确实能够猜对。这种情况不常发生,但是发生的频率之多,让不少人误以为猜测也是调试代码中的有效手段之一。

  大部分时候,你可能会花上几个小时、几天甚至几周来猜测问题究竟出在哪里,并且尝试各种除了让代码更复杂之外毫无实际用处的修复方案。你会发现在一些代码库中充斥着仅依据猜测编写的用于修复“bug”的“解决方案”——这些所谓的“解决方案”恰恰是代码库复杂性的一大来源。

  有一条有趣的原则可以作为修复代码时的友情提示。通常来说,成功对bug进行修复,也应该意味着系统在变得更好,比如系统变得更简单了,架构设计得到了优化,等等。我会在接下来的内容里对这个观点继续展开说明,但现在请先记住它就好。通常,bug的最佳修复方案,会在修复问题的同时,间接地移除冗余代码,并且简化系统设计。

  但是首先回到调试代码流程本身,正确的做法应该是什么?猜测是在浪费时间,设想出错原因也是浪费时间——基本上在遇到问题的第一时间内,你脑海中冒出的想法都属于无稽之谈。此时此刻你需要了解的只有两件事:

  1. 记住系统正确的行为是什么。
  2. 想清楚应该通过追踪哪一部分代码来收集更多的有效信息。

  这才是调试代码中最重要的原则:

调试代码指的是在你找到问题的起因之前,持续收集信息的过程。

  可以通过深入了解系统的工作原理来收集信息。以服务器无法返回页面的情况为例,或许你可以在通过查阅系统日志找到线索。又或者你可以尝试重现问题,并通过观察服务器此时的工作状态来发现蛛丝马迹。这也是为什么处理问题的人总是希望能“还原现场”(通过一系列步骤能够让你复现问题)。这样他们就能在bug发生时回溯出了什么样的问题。

明确bug

  有时你的首要任务是明确bug究竟是什么。通常用户上报的bug信息内容都相当有限。例如此时用户上报的一个bug,内容是“当我加载页面时,服务器没有返回任何数据”。

  这种信息远远不够。他们正在尝试加载哪个页面?他们所说的“没有返回任何数据”指的又是什么?他们看到的仅仅是空白页面而已吗?你当然可以推测用户想表达的意思,但大部分时候你的推测都是错误的。你的用户越是没有计算机相关背景,在缺乏引导的情况下他能够准确表达问题的可能性就越低。在这些情况下,除非问题十分紧急,否则我首先要做的事情就是请求用户给出更详细的出错信息,并且在我得到回复之前我不会采取任何行动。也就是说,在他们明确bug之前我绝不会自行尝试解决这个问题。

  如果在对问题一知半解的情况下就着手尝试解决它,那么我可能会把时间都浪费在查看各种和问题无关的系统随机角落上。所以为了让时间花得更有价值我才选择等待用户的进一步反馈,并且最终当我确实拿到一份完整的bug报告时,我才会着手探寻bug背后的原因。请注意,不要因为用户提交的bug信息不够丰富而迁怒于他们。虽然他们对系统的了解不如你,但并不意味着你有资格用不屑的态度鄙视他们。为了获取信息你应该直言不讳地提出问题。上报bug的人并非故意表现得笨手笨脚——他们只是对系统不甚了解,要知道引导他们提供正确的信息也是你的工作职责之一。如果人们总是无法提供正确的信息,你可以尝试在报错页面提供一个表单来帮助他们梳理出正确的信息有哪些。我想表达的是帮助其实是互惠的,只有你帮助了他们,他们才能反过来帮助你,这样你才更容易地解决问题。

深入系统

  一旦明确bug,接下来你就需要对系统的不同组件进行排查以找到错误原因。至于从哪些组件入手排查取决于你对系统的了解程度。通常是从日志信息、系统监控、错误消息、核心转储或者是系统其他的输出信息入手。如果系统无法为你提供这些信息,你或许需要考虑在继续排查问题之前,发布一个能够收集这些信息的新版本系统。

  尽管对于只修复单个bug而言,这看上去似乎需要耗费不少的工作量,但相比你在系统内毫无目的地碰运气来猜测问题的原因,发布能够提供有效信息的新版本系统还是能够提升不少效率的。这也是支撑快速发布、频繁发布实践的有力论点:发布新版本的频率越高,你收集到的调试信息速度也就越快。有时你甚至可以定向地为遇到问题的用户发布新版本系统,这也可以作为收集信息的捷径。

  还记得我上面提到过的你需要记住系统的正确行为是什么吗?这是因为还有另一条关于调试代码的原则:

调试代码是一类将已有数据与期望数据进行比较的行为。

  当你在系统日志中看到一条信息时,这是一条普通的信息还是一条错误信息?或许信息的内容是:“警告:所有数据都已丢失。”这看上去像是一个错误,但庆幸的是实际上服务器在每次问题发生时都准确地把它记录了下来。你需要意识到这是正常工作中服务器的行为。而真正需要留意的是正常工作系统中被遗漏记录的行为或输出。

  同时你还要理解所有这些信息的背后含义。或许你用不上服务器默认提供的用户数据库,但这会导致你收到了一条警告日志信息——因为你故意造成了“用户信息”的缺失。

找到根本原因

  你终于发现了运行系统中的某些异常行为。但你也不应该立即推断这就是问题的根本原因。举个例子,日志可能会记录:“出错信息:昆虫正在蚕食所有的小甜饼。”“修复”这个异常的方式之一就是删除这条日志。现在系统行为看上去正常多了不是吗?不是的——bug依然在发生。

  这个例子听上去有些愚蠢,但人们真实的行为也不会好到哪里去,他们会选择不去修复这个bug。就像我在第16章说的那样,他们不会继续搜寻引起问题的根本原因。相反,他们会用一些永远遗留在代码库中的解决方法来掩盖这个bug,并从那时起,给每个在那个代码领域工作的人带来复杂性。

  仅仅指出“当你意识到找到问题的根本原因时,是当你发现修复它就解决了bug的时候”是不够的,虽然它已经非常接近我们想表达的思想,但是更准确的说法是:

   “当你意识到找到问题的根本原因时,是当你十分肯定在将它修复完毕之后错误就再也不会发生了的时候。”

  这不是绝对的——关于如何“修复”bug还有可以讨论的空间。

  bug需要修复到何种程度取决于你的解决方案想解决到哪个层次,以及你想要在上面花费多少时间。通常在你找到某个问题的深层原因,并且将它修复之后,就能看出你最终做出了什么样的选择——这再明显不过了。但我依然想要警告你,只解决问题的表面症状而不解决引起问题的深层原因是有风险的

  当然,在找到原因的当下就马上修复它。这其实是正常情况下最直接的方式。

四个步骤

  这些就是调试代码的四个主要步骤:

  1. 熟悉正常工作的系统行为应该是什么样的。
  2. 接受其实你并不知道问题原因的这个事实。
  3. 追踪代码直到你找到问题的原因是什么。
  4. 修复根本原因而不是表面症状。

  这听起来十分简单,但我基本上看不到有人能遵守这一系列准则。我的所见所闻是,大部分程序员在遇到bug时,喜欢坐下来思考,或者通过询问他人找到问题可能发生的原因——这两种做法都无异于猜测。

  与那些对系统有一定的了解,并且能给出可以从何处收集有助于调试信息的人沟通是办法之一。但是与其一群人坐在那里猜测问题的原因,其实和你一个坐在那瞎猜没有区别,唯一的收获可能是和你喜欢的同事聊天产生的一些愉悦感吧。上面的做法无非是用浪费大伙的时间代替浪费你自己的时间而已。

  所以请不要浪费大家的时间,不要在代码库中引入不必要的复杂性。上面给出的代码调试方法是可行的。无论在何时何地,对什么样的代码库或者系统而言都是适用的。

  有时候“收集信息”的过程会相对困难,特别是对于那些你无法重现的bug,但最坏的情况也无非是通过阅读代码来收集信息,尝试找到代码中bug所在,又或者把系统的工作流程图画出来,看是否能发现症结在哪里。我建议把这些方法当作没有办法的办法,但是即使你这么做,也比猜测问题出在哪里或者假设你已经知道问题在哪里要强。

  有时候,通过解读收集到的正确数据就能神奇地将问题解决。你可以试试看,非常有趣。