编译: 伯乐在线/学以致用123
2017 年 10 月初,我在贝洛奥里藏特(巴西) 的 Python Brasil 大会做了一个主题演讲。下面是这个演讲的笔记 。
我热爱 bug
我现在是 Pilot.com 的高级工程师,为初创公司开发自动记账系统。在这之前,我在 Dropbox 的桌面客户端团队工作,后面我会讲到在那里工作时的一些小故事。在那之前,我是 Recurse Center 的一个推进者,Recurse Center 对于程序员的感觉很像写作者的隐居地。 我在大学学习的是天体物理学,在成为工程师之前在金融机构工作了几年。
但是这些事情没有一件是重要的—你只需要记住我热爱 bug 就足够了。我热爱 bug 是因为它们非常有趣。它们富有戏剧性。一个大 bug 的查找过程曲折离奇。一个大 bug 很像一个很好的笑话或谜语,你期待一个输出,但结果却大相径庭。
在这个讲演中,我将会讲述一些我热爱的 bug,解释我为什么如此热爱 bug,然后说服你也应该热爱 bug。
第一个 Bug
好,直接进入 第一个 Bug。这是我在 Dropbox 遇到的一个 bug 。你可能知道,Dropbox 是个应用程序,可以将文件从一台计算机同步到云端,并同步到其它计算机。
这是一个简化的 Dropbox 架构图。桌面客户端在本地监控文件系统的变化。当它找到一个改变的文件,将阅读文件并对 4MB 块中的内容进行哈希处理。这些块存储在一个巨大的键值对存储后端,我们称之为 blockserver。键是经哈希处理的内容的摘要,值是内容本身。
当然,我们要避免多次上传同一个块。想象一下,你正在写一个文档,很可能只是更改了结尾–我们不希望一次又一次的上传开头部分。因此,在将块上传到 blockserver 之前,客户端与另一个管理 metadata 和权限的服务器通信,客户端询问 meta 服务器是否需要这个块或者是否见过这个块。meta 服务器对每个块是否需要上传进行响应。
所以,请求和响应看起来是这样的:客户端:’我有一个由哈希块 组成的更改文件。服务器响应”我有前面两个,上传第三个”。然后客户端将第三个上传到 blockserver。
上面是设想,下面的则是 bug 。
有时,客户端会发出一个奇怪的请求:每个哈希值应该是16个字符长,但是请求的长度是33个字符,比期望长度的两倍还多 1 。服务器不知道该怎么处理这个异常,会抛出一个异常。我们看到这个异常报告,并查看客户端的日志文件,真是奇怪的现象—客户端本地数据库损坏了,或者 python 将抛出 MemoryErrors ,所有这些都没有道理。
如果你从没有见过这个问题,那么这完全是个谜。但是一旦见过一次,之后的每一次都会认出它。这里有个提示:我们经常看到的 33 个字符长的字符串的中间的字符不是逗号而是。下面是我们在中间位置看到的其他字符:
l\x0c
逗号的 ascii 码是 44 ,的 ascii 码是 108,在二进制中,它们是这表示的:
bin(ord(',')):0101100
bin(ord('l')):1101100
你将发现与逗号仅仅相差 1 位。而这就是问题所在:一个位翻转 (bitflip)。客户端使用的内存有一个 bit 损坏了,现在客户端正在向服务器发送垃圾请求。
下面是出现位翻转时我们经常看到代替逗号的其他字符:
,:0101100
l:1101100
\x0c:0001100
$:0100100
(:0101000
.:0101110
- :0101101
位翻转是真实存在的!
我热爱这个 bug 是因为它证明了位翻转是真实存在的,而不只是理论概念。实际上,这种情况在一些领域中比其他领域更常见。 从低端或老硬件的用户获得请求是其中一个,这是很多运行 Dropbox 的笔记本电脑的真实情况。 另外一个有很多位翻转的领域是外层空间——太空没有大气层来保护内存免受高能粒子和辐射的影响,所以位翻转很常见。
在太空中,你可能真的非常关心数据的正确性。比如,你的代码可能用于让国际空间站中的宇航员生存下去,即使不是这样的关键任务,在太空中进行软件更新是很难的。如果真的需要应用程序不存在位翻转 ,可以采取多种硬件和软件方法。对于这个问题,Katie Betchold 有一个非常有趣的演讲。
Dropbox 不需要处理位翻转 。损坏内存的电脑是用户的,我们可以检测到逗号是否发生了位翻转,但如果它是不同的字符,我们不一定会知道,如果位翻转发生在磁盘读取的实际文件中,我们就不知道了。我们可以发现这个问题的空间太有限了,因此我们决定不对异常进行处理并继续。这类 bug 通常可以通过客户端重启电脑解决。
不容易发生的 bug 并不是不可能的
这是它成为我最喜欢的 bug 的原因之一。 它可以提醒我们 unlikely 和 impossible 的区别。 在足够的规模下, unlikely 事件以明显的速率发生。
通用 bug
我最喜欢这个错误的第二个原因在于通用。 这个 bug 可能发生在桌面客户端与 server 通信的任何位置,系统中有很多不同的端点和组件。这意味着 Dropbox 的许多工程师将会看到这个 bug 的不同版本。当你第一次看到它时,真的非常伤脑筋,但是之后很容易诊断,而且检查非常快:只需要看看中间的字符是不是。
文化差异
这个 bug 的一个有趣的副作用是它暴露了服务器团队和客户端团队的文化差异。 有时候服务器小组的成员会发现这个 bug 并进行调查。 如果一台服务器正在翻转位,这可能不是偶然的现象 – 很可能是内存损坏,你需要找到受影响的机器并尽快将其从服务器池中移出,否则可能会损坏大量的用户数据。 这是一个事件,你需要快速回应。 但是,如果用户的机器正在损坏数据,那么可以做的事情就不多了。
分享你的 Bug
所以,如果你正在研究一个令人困惑的 bug ,特别是大系统中的一个 bug ,不要忘了与别人交流。 也许你的同事之前看到过一个这样 bug 。 如果他们看到过,可以节省很多时间。 如果他们不知道,记得告诉别人解决问题的方法 – 写下来或在团队会议上讲出来。 下一次你们的队伍有类似的事情发生时,你们会更有准备。
Bug 如何帮助我们学习
Recurse Center
加入 Dropbox 之前,我在 Recurse Center (RC) 工作。RC 是一个社区,它的的理念是帮助具备自我导向的学习者通过协作共同成长为更好的程序员。这是 RC 的全部:这里没有任何课程、作业或者截止日期。唯一的课题是分享变为更好的程序员的目标。我们看到很多获得 CS 学位但是对实际编程没有把握的人参加这个项目,或者写了十年 Java 又想学习 Clojure 或者 Haskell 的人参加这个项目,当然还有很多其他的参与者。
我的工作是推进者,工作职责是帮助用户填补缺乏的结构和根据从以前的参与者身上学到的东西提供指导。 所以我和我的同事对于帮助自我激励的成年人学习最好的技术非常感兴趣。
刻意练习
这个领域有很多不同的研究,我认为最有趣的一项研究是刻意练习的思想。刻意练习试图解释专家与业余爱好者的差别。这里的指导原则是,如果你只关注与生俱来的特征-遗传或其他-它们不会对解释差异做出太大贡献。因此研究人员(开始是 Ericsson , Krampe 和 Tesch-Romer )开始研究是什么造成了这些差异。他们的结论是花费在刻意练习上的时间。
刻意练习定义的范围非常狭窄:不是为了报酬,也不是为了玩乐。我们必须在自己能力的边缘进行练习,做一个适合自己水平的项目(不会容易的学不到任何东西,也不会困难到毫无进展)。还必须获得做法是否有效的及时反馈。
这非常令人兴奋,因为这是如何构建专业知识的框架。但是挑战在于,对于程序员来讲,这个建议难以实现。程序员很难知道自己是否在能力边缘工作,及时反馈也非常罕见(在某些情况下可能会立即得到反馈,而在其他情况下可能需要几个月的时间才会有反馈)。你可以在 REPL 等一些小事上得到快速反馈,但是如何进行设计决策或者选择技术,很可能很长时间都无法得到反馈。
但是刻意练习对于调试代码非常有用。如果编写代码,编代码时会有代码如何工作的心智模式。如果代码有一个 bug ,那么心智模式并不完全正确。根据刻意练习的定义,你处在理解的边缘,太棒了,你即将学习新的东西。如果你能够重现 bug ,那么可以立即获得修复是否正确的反馈(这种情况非常罕见)。
这种类型的 bug 可能会使你了解一些关于自己程序的信息,也有可能学到代码所运行的系统的更多内容。我这里有一个这样的 bug 的故事。
第二个 Bug
这个 bug 也是在 Dropbox 工作时遇到的。那时,我正在研究为什么有些桌面客户端不按时发送日志 。我深入研究了客户端日志系统并发现一些有意思的 bug 。我们这里谈到的只是其中与这个故事有关一部分 。
下面是系统架构简图。
桌面客户端将生成日志 。这些日志被压缩、加密并写入磁盘,然后客户端定期将它们发送到服务器。客户端将从磁盘读取日志并将它们发送到日志服务器。日志服务器将解密并存储,然后返回 200 响应。
如果客户端无法连接日志服务器,它不会让日志目录无限增大。当日志目录达到一定大小时,客户端将删除日志从而保证日志目录的大小在最大范围之内。
最初的两个 bug 是些小问题。第一个是桌面客户端向服务器发送日志时从最旧的开始(而不是从最新的开始)。这不是我们想要的,比如,如果客户端报告了一个异常,服务器将要求客户端发送日志文件,这时你可能关心刚刚发生的情况的日志,而不是磁盘上最旧的日志。
第二个 bug 与第一个类似:如果日志目录达到设置的最大值,客户端将从最新的日志开始删除(而不是删除最旧的日志)。这时,哪种方法都会删除日志,只是我们更关心比较新的日志。
第三个 bug 与加密有关。有时,服务器无法解密日志文件(我们通常无法找到原因-可能是字节反转)。后端无法正确处理这个错误,因此服务器会返回 500 响应。客户端在接收到 500 响应时的表现相当合理:它将假设服务器已关闭。因此,它会停止发送日志文件,不再尝试发送其它文件。
对损坏的日志文件返回 500 响应显然是错误的行为。我们可以考虑返回 400 响应,因为这是客户端的问题。但是客户端也无法解决这个问题-如果日志文件现在无法解密,将来也无法解密。因此,我们真正想让客户端做的只是删除日志文件并继续工作。实际上,客户端从服务器获取 200 响应时默认日志文件存储成功。所以,如果日志文件无法解密,返回 200 响应就可以了。
所有这些 bug 都很容易修复。前两个错误发生在客户端,所以我们在 alpha 版本进行修复,但是还没有发布给大多数客户。我们在服务器上修复第三个错误并部署。
突然之间,日志集群流量激增。服务团队询问我们是否知道发生了什么事情。我花了一分钟的时间把所有情况放在一起。
在这些问题修复之前,四件事情正在发生:
日志文件从最老版本开始发送
日志文件从最新版本开始删除
如果服务器无法解密日志文件,它将返回 500 响应
如果客户端接收到 500 响应,它将停止发送日志
客户端可能会尝试发送损坏的日志文件,服务器返回 500 响应,客户端放弃发送日志。下一次运行时,它会尝试再次发送相同的文件,再次失败并再次放弃。最终日志目录会变满,客户端将开始删除最新日志文件,并将损坏的日志文件保留在磁盘上。
这三个 bug 的结果是:如果客户端曾经有一个损坏的日志文件,我们将再也看不到来自该客户端的日志文件。
问题在于,处于这种状态的客户端比我们想象的要多得多。 任何具有单个损坏文件的客户端都无法将日志文件发送到服务器。 现在这个问题被解决了,他们都在发送日志目录中的其余内容。
我们的选择
世界各地的机器会造成很大的流量,我们可以做什么呢?(在与 Dropbox 规模相当的公司工作是件有趣的事情,特别是 Dropbox 的桌面客户端规模:你可以轻易地触发自我 DDOS )。
进行部署时,发现问题的第一个选择是回滚。这是完全合理的选择,但是在这种情况下没有任何帮助。我们要转换的不是服务器上的状态,而是客户端上的状态–我们已经删除了这些文件。回滚服务器将防止其它客户端进入这个状态,但是不能解决问题。
增加日志集群的规模可行吗?我们这样做了,并开始接收到更多的请求,现在我们已经进行了扩容。我们又进行了一次扩容,但是不能总这样。为什么不能?这些集群不是隔离的,它将请求另外一个集群(这里是为了处理异常)。如果遇到指向一个集群的 DDOS ,并且持续扩大集群规模,那么需要解决它们的依赖关系,这样就变成两个问题了。
我们考虑的另一个选择是减轻负担-你不需要每个日志文件,所以我们可以放弃请求。这里的一个挑战在于很难确定哪个需要哪个不需要,我们无法快速区分新日志和旧日志。
我们确定的解决方案是 Dropbox 在许多不同场合使用的解决方案:我们有一个自定义标头,所有的客户端都可以接收这个标头。如果客户端接收到包含这个标头的响应,那么它在设定时间内不发送任何请求。有人非常明智的在很早的时候将它添加到 Dropbox 客户端中,多年来它不止一次派上用场。日志记录服务器无法设置这个标头,但这是一个容易解决的问题。我们的两个同事( Isaac Goldberg 和 John Lai )提供了支持。我们首先将日志集群的 chillout 设置为两分钟,高峰过去几天之后再将其关闭。
了解你的系统
这个 bug 的第一个教训是了解你的系统。我头脑中有一个很好的客户端和服务器进行交互的模型。但是,我并没有想到服务器同时与所有客户端交互时会发生什么?这是我从来没有想到过的复杂程度。
了解你的工具
第二个教训是了解你的工具。如果事情发生了,你可以采取什么措施?你可以反转迁移吗?如果事情发生了,你如何了解它,如何找到更多信息?最好在危机发生之前了解这些内容,如果你没有这样做,你将在危机发生过程中学到,然后永远不会忘记。
功能标志位 & 服务端门控
如果写移动或客户端应用,这是第三个教训:需要服务端特性门控和服务端标志位。当你发现一个问题并且无法控制服务端,发布一个新的版本或者向应用商店提交一个新版本可能需要几天甚至几周的时间。那是一种很不好的方法。Dropbox 客户端不需要处理应用商店审查流程,但是向几千万客户端推送也需要时间。我们也可以这样解决,出现问题时翻转服务器上的开关然后十分钟解决问题。
但是,这个策略也有开销。添加很多标志位会增加代码的复杂度。在测试中会遇到组合问题:如何同时启用了功能 A 和功能 B,或者只有一个,或者一个都不启动 —如果具有 N 个特性则会非常复杂。完成之后请工程师清理功能标志位也将会非常困难(我也犯了这个错误)。对于桌面客户端来讲,可能同时会有很多版本,这将很难处理。
但是好处在于—当你需要它们时,你真的非常需要它。
如何热爱 bugs
我谈到了我喜欢的一些 bug,并且谈到了为什么热爱这些 bug 。 现在我想告诉你如何去热爱 bug 。 如果你还不喜欢 bug,我知道一种学习方式–具有成长思维模式。
社会学家 Carol Dweck 在人们如何看待能力方面做过很多有趣的研究。她发现人们使用两种不同的框架认识能力。第一个,她称之为固定思维模式,认为能力是一成不变的,人们无法改变自己的能力。另一个思维模式为成长思维模式,在成长思维模式下,人们认为能力是可塑的,不断的努力可以让能力变得更强。
Dweck 发现一个人的能力框架-他们持有固定思维模式还是成长思维模式-会非常明显的影响他们选择任务的方式、他们应对挑战的方式、他们的认知表现、甚至他们的诚实。
我在 Kiwi PyCon 主题演讲中也谈到了成长思维,下面这些只是部分摘录,你可以阅读完整版本这里
关于诚实:
之后,他们让学生把这项研究的结果写信告诉笔友:“我们在学校做了这项研究,这是我得到的分数。” 他们发现近一半因为聪明被称赞的学生篡改了分数,因为努力工作而受称赞的学生则基本没有不诚实的。
关于努力:
几项研究发现,有固定思维模式的人可能不愿意付出努力,因为他们认为需要努力意味着他们不擅长正在从事的事情。Dweck 指出:“ 如果每次任务都需要努力,那么很难保持对自己能力的信心,你的能力将会受到质疑。”
对混乱的反应:
他们发现,不管资料里是否含有混乱的段落,成长思维的学生大约能够掌握资料的 70% 。固定思维的学生中,如果阅读不包括混乱段落的书,他们也可以掌握资料的 70%。但是当固定思维的学生遇到混乱的段落,他们的掌握率下降到 30% 。固定思维的学生在从混乱恢复过来的过程中会遇到很大的困难。
这些发现表明,debug 过程中成长思维非常关键。我们需要从混乱过程中恢复过来,对我们理解的局限性保持坦诚,有时找到解决方案的道路真的非常曲折—所有这些,具有成长思维的人更容易处理,遇到痛苦也会少一些。
热爱你的 bug
通过在 Recurse Center 工作时的庆祝挑战,我学会了热爱 bug 。一位参与者会坐到我旁边说:“[叹气] 我想我遇到了一个奇怪的 Python 错误”,我说:“太棒了,我热爱奇怪的 Python 错误!”首先,这是绝对正确的,但是更重要的是,这强调参与者找出一些他们努力取得成就的东西,完成它对于他们来说是件好事。
正如我提到的, Recurse Center 没有截止期限和重要节点,这种环境非常自由。我会说:“你可以花一整天的时间去查找 Flask 中这个奇怪的 bug ,多么刺激!” 在 Dropbox 和 Pilot,我们要发布产品、有截止日期、有用户,我并不总能花一天的时间解决一个奇怪的 bug 。因此,我对具有截止日期的现实世界深表同情。但是,如果我有一个需要修复的 bug ,我必须修复它,抱怨这个错误并不会帮助我更快地修复它。 我认为,即使在最终期限的即将到来的时候,你仍然可以持这种态度。
如果你热爱 bug ,在解决棘手问题时可能会获得更多的乐趣。你可能不那么担心并更加专注。最终会从中学到更多。最后,你可以与朋友和同事分享 bug ,这可以帮助你和你的队友。
谢谢
感谢那些对这次演讲给我反馈以及帮助我来到这里的朋友:
Sasha Laundy
Amy Hanlon
Julia Evans
Julian Cooper
Raphael Passini Diniz 和 Python Brasil 团队的其他成员
【关于投稿】
如果大家有原创好文投稿,请直接给公号发送留言。
① 留言格式:
【投稿】+《 文章标题》+ 文章链接
② 示例:
【投稿】《不要自称是程序员,我十多年的 IT 职场总结》:http://blog.jobbole.com/94148/
③ 最后请附上您的个人简介哈~
看完本文有收获?请转发分享给更多人
关注「Python开发者」,提升Python技能
领取专属 10元无门槛券
私享最新 技术干货