那些让人睡不着觉的bug,你有没有遭遇过?

我先讲一个小故事,以前在外企工作时的一个亲身经历。

当时我所在的team,负责手机上多媒体Library方面的开发。有一天,一个具有最高等级的bug被转到了我的手上。这个bug非常诡异,光是重现它就需要花很长时间。在公司内部的issue追踪系统上,测试人员描述了详尽的重现步骤,大概意思是说,用某个指定产品线的手机去播放一段《黑客帝国》的视频,大概播放到半个小时左右的时候,程序就会突然崩溃。

你可以想象,造成崩溃问题的可能原因实在太多了,比如某个局部算法的实现发生地址越界了,或者多线程执行的时序混乱了,再或者传给解码器的参数传递错了,等等,诸如此类。问题可能出在上层应用,也可能出在中间的Library,或者编解码器有问题,甚至是底下的内核或硬件不稳定(我当时所在的公司是一家手机制造商,软件和硬件都是自己设计),总之,你能想到的或想不到的都有可能。

事实上,对于这个问题的分析也是按照从上至下的顺序进行的。首先,既然播放会崩溃,那至少看起来是播放器的问题啊,OK,issue会先转给播放器的开发团队。播放器的开发团队经过分析之后发现,并不是他们的代码引发的问题,接下来他们把分析结果附在issue的处理历史上,并把issue转给下一个团队处理。那应该转给哪个团队呢?就要看上一个团队的分析结果了。他们在分析的过程中,会追踪到最终崩溃的地方是发生在他们引用的哪个代码模块中,然后就有专门的人负责找到维护相关模块的团队。就这样,这个bug从上层开始,经过层层流转,终于有一天来到了我的手上。

一个团队被分发到这样一个最高等级的bug,就意味着必须停下正在进行的一些工作,立即分出人手来处理它。这就像一个烫手的山芋,谁也不想让它在自己的团队里待上太长时间。我经过大半天的分析,终于证明了崩溃的精确位置并不在我们负责维护的代码区域里,而是在我们调用的更下层的一个模块中。OK,在系统中填上分析结果,附上分析日志,再起草一封总结邮件,我的处理工作就此愉快地结束。但是,bug依然存在!

有人会好奇,这个bug后来怎么样了?它就这样在issue追踪系统上转了个把月,最后由于对应的产品线被cancel了(也就是那个产品线被砍掉了),自然所有相关的bug也就没必要再去解决了。这个bug就这样不了了之了......

···

我所说的这家外企,曾经以完善的工作流程和质量管理体系而著名。不管是开发新特性,还是解bug,都是靠流程去推动。公司员工众多,并且分布在全球范围,这样的一套内部管理流程自然是必不可少的。假设当时那个bug,如果最后不是被cancel了,那么它会不会在流程的推动下最终被解决呢?我觉得,会,一定会。只要时间足够长,最终它肯定会被转到真正能够解决它的人手里。只不过,这里的问题是,整体运转的效率太低下了。

与传统的IT公司不同,互联网公司一般被认为是运转效率更高的。但有些bug其实更加难解,因为互联网产品运行的环境更加复杂多变。面对一些难解决的问题,比如对于某些用户报出来的但我们自己却无法重现的问题,我们有时候会碰到这样一幕:后端的同学查完,宣称后端没有发现问题;然后客户端或前端同学查完,也宣称没有发现问题。最后,大家也不能一直耗在这一个问题上,后面还有数不清的开发需求在排队,于是,问题也同样不了了之了。等过了一个月,两个月,甚至是一年,有些「老大难」的问题依然存在。

这显然不是我们希望看到的结果。那么问题到底出在哪呢?

首先,没有人能了解全貌。像我开头讲的外企中的那个例子,每个团队基本只了解自己负责的模块,没有人知道问题真正出在哪。这时候最理想的情况是,公司有一些元老级的技术专家,他们可能在公司初创的时候就在,随着公司一起成长,既懂业务又懂技术,能够从上层一直分析到底层,最终把问题解决掉,或者至少分析到足够的细节再转给真正能解决问题的人。但事实往往事与愿违,公司就算有一些元老的员工,他们也往往过早地脱离了技术。他们通常很忙,忙着开各种各样的会(当然开会并不是一个贬义词)...... 那实际中我们如果没有这样了解全盘的人该怎么办呢?这就需要责任心极强的人,能够把解决问题的各方串起来。

其次,缺少足够的分析问题的手段和工具。对于知道如何重现的问题,一般来说都比较容易解决,工程师通过调试,一步步跟踪,总能找到问题所在。但对于那些不好重现的问题,往往令人一筹莫展,因为我们不知道问题发生时的真实情况,也就是抓不到「现场」。

记得刚开始出来创业那会儿,我们的服务器发生了一件奇怪的事。每隔一两天,就会有台Web服务器莫名地死掉。当时的报警机制也不太完善,问题发生时又多是在深夜,等问题出现时去看的时候,服务器已经登录不了了,于是只能重启解决,而重启之后问题也就消失了。通过一些监控工具去观察,只能看到机器重启前CPU暴涨,跑到了100%,可能是由于用的是虚拟机的缘故,那个时候机器就陷入「假死」了。经过反复追踪,终于有一次抓到「现场」了,在CPU跑满之前把流量从出问题的机器上卸了下来,结果那台机器的CPU竟依然居高不下。最后使用jstack分析了半天,发现有一些线程出现了死循环(仔细看才能看出来),原来是有一个HashMap被用在了多线程的环境下,结果内部的数据结构发生混乱了,在JDK内部对Map进行遍历操作的时候出现了死循环,最后把CPU跑满了。本来是个线程安全问题,表现出来却是一个性能问题。现在回想起来,如果当时有更完善的监控工具,就能尽早地发现问题;如果对程序的栈结构和jstack工具有更深的了解,就能更快地分析出问题原因。

另外,对于互联网产品上经常出现的那种用户侧有问题,而我们却无法重现的情况,技术同学感觉到解决困难的原因,也往往是供他分析的「资料」不足。

第三,也是最重要的,我们需要的是锲而不舍的精神。顽固的bug就像狡猾的猎物,它会激起出色猎手的兴趣,而普通的猎手则会轻易放弃。出色的猎手会一直追踪它,直到最终捕获。对待顽固的那些bug,真正的解决之道其实只有一个,那就是你要比它们更加顽固。

很多人会产生这样一种想法,认为解bug纯粹是个体力活,不值得长时间投入。实际上,对于技术专业本身的进阶来说,这却是打怪升级,使得技艺登堂入室的必要一步。一方面,当你一直在留心观察某一个问题的时候,你对于系统相关的运行模式会愈发地熟悉。你知道正常情况下的参数水平,也能识别出每一个异常情况。几乎没有别的方式能让你对系统的了解达到如此深入和敏感的程度。另一方面,我之前在《技术攻关:从零到精通》一文中也提到过,研究某个具体问题本身就可能引发整个架构的调整。 当旧的架构怎么修补也无法解决问题的时候,它最终将化茧成蝶、浴火重生,所有这些因素逼迫系统的架构向着更高的层次进化。

···

实际中我们一般会碰到哪些比较顽固的问题呢?至少有这么三类:

    不一定什么时候出现的;
    跟性能有关的(找性能瓶颈);
    只在特定环境中出现的。

我前面提到的那个CPU跑满的例子,就属于第一类。对这种问题,一方面,要仔细研究代码,另一方面,就是在问题出现之前做好充分的准备,记录下足够多的日志信息,这样才能在问题真正出现时「抓住」它。

跟性能有关的问题,它的难点就在于当问题出现时它所表现出来的各个因素相互影响,分不清哪个是因哪个是果。我们有时需要进行复杂的Profiing(动态的性能分析)才能找到原因。客户端的问题相对单纯一点,有很多成熟的Profiling的工具,而服务器的情况相对复杂一些。突然想到了胡峰同学在他的公众号「瞬息之间」上翻译过的一篇文章《认清性能问题》,写得很好,值得一读。文章对于响应时间和吞吐量的关系,以及性能拐点的描述,令人印象深刻,很有指导意义。

在创业的这几年中,随着访问量的增大,性能问题一个接着一个(特别是数据库的性能问题)。但真正印象深刻的还是创业初期碰到的那些问题,也许是因为当时经验不足,所以才感触比较深吧。记得有一天早上流量高峰期,几台Web服务器相继宕机。重启之后内存渐渐走高,坚持不了几分钟,内存就又爆掉。大家简单地分析之后,还是不确定是什么具体原因。于是我跟坐在旁边的李甫同学商量,要不你负责把服务器定期地提前重启一下,别等它自己OOM了......至少线上服务不会一点都不可用,而我来负责用工具分析一下。然后用jmap把整个heap都dump了出来,把dump文件拷贝到一台空闲机器上,再启动jhat来观察。结果一下子看得比较明显了,是源于ConcurrentHashMap相关的一些数据结构造成了内存泄露。分析到这里,如果没有相关的经验,可能仍然不知道是怎么回事。但是结合网上的资料,就能把怀疑指向一点:可能代码中使用了HttpSession,而HttpSession是由ConcurrentHashMap来管理的。查找一下工程代码,果然,用到了request.getSession()。在分布式的Web架构中,HttpSession就是个鸡肋,很多有开发经验的公司都会禁止程序员使用它,但总有人忘了这件事。还好,这些调用的地方一般都比较容易消除。改掉之后内存问题也就随之消失了。

第三类「只在特定环境中出现的」问题,更是难以解决。通常它们只影响少部分用户,所以人们的重视一般也不够。这种问题客户端开发遭遇的会比较多,特别是安卓客户端,主要是执行环境太复杂了。它们有时候只在某些特定机型上出现,有时候只对于某些特定用户出现,还有时候甚至只在特定的网络环境下才出现。

比如有一次,有些用户报告我们的App里某个游戏打不开,原因是资源下载总是失败,而且只在手机连接WIFI的时候下载失败,如果换成了3G信号就可以下载成功了。我们自然是重现不了,直到后来拿到了一些用户手机上下载的部分文件才弄清怎么回事。原来是我们的游戏资源中包含一个XML配置文件,而这个文件被插入了一段JS代码,于是对于这个文件我们的下载程序就校验不过去了。那是谁插入的JS代码呢?答案是运营商。为什么要插入呢?是为了显示一个广告......没错,这就是传说中的被运营商「流量劫持」了。运营商的程序把这个XML文件误认为是一个网页了。

还有一次,突然有用户报告在某些vivo机型上,QQ登录失败。我们自己重现不了,我们拿现有的vivo手机也重现不了,甚至尝试把设置里的「不保留活动」选项打开,仍然重现不了。记得当时是皇甫同学在解决这个问题,花了不少力气,最终想办法拿到了用户手机上的执行日志,才搞清了是什么状况。原来是在跳转到QQ去登录之前,我们的程序在内存里保存了一个变量,等从QQ登录完跳回来之后,这个变量的值消失了。这个变量保存在一个单例的实例里面,按说不应该被释放。但可能是由于那个手机型号上系统资源严重不足,或者是系统有些特殊的设置,导致跳转到QQ之后,我们的进程被系统KILL了(这在安卓系统上应该属于正常的行为),自然所有内存的值也都保存不住了。

大家可能已经看出来了,解决在用户侧发生的特定问题,关键是能够收集到用户的本地运行日志。比如微信开放出来的Mars Xlog,就是做这个事情的,是很好的一个工具。据说作者最近还会放出一些新的特性。如果你用的是iOS版微信,那么在添加朋友的时候输入「:up」,就能看到微信的日志上报界面(安卓版微信我不知道怎么能点出来,有知道的同学可以在下面留言)。这里上报的日志据说就是通过Xlog打印出来的。

总之,当用户报此类「只在特定环境中出现的」问题时,由于我们一般都无法重现,开发人员首先会觉得非常奇怪。但奇怪本身并解决了不了任何问题。当客户端技术人员在排查完之后宣称代码没有问题的时候,很可能他并不了解用户侧真正发生的情况,而他得出的结论也只是一种「猜测」而已。用事实说话,而不是凭借主观臆断,应该是技术人员行事的基本原则。如果一定要违背这个原则才能做出论断,那我们宁可不做这个论断。

···

事情永远不可能完美,这个世界也没有完美的状态。程序在运转的过程中也总会出错。

记得以前在一个测试技术的培训会上,有一位讲师说过,「 哪怕只碰到一次的问题,也是问题。」关键在于,我们能承认和接受这种不完美,而不是去逃避或视而不见。要知道,工程技术的核心,就是设法让完美的逻辑模型在不完美的世界中能够畅快运行的一项技艺。

只要我们持续努力,就一定会比过去做得更好。

(完)

作者/来源:张铁蕾