Teaser Image

mindwind

十日画一水,五日画一石




「在程序的赛道上,性能漂移问题是每个‘车手’不得不面对的。」

‘漂移’ 这个词是一个赛车术语,指让车头的指向与车身实际运动方向之间产生较大的夹角,使车身侧滑过弯的系列操作。 漂移让汽车的转弯动作更优雅,但同时也使汽车的稳定性降低。 而在软件系统中出现的性能漂移可没有让系统运行的更优雅,只能让系统运行的更不稳定。

在最近的一次性能调优实战中,所面对的系统是一个典型的生产者、消费者模型实现系统。 生产者的数据来源是一个数据库系统,生产者按规则提取数据,经过系统产生一系列的转换渲染后发送到多个外部系统。 在以数据库为核心的系统中,根据过去的经验,最大的瓶颈就是数据库系统本身,果然一开始这次也不例外。 因为数据库的不堪重负,生产者的生产能力不足,从而导致消费者饥饿,系统整体性能表现不佳。 通过和DBA一番合作,找出高频SQL进行优化,但最终效果依然不佳。 遂改造设计实现,在数据库和系统之间增加一个内存缓冲区从而缓解了数据库的负载压力。 缓冲区的效果,类似大河之上的堤坝,旱时积水,涝时泄洪。 引入缓冲区后,生产者的生产能力得到了有效保障,生产能力高效且稳定。 本以为至此解决了该系统的性能问题,但在生产环境运行了一段时间后,系统表现为速度时快时慢,让人摸不着头脑。 这时一句老话突然闪现在我的头脑里:性能瓶颈是会转移的。 它不仅转移了,而且还像星星般忽闪忽现的,对你眨着眼。

系统性能瓶颈90%都在I/O,这是一句经验之谈,于是我把目光锁定在了消费者与外部系统的I/O通信上。 既然锁定了怀疑区域,接下来就是用证据来证明,并还能合理的解释为何瓶颈会时隐时现。 自然想到的原因是在某些情况下触碰到了临界极限,当达到临界点时程序性能急剧下降。 那么到底是触碰到了什么临界区域呢?首先的怀疑对象是我们刚才根据经验得出的IO通信。 消费者需要与多达30个外部系统并发通信,极有可能瓶颈在此。 不过这还停留在怀疑阶段,必须量化这个推测。 由于生产环境的不透明性,我只好在本地进行了模拟测试,用一台主机模拟外部系统,一台主机模拟消费者。 模拟主机上的线程池配置等参数完全保持和生产环境一致,以模仿一致的并发数。 通过不断改变通信数据的大小,发现在数据接近100K大小时,两台主机之间直连的千兆网络I/O达到满负载。 于是,回头去观察生产环境的运行状况,当发现性能突然急剧下降的情况时,立刻分析了生产者的数据来源。 其中果然有不少大报文数据,有些甚至高达200K,至此基本确定了与外部系统的IO通信瓶颈。 解决办法是增加了数据压缩功能,以牺牲CPU换取I/O。

在信心满满的修改了程序实现,增加了压缩功能重新上线后,问题却依然存在,系统性能仍然时不时的急剧降低。 看来瓶颈再次转移了,我这么想着,但到底在哪里呢? 于是只好去复审代码实现,详细的分析每一步的实现,对所有值得怀疑的代码块增加了时间度量。 通过增加细粒度的时间度量,并在生产环境运行一段时间搜集了统计数据后,发现了一个现象。 在消费者的完整代码路径中,多线程执行该路径的平均时间达到了4.5秒,这比我的预期值高出了近2个数量级。 但分析该路径每个独立代码块的执行时间总和却仅有几十毫秒,最高也就在一百毫秒左右。 通过这两个时间度量的巨大差异,我意识到了,线程执行该代码路径的时间其实并不长,但花在等待CPU调度的时间却平均高达好几秒。 那么是CPU达到了瓶颈么?通过运维配合观察服务器的CPU消耗,平均负载却不高。 只好再次分析代码实现机制,终于在数据转换渲染子程序中找到了一段可疑的代码实现,为了验证疑点,再次做了一下实验测试。 找了一个150K的线上数据报文作为该程序输入,单线程运行了下,发现耗时居然接近50毫秒,意识到这可能是整个代码路径中最耗时的一个代码块。 由于这个子程序来自上上代程序员的遗留代码,包含些稀奇古怪且复杂的渲染逻辑判断和业务规则,基本很久没人动过了。 仔细分析了其实现中,基本就是大量的文本匹配和替换,还包含一些加密、HASH算法,这明显是一个CPU高密集型的函数啊。 那么在多线程环境下(实际线程数是CPU核数 * 16),运行这个函数大概平均每个线程需要多少时间呢。 先从理论上来分析下,假如我们的服务器是4核,那么理想情况下同一时间可以运行4个线程,而每个线程执行该函数约为50毫秒。 这里我们假设CPU 50毫秒才进行线程上下文切换,那么这个调度模型就被简化了,这里一共会有4*16=64个线程。 第一组4个线程会立刻执行,第二组4个线程会等待50ms,第三组会等待100ms,如此类推,第16组线程执行时会等待750ms。 那么平均下来,每组线程执行前的平均等待时间应该是在300~350ms之间,这是一个理论值,实际运行下测试,平均每个线程花费了2.6秒左右。 这是为什么呢?因为上面理论上的调度模型简化了CPU调度机制,在线程执行过程的50ms中,CPU将发生非常多次的线程上下文切换。 50ms对于CPU的时间分片来说,实在是太长了,线程上下文的多次切换和CPU争夺带来的额外开销,实际比理论值高了8倍左右。 而在生产环境上,实际的监测值达到了4.5秒,因为整个代码路径中除了这个非常耗时的子程序函数,还有额外的线程同步、通知和I/O操作。

搞清楚了这一切,通过简单的优化了下该子程序的渲染算法,从近50ms降低到3~4ms后,整个代码路径的线程平均执行时间下降到100ms左右。 收益是明显的,该子程序函数性能得到了10倍的提高,而整体从4.5秒降低为100ms,性能提高了45倍。 软件开发中性能调优就像为钢琴调音,每个钢琴都有不同的调法,每个系统也是一样。 发现性能问题时,要关注瓶颈,同时要记得瓶颈也许不止一个,而且它还是会漂移的。