我印象深刻的一个 Bug,是一个服务器网络框架无锁队列的 Bug 。那个 Bug 连续查找了五天的时间,才最后定位出来。
当时我们的分布式存储系统出现了性能瓶颈,定位后发现瓶颈是在服务器网络框架上,所以我们决定为此替换一个最新研发的网络框架。这个新的网络框架为了追求高的性能,采用了无锁队列的设计。
第一天编码测试完成后,在测试环境跑,完全正常,特地搞了一堆 Log 来重放请求,程序跑得特别欢快。
解决了当时的性能瓶颈,感觉特别的开心,但好景不长,服务部署到现网环境,跑不到一个小时,就 Core Dump 了。
尝试上线了几次,每次都是跑半个多小时,就 Core Dump 。
当时的第一反应是新网络框架的问题,这是很直觉的反应,但很快就产生了怀疑,因为这个框架也有其它模块在用,也没产生问题,当时觉得诡异了。
仔细查看了代码的修改记录,特定检查了版本管理系统的 Log,做了代码 Diff,确定确实只有这部分的修改。
凭着经验,我说服自己,这个时候应该坚信最明确的逻辑,不要走到其他歪路上去。
第二天,我把这个框架单独拎了出来,特地写了一个测试的模块,并用测试代码生成了一堆的请求数据,发送给测试模块。
小编是一个有着6年工作经验的工程师,关于C++,编程,自己有做材料的整合,一个完整的C++编程学习路线,学习资料和工具,能够进我的群7253,-91790收取,免费送给大家,希望你也能凭着自己的努力,成为下一个优秀的程序员
它疯狂的运转起来,跑了一个多小时,跑得很欢快,一切正常,啥 Bug 都没有。
开大了并发,继续压,依然没有问题。懵逼了!不知咋回事。再次小心得灰度系统上线,跑不到一个小时还是 Core Dump 了,这个时候,我开始怀疑人生了,这个是咋回事了,都想砸键盘了都。
然后我冷静了下来,经验告诉我,这时应该按照正常流程完整地跑一遍测试模块。
于是我把那个测试模块打包成了现网模块,切走了一台现网机器的流量,把测试模块给上线到了一台现网机器。
之后用工具往现网机器发送数据,不到一个小时,Core Dump 了。终于复现了这个 Corde Dump, 那一刻犹如哥伦布发现了新大陆,简直欣喜若狂啊。这个时候已经是第三天了。
我复现了 Bug,但依然没办法定位出具体的原因。Core Dump 出来的栈是全乱的,没有任何价值,接下来,就开始用 Log 跟踪法了。
我依据数据的流转过程,在每个关键点,都打上 Log,Log 包含了所在的函数,行数,程序逻辑的编号,全部的关键数据等等。
我仔细地设计了这个 Log,争取打得不多不少,太多容易看晕头,而且太多无效的信息,会掩盖了真正的问题;太少,信息不足,又不足以判断,所以这种情况下打 Log 也是个艺术活。
通过精心设计的 Log,终于发现数据在一个特定的环节混乱后,程序就一定会 Core Dump。
分析 Log , 发现数据包在最后时刻是完整的,但包似乎出现了乱序和重复。这个时候,才开始意识到可能是无锁队列的问题,因为只有队列出问题,包的进出顺序才会乱掉。
然后又花了半天的时间,专门为无锁队列写了测试用例,用数据疯狂地怼。在测试环境,依然一切安好,但上线到正式环境,压测半小时后,终于挂了。终于看到了胜利的曙光!这个时候已经是第四天了!
到现在已经很明确是无锁队列的问题了。但这个数据结构的代码不到 200 行。我拉了两个同事一起 Review,都没发现问题。但就是 Core Dump 了。
奇葩了,又陷入了人生怀疑,开始怀疑内存,怀疑 CPU,结果换了机器,还是一样。
后来,仔细地对比了现网环境和测试环境的区别,机器类型,操作系统版本都一样。然后编译器?咦!编译器?上去看了一下,结果发现编译器的版本不一样!
这段时间我所使用的现网编译环境升级了新的 GCC 版本,但测试编译环境,还是旧的版本的。(这个也比较坑)
当时的直觉是肯定跟编译器相关,但代码都一样,难道是编译器 Bug?不可能吧 ?!
后来想,不如将它们转换成汇编看看吧。于是用两个版本的编译器将 C 的代码各自转换成了汇编。然后 Diff 汇编代码,哇!发现真的有一行是不同的!
后来自己分析对比,发现是因为我们开启了 GCC 最高级别的代码性能优化,不同版本的 GCC 在一些没有特定依赖的语句上的优化是不同的。
说人话,就是有一段代码,如果加了锁,两个版本的编译器下,都会产生一样的汇编,如果没有加锁,代码有一行的顺序被调整了,当然,从编译器优化的角度讲是对的,是我们使用姿势不对。
但无论怎么样,终于找出了这个问题。苍天啊! 找了五天呀!最后当然是开开心心地上线了。
查这个 Bug 确实花费了很多的时间,不过也是没办法,你不解决 Bug,就不能上线,但中间也收获不少,特别是对编译器优化有了很深的印象,也算是为自己的查 Bug 能力,又贡献了一波经验吧。
对于 Bug , 我分享下自己的一些认识和建议。
面对 Bug 的态度
只要你持续地写代码,就一定会持续的产生 Bug,所以第一个事情是要摆正对 Bug 的心态。我遇到过两个极端。
第一个极端
遇到过一个 Leader,对系统质量相当重视,对我们写的代码要求很高,每次设计并写完一个新的系统,他喜欢跟你算这次的系统上线,产生了多少次故障,这半年时间产生了多少个 Bug,每个 Bug 的影响范围如何。
我们一堆人被搞得特别累,战战兢兢,到后面,大家都比较排斥去做优化,去重构代码,只求无过,不求有功了。
所以,我觉得这种方式不好,对 Bug 带着一种比较包容的态度去看待,可以减少不少的心理负担。
第二个极端
后来去了一个新的团队。新的团队很重视业务和工程迭代的速度,所以对代码质量和 Bug 的容忍度很高。如果是一个新上线的业务,是默许 Bug 存在的。
这种对质量过于松散的要求,在后面也带来不好的影响。大家对 Bug 太免疫了,以至于出现 Bug 和故障的时候,大家都不够紧张。
系统质量有一段时间出现比较大的问题,还因此被部门经理特训了一番,后面通过各种措施,才慢慢提高了整体的系统质量。
上面两种极端都不可取,应该很重视 Bug,尽量避免 Bug,但也不应该唯 Bug 多少论业绩。
具体到 Bug 的查找上,我说说我的一些经验。
Bug 的复现
我把 Bug 分为可重现的 Bug 和不可复现的 Bug 。
对于可重现的 Bug , 查找起来比较容易,比如可以用”二分查找“的方式,从模块层面开始定位起,每次折半,每次折半地缩小范围,一直到代码层面。
在代码层面,遵循一些常用的原则,比如:
看到内存拷贝,直觉上要想到内存越界。
看到数组,就要考虑是否索引越界。
看到指针,就要考虑是否正确解构。
看到多线程,就要考虑是否线程安全。
对于不可重现的 Bug,第一步就是要把它重现出来。
有时候特别的难,特别是并发形态下产生的 Bug,出现的时机和触发条件都不清楚。对于这种 Bug,只能通过各种尝试去复现它。
比如将多并发调整为单并发的方式,看能否复现,如果可以复现,就可以转化为可复现的 Bug,用”二分查找“的方式去排查。
如果不能复现,那极大概率是并发问题。这个时候最好先停止排查,仔细分析程序在并发状态下可能出问题的点。
大部分并发问题的根源,是互斥数据没有被正确读写,或者一些共享状态被错误修改。
静态代码检查
利用 Coverity 等代码检查工具进行代码的静态检查可以发现很多潜在的问题,而且修复的成本很低。团队后来引入了这个检查工具,确实带来了不错的效果。类似变量未初始化,疑似的内存越界等都有可能被检查出来。
编译器的 Warning。有些同学一开始的时候对 Warning 不重视。我们团队早期也遇到过这个情况。
那时候产品迭代的速度很快,所以大家写完代码,能够编译通过,就进行各种测试,然后准备上线了。
一开始的一两个 Warning,不太理会。后面发现越积越多,到最后终于成为一个不得不解决的问题。
部门还为此特地立项,来消除 Warning。先是在内部多次强调了这个理念,然后从基础库,基础模块开始实施,基础代码部分统一 Fix Warning,然后开启编译器把 Warning 当 Error 的开关。
完成之后,再逐步地推业务模块进行修改。反正折腾了好一段时间。
工欲善其事必先利其器
代码 Bug 出现的时候,善用一些排查工具可以极大得提升效率。比如对于 C/C++ 的 GDB 调试,内存泄漏时候 Valgrind 的检测,Linux 下面用 Perf Top 来析 CPU 的消耗等。
一开始的时候,我对这些工具不重视,老是觉得真正使用的时候,去查文档就行。
后面才发现,用工具查着问题时候,遇到不会用的命令或功能,再去查文档,是个痛苦的事情,来回切换的开销也使得效率低下。
后面就对这些辅助工具的使用重视了起来,专门花时间去学习和练习使用,反而提升了不少的效率。
打 Log 的艺术
很多时候,出现一个 Bug,未能定位出来,需要打上更多额外的 Log 来辅助排查。一开始的时候,是想到一点,打一个 Log,后面发现这么做没有章法,逻辑不清晰, 排查效率低下。
后来学会了,遇到 Bug 后,先在脑子里面分析一番,然后花一两个小时详细地设计 Log 的格式和打 Log 的位置。发现这种方式对查问题的效率提升很大。
所以遇到 Bug 的时候不要急躁,先静下来心来分析,在脑海里尽力重现出完整的运行逻辑,然后仔细地进行 Log 设计,包括 Log 包含的字段,打 Log 的点等。这样能极大的提升排查问题的效率。
结语
Bug 是程序员最不愿意面对,但又经常出现的一个 “诡异生物“。对待 Bug 要有正确的心态,最好不要跟业绩强绑定,也不能太过于疏忽。
Bug 的排查是个很复杂的事情,每个人都有自己的方式和做法,如果你有好的做法和建议,也欢迎在留言区分享给大家!