关于系统性能优化的十个建议

Martin Thompson是LMAX的联合创始人,在QCon圣保罗2016上做过关于性能的keynote演讲,演讲中他列出了他在生产环境中见到的十大性能错误,并且还包含了如何避免的建议。

1、不进行升级。

很多人抱怨他们的系统不够快,并通过编写更好的算法和数据结构来寻求帮助,Thompson认为实际上“他们所需的仅仅就是进行升级”。升级操作系统、JVM、CLR等等。不进行升级的常见借口就是“在新版本中可能会有bug。”

为了避免这种状况,可以进行定期的持续集成和测试,这应该是开发流程的基础组成部分。Thompson以一个实时系统进行了例证,开发人员针对新版本的数据库进行了测试,在所有的测试通过之后,他们就将其发布到了生产环境之中。

2、重复性的工作。

Thompson 讲述了某个系统的故事,这个系统是用来提供Web页面的,它非常缓慢,开发人员最初认为是数据库的问题并试图在这方面进行调优。但是当他在系统上运行 profiler时,发现在一个循环中,ORM被调用了7,000次,这才是页面加载缓慢的罪魁祸首。当这个循环的问题修复之后,系统的响应变得完全正 常。这里学到的经验就是“对系统进行度量。如果系统是一个黑盒的话,你就无法说明时间都耗费在了哪里。”

3、加载性能依赖于数据。

Thompson展现了一个基准测试结果,它会执行一项操作,该操作会对内存(RAM)中1GB数组的所有long型进行求和。这里所耗费的时间取决于内存是如何访问的,如下面的表格所示:

这个基准测试的结果显示并非所有的内存操作都是等价的,我们需要关注它是如何进行处理的。Thompson认为非常重要的一点在于了解各种数据结构的性能,他指出对于2GB以上的场景,Java的HashMap要比.NET的Dictionary慢十倍以上。他还补充说,也有一些场景.NET要比Java慢得多。

4、分配的内存太多。

尽管在很多场景中,内存分配几乎是没有什么成本的,但是它们的回收却并非如此,因为在面对大量的数据集时,垃圾收集器需要更多的时间。当分配大量的数据时,缓存会被填满,较旧的数据会被舍弃,使得在数据操作上的效率变为90ns/op而不是7ns/op,这里变慢了不止一个数量级。

5、 采用并行。

尽管对于特定的算法来说,采用并行很有吸引力,但是它也有一些局限性和相关的开销。Thompson引用了“可扩展性!但是其COST如何?”这篇论文,论文的作者通过引入COST(胜过单线程的配置,Configuration that Outperforms a Single Thread)对比了并行系统以及单线程的系统,COST的定义如下:

在特定的平台中,特定问题的COST指的是优于单线程方案所需的硬件配置。COST将系统的扩展性与系统所引入的开销进行了权衡,并指明了系统实际所能取得的性能,它们可能并没有带来实际的收益,却增加了并行所引入了开销。

作者分析了各种数据并行系统的测量结果,并得出如下的结论:“很多的系统要么具有非常高的COST,通常会需要上百个核心,要么针对他们所报告的配置,其性能要比单线程方案更差。”

在这个话题中,Thompson指出,并行任务会有相关的通信和同步开销,并且有些活动本质上要求是串行的,不能实现并行。按照Amdahl定律,如果系统中有5%的活动需要串行,那么不管使用了多少个处理器,系统的速度提升最多只能达到20倍。

Thompson还提到了Neil J. Gunther在1993年所提出的通用可扩展性定律(Universal Scalability Law,PDF),该定律指出在并行非共享系统(shared-nothing)中甚至会存在更多的局限性,当所使用的处理器数量达到一定程度后,速度会出现下降,这取决于并发、竞争以及同步的水平。(更多的细节可以参考如何量化可扩展性这个页面。)按照上述两个规律所总结的速度与处理器数量之间的关系如下图所示:

Thompson指出通过USL能够看到性能的下降,这要归因于并行系统中组件之间进行通信所消耗的成本:“在系统中,所投入资源越多,通信路径也会随之增多,这会使算法的效率降低。”

Thompson补充说,在构建并行系统时,主要的建议是避免共享可变(mutable)的状态,因为“它非常难以进行判断……最终你会遇到很多的bug”。推荐的方式是要么采用非共享架构,要么针对特定的一块数据,只使用一个写入器。

对这个性能问题,他的最终建议:如果你想提升算法的速度的话,在尝试并行方案之前,先设法提升单线程版本的性能,因为并行方案实在是太难了。

6、不理解TCP。

针对这个话题,Thompson认为很多在考虑微服务架构的人对TCP并没有充分的理解。在特定的场景中,有可能会遇到延迟的ACK,它会限制链路上所发送的数据包,每秒钟只会有2-5个数据包。这是因为TCP两个算法所引起的死锁:Nagle以及TCP Delayed Acknowledgement。在200-500ms的超时之后,会打破这个死锁,但是微服务之间的通信却会分别受到影响。推荐的方案是使用TCP_NODELAY,它会禁用Nagle的算法,多个更小的包可以依次发送。按照Thompson的说法,其中的差别在5到500 req/sec。

7、同步通信。

客户端和服务器之间的同步通信会带来时间的损耗,对于需要快速通信的系统来说,这会成为一个问题。Thompson说,它的解决方案并不是购买更加昂贵和快速的硬件,而是使用异步通信。在这种场景下,客户端可以发送多个请求到服务器端,而不必等待它们之间的响应。采用这种方式需要改变客户端发送请求的方式,但这是值得的。

8、文本编码。

开发人员很多时候会选择使用文本编码格式实现链路上的数据传输,比如JSON、XML或Base64,因为“这对人类是可读的”。但是Thompson指出在两个系统之间进行对话的时候,是没有人读这些数据的。借助这种方式,使用简单的文本编辑器就能很容易地进行调试,但是在将二进制数据与文本之间进行互相转换的时候,这会带来很高的CPU损耗。该问题的解决方案是使用能够理解二进制的更好的工具,Thompson提到了Wireshark。

9、API设计。

按照Thompson的说法,有一些与性能相关的最负面影响是由API引起的。它使用如下的代码来阐述较差的代码签名:

public void startElement( String uri, String localName, String qName, Attributes atts) throws SAXException

描述:

在处理XML的时候,通常我们并不会使用这些值[三个String以及属性的集合]。我们分配了很多的内容,但是却将其浪费并抛弃掉了。这会损耗电池的寿命,白白地浪费资源。我们需要使其更加简单一些。

他建议采用如下的签名,实现更加简单的方法:

public void characters( char[] ch, int start, int length) throws SAXException

有些人可能会抱怨后面的这个方法要比前一个更难用,Thompson建议采用组合的方式,将其中一个用另一个封装起来,这样的话,能够给用户多一个选择。如果性能不是什么问题的话,可以采用第一种(使用String),否则的话,第二个方案会更好一些。

Thompson提到的第二个样例是字符串拆分:

public String[] split(String regex)

这个方法签名相关的性能问题包括:

  • 每次方法调用的时候,正则表达式都需要进行编译;
  • 需要实例化一个动态的结构,用来存储字符串中所包含的初始数量未知的token;
  • 返回的结构是一个固定大小的数组,这就必须要将token收集到一个临时的结构中,然后再拷贝到数组里面;
  • 如果调用者想要对这些token进行一些操作的话,比如排序,需要将它们拷贝到另外一个结构之中。

更好的方案是使用Iterable,它能够避免在内存中创建中间状态的token副本:

public Iterable split(String regex)

另外一种方案是允许调用者提供存储token的集合。如果调用者想要对token列表去重的话,应该传递一个Set进来,如果想得到有序列表的话,就需要传递一个TreeMap进来:

public void split( String regex, Collection dst)

10、日志。

Thompson所列的排名第一的性能问题是写日志所耗费的时间。他通过一个图表展现了当线程数增加的时候,日志操作所耗费的平均时间:

这个图显示了一个100%的顺序操作,不管使用多少线程来记录日志,所需的时间均呈线性增长。Thompson说大多数已有的日志系统都可以得出这样一幅图表,“Logger是系统中最大的瓶颈之一”。这个问题的解决方案是使用异步的Logger。

另外,Logger所记录的数据应该是结构化的数据,便于后续的工具进行读取和处理,而不应该是一堆String。如果是记录重复的错误,他建议在错误第一次出现的时候进行记录,后续出现时只需对一个计数器进行递增,告知对应的错误出现了多少次即可。对于实时系统的调试,Thompson建议使用代码编织(code weaver)的技术,如Byte Buddy,因为它能够避免编写和运行不必要的日志代码。

余下全文(1/3)
分享这篇文章:

请关注我们:

共有 1 条讨论

  1. 陈真  这篇文章

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注