弄啥嘞?热爱你的Bug
有人喜欢创造世界,他们做了开发者;有的人喜欢开发者,他们做了测试员。什么是软件测试?软件测试就是一场本该在用户面前发生的灾难提前在自己面前发生了,这会让他们生出一种救世主的感觉,拯救了用户,也就拯救者这个软件,避免了他们被卸载的命运。
我爱 bug
我目前是 Pilot.com 的一位高级工程师,负责给创业公司提供自动记账服务。在此之前,我曾是 Dropbox 的桌面客户端组的成员,我今天将分享关于我当时工作的一些故事。更早之前,我是 Recurse Center 的导师,给身在纽约的程序员提供临时的训练环境。在成为工程师之前,我在大学攻读天体物理学并在金融界工作过几年。
但这些都不重要——关于我你唯一需要知道的是,我爱 bug。我爱 bug 因为它们有趣。它们富有戏剧性。调试一个好的 bug 的过程可以非常迂回曲折。一个好的 bug 像是一个有趣的笑话或者或者谜语——你期望看到某种结果,但却事与愿违。
在这个演讲中我会给你们讲一些我曾经热爱过的 bug,解释为什么我如此爱 bug,然后说服你们也同样去热爱 bug。
Bug 1 号
好,让我们直接来看第一个 bug。这是我在 Dropbox 工作时遇到的一个 bug。你们或许听说过,Dropbox 是一个将你的文件从一个电脑上同步到云端和其他电脑上的应用。
+--------------+ +---------------+
| | | |
| 元数据服务器 | | 块服务器 |
| | | |
+-+--+---------+ +---------+-----+
^ | ^ | | |
| | +----------+ |
| +---> | | |
| | 客户端 +--------+
+--------+ |
+----------+
这是个极度简化的 Dropbox 架构图。桌面客户端在你的电脑本地运行,监听文件系统的变动。当它检测到文件改动时,它读取改变的文件,并把它的内容 hash 成 4 MB 大小的文件块。这些文件块被存放在后端一个叫做块服务器blockserver的巨大的键值对数据库key-value store中。
当然,我们想避免多次上传同一个文件块。可以想见,如果你在编写一份文档,你应该大部分时候都在改动文档最底部——我们不想一遍又一遍地上传开头部分。所以在上传文件块到块服务器之前之前,客户端会先和一个负责管理元数据和权限等等的服务器沟通。客户端会询问这个元数据服务器metaserver它是需要这个文件块,还是已经见过这个文件块了。元数据服务器会返回每一个文件块是否需要上传。
所以这些请求和响应看上去大概是这样:客户端说“我有一个改动过的文件,分为这些文件块,它们的 hash 是 'abcd,deef,efgh'
。服务器响应说“我有前两块,但需要你上传第三块”。然后客户端会把那个文件块上传到块服务器。
+--------------+ +---------------+
| | | |
| 元数据服务器 | | 块服务器 |
| | | |
+-+--+---------+ +---------+-----+
^ | ^ | | '有, 有, 无' |
'abcd,deef,efgh' | | +----------+ | efgh: [内容] | +---> | | |
| | 客户端 +--------+
+--------+ |
+----------+
这是问题的背景。下面是 bug。
+--------------+
| |
| 块服务器 |
| |
+-+--+---------+
^ |
| | '???'
'abcdldeef,efgh' | | +----------+
^ | +---> | |
^ | | 客户端 +
+--------+ |
+----------+
有时候客户端会提交一个奇怪的请求:每个 hash 值应该包含 16 个字母,但它却发送了 33 个字母——所需数量的两倍加一。服务器不知道该怎么处理它,于是会抛出一个异常。我们收到这个异常的报告,于是去查看客户端的记录文件,然后会看到非常奇怪的事情——客户端的本地数据库损坏了,或者 python 抛出 MemoryError,没有一个合乎情理的。
如果你以前没见过这个问题,可能会觉得毫无头绪。但当你见过一次之后,你以后每次看到都能轻松地认出它来。给你一个提示:在那些 33 个字母的字符串中,l
经常会代替逗号出现。其他经常出现的字符是:
l \x0c < $ ( . -
英文逗号的 ASCII 码是 44。l
的 ASCII 码是 108。它们的二进制表示如下:
bin(ord(',')): 0101100 bin(ord('l')): 1101100
你会注意到 l
和逗号只差了一位。问题就出在这里:发生了位反转。桌面客户端使用的内存中的一位发生了错误,于是客户端开始向服务器发送错误的请求。
这是其他经常代替逗号出现的字符的 ASCII 码:
, : 0101100
l : 1101100
\x0c : 0001100
< : 0111100
$ : 0100100
( : 0101000
. : 0101110
- : 0101101
位反转是真的!
我爱这个 bug 因为它证明了位反转是可能真实发生的事情,而不只是一个理论上的问题。实际上,它在某些情况下会比平时更容易发生。其中一种情况是用户使用的是低配或者老旧的硬件,而运行 Dropbox 的电脑很多都是这样。另外一种会造成很多位反转的地方是外太空——在太空中没有大气层来保护你的内存不受高能粒子和辐射的影响,所以位反转会十分常见。
你大概非常在乎在宇宙中运行的程序的正确性——你的代码或许事关国际空间站中宇航员的性命,但即使没有那么重要,也还要考虑到在宇宙中很难进行软件更新。如果你的确需要让你的程序能够处理位反转,有很多硬件和软件措施可供你选择,Katie Betchold 还关于这个问题做过一个非常有意思的讲座。
在刚才那种情况下,Dropbox 并不需要处理位反转。出现内存损坏的是用户的电脑,所以即使我们可以检测到逗号字符的位反转,但如果这发生在其他字符上我们就不一定能检测到了,而且如果从硬盘中读取的文件本身发生了位反转,那我们根本无从得知。我们能改进的地方很少,于是我们决定无视这个异常并继续程序的运行。这种 bug 一般都会在客户端重启之后自动解决。
不常见的 bug 并非不可能发生
这是我最喜欢的 bug 之一,有几个原因。第一,它提醒我注意不常见和不可能之间的区别。当规模足够大的时候,不常见的现象会以值得注意的频率发生。
覆盖面广的 bug
这个 bug 第二个让我喜欢的地方是它覆盖面非常广。每当桌面客户端和服务器交流的时候,这个 bug 都可能悄然出现,而这可能会发生在系统里很多不同的端点和组件当中。这意味着许多不同的 Dropbox 工程师会看到这个 bug 的各种版本。你第一次看到它的时候,你 真的 会满头雾水,但在那之后诊断这个 bug 就变得很容易了,而调查过程也非常简短:你只需找到中间的字母,看它是不是个 l
。
文化差异
这个 bug 的一个有趣的副作用是它展示了服务器组和客户端组之间的文化差异。有时候这个 bug 会被服务器组的成员发现并展开调查。如果你的 服务器 上发生了位反转,那应该不是个偶然——这很可能是内存损坏,你需要找到受影响的主机并尽快把它从集群中移除,不然就会有损坏大量用户数据的风险。这是个事故,而你必须迅速做出反应。但如果是用户的电脑在破坏数据,你并没有什么可以做的。
分享你的 bug
如果你在调试一个难搞的 bug,特别是在大型系统中,不要忘记跟别人讨论。也许你的同事以前就遇到过类似的 bug。若是如此,你可能会节省很多时间。就算他们没有见过,也不要忘记在你解决了问题之后告诉他们解决方法——写下来或者在组会中分享。这样下次你们组遇到类似的问题时,你们都会早有准备。
Bug 如何帮助你进步
Recurse Center
在加入 Dropbox 之前,我曾在 Recurse Center 工作。它的理念是建立一个社区让正在自学的程序员们聚到一起来提高能力。这就是 Recurse Center 的全部了:我们没有大纲、作业、截止日期等等。唯一的前提条件是我们都想要成为更好的程序员。参与者中有的人有计算机学位但对自己的实际编程能力不够自信,有的人已经写了十年 Java 但想学 Clojure 或者 Haskell,还有各式各样有着其他的背景的参与者。
我在那里是一位导师,帮助人们更好地利用这个自由的环境,并参考我们从以前的参与者那里学到的东西来提供指导。所以我的同事们和我本人都非常热衷于寻找对成年自学者最有帮助的学习方法。
刻意练习
在学习方法这个领域有很多不同的研究,其中我觉得最有意思的研究之一是刻意练习的概念。刻意练习理论意在解释专业人士和业余爱好者的表现的差距。它的基本思想是如果你只看内在的特征——不论先天与否——它们都无法非常好地解释这种差距。于是研究者们,包括最初的 Ericsson、Krampe 和 Tesch-Romer,开始寻找能够解释这种差距的理论。他们最终的答案是在刻意练习上所花的时间。
他们给刻意练习的定义非常精确:不是为了收入而工作,也不是为了乐趣而玩耍。你必须尽自己能力的极限,去做一个和你的水平相称的任务(不能太简单导致你学不到东西,也不能太难导致你无法取得任何进展)。你还需要获得即时的反馈,知道自己是否做得正确。
这非常令人兴奋,因为这是一套能够用来建立专业技能的系统。但难点在于对于程序员来说这些建议非常难以实施。你很难知道你是否处在自己能力的极限。也很少有即时的反馈帮助你改进——有时候你能得到任何反馈都已经算是很幸运了,还有时候你需要等几个月才能得到反馈。对于在 REPL 中做的简单的事情你可以很快地得到反馈,但如果你在做一个设计上的决定或者技术上的选择,你在很长一段时间里都无法得到反馈。
但是在有一类编程工作中刻意练习是非常有用的,它就是 debug。如果你写了一份代码,那么当时你是理解这份代码是如何工作的。但你的代码有 bug,所以你的理解并不完全正确。根据定义来说,你正处在你理解能力的极限上——这很好!你马上要学到新东西了。如果你可以重现这个 bug,那么这是个宝贵的机会,你可以获得即时的反馈,知道自己的修改是否正确。
像这样的 bug 也许能让你学到关于你的程序的一些小知识,但你也可能会学到一些关于运行你的代码的系统的一些更复杂的知识。我接下来要讲一个关于这种 bug 的故事。
Bug 2 号
这也是我在 Dropbox 工作时遇到的 bug。当时我正在调查为什么有些桌面客户端没有像我们预期的那样持续发送日志。我开始调查客户端的日志系统并且发现了很多有意思的 bug。我会挑一些跟这个故事有关的 bug 来讲。
和之前一样,这是一个非常简化的系统架构。
+--------------+
| |
+---+ +----------> | 日志服务器 |
|日志| | | |
+---+ | +------+-------+ | |
+-----+----+ | 200 ok | | |
| 客户端 | <-----------+ | |
+-----+----+
^
+--------+--------+--------+
| ^ ^ |
+--+--+ +--+--+ +--+--+ +--+--+
| 日志 | | 日志 | | 日志 | | 日志 |
| | | | | | | |
| | | | | | | |
+-----+ +-----+ +-----+ +-----+
桌面客户端会生成日志。这些日志会被压缩、加密并写入硬盘。然后客户端会间歇性地把它们发送给服务器。客户端从硬盘读取日志并发送给日志服务器。服务器会将它解码并存储,然后返回 200。
如果客户端无法连接到日志服务器,它不会让日志目录无限地增长。超过一定大小之后,它会开始删除日志来让目录大小不超过一个最大值。
最初的两个 bug 本身并不严重。第一个 bug 是桌面客户端向服务器发送日志时会从最早的日志而不是最新的日志开始。这并不是很好——比如服务器会在客户端报告异常的时候让客户端发送日志,所以你可能最在乎的是刚刚生成的日志而不是在硬盘上的最早的日志。
第二个 bug 和第一个相似:如果日志目录的大小达到了上限,客户端会从最新的日志而不是最早的日志开始删除。同理,你总是会丢失一些日志文件,但你大概更不在乎那些较早的日志。
第三个 bug 和加密有关。有时服务器会无法对一个日志文件解码(我们一般不知道为什么——也许发生了位反转)。我们在后端没有正确地处理这个错误,而服务器会返回 500。客户端看到 500 之后会做合理的反应:它会认为服务器停机了。所以它会停止发送日志文件并且不再尝试发送其他的日志。
对于一个损坏的日志文件返回 500 显然不是正确的行为。你可以考虑返回 400,因为问题出在客户端的请求上。但客户端同样无法修复这个问题——如果日志文件现在无法解码,我们后也永远无法将它解码。客户端正确的做法是直接删除日志文件然后继续运行。实际上,这正是客户端在成功上传日志文件并从服务器收到 200 的响应时的默认行为。所以我们说,好——如果日志文件无法解码,就返回 200。
所有这些 bug 都很容易修复。前两个 bug 出在客户端上,所以我们在 alpha 版本修复了它们,但大部分的客户端还没有获得这些改动。我们在服务器代码中修复了第三个 bug 并部署了新版的服务器。
激增
突然日志服务器集群的流量开始激增。客服团队找到我们并问我们是否知道原因。我花了点时间把所有的部分拼到一起。
在修复之前,这四件事情会发生:
-
日志文件从最早的开始发送
-
日志文件从最新的开始删除
-
如果服务器无法解码日志文件,它会返回 500
-
如果客户端收到 500,它会停止发送日志
一个存有损坏的日志文件的客户端会试着发送这个文件,服务器会返回 500,客户端会放弃发送日志。在下一次运行时,它会尝试再次发送同样的文件,再次失败,并再次放弃。最终日志目录会被填满,然后客户端会开始删除最新的日志文件,而把损坏的文件继续保留在硬盘上。
这三个 bug 导致的结果是:如果客户端在任何时候生成了损坏的日志文件,我们就再也不会收到那个客户端的日志了。
问题是,处于这种状态的客户端比我们想象的要多很多。任何有一个损坏文件的客户端都会像被关在堤坝里一样,无法再发送日志。现在这个堤坝被清除了,所有这些客户端都开始发送它们的日志目录的剩余内容。
我们的选择
好的,现在文件从世界各地的电脑如洪水般涌来。我们能做什么?(当你在一个有 Dropbox 这种规模,尤其是这种桌面客户端的规模的公司工作时,会遇到这种有趣的事情:你可以非常轻易地对自己造成 DDoS 攻击)。
当你部署的新版本发生问题时,第一个选项是回滚。这是非常合理的选择,但对于这个问题,它无法帮助我们。我们改变的不是服务器的状态而是客户端的——我们删除了那些出错文件。将服务器回滚可以防止更多客户端进入这种状态,但它并不能解决根本问题。
那扩大日志集群的规模呢?我们试过了——然后因为处理能力增加了,我们开始收到更多的请求。我们又扩大了一次,但你不可能一直这么下去。为什么不能?因为这个集群并不是独立的。它会向另一个集群发送请求,在这里是为了处理异常。如果你的一个集群正在被 DDoS,而你持续扩大那个集群,你最终会把它依赖的集群也弄坏,然后你就有两个问题了。
我们考虑过的另一个选择是减低负载——你不需要每一个日志文件,所以我们可以直接无视一些请求。一个难点是我们并没有一个很好的方法来区分好的请求和坏的请求。我们无法快速地判断哪些日志文件是旧的,哪些是新的。
我们最终使用的是一个 Dropbox 里许多不同场合都用过的一个解决方法:我们有一个自定义的头字段,chillout
,全世界所有的客户端都遵守它。如果客户端收到一个有这个头字段的响应,它将在字段所标注的时间内不再发送任何请求。很早以前一个英明的程序员把它加到了 Dropbox 客户端里,在之后这些年中它已经不止一次地起了作用。
了解你的系统
这个 bug 的第一个教训是要了解你的系统。我对于客户端和服务器之间的交互有不错的理解,但我并没有考虑到当服务器和所有这些客户端同时交互的时候会发生什么。这是一个我没有完全搞懂的层面。
了解你的工具
第二个教训是要了解你的工具。如果出了差错,你有哪些选项?你能撤销你做的迁移吗?你如何知道事情出了差错,你又如何发现更多信息?所有这些事情都应该在危机发生之前就了解好——但如果你没有,你会在危机发生时学到它们并不会再忘记。
功能开关 & 服务器端功能控制
第三个教训是专门针对移动端和桌面应用开发者的:你需要服务器端功能控制和功能开关。当你发现一个问题时如果你没有服务器端的功能控制,你可能需要几天或几星期来推送新版本或者提交新版本到应用商店中,然后问题才能得到解决。这是个很糟糕的处境。Dropbox 桌面客户端不需要经过应用商店的审查过程,但光是把一个版本推送给上千万的用户就已经要花很多时间。相比之下,如果你能在新功能遇到问题的时候在服务器上翻转一个开关:十分钟之后你的问题就已经解决了。
这个策略也有它的代价。加入很多的功能开关会大幅提高你的代码的复杂度。而你的测试代码更是会成指数地复杂化:要考虑 A 功能和 B 功能都开启,或者仅开启一个,或者都不开启的情况——然后每个功能都要相乘一遍。让工程师们在事后清理他们的功能开关是一件很难的事情(我自己也有这个毛病)。另外,桌面客户端会同时有好几个版本有人使用,也会加大思考难度。
但是它的好处——啊,当你需要它的时候,你真的是很需要它。
如何去爱 bug
我讲了几个我爱的 bug,也讲了为什么要爱 bug。现在我想告诉你如何去爱 bug。如果你现在还不爱 bug,我知道唯一一种改变的方法,那就是要有成长型心态。
社会学家 Carol Dweck 做了很多关于人们如何看待智力的研究。她找到两种不同的看待智力的心态。第一种,她叫做固定型心态,认为智力是一个固定的特征,人类无法改变自己智力的多寡。另一种心态叫做成长型心态。在成长型心态下,人们相信智力是可变的而且可以通过努力来增强。
Dweck 发现一个人看待智力的方式——固定型还是成长型心态——可以很大程度地影响他们选择任务的方式、面对挑战的反应、认知能力、甚至是他们的诚信度。
【我在新西兰 Kiwi Pycon 会议所做的主题演讲中也讨论过成长型心态,所以在此只摘录一部分内容。你可以在这里找到完整版的演讲稿】
关于诚信的发现:
在这之后,他们让学生们给笔友写信讲这个实验,信中说“我们在学校做了这个实验,这是我得的分数”。他们发现 因智力而受到表扬的学生中几乎一半人谎报了自己的分数 ,而因努力而受表扬的学生则几乎没有人不诚实。
关于努力:
数个研究发现有着固定型心态的人会不愿真正去努力,因为他们认为这意味着他们不擅长做他们正努力去做的这件事情。Dweck 写道,“如果每当一个任务需要努力的时候你就会怀疑自己的智力,那么你会很难对自己的能力保持自信。”
关于面对困惑:
他们发现有成长型心态的学生大约能理解 70% 的内容,不论里面是否有难懂的段落。在有固定型心态的学生中,那些被分配没有难懂段落的手册的学生同样可以理解大约 70%。但那些看到了难懂段落的持固定型心态的学生的记忆则降到了 30%。有着固定型心态的学生非常不擅长从困惑中恢复。
这些发现表明成长型心态对 debug 至关重要。我们必须从从困惑中重整旗鼓,诚实地面对我们理解上的不足,并时不时地在寻找答案的路上努力奋斗——成长型心态会让这些都变得更简单而且不那么痛苦。
热爱你的 bug
我在 Recurse Center 工作时会直白地欢迎挑战,我就是这样学会热爱我的 bug 的。有时参与者会坐到我身边说“唉,我觉得我遇到了个奇怪的 Python bug”,然后我会说“太棒了,我 爱 奇怪的 Python bug!” 首先,这百分之百是真的,但更重要的是,我这样是在对参与者强调,找到让自己觉得困难的事情是一种成就,而他们做到了这一点,这是件好事。
像我之前说过的,在 Recurse Center 没有截止日期也没有作业,所以这种态度没有任何成本。我会说,“你现在可以花一整天去在 Flask 里找出这个奇怪的 bug 了,多令人兴奋啊!”在 Dropbox 和之后的 Pilot,我们有产品需要发布,有截止日期,还有用户,于是我并不总是对在奇怪的 bug 上花一整天而感到兴奋。所以我对有截止日期的现实也是感同身受。但是如果我有 bug 需要解决,我就必须得去解决它,而抱怨它的存在并不会帮助我之后更快地解决它。我觉得就算在截止日期临近的时候,你也依然可以保持这样的心态。
如果你热爱你的 bug,你可以在解决困难问题时获得更多乐趣。你可以担心得更少而更加专注,并且从中学到更多。最后,你可以和你的朋友和同事分享你的 bug,这将会同时帮助你自己和你的队友们。
软件测试学习交流群:273462828,群里有分享的视频,面试指导,测试资料,还有思维导图、群里有视频,都是干货的,你可以下载来看。主要分享测试基础、接口测试、性能测试、自动化测试、TestOps架构、Jmeter、LoadRunner、Fiddler、MySql、Linux、简历优化、面试技巧以及大型测试项目实战视频资料。合理利用自己的时间来学习提升自己,不要在焦虑中举步维艰,迷失在焦急慌乱之间。
获取往期资深测开工程师精讲资料、精讲视频、Jmeter、自动化测试、脚本编写技巧、Fiddler进阶抓包、接口自动化测试实战等等技术,可加入软件测试学习交流群:273462828