性能优化的一般性原则

一般性原则

依据数据而不是凭空猜测

这是前端性能优化的第一原则,当我们怀疑前端性能有问题的时候,应该通过测试、浏览器开发者工具、性能分析工具来分析出哪里有问题,有的放矢,而不是凭感觉、撞运气。一个前端页面有了性能问题,瓶颈有可能是 JavaScript 执行时间过长,有可能是网络请求过多或请求资源过大,有可能是 DOM 操作频繁导致重排重绘,大方向的定位可以使用浏览器开发者工具的 Performance 面板来定位,针对具体的代码块,可以使用 console.time 和 console.timeEnd 来分析执行时间。

忌过早优化

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 工具也主要是在这个阶段生效。除了静态的代码的优化,还有编译时优化,运行时优化。后二者要求就很高了,程序员可控性较弱。

代码层面,造成性能瓶颈的原因通常是高频调用的函数、或者单次消耗非常高的函数、或者二者的结合。

一般性方法

缓存

没有什么性能问题是缓存解决不了的,如果有,那就再加一级缓存

缓存的本质是加速访问,访问的数据要么是其他数据的副本 – 让数据离用户更近;要么是之前的计算结果 – 避免重复计算.

缓存需要用空间换时间,在缓存空间有限的情况下,需要优秀的置换换算来保证缓存有较高的命中率。

数据的缓存

这是我们最常见的缓存形式,将数据缓存在离使用者更近的地方。比如操作系统中的 CPU cache、disk cache。对于一个 web 应用,前端会有浏览器缓存,有 CDN,有反向代理提供的静态内容缓存;后端则有本地缓存、分布式缓存。

数据的缓存,很多时候是设计层面的考虑。

对于数据缓存,需要考虑的是缓存一致性问题。对于分布式系统中有强一致性要求的场景,可行的解决办法有 lease,版本号。

计算结果的缓存

对于消耗较大的计算,可以将计算结果缓存起来,下次直接使用。

我们知道,对递归代码的一个有效优化手段就是缓存中间结果,lookup table,避免了重复计算。python 中的 method cache 就是这种思想.

对于可能重复创建、销毁,且创建销毁代价很大的对象,比如进程、线程,也可以缓存,对应的缓存形式如单例、资源池(连接池、线程池)。

对于计算结果的缓存,也需要考虑缓存失效的情况,对于 pure function,固定的输入有固定的输出,缓存是不会失效的。

并发

一个人干不完的活,那就找两个人干。并发既增加了系统的吞吐,又减少了用户的平均等待时间。

惰性

将计算推迟到必需的时刻,这样很可能避免了多余的计算,甚至根本不用计算。

CopyOnWrite、Dirty flag

批量,合并

前端开发中经常会有资源的压缩和合并。

当涉及到网络请求的时候,网络传输的时间可能远大于请求的处理时间,因此合并网络请求就很有必要。

更高效的实现

同一个算法,肯定会有不同的实现,那么就会有不同的性能;有的实现可能是时间换空间,有的实现可能是空间换时间,那么就需要根据自己的实际情况权衡。

程序员都喜欢早轮子,用于练手无可厚非,但在项目中,使用成熟的、经过验证的轮子往往比自己造的轮子性能更好。当然不管使用别人的轮子,还是自己的工具,当出现性能的问题的时候,要么优化它,要么替换掉他。

缩小解空间

缩小解空间的意思是说,在一个更小的数据范围内进行计算,而不是遍历全部数据。最常见的就是索引,通过索引,能够很快定位数据,对数据库的优化绝大多数时候都是对索引的优化。

如果有本地缓存,那么使用索引也会大大加快访问速度。不过,索引比较适合读多写少的情况,毕竟索引的构建也是需有消耗的。

性能优化与代码质量

衡量代码质量的标准是可读性、可维护性、可扩展性,但性能优化有可能会违背这些特性,比如为了屏蔽实现细节与使用方式,我们会可能会加入接口层(虚拟层),这样可读性、可维护性、可扩展性会好很多,但是额外增加了一层函数调用,如果这个地方调用频繁,那么也是一笔开销;又如前面提到的 C 扩展,也是会降低可维护性、

这种有损代码质量的优化,应该放到最后,不得已而为之,同时写清楚注释与文档。

为了追求可扩展性,我们经常会引入一些设计模式,如状态模式、策略模式、模板方法、装饰器模式等,但这些模式不一定是性能友好的。所以,为了性能,我们可能写出一些反模式的、定制化的、不那么优雅的代码,这些代码其实是脆弱的,需求的一点点变动,对代码逻辑可能有至关重要的影响,所以还是回到前面所说,不要过早优化,不要过度优化。