性能优化的一般性原则与方法
0、系统性能定义
这个定义非常关键。总体来说,系统性能就是两个事:1、Throughput ,吞吐量。也就是每秒钟可以处理的请求数,任务数。
2、Latency, 系统延迟。也就是系统在处理一个请求或一个任务时的延迟。
一般来说,一个系统的性能受到这两个条件的约束,缺一不可。比如,我的系统可以顶得住一百万的并发,但是系统的延迟是2分钟以上,那么,这个一百万的负载毫无意义。系统延迟很短,但是吞吐量很低,同样没有意义。所以,一个好的系统的性能测试必然受到这两个条件的同时作用。 这两个东西的一些关系:
Throughput越大,Latency会越差。因为请求量过大,系统太繁忙,所以响应速度自然会低。
Latency越好,能支持的Throughput就会越高。因为Latency短说明处理速度快,于是就可以处理更多的请求。
关于Latency,如果吞吐量很少,这个值估计会非常稳定,当吞吐量越来越大时,系统的Latency会出现非常剧烈的抖动,所以,我们在测量Latency的时候,我们需要注意到Latency的分布,也就是说,有百分之几的在我们允许的范围,有百分之几的超出了,有百分之几的完全不可接受。也许,平均下来的Latency达标了,但是其中仅有50%的达到了我们可接受的范围。那也没有意义。
1、一般性原则
1.1 依据数据而不是凭空猜测这是性能优化的第一原则,当我们怀疑性能有问题的时候,应该通过测试、日志、profillig来分析出哪里有问题,有的放矢,而不是凭感觉、撞运气。一个系统有了性能问题,瓶颈有可能是CPU,有可能是内存,有可能是IO(磁盘IO,网络IO),大方向的定位可以使用top以及stat系列来定位(vmstat,iostat,netstat...),针对单个进程,可以使用pidstat来分析。
1.2 深入理解业务
代码是服务于业务的,也许是服务于最终用户,也许是服务于其他程序员。不了解业务,很难理解系统的流程,很难找出系统设计的不足之处。后面还会提及对业务理解的重要性。
1.3 忌过度优化
性能优化的目标是追求合适的性价比。
在不同的阶段,我们对系统的性能会有一定的要求,比如吞吐量要达到多少多少。如果达不到这个指标,就需要去优化。如果能满足预期,那么就无需花费时间精力去优化,比如只有几十个人使用的内部系统,就不用按照十万在线的目标去优化。而且,后面也会提到,一些优化方法是“有损”的,可能会对代码的可读性、可维护性有副作用。这个时候,就更不能过度优化。
1.4 忌过早优化
我们不应该把大量时间都耗费在哪些小的性能改进上,过早考虑优化是所有噩梦的根源。
这句名言的重点是,最终你应该编写清晰、直接、易读和易理解的代码。这里的优化应该理解为虽然算法和设计改变了复杂程序的结构,但是提供了更好的性能。那些真正的优化最好留到以后,等到性能分析表明这些措施有巨大收益的时候才进行。
而这里所指的过早优化,并不包括避免那些已经知道对性能不好的代码结构。每行代码,如果有两种简单、直接的编程方式,那就应该选择性能更好的那种。
1.5 性能优化是持久战
当核心业务方向明确之后,就应该开始关注性能问题,当项目上线之后,更应该持续的进行性能检测与优化。现在的互联网产品,不再是一锤子买卖,在上线之后还需要持续的开发,用户的涌入也会带来性能问题。因此需要自动化的检测性能问题,保持稳定的测试环境,持续的发现并解决性能问题,而不是被动地等到用户的投诉。
1.6 选择合适的衡量指标、测试用例、测试环境
正因为性能优化是一个长期的行为,所以需要固定衡量指标、测试用例、测试环境,这样才能客观反映性能的实际情况,也能展现出优化的效果。
衡量性能有很多指标,比如系统响应时间、系统吞吐量、系统并发量。不同的系统核心指标是不一样的,首先要明确本系统的核心性能诉求,固定测试用例;其次也要兼顾其他指标,不能顾此失彼。
测试环境也很重要,有一次突然发现我们的QPS高了许多,但是程序压根儿没优化,查了半天,才发现是换了一个更牛逼的物理机做测试服务器。
2、性能优化的层次
可以分为需求阶段,设计阶段,实现阶段;越上层的阶段优化效果越明显,同时也更需要对业务、需求的深入理解。2.1 需求阶段
程序员的需求可能来自PM、UI的业务需求(或者说是功能性需求),也可能来自Team Leader的需求。当我们拿到一个需求的时候,首先需要的是思考、讨论需求的合理性,而不是立刻去设计、去编码。
需求是为了解决某个问题,问题是本质,需求是解决问题的手段。那么需求是否能否真正的解决问题,程序员也得自己去思考,产品经理(特别是知道一点技术的产品经理)的某个需求可能只是某个问题的解决方案,他认为这个方法可以解决他的问题,于是把解决方案当成了需求,而不是真正的问题。
需求讨论的前提对业务的深入了解,如果不了解业务,根本没法讨论。即使需求已经实现了,当我们发现有性能问题的时候,首先也可以从需求出发。
需求分析对性能优化有什么帮助呢,第一,为了达到同样的目的,解决同样问题,也许可以有性能更优(消耗更小)的办法。这种优化是无损的,即不改变需求本质的同时,又能达到性能优化的效果;第二种情况,有损的优化,即在不明显影响用户的体验,稍微修改需求、放宽条件,就能大大解决性能问题。PM退步一小步,程序前进一大步。
2.2 设计阶段
高手都是花80%时间思考,20%时间实现;新手写起代码来很快,但后面是无穷无尽的修bug
设计的概念很宽泛,包括架构设计、技术选型、接口设计等等。架构设计约束了系统的扩展、技术选型决定了代码实现。编程语言、框架都是工具,不同的系统、业务需要选择适当的工具集。如果设计的时候做的不够好,那么后面就很难优化,甚至需要推到重来。
2.3 实现阶段
实现是把功能翻译成代码的过程,这个层面的优化,主要是针对一个调用流程,一个函数,一段代码的优化。各种profile工具也主要是在这个阶段生效。除了静态的代码的优化,还有编译时优化,运行时优化。后二者要求就很高了,程序员可控性较弱。
代码层面,造成性能瓶颈的原因通常是高频调用的函数、或者单次消耗非常高的函数、或者二者的结合。
3、一般性方法
3.1 缓存没有什么性能问题是缓存解决不了的,如果有,那就再加一级缓存
缓存的本质是加速访问,访问的数据要么是其他数据的副本 -- 让数据离用户更近;要么是之前的计算结果 -- 避免重复计算.缓存需要用空间换时间,在缓存空间有限的情况下,需要优秀的置换换算来保证缓存有较高的命中率。
3.1.1 数据的缓存
这是我们最常见的缓存形式,将数据缓存在离使用者更近的地方。比如操作系统中的CPU cache、disk cache。对于一个web应用,前端会有浏览器缓存,有CDN,有反向代理提供的静态内容缓存;后端则有本地缓存、分布式缓存。
数据的缓存,很多时候是设计层面的考虑。
对于数据缓存,需要考虑的是缓存一致性问题。对于分布式系统中有强一致性要求的场景,可行的解决办法有lease,版本号。
3.1.2 计算结果的缓存
对于消耗较大的计算,可以将计算结果缓存起来,下次直接使用。
我们知道,对递归代码的一个有效优化手段就是缓存中间结果,lookup table,避免了重复计算。python中的method cache就是这种思想.对于可能重复创建、销毁,且创建销毁代价很大的对象,比如进程、线程,也可以缓存,对应的缓存形式如单例、资源池(连接池、线程池)。对于计算结果的缓存,也需要考虑缓存失效的情况,对于pure function,固定的输入有固定的输出,缓存是不会失效的。但如果计算受到中间状态、环境变量的影响,那么缓存的结果就可能失效。
3.2 并发
一个人干不完的活,那就找两个人干。并发既增加了系统的吞吐,又减少了用户的平均等待时间。
这里的并发是指广义的并发,粒度包括多机器(集群)、多进程、多线程。
对于无状态(状态是指需要维护的上下文环境,用户请求依赖于这些上下文环境)的服务,采用集群就能很好的伸缩,增加系统的吞吐,比如挂载nginx之后的web server。对于有状态的服务,也有两种形式,每个节点提供同样的数据,如mysql的读写分离;每个节点只提供部分数据,如mongodb中的sharding。分布式存储系统中,partition(sharding)和replication(backup)都有助于并发。绝大多数web server,要么使用多进程,要么使用多线程来处理用户的请求,以充分利用多核CPU,再有IO阻塞的地方,也是适合使用多线程的。比较新的协程(Python greenle、goroutine)也是一种并发。
3.3 惰性
将计算推迟到必需的时刻,这样很可能避免了多余的计算,甚至根本不用计算,这个在之前的《lazy ideas in programming》一文中举了许多例子。
3.4 批量,合并
在有IO(网络IO,磁盘IO)的时候,合并操作、批量操作往往能提升吞吐,提高性能。
我们最常见的是批量读:每次读取数据的时候多读取一些,以备不时之需。如GFS client会从GFS master多读取一些chunk信息;如分布式系统中,如果集中式节点复杂全局ID生成,俺么应用就可以一次请求一批id。特别是系统中有单点存在的时候,缓存和批量本质上来说减少了与单点的交互,是减轻单点压力的经济有效的方法。在前端开发中,经常会有资源的压缩和合并,也是这种思想。当涉及到网络请求的时候,网络传输的时间可能远大于请求的处理时间,因此合并网络请求就很有必要,比如mognodb的bulk operation,redis 的pipeline。写文件的时候也可以批量写,以减少IO开销,GFS中就是这么干的
3.5 更高效的实现
同一个算法,肯定会有不同的实现,那么就会有不同的性能;有的实现可能是时间换空间,有的实现可能是空间换时间,那么就需要根据自己的实际情况权衡。
程序员都喜欢早轮子,用于练手无可厚非,但在项目中,使用成熟的、经过验证的轮子往往比自己造的轮子性能更好。当然不管使用别人的轮子,还是自己的工具,当出现性能的问题的时候,要么优化它,要么替换掉他。比如,我们有一个场景,有大量复杂的嵌套对象的序列化、反序列化,开始的时候是使用python(Cpython)自带的json模块,即使发现有性能问题也没法优化,网上一查,替换成了ujson,性能好了不少。
上面这个例子是无损的,但一些更高效的实现也可能是有损的,比如对于python,如果发现性能有问题,那么很可能会考虑C扩展,但也会带来维护性与灵活性的丧失,面临crash的风险。
3.6 缩小解空间
缩小解空间的意思是说,在一个更小的数据范围内进行计算,而不是遍历全部数据。最常见的就是索引,通过索引,能够很快定位数据,对数据库的优化绝大多数时候都是对索引的优化。如果有本地缓存,那么使用索引也会大大加快访问速度。不过,索引比较适合读多写少的情况,毕竟索引的构建也是需有消耗的。
4、性能优化与代码质量
很多时候,好的代码也是高效的代码,各种语言都会有一本类似的书《effective xx》。比如对于python,pythonic的代码通常效率都不错,如使用迭代器而不是列表(python2.7 dict的iteritems(), 而不是items())。衡量代码质量的标准是可读性、可维护性、可扩展性,但性能优化有可能会违背这些特性,比如为了屏蔽实现细节与使用方式,我们会可能会加入接口层(虚拟层),这样可读性、可维护性、可扩展性会好很多,但是额外增加了一层函数调用,如果这个地方调用频繁,那么也是一笔开销;又如前面提到的C扩展,也是会降低可维性、
这种有损代码质量的优化,应该放到最后,不得已而为之,同时写清楚注释与文档。
为了追求可扩展性,我们经常会引入一些设计模式,如状态模式、策略模式、模板方法、装饰器模式等,但这些模式不一定是性能友好的。所以,为了性能,我们可能写出一些反模式的、定制化的、不那么优雅的代码,这些代码其实是脆弱的,需求的一点点变动,对代码逻辑可能有至关重要的影响,所以还是回到前面所说,不要过早优化,不要过度优化。
正文到此结束