一般性原则
依据数据而不是凭空猜测
这是性能优化的第一原则,当我们怀疑性能有问题的时候,应该通过测试、日志、 profillig 来分析 出哪里 有问题,有的放矢,而不是凭感觉、撞运气。一个系统有了性能问题,瓶颈有可能是 CPU ,有可能是内存,有可能是 IO (磁盘 IO ,网络 IO ),大方向的定位可以使用 top 以及 stat 系列来定位( vmstat , iostat , netstat ... ),针对单个进程,可以使用 pidstat 来分析。
在本文中,主要讨论的是 CPU 相关的性能问题。按照 80/20 定律,绝大多数的时间都耗费在少量的代码片段里面,找出这些代码唯一可靠的办法就是 profile ,我所知的编程语言,都有相关的 profile 工具,熟练使用这些 profile 工具是性能优化的第一步。
忌过早优化
The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming.
我并不十分清楚 Donald Knuth 说出这句名言的上下文环境,但我自己是十分认同这个观念的。在我的工作环境(以及典型的互联网应用开发)与编程模式下,追求的是快速的迭代与试错,过早的优化往往是无用功。而且,过早的优化很容易拍脑袋,优化的点往往不是真正的性能瓶颈。
忌过度优化
As performance is part of the specification of a program – a program that is unusably slow is not fit for purpose
性能优化的目标是追求合适的性价比。
在不同的阶段,我们对系统的性能会有一定的要求,比如吞吐量要达到多少 多少 。如果达不到这个指标,就需要去优化。如果能满足预期,那么就无需花费时间精力去优化,比如只有几十个人使用的内部系统,就不用按照十万在线的目标去优化。
而且,后面也会提到,一些优化方法是 “ 有损 ” 的,可能会对代码的可读性、可维护性有副作用。这个时候,就更不能过度优化。
深入理解业务
代码是服务于业务的,也许是服务于最终用户,也许是服务于其他程序员。不了解业务,很难理解系统的流程,很难找出系统设计的不足之处。后面还会提及对业务理解的重要性。
性能优化是持久战
当核心 业务方向明确之后,就应该开始关注性能问题,当项目上线之后,更应该持续的进行性能检测与优化。
现在的互联网产品,不再是一锤子买卖,在上线之后还需要持续的开发,用户的涌入也会带来性能问题。因此需要自动化的检测性能问题,保持稳定的测试环境,持续的发现并解决性能问题,而不是被动地等到用户的投诉。
选择合适的衡量指标、测试用例、测试环境
正因为性能优化是一个长期的行为,所以需要固定衡量指标、测试用例、测试环境,这样才能客观反映性能的实际情况,也能展现出优化的效果。
衡量性能有很多指标,比如系统响应时间、系统吞吐量、系统并发量。不同的系统核心指标是不一样的,首先要明确本系统的核心性能诉求,固定测试用例;其次也要兼顾其他指标,不能顾此失彼。
测试环境也很重要,有一次突然发现我们的 QPS 高了许多,但是程序压根儿没优化,查了半天,才发现是换了一个更牛逼的 物理机 做测试服务器。
性能优化的层次
按照我的理解可以分为需求阶段,设计阶段,实现阶段;越上层的阶段优化效果越明显,同时也更需要对业务、需求的深入理解。
需求阶段
不战而屈人之兵,善之善者也
程序员的需求可能来自 PM 、 UI 的业务需求(或者说是功能性需求),也可能来自 Team Leader 的需求。当我们拿到一个需求的时候,首先需要的是思考、讨论需求的合理性,而不是立刻去设计、去编码。
需求是为了解决某个问题,问题是本质,需求是解决问题的手段。那么需求是否能否真正的解决问题,程序员也得自己去思考,产品经理(特别是知道一点技术的产品经理)的某个需求可能只是某个问题的解决方案,他认为这个方法可以解决他的问题,于是把解决方案当成了需求,而不是真正的问题。
需求讨论的前提对业务的深入了解,如果不了解业务,根本没法讨论。即使需求已经实现了,当我们发现有性能问题的时候,首先也可以从需求出发。
需求分析对性能优化有什么帮助呢,第一,为了达到同样的目的,解决同样问题,也许可以有性能更优(消耗更小)的办法。这种优化是无损的,即不改变需求本质的同时,又能达到 性能优化的效果;第二种情况,有损的优化,即在不明显影响用户的体验,稍微修改需求、放宽条件,就能大大解决性能问题。 PM 退步 一 小步,程序前进一大步。
需求讨论也有助于设计时更具扩展性,应对未来的需求变化,这里按下不表。
设计阶段
高手都是花 80% 时间思考, 20% 时间实现;新手写起代码来很快,但后面是无穷无尽的修 bug
设计的概念很宽泛,包括架构设计、技术选型、接口设计等等。架构设计约束了系统的扩展、技术选型决定了代码实现。编程语言、框架都是工具,不同的系统、业务需要选择适当的工具集。如果设计的时候做的不够好,那么后面就很难优化,甚至需要推到重来。
实现阶段
实现是把功能翻译成代码的过程,这个层面的优化,主要是针对一个调用流程,一个函数, 一 段代码的优化。各种 profile 工具也主要是在这个阶段生效。除了静态的代码的优化,还有编译时优化,运行时优化。后二者要求就很高了,程序员可控性较弱。
代码层面,造成性能瓶颈的原因通常是高频调用的函数、或者单次消耗非常高的函数、或者二者的结合。
下面介绍针对设计阶段与实现阶段的优化手段。
一般性方法
缓存
没有什么性能问题是缓存解决不了的,如果有,那就再加一级缓存
a cache / kæ ʃ / KASH,[1] is a hardware or software component that stores data so future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation, or the duplicate of data stored elsewhere.
缓存的本质是加速访问,访问的数据要么是其他数据的副本 -- 让数据离用户更近;要么 是之前 的计算结果 -- 避免重复计算 .
缓存需要用空间换时间,在缓存空间有限的情况下,需要优秀的置换换算来保证缓存有较高的命中率。
数据的缓存
这是我们最常见的缓存形式,将数据缓存在 离使用 者更近的地方。比如操作系统中的 CPU cache 、 disk cache 。对于一个 web 应用,前端会有浏览器缓存,有 CDN ,有反向代理提供的静态内容缓存;后端则有本地缓存、分布式缓存。
数据的缓存,很多时候是设计层面的考虑。
对于数据缓存,需要考虑的是缓存一致性问题。对于分布式系统中有强一致性要求的场景,可行的解决办法有 lease ,版本号。
计算结果的缓存
对于消耗较大的计算,可以将计算结果缓存起来,下次直接使用。
我们知道,对递归代码的一个有效优化手段就是缓存中间结果, lookup table ,避免了重复计算。 python 中的 method cache 就是这种思想 .
对于可能重复创建、销毁, 且创建 销毁代价很大的对象,比如进程、线程,也可以缓存,对应的缓存形式如单例、资源池(连接池、线程池)。
对于计算结果的缓存,也需要考虑缓存失效的情况,对于 pure function ,固定的输入有固定的输出,缓存是不会失效的。但如果计算受到中间状态、环境变量的影响,那么缓存的结果就可能失效
并发
一个人干不完的活,那就找两个人干。 并发既 增加了系统的吞吐,又减少了用户的平均等待时间。
这里的并发是指广义的并发,粒度包括多机器(集群)、多进程、多线程。
对于无状态(状态是指需要维护的上下文环境,用户请求依赖于这些上下文环境)的服务,采用集群就能很好的伸缩,增加系统的吞吐,比如挂载 nginx 之后的 web server
对于有状态的服务,也有两种形式,每个节点提供同样的数据,如 mysql 的读写分离;每个节点只提供部分数据,如 mongodb 中的 sharding
分布式存储系统中, partition ( sharding )和 replication ( backup )都有助于并发。
绝大多数 web server ,要么使用多进程,要么使用多线程来处理用户的请求,以充分利用多核 CPU ,再有 IO 阻塞的地方,也是适合使用多线程的。比较新的协程( Python greenle 、 goroutine )也是一种并发。
惰性
将计算推迟到必需的时刻,这样很可能避免了多余的计算,甚至根本不用计算
批量,合并
在有 IO (网络 IO ,磁盘 IO )的时候,合并操作、批量操作往往能提升吞吐,提高性能。
我们最常见的是批量读:每次读取数据的时候多读取一些,以备不时之需。如 GFS client 会从 GFS master 多读取一些 chunk 信息;如分布式系统中,如果集中式节点复杂全局 ID 生成, 俺么应用 就可以一次请求一批 id 。
特别是系统中有单点存在的时候,缓存和批量本质上来说减少了与单点的交互,是减轻单点压力的经济有效的方法
在前端开发中,经常会有资源的压缩和合并,也是这种思想。
当涉及到网络请求的时候,网络传输的时间可能远大于请求的处理时间,因此合并网络请求就很有必要,比如 mognodb 的 bulk operation , redis 的 pipeline 。写文件的时候也可以批量写,以减少 IO 开销, GFS 中就是这么干的
更高效的实现
同一个算法,肯定会有不同的实现,那么就会有不同的性能;有的实现可能是时间换空间,有的实现可能是空间换时间,那么就需要根据自己的实际情况权衡。
程序员都喜欢早轮子,用于练手无可厚非,但在项目中,使用成熟的、经过验证的轮子往往比自己造的轮子性能更好。当然不管使用别人的轮子,还是自己的工具,当出现性能的问题的时候,要么优化它,要么替换掉他。
比如,我们有一个场景,有大量复杂的嵌套对象的序列化、反序列化,开始的时候是使用 python ( Cpython )自带的 json 模块,即使发现有性能问题也没法优化,网上一查,替换成了 ujson ,性能好了不少。
上面这个例子是无损的,但一些更高效的实现也可能是有损的,比如对于 python ,如果发现性能有问题,那么很可能会考虑 C 扩展,但也会带来维护性与灵活性的丧失,面临 crash 的风险。
缩小解空间
缩小 解空间 的意思是说,在一个更小的数据范围内进行计算,而不是遍历全部数据。最常见的就是索引,通过索引,能够很快定位数据,对数据库的优化绝大多数时候都是对索引的优化。
如果有本地缓存,那么使用索引也会大大加快访问速度。不过,索引比较适合读多写少的情况,毕竟索引的构建也是需有消耗的。
另外在游戏服务端,使用的分线和 AOI (格子算法)也都是缩小 解空间 的方法。
性能优化与代码质量
很多时候,好的代码也是高效的代码,各种语言都会有一本类似的书《 effective xx 》。比如对于 python , pythonic 的代码通常效率都不错,如使用迭代器而不是列表( python2.7 dict 的 iteritems (), 而不是 items()) 。
衡量代码质量的标准是可读性、可维护性、可扩展性,但性能优化有可能会违背这些特性,比如为了屏蔽实现细节与使用方式,我们会可能会加入接口层(虚拟层),这样可读性、可维护性、可扩展性会好很多,但是额外增加了一层函数调用,如果这个地方调用频繁,那么也是一笔开销;又如前面提到的 C 扩展,也是会降低可维护性、
这种有损代码质量的优化,应该放到最后,不得已而为之,同时写清楚注释与文档。
为了追求可扩展性,我们经常会引入一些设计模式, 如状态 模式、策略模式、模板方法、 装饰器模式 等,但这些模式不一定是性能友好的。所以,为了性能,我们可能写出一些 反模式 的、定制化的、不那么优雅的代码,这些代码其实是脆弱的,需求的一点点变动,对代码逻辑可能有至关重要的影响,所以还是回到前面所说,不要过早优化,不要过度优化。