4 月份的时候看到一道面试题,听说是腾讯校招面试官提的:在多线程和高并发环境下,若是有一个平均运行一百万次才出现一次的 bug,你如何调试这个 bug?知乎原贴地址以下:腾讯实习生面试,这两道题目该怎么回答? – 编程html
遗憾的是知乎不少答案在抨击这道题自己的正确性,虽然我不是此次的面试官,但我认为这是一道很是好的面试题。固然,只是道加分题,答不上,不扣分。答得不错,说明解决问题的思路和能力要超过应届平生均水平。linux
之因此写上面这段,是由于我以为大部分后台服务端开发都有可能遇到这样的 BUG,即便没有遇到,这样的题目也可以激发你们不断思考和总结。很是凑巧的是,我在 4 月份也遇到了一个相似的并且要更加严重的 BUG,这是我本身挖的一个很深的坑,不填好,整个项目就没法上线。如今已通过去了一个多月,趁着有时间,本身好好总结一下,但愿里面提到的一些经验和工具可以带给你们一点帮助。nginx
咱们针对 nginx 事件框架和 openssl 协议栈进行了一些深度改造,以提高 nginx 的 HTTPS 彻底握手计算性能。c++
因为原生 nginx 使用本地 CPU 作 RSA 计算,ECDHE_RSA 算法的单核处理能力只有 400 qps 左右。前期测试时的并发性能很低,就算开了 24 核,性能也没法超过 1 万。web
核心功能在去年末就完成了开发,线下测试也没有发现问题。通过优化后的性能提高几倍,为了测试最大性能,使用了不少客户端并发测试 https 性能。很快就遇到了一些问题:面试
1.第一个问题是 nginx 有极低几率(亿分之一)在不一样地方 core dump。白天线下压力测试 2W qps 通常都要两三个小时才出一次 core。每次晚上睡觉以前都会将最新的调试代码编译好并启动测试,到早上醒来第一眼就会去查看机器并祈祷不要出 core,不幸的是,通常都会有几个到几十个 core,而且会发现常常是在一个时间点集中 core dump。线上灰度测试运行了 6 天,在第 6 天的早上才集中 core dump 了几十次。这样算来,这个 core dump 的几率至少是亿分之一了。 不过和面试题目中多线程不一样的是,nginx 采用的是多进程 + 全异步事件驱动的编程模式(目前也支持了多线程,但只是针对 IO 的优化,核心机制仍是多进程加异步)。在 webserver 的实现背景下,多进程异步相比多线程的优势是性能高,没有太多线程间的切换,并且内存空间独立,省去线程间锁的竞争。固然也有缺点,就是异步模式编程很是复杂,将一些逻辑上连续的事件从空间和时间切割,不符合人的正常思考习惯,出了问题后比较难追查。另外异步事件对网络和操做系统的底层知识要求较高,稍不当心就容易挖坑。redis
2.第二个问题是高并发时 nginx 存在内存泄漏。在流量低的时候没有问题,加大测试流量就会出现内存泄漏。算法
3.第三个问题,由于咱们对 nginx 和 openssl 的关键代码都作了一些改造,但愿提高它的性能。那么如何找到性能热点和瓶颈并持续优化呢?apache
其中第一和第二个问题的背景都是,只有并发上万 qps 以上时才有可能出现,几百或者一两千 QPS 时,程序没有任何问题。编程
首先说一下 core 的解决思路,主要是以下几点:
1.gdb 及 debug log 定位,发现做用不大。
2.如何重现 bug?
3.构造高并发压力测试系统。
4.构造稳定的异常请求。
由于有 core dump ,因此这个问题初看很容易定位。gdb 找到 core dump 点,btrace 就能知道基本的缘由和上下文了。 core 的直接缘由很是简单和常见,所有都是 NULL 指针引用致使的。不过从函数上下文想不通为何会出现 NULL 值,由于这些指针在原生 nginx 的事件和模块中都是这么使用的,不该该在这些地方变成 NULL。
因为暂时找不到根本缘由,仍是先解决 CORE dump 吧,修复办法也很是简单,直接判断指针是否 NULL,若是是 NULL 就直接返回,不引用不就完事了,这个地方之后确定不会出 CORE 了。
这样的防守式编程并不提倡,指针 NULL 引用若是不 core dump,而是直接返回,那么这个错误颇有可能会影响用户的访问,同时这样的 BUG 还不知道何时能暴露。因此 CORE DUMP 在 NULL 处,实际上是很是负责任和有效的作法。
在 NULL 处返回,确实避免了在这个地方的 CORE,可是过几个小时又 core 在了另一个 NULL 指针引用上。因而我又继续加个判断并避免 NULL 指针的引用。悲剧的是,过了几个小时,又 CORE 在了其余地方,就这样过了几天,我一直在想为何会出现一些指针为 NULL 的状况?为何会 CORE 在不一样地方?为何我用浏览器和 curl 这样的命令工具访问却没有任何问题?
熟悉 nginx 代码的同窗应该很清楚,nginx 极少在函数入口及其余地方判断指针是否为 NULL 值。特别是一些关键数据结构,好比‘ngx_connection_t’及 SSL_CTX 等,在请求接收的时候就完成了初始化,因此不可能在后续正常处理过程当中出现 NULL 的状况。
因而我更加迷惑,显然 NULL 值致使出 CORE 只是表象,真正的问题是,这些关键指针为何会被赋值成 NULL?这个时候异步事件编程的缺点和复杂性就暴露了,好好的一个客户端的请求,从逻辑上应该是连续的,可是被读写及时间事件拆成了多个片段。虽然 GDB 能准确地记录 core dump 时的函数调用栈,可是却没法准确记录一条请求完整的事件处理栈。根本就不知道上次是哪一个事件的哪些函数将这个指针赋值为 NULL 的, 甚至都不知道这些数据结构上次被哪一个事件使用了。
举个例子:客户端发送一个正常的 get 请求,因为网络或者客户端行为,须要发送两次才完成。服务端第一次 read 没有读取彻底部数据,此次读事件中调用了 A,B 函数,而后事件返回。第二次数据来临时,再次触发 read 事件,调用了 A,C 函数。而且 core dump 在了 C 函数中。这个时候,btrace 的 stack frame 已经没有 B 函数调用的信息了。
因此经过 GDB 没法准肯定位 core 的真正缘由
这时候强大的 GDB 已经派不上用场了。怎么办?打印 nginx 调试日志。可是打印日志也很郁闷,只要将 nginx 的日志级别调整DEBUG,CORE 就没法重现。为何?由于 DEBUG 的日志信息量很是大,频繁地写磁盘严重影响了 NGINX 的性能,打开 DEBUG 后性能由几十万直线降低到几百 qps。
调整到其余级别好比 INFO, 性能虽然好了,可是日志信息量太少,没有帮助。尽管如此,日志倒是个很好的工具,因而又尝试过如下办法:
1.针对特定客户端 IP 开启 debug 日志,好比 IP 是 10.1.1.1 就打印 DEBUG,其余 IP 就打印最高级别的日志,nginx 自己就支持这样的配置。
2.关闭 DEBUG 日志,本身在一些关键路径添加高级别的调试日志,将调试信息经过 EMERG 级别打印出来。
3.nginx 只开启一个进程和少许的 connection 数。抽样打印链接编号(好比尾号是 1)的调试日志。
整体思路依然是在不明显下降性能的前提下打印尽可能详细的调试日志,遗憾的是,上述办法仍是不能帮助问题定位,固然了,在不断的日志调试中,对代码和逻辑愈来愈熟悉。
这时候的调试效率已经很低了,几万 QPS 连续压力测试,几个小时才出一次 CORE,而后修改代码,添加调试日志。几天过去了,毫无进展。因此必需要在线下构造出稳定的 core dump 环境,这样才能加快 debug 效率。虽然尚未发现根本缘由,可是发现了一个很可疑的地方:出 CORE 比较集中,常常是在凌晨 4,5 点,早上 7,8 点的时候 dump 几十个 CORE。
联想到夜间有不少的网络硬件调整及故障,我猜想这些 core dump 可能跟网络质量相关。特别是网络瞬时不稳定,很容易触发 BUG 致使大量的 CORE DUMP。最开始我考虑过使用 TC(traffic control) 工具来构造弱网络环境,可是转念一想,弱网络环境致使的结果是什么?显然是网络请求的各类异常啊, 因此还不如直接构造各类异常请求来复现问题。因而准备构造测试工具和环境,须要知足两个条件:
1.并发性能强,可以同时发送数万甚至数十万级以上 qps。
2.请求须要必定几率的异常。特别是 TCP 握手及 SSL 握手阶段,须要异常停止。
traffic control 是一个很好的构造弱网络环境的工具,我以前用过测试 SPDY 协议性能。可以控制网络速率、丢包率、延时等网络环境,做为 iproute 工具集中的一个工具,由 linux 系统自带。但比较麻烦的是 TC 的配置规则很复杂,facebook 在 tc 的基础上封装成了一个开源工具 apc,有兴趣的能够试试。
因为高并发流量时才可能出 core,因此首先就须要找一个性能强大的压测工具。WRK是一款很是优秀的开源 HTTP 压力测试工具,采用多线程 + 异步事件驱动的框架,其中事件机制使用了 redis 的 ae 事件框架,协议解析使用了 nginx 的相关代码。相比 ab(apache bench)等传统压力测试工具的优势就是性能好,基本上单台机器发送几百万 pqs, 打满网卡都没有问题。wrk 的缺点就是只支持 HTTP 类协议,不支持其余协议类测试,好比 protobuf,另外数据显示也不是很方便。
nginx 的测试用法: wrk -t500 -c2000 -d30s https://127.0.0.1:8443/index.html
因为是 HTTPS 请求,使用 ECDHE_RSA 密钥交换算法时,客户端的计算消耗也比较大,单机也就 10000 多 qps。也就是说若是 server 的性能有 3W qps,那么一台客户端是没法发送这么大的压力的,因此须要构建一个多机的分布式测试系统,即经过中控机同时控制多台测试机客户端启动和中止测试。
以前也提到了,调试效率过低,整个测试过程须要可以自动化运行,好比晚上睡觉前,能够控制多台机器在不一样的协议,不一样的端口,不一样的 cipher suite 运行整个晚上。白天由于一直在盯着,运行几分钟就须要查看结果。 这个系统有以下功能:
并发控制多台测试客户端的启停,最后汇总输出总的测试结果。
支持 https,http 协议测试,支持 webserver 及 revers proxy 性能测试。
支持配置不一样的测试时间、端口、URL。
根据端口选择不一样的 SSL 协议版本,不一样的 cipher suite。
根据 URL 选择 webserver、revers proxy 模式。
压力测试工具和系统都准备好了,仍是不能准确复现 core dump 的环境。接下来还要完成异常请求的构造。构造哪些异常请求呢?因为新增的功能代码主要是和 SSL 握手相关,这个过程是紧接着 TCP 握手发生的,因此异常也主要发生在这个阶段。因而我考虑构造了以下三种异常情形:
异常的 tcp 链接。即在客户端 tcp connent 系统调用时,10% 几率直接 close 这个 socket。
异常的 ssl 链接。考虑两种状况,full handshake 第一阶段时,即发送 client hello 时,客户端 10% 几率直接 close 链接。full handshake 第二阶段时,即发送 clientKeyExchange 时,客户端 10% 几率直接直接关闭 TCP 链接。
异常的 HTTPS 请求,客户端 10% 的请求使用错误的公钥加密数据,这样 nginx 解密时确定会失败。
构造好了上述高并发压力异常测试系统,果真,几秒钟以内必然出 CORE。有了稳定的测试环境,那 bug fix 的效率天然就会快不少。虽然此时经过 gdb 仍是不方便定位根本缘由,可是测试请求已经知足了触发 CORE 的条件,打开 debug 调试日志也能触发 core dump。因而能够不断地修改代码,不断地 GDB 调试,不断地增长日志,一步步地追踪根源,一步步地接近真相。最终经过不断地重复上述步骤找到了 core dump 的根本缘由。
其实在写总结文档的时候,core dump 的根本缘由是什么已经不过重要,最重要的仍是解决问题的思路和过程,这才是值得分享和总结的。不少状况下,千辛万苦排查出来的,实际上是一个很是明显甚至愚蠢的错误。好比此次 core dump 的主要缘由是:因为没有正确地设置 non-reusable,并发量太大时,用于异步代理计算的 connection 结构体被 nginx 回收并进行了初始化,从而致使不一样的事件中出现 NULL 指针并出 CORE。
虽然解决了 core dump,可是另一个问题又浮出了水面,就是 ** 高并发测试时,会出现内存泄漏,大概一个小时 500M 的样子。
出现内存泄漏或者内存问题,你们第一时间都会想到 valgrind。valgrind 是一款很是优秀的软件,不须要从新编译程序就可以直接测试。功能也很是强大,可以检测常见的内存错误包括内存初始化、越界访问、内存溢出、free 错误等都可以检测出来。推荐你们使用。
valgrind 运行的基本原理是:待测程序运行在 valgrind 提供的模拟 CPU 上,valgrind 会纪录内存访问及计算值,最后进行比较和错误输出。我经过 valgrind 测试 nginx 也发现了一些内存方面的错误,简单分享下 valgrind 测试 nginx 的经验:
nginx 一般都是使用 master fork 子进程的方式运行,使用–trace-children=yes 来追踪子进程的信息
测试 nginx + openssl 时,在使用 rand 函数的地方会提示不少内存错误。好比 Conditional jump or move depends on uninitialised value,Uninitialised value was created by a heap allocation 等。这是因为 rand 数据须要一些熵,未初始化是正常的。若是须要去掉 valgrind 提示错误,编译时须要加一个选项:-DPURIFY
若是 nginx 进程较多,好比超过 4 个时,会致使 valgrind 的错误日志打印混乱,尽可能减少 nginx 工做进程, 保持为 1 个。由于通常的内存错误其实和进程数目都是没有关系的。
上面说了 valgrind 的功能和使用经验,可是 valgrind 也有一个很是大的缺点,就是它会显著下降程序的性能,官方文档说使用 memcheck 工具时,下降 10-50 倍。也就是说,若是 nginx 彻底握手性能是 20000 qps, 那么使用 valgrind 测试,性能就只有 400 qps 左右。对于通常的内存问题,下降性能没啥影响,可是我此次的内存泄漏是在大压力测试时才可能遇到的,若是性能下降这么明显,内存泄漏的错误根本检测不出来。
address sanitizer(简称 asan)是一个用来检测 c/c++ 程序的快速内存检测工具。相比 valgrind 的优势就是速度快,官方文档介绍对程序性能的下降只有 2 倍。对 Asan 原理有兴趣的同窗能够参考 asan 的算法这篇文章,它的实现原理就是在程序代码中插入一些自定义代码,以下:
和 valgrind 明显不一样的是,asan 须要添加编译开关从新编译程序,好在不须要本身修改代码。而 valgrind 不须要编程程序就能直接运行。
address sanitizer 集成在了 clang 编译器中,GCC 4.8 版本以上才支持。咱们线上程序默认都是使用 gcc4.3 编译,因而我测试时直接使用 clang 从新编译 nginx:
因为 AddressSanitizer 对 nginx 的影响较小,因此大压力测试时也能达到上万的并发,内存泄漏的问题很容易就定位了。这里就不详细介绍内存泄漏的缘由了,由于跟 openssl 的错误处理逻辑有关,是我本身实现的,没有广泛的参考意义。最重要的是,知道 valgrind 和 asan 的使用场景和方法,遇到内存方面的问题可以快速修复。
到此,通过改造的 nginx 程序没有 core dump 和内存泄漏方面的风险了。但这显然不是咱们最关心的结果(由于代码本该如此),咱们最关心的问题是:
代码优化前,程序的瓶颈在哪里?可以优化到什么程度?
代码优化后,优化是否完全?会出现哪些新的性能热点和瓶颈?
这个时候咱们就须要一些工具来检测程序的性能热点。
perf,oprofile,gprof,systemtap
linux 世界有许多很是好用的性能分析工具,我挑选几款最经常使用的简单介绍下:
[perf](Perf Wiki) 应该是最全面最方便的一个性能检测工具。由 linux 内核携带而且同步更新,基本能知足平常使用。** 推荐你们使用 **。
oprofile,我以为是一个较过期的性能检测工具了,基本被 perf 取代,命令使用起来也不太方便。好比 opcontrol –no-vmlinux , opcontrol –init 等命令启动,而后是 opcontrol –start, opcontrol –dump, opcontrol -h 中止,opreport 查看结果等,一大串命令和参数。有时候使用还容易忘记初始化,数据就是空的。
gprof主要是针对应用层程序的性能分析工具,缺点是须要从新编译程序,并且对程序性能有一些影响。不支持内核层面的一些统计,优势就是应用层的函数性能统计比较精细,接近咱们对平常性能的理解,好比各个函数时间的运行时间,,函数的调用次数等,很人性易读。
systemtap 实际上是一个运行时程序或者系统信息采集框架,主要用于动态追踪,固然也能用作性能分析,功能最强大,同时使用也相对复杂。不是一个简单的工具,能够说是一门动态追踪语言。若是程序出现很是麻烦的性能问题时,推荐使用 systemtap。
这里再多介绍一下 perf 命令,tlinux 系统上默认都有安装,好比经过 perf top 就能列举出当前系统或者进程的热点事件,函数的排序。
perf record 可以纪录和保存系统或者进程的性能事件,用于后面的分析,好比接下去要介绍的火焰图。
火焰图 flame graph
perf 有一个缺点就是不直观。火焰图就是为了解决这个问题。它可以以矢量图形化的方式显示事件热点及函数调用关系。好比我经过以下几条命令就能绘制出原生 nginx 在 ecdhe_rsa cipher suite 下的性能热点:
1.perf record -F 99 -p PID -g — sleep 10
2.erf script | ./stackcollapse-perf.pl > out.perf-folded
3../flamegraph.pl out.perf-folded>ou.svg
直接经过火焰图就能看到各个函数占用的百分比,好比上图就能清楚地知道 rsaz_1024_mul_avx2 和 rsaz_1024_sqr_avx2 函数占用了 75% 的采样比例。那咱们要优化的对象也就很是清楚了,能不能避免这两个函数的计算?或者使用非本地 CPU 方案实现它们的计算?
固然是能够的,咱们的异步代理计算方案正是为了解决这个问题,
心态
为了解决上面提到的 core dump 和内存泄漏问题,花了大概三周左右时间。压力很大,精神高度紧张, 说实话有些狼狈,看似几个很简单的问题,搞了这么长时间。内心固然不是很爽,会有些着急,特别是项目的关键上线期。但即便这样,整个过程我仍是很是自信而且斗志昂扬。我一直在告诉本身:
1.调试 BUG 是一次很是可贵的学习机会,不要把它当作是负担。无论是线上仍是线下,可以主动地,高效地追查 BUG 特别是有难度的 BUG,对本身来讲一次很是宝贵的学习机会。面对这么好的学习机会,天然要充满热情,要如饥似渴,回首一看,若是不是由于这个 BUG,我也不会对一些工具备更深刻地了解和使用,也就不会有这篇文档的产生。
2.无论什么样的 BUG,随着时间的推移,确定是可以解决的。这样想一想,其实会轻松不少,特别是接手新项目,改造复杂工程时,因为对代码,对业务一开始并非很熟悉,须要一个过渡期。但关键是,你要把这些问题放在心上。白天上班有不少事情干扰,上下班路上,晚上睡觉前,大脑反而会更加清醒,思路也会更加清晰。特别是白天上班时容易思惟定势,陷入一个长时间的误区,在那里调试了半天,结果大脑一片混沌。睡觉前或者上下班路上一我的时,反而能想出一些新的思路和办法。
3.开放地讨论。遇到问题不要很差意思,无论多简单,多低级,只要这个问题不是你 google 一下就能获得的结论,大胆地,认真地和组内同事讨论。此次 BUG 调试,有几回关键的讨论给了我很大的启发,特别是最后 reusable 的问题,也是组内同事的讨论才激发了个人灵感。谢谢你们的帮助。
原本来源于:http://mp.weixin.qq.com/s?__biz=MzI5NjAxODQyMg==&mid=2676478541&idx=1&sn=60537ca932bc3577cc9a1118eaecdc4e&scene=0#rd