热门推荐
性能优化的十种手段,建议收藏
2024-12-27 10:02

最近看到一个关于性能优化的不错的文章。作者写了上中下三篇,由浅入深的写了关于性能优化的方方面面,并不仅仅局限于代码层面。 我看了之后还是很有收获的,同时也惊叹于作者扎实的技术能力与思考能力。于是借花献佛,把作者的三篇整理合并之后分享给大家。希望你也能有所收获。

上篇

软件设计开发某种意义上是“取”与“舍”的艺术。

关于性能方面,就像建筑设计成抗震9度需要额外的成本一样,高性能软件系统也意味着更高的实现成本,有时候与其他质量属性甚至会冲突,比如安全性、可扩展性、可观测性等等。

性能优化的十种手段,建议收藏

大部分时候我们需要的是:在业务遇到瓶颈之前,利用常见的技术手段将系统优化到预期水平。

那么,性能优化有哪些技术方向和手段呢?

性能优化通常是“时间”与“空间”的互换与取舍。

本篇分两个部分,在上篇,讲解六种通用的“时间”与“空间”互换取舍的手段:

在下篇,介绍四种进阶性的内容,大多与提升并行能力有关

每种性能优化的技术手段,我都找了一张应景的《火影忍者》中人物或忍术的配图,评论区答出任意人物或忍术送一颗小星星。

(注:所有配图来自动漫《火影忍者》,部分图片添加了文字方便理解,仅作技术交流用途)

10ms之后。

索引的原理是拿额外的存储空间换取查询时间,增加了写入数据的开销,但使读取数据的时间复杂度一般从O(n)降低到O(logn)甚至O(1)。

索引不仅在数据库中广泛使用,前后端的开发中也在不知不觉运用。

在数据集比较大时,不用索引就像从一本没有目录而且内容乱序的新华字典查一个字,得一页一页全翻一遍才能找到;

用索引之后,就像用拼音先在目录中先找到要查到字在哪一页,直接翻过去就行了。

书籍的目录是典型的树状结构,那么软件世界常见的索引有哪些数据结构,分别在什么场景使用呢?

数据库主键之争:自增长 vs UUID。主键是很多数据库非常重要的索引,尤其是MySQL这样的RDBMS会经常面临这个难题:是用自增长的ID还是随机的UUID做主键?

自增长ID的性能最高,但不好做分库分表后的全局唯一ID,自增长的规律可能泄露业务信息;而UUID不具有可读性且太占存储空间。

争执的结果就是找一个兼具二者的优点的折衷方案:

用雪花算法生成分布式环境全局唯一的ID作为业务表主键,性能尚可、不那么占存储、又能保证全局单调递增,但引入了额外的复杂性,再次体现了取舍之道。

再回到数据库中的索引,建索引要注意哪些点呢?

数据库之外,在代码中也能应用索引的思维,比如对于集合中大量数据的查找,使用Set、Map、Tree这样的数据结构,其实也是在用哈希索引或树状索引,比直接遍历列表或数组查找的性能高很多。

缓存优化性能的原理和索引一样,是拿额外的存储空间换取查询时间。缓存无处不在,设想一下我们在浏览器打开这篇文章,会有多少层缓存呢?

这里列举的仅仅是一部分常见的缓存,就有多种多样的形式:从廉价的磁盘到昂贵的CPU高速缓存,最终目的都是用来换取宝贵的时间。

既然缓存那么好,那么问题就来了:缓存是“银弹”吗?

不,Phil Karlton 曾说过:

There are only two hard things in Computer Science: cache invalidation and naming things.

计算机科学中只有两件困难的事情:缓存失效和命名规范。

缓存的使用除了带来额外的复杂度以外,还面临如何处理缓存失效的问题。

除了通常意义上的缓存外,对象重用的池化技术,也可以看作是一种缓存的变体。

常见的诸如JVM,V8这类运行时的常量池、数据库连接池、HTTP连接池、线程池、Golang的sync.Pool对象池等等。

在需要某个资源时从现有的池子里直接拿一个,稍作修改或直接用于另外的用途,池化重用也是性能优化常见手段。

说完了两个“空间换时间”的,我们再看一个“时间换空间”的办法——压缩。

压缩的原理消耗计算的时间,换一种更紧凑的编码方式来表示数据。

为什么要拿时间换空间?时间不是最宝贵的资源吗?

举一个视频网站的例子,如果不对视频做任何压缩编码,因为带宽有限,巨大的数据量在网络传输的耗时会比编码压缩的耗时多得多。

对数据的压缩虽然消耗了时间来换取更小的空间存储,但更小的存储空间会在另一个维度带来更大的时间收益。

这个例子本质上是:“操作系统内核与网络设备处理负担 vs 压缩解压的CPU/GPU负担”的权衡和取舍。

我们在代码中通常用的是无损压缩,比如下面这些场景:

信息论告诉我们,无损压缩的极限是信息熵。进一步减小体积只能以损失部分信息为代价,也就是有损压缩。

那么,有损压缩有哪些应用呢?

除了有损/无损压缩,但还有一个办法,就是压缩的极端——从根本上减少数据或彻底删除。

能减少的就减少:

能删除的就删除:

毕竟有位叫做 Kelsey Hightower 的大佬曾经说过:

No code is the best way to write secure and reliable applications. Write nothing; deploy nowhere

不写代码,是编写安全可靠的应用程序的最佳方式。什么都不写;哪里都不部署。

预取通常搭配缓存一起用,其原理是在缓存空间换时间基础上更进一步,再加上一次“时间换时间”,也就是:用事先预取的耗时,换取第一次加载的时间。

当可以猜测出以后的某个时间很有可能会用到某种数据时,把数据预先取到需要用的地方,能大幅度提升用户体验或服务端响应速度。

是否用预取模式就像自助餐餐厅与厨师现做的区别,在自助餐餐厅可以直接拿做好的菜品,一般餐厅需要坐下来等菜品现做。

那么,预取在哪些实际场景会用呢?

天上不会掉馅饼,预取也是有副作用的

正如烤箱预热需要消耗时间和额外的电费,在软件代码中做预取/预热的副作用通常是启动慢一些、占用一些闲时的计算资源、可能取到的不一定是后面需要的。

削峰填谷的原理也是“时间换时间”,谷时换峰时。

削峰填谷与预取是反过来的:预取是事先花时间做,削峰填谷是事后花时间做。就像三峡大坝可以抗住短期巨量洪水,事后雨停再慢慢开闸防水。软件世界的“削峰填谷”是类似的,只是不是用三峡大坝实现,而是用消息队列、异步化等方式。

常见的有这几类问题,我们分别来看每种对应的解决方案:

批量处理同样可以看成“时间换时间”,其原理是减少了重复的事情,是一种对执行流程的压缩。以个别批量操作更长的耗时为代价,在整体上换取了更多的时间。

批量处理的应用也非常广泛,我们还是从前端开始讲:

批量处理如此好用,那么问题来了,每一批放多大最合适呢?

这个问题其实没有定论,有一些个人经验可以分享。

总之,多大一批可以确保单批响应时间不太长的同时让整体性能最高,是需要在实际情况下做基准测试的,不能一概而论。而批量处理的副作用在于:处理逻辑会更加复杂,尤其是一些涉及事务、并发的问题;需要用数组或队列用来存放缓冲一批数据,消耗了额外的存储空间。

中篇

前面我们总结了六种普适的性能优化方法,包括 索引、压缩、缓存、预取、削峰填谷、批量处理,简单讲解了每种技术手段的原理和实际应用。

在开启最后一篇前,我们先需要搞清楚:

在程序运行期间,时间和空间都耗在哪里了?

人眨一次眼大约100毫秒,而现代1核CPU在一眨眼的功夫就可以执行数亿条指令。

现代的CPU已经非常厉害了,频率已经达到了GHz级别,也就是每秒数十亿个指令周期。

即使一些CPU指令需要多个时钟周期,但由于有流水线机制的存在,平均下来大约每个时钟周期能执行1条指令,比如一个3GHz频率的CPU核心,每秒大概可以执行20亿到40亿左右的指令数量。

程序运行还需要RAM,也可能用到持久化存储,网络等等。随着新的技术和工艺的出现,这些硬件也越来越厉害,比如CPU高速缓存的提升、NVMe固态硬盘相对SATA盘读写速率和延迟的飞跃等等。这些硬件具体有多强呢?

有一个非常棒的网站“Latency Numbers Every Programmer Should Know”,可以直观地查看从1990年到现在,高速缓存、内存、硬盘、网络时间开销的具体数值。

https://colin-scott.github.io/personal_website/research/interactive_latency.html

下图是2020年的截图,的确是“每个开发者应该知道的数字”。

这里有几个非常关键的数据:

看到不同硬件之间数量级的差距,就很容易理解性能优化的一些技术手段了。

比如一次网络传输的时间,是主存访问的5000倍,明白这点就不难理解写for循环发HTTP请求,为什么会被扣工资了。

放大到我们容易感知的时间范围,来理解5000倍的差距:如果一次主存访问是1天的话,一趟局域网数据传输就要13.7年。

如果要传输更多网络数据,每两个网络帧之间还有固定的间隔(Interpacket Gap),在间隔期间传输Idle信号,数据链路层以此来区分两个数据包,具体数值在链接Wiki中有,这里截取几个我们熟悉的网络来感受一下:

不过,单纯看硬件的上限意义不大,从代码到机器指令中间有许多层抽象,仅仅是在TCP连接上发一个字节的数据包,从操作系统内核到网线,涉及到的基础设施级别的软硬件不计其数。到了应用层,单次操作耗时虽然没有非常精确的数字,但经验上的范围也值得参考:

在计算机历史上,非易失存储技术的发展速度超过了摩尔定律。除了嵌入式设备、数据库系统等等,现在大部分场景已经不太需要优化持久化存储的空间占用了,这里主要讲的是另一个相对稀缺的存储形式 —— RAM,或者说主存/内存。

以JVM为例,在堆里面有很多我们创建的对象(Object)。

如果在32G以上内存的机器上,禁用了对象指针压缩,对象指针会变成8字节,包括Header中的Klass指针,这也就不难理解为什么堆内存超过32G,JVM的性能直线下降了。

举个例子,一个有8个int类型成员的对象,需要占用48个字节(12+32+4),如果有十万个这样的Object,就需要占用4.58MB的内存了。这个数字似乎看起来不大,而实际上一个Java服务的堆内存里面,各种各样的对象占用的内存通常比这个数字多得多,大部分内存耗在char[]这类数组或集合型数据类型上。

举个例子,一个有8个int类型成员的对象,需要占用48个字节(12+32+4),如果有十万个这样的Object,就需要占用4.58MB的内存了。这个数字似乎看起来不大,而实际上一个Java服务的堆内存里面,各种各样的对象占用的内存通常比这个数字多得多,大部分内存耗在char[]这类数组或集合型数据类型上。

堆内存之外,又是另一个世界了。

从操作系统进程的角度去看,也有不少耗内存的大户,不管什么Runtime都逃不开这些空间开销:每个线程需要分配MB级别的线程栈,运行的程序和数据会缓存下来,用到的输入输出设备需要缓冲区……

代码“写出来”的内存占用,仅仅是冰山之上的部分,真正的内存占用比“写出来”的要更多,到处都存在空间利用率的问题。

比如,即使我们在Java代码中只是写了 response.getWriter().print(“OK”),给浏览器返回2字节,网络协议栈的层层封装,协议头部不断增加的额外数据,让最终返回给浏览器的字节数远超原始的2字节,像IP协议的报头部就至少有20个字节,而数据链路层的一个以太网帧头部至少有18字节。

如果传输的数据过大,各层协议还有最大传输单元MTU的限制,IPv4一个报文最大只能有64K比特,超过此值需要分拆发送并在接收端组合,更多额外的报头导致空间利用率降低(IPv6则提供了Jumbogram机制,最大单包4G比特,“浪费”就减少了)。

这部分的“浪费”有多大呢?下面的链接有个表格,传输1460个字节的载荷,经过有线到无线网络的转换,至少再添120个字节,**空间利用率<92.4%**。

https://en.wikipedia.org/wiki/Jumbo_frame

这种现象非常普遍,使用抽象层级越高的技术平台,平台提供高级能力的同时,其底层实现的“信息密度”通常越低。

像Java的Object Header就是使用JVM的代价,而更进一步使用动态类型语言,要为灵活性付出空间的代价则更大。哈希表的自动扩容,强大的反射能力等等,背后也付出了空间的代价。

再比如,二进制数据交换协议通常比纯文本协议更加节约空间。但多数厂家我们仍然用JSON、XML等纯文本协议,用信息的冗余来换取可读性。即便是二进制的数据交互格式,也会存在信息冗余,只能通过更好的协议和压缩算法,尽量去逼近压缩的极限 —— 信息熵。

理解了时间和空间的消耗在哪后,还不能完全解释软件为何倾向于耗尽硬件资源。有一条定律可以解释,正是它锤爆了摩尔定律。

它就是安迪-比尔定律。

“安迪给什么,比尔拿走什么”。

安迪指的是Intel前CEO安迪·葛洛夫,比尔指的是比尔·盖茨。

这句话的意思就是:软件发展比硬件还快,总能吃得下硬件

20年前,在最强的计算机也不见得可以玩赛车游戏;

10年前,个人电脑已经可以玩画质还可以的3D赛车游戏了;

现在,自动驾驶+5G云驾驶已经快成为现实。

在这背后,是无数的硬件技术飞跃,以及吃掉了这些硬件的各类软件。

这也是我们每隔两三年都要换手机的原因:不是机器老化变卡了,是嗜血的软件在作怪。

因此,即使现代的硬件水平已经强悍到如此境地,性能优化仍然是有必要的。

软件日益复杂,抽象层级越来越高,就越需要底层基础设施被充分优化。

对于大部分开发者而言,高层代码逐步走向低代码化、可视化,“一行代码”能产生的影响也越来越大,写出低效代码则会吃掉更多的硬件资源。

下篇

本篇也是本系列最硬核的一篇,本人技术水平有限,可能存在疏漏或错误之处,望斧正。仍然选取了《火影忍者》的配图和命名方式帮助理解:

(注:这些“中二”的前缀仅是用《火影》中的一些术语,形象地描述技术方案)

让硬件资源都在处理真正有用的逻辑计算,而不是做无关的事情或空转。

从晶体管到集成电路、驱动程序、操作系统、直到高级编程语言的层层抽象,每一层抽象带来的更强的通用性、更高的开发效率,多是以损失运行效率为代价的。

但我们可以在用高级编程语言写代码的时候,在保障可读性、可维护性基础上用运行效率更高、更适合运行时环境的方式去写,减少额外的性能损耗《Effective XXX》、《More Effective XXX》、《高性能XXX》这类书籍所传递的知识和思想。

落到技术细节,下面用四个小节来说明如何减少“无用功”、避免空转、榨干硬件。

聚焦

减少系统调用与上下文切换,让CPU聚焦。

可以看看两个 stackoverflow 上的帖子:

https://stackoverflow .com/questions/21887797/what-is-the-overhead-of-a-context-switch

    以上就是本篇文章【性能优化的十种手段,建议收藏】的全部内容了,欢迎阅览 ! 文章地址:https://sicmodule.kub2b.com/quote/14278.html 
     栏目首页      相关文章      动态      同类文章      热门文章      网站地图      返回首页 企库往资讯移动站https://sicmodule.kub2b.com/mobile/,查看更多   
发表评论
0评