架构设计|性能优化的十种手段(下篇)

共 5374字,需浏览 11分钟

 ·

2022-09-29 19:09

前文回顾,架构设计|性能优化的十种手段(上篇)中,我们总结了六种普适的性能优化方法,包括索引压缩缓存预取削峰填谷批量处理,简单讲解了每种技术手段的原理和实际应用。

架构设计|性能优化的十种手段(中篇)中,我们简单了解了程序是如何消耗执行时间和内存空间的。

这一篇,再讲另外几类涉及更多技术细节的性能优化方向。

本文转载信息如下:

作者:code2life
链接:https://code2life.top/2020/08/13/0056-performance3/

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

  • 八门遁甲 —— 榨干计算资源
  • 影分身术 —— 水平扩容
  • 奥义 —— 分片术
  • 秘术 —— 无锁术

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

八门遁甲 —— 榨干计算资源

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

从晶体管到集成电路、驱动程序、操作系统、直到高级编程语言的层层抽象,每一层抽象带来的更强的通用性、更高的开发效率,多是以损失运行效率为代价的。但我们可以在用高级编程语言写代码的时候,在保障可读性可维护性基础上用运行效率更高、更适合运行时环境的方式去写,减少额外的性能损耗。《Effective XXX》、《More Effective XXX》、《高性能XXX》这类书籍所传递的知识和思想。

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

聚焦

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

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

less copy, less context switch, less system call fsync 10-50ms, ssd 100-10000μs (SATA NVME) ctx switch : system call -> mode switch, thread switch: cache change, work set change, full ctx switch (1-30 μs)

大部分互联网应用服务,耗时的部分不是计算,而是I/O。

减少I/O wait, 各司其职,专心干I/O,专心干计算,epoll批量捞任务,(refer: event driven)

  • 利用DMA减少CPU负担 - 零拷贝 NewI/O Redis SingleThread (even 6.0), Node.js

避免不必要的调度 - Context Switch。

CPU亲和性,让CPU更加聚焦。

蜕变

用更高效的数据结构、算法、第三方组件,让程序本身蜕变。

从逻辑短路、Map代替List遍历、减少锁范围、这样的编码技巧,到应用FisherYates、Dijkstra这些经典算法,注意每一行代码细节,量变会发生质变。更何况某个算法就足以让系统性能产生一两个数量级的提升。

适应

因地制宜,适应特定的运行环境

在浏览器中主要是优化方向是I/O、UI渲染引擎、JS执行引擎三个方面。I/O越少越好,能用WebSocket的地方就不用Ajax,能用Ajax的地方就不要刷整个页面;UI渲染方面,减少重排和重绘,比如Vue、React等MVVM框架的虚拟DOM用额外的计算换取最精简的DOM操作;JS执行引擎方面,少用动态性极高的写法,比如eval、随意修改对象或对象原型的属性。

前端的优化有个神器:Light House,在新版本Chrome已经嵌到开发者工具中了,可以一键生成性能优化报告,按照优化建议改就完了。

与浏览器环境颇为相似的Node.js环境,

  • https://segmentfault.com/a/1190000007621011#articleHeader11

Java

C1 C2 JIT编译器 栈上分配

Linux

  • 各种参数优化
  • 内存分配和GC策略
  • Linux内核参数 Brendan Gregg
  • 内存区块配置(DB,JVM,V8,etc.)

利用语言特性和运行时环境 - 比如写出利于JIT的代码

  • 多静态少动态 - 舍弃动态特性的灵活性 - hardcode/if-else,强类型,弱类型语言避免类型转换 AOT/JIT vs 解释器, 汇编,机器码 GraalVM

减少内存的分配和回收,少对列表做增加或删除

对于RAM有限的嵌入式环境,有时候时间不是问题,反而要拿时间换空间,以节约RAM的使用。

运筹

把眼界放宽,跳出程序和运行环境本身,从整体上进行系统性分析最高性价比的优化方案,分析潜在的优化切入点,以及能够调配的资源和技术,运筹帷幄。

其中最简单易行的几个办法,就是花钱,买更好或更多的硬件基础设施,这往往是开发人员容易忽视的,这里提供一些妙招:

  • 服务器方面,云服务厂商提供各种类型的实例,每种类型有不同的属性侧重,带宽、CP、磁盘的I/O能力,选适合的而不是更贵的
  • 舍弃虚拟机 - Bare Mental,比如神龙服务器
  • 用ARM架构CPU的服务器,同等价格可以买到更多的服务器,对于多数可以跨平台运行的服务端系统来说与x86区别并不大,ARM服务器的数据中心也是技术发展趋势使然
  • 如果必须用x86系列的服务器,AMD也Intel的性价比更高。

第一点非常重要,软件性能遵循木桶原理,一定要找到瓶颈在哪个硬件资源,把钱花在刀刃上。如果是服务端带宽瓶颈导致的性能问题,升级再多核CPU也是没有用的。我有一次性能优化案例:把一个跑复杂业务的Node.js服务器从AWS的m4类型换成c4类型,内存只有原来的一半,但CPU使用率反而下降了20%,同时价格还比之前更便宜,一石二鸟。

这是因为Node.js主线程的计算任务只有一个CPU核心在干,通过CPU Profile的火焰图,可以定位到该业务的瓶颈在主线程的计算任务上,因此提高单核频率的作用是立竿见影的。而该业务对内存的消耗并不多,套用一些定制v8引擎内存参数的方案,起不了任何作用。

毕竟这样的例子不多,大部分时候还是要多花钱买更高配的服务器的,除了这条花钱能直接解决问题的办法,剩下的办法难度就大了:

  • 利用更底层的特性实现功能,比如FFI WebAssembly调用其他语言,Java Agent Instrument,字节码生成(BeanCopier, Json Lib),甚至汇编等等
  • 使用硬件提供的更高效的指令
  • 各种提升TLB命中率的机制,减少内存的大页表
  • 魔改Runtime,Facebook的PHP,阿里腾讯定制的JDK
  • 网络设备参数,MTU
  • 专用硬件:GPU加速(cuda)、AES硬件卡和高级指令加速加解密过程,比如TLS
  • 可编程硬件:地狱级难度,FPGA硬件设备加速特定业务
  • NUMA
  • 更宏观的调度,VM层面的共享vCPU,K8S集群调度,总体上的优化

小结

有些手段,是凭空换出来更多的空间和时间了吗?天下没有免费的午餐,即使那些看起来空手套白狼的优化技术,也需要额外的人力成本来做,副作用可能就是专家级的发际线吧。还好很多复杂的性能优化技术我也不会,所以我本人发际线还可以。

这一小节总结了一些方向,有些技术细节非常深,这里也无力展开。不过,即使榨干了单机性能,也可能不足以支撑业务,这时候就需要分布式集群出场了,因此后面介绍的3个技术方向,都与并行化有关。

影分身术 —— 水平扩容

本节的水平扩容以及下面一节的分片,可以算整体的性能提升而不是单点的性能优化,会因为引入额外组件反而降低了处理单个请求的性能。但当业务规模大到一定程度时,再好的单机硬件也无法承受流量的洪峰,就得水平扩容了,毕竟”众人拾柴火焰高”。

在这背后的理论基础是,硅基半导体已经接近物理极限,随着摩尔定律的减弱,阿姆达尔定律的作用显现出来。

  • https://en.wikipedia.org/wiki/Amdahl%27s_law

水平扩容必然引入负载均衡

  • 多副本
  • 水平扩容的前提是无状态
  • 读>>写, 多个读实例副本 (CDN)
  • 自动扩缩容,根据常用的或自定义的metrics,判定扩缩容的条件,或根据CRON
  • 负载均衡策略的选择

原理:并行化

奥义 —— 分片术

水平扩容针对无状态组件,分片针对有状态组件。二者原理都是提升并行度,但分片的难度更大。负载均衡也不再是简单的加权轮询了,而是进化成了各个分片的协调器。

  • 分片 - 百科全书分册
  • Java1.7的及之前的 ConcurrentHashMap分段锁 https://www.codercto.com/a/57430.html
  • 有状态数据的分片
  • 如何选择Partition/Sharding Key
  • 负载均衡难题
  • 热点数据,增强缓存等级,解决分散的缓存带来的一致性难题
  • 数据冷热分离,SSD - HDD

分开容易合并难

区块链的优化,分区域

秘术 —— 无锁术

Don’t communicate by sharing memory, share memory by communicating

有些业务场景,比如库存业务,按照正常的逻辑去实现,水平扩容带来的提升非常有限,因为需要锁住库存,扣减,再解锁库存。票务系统也类似,为了避免超卖,需要有一把锁禁锢了横向扩展的能力。

不管是单机还是分布式微服务,锁都是制约并行度的一大因素。比如上篇提到的秒杀场景,库存就那么多,系统超卖了可能导致非常大的经济损失,但用分布式锁会导致即使服务扩容了成千上万个实例,最终无数请求仍然阻塞在分布式锁这个串行组件上了,再多水平扩展的实例也无用武之地。

避免竞争Race Condition 是最完美的解决办法。上篇说的应对秒杀场景,预取库存就是减轻竞态条件的例子,虽然取到服务器内存之后仍然有多线程的锁,但锁的粒度更细了,并发度也就提高了。

  • 线程同步锁
  • 分布式锁
  • 数据库锁 update select子句
  • 事务锁
  • 顺序与乱序
  • 乐观锁/无锁 CAS Java 1.8之后的ConcurrentHashMap
  • pipeline技术 - CPU流水线 Redis Pipeline 大数据分析 并行计算
  • TCP的缓冲区排头阻塞 QUIC HTTP3.0

原理:并行化

总结

以ROI的视角看软件开发,初期人力成本的投入,后期的维护成本,计算资源的费用等等,选一个合适的方案而不是一个性能最高的方案。

本篇结合个人经验总结了常见的性能优化手段,这些手段只是冰山一角。在初期就设计实现出一个完美的高性能系统是不可能的,随着软件的迭代和体量的增大,利用压测,各种工具(profiling,vmstat,iostat,netstat),以及监控手段,逐步找到系统的瓶颈,因地制宜地选择优化手段才是正道。

有利必有弊,得到一些必然会失去一些,有一些手段要慎用。Linux性能优化大师Brendan Gregg一再强调的就是:切忌过早优化、过度优化。

持续观测,做80%高投入产出比的优化。

除了这些设计和实现时可能用到的手段,在技术选型时选择高性能的框架和组件也非常重要。

另外,部署基础设施的硬件性能也同样,合适的服务器和网络等基础设施往往会事半功倍,比如云服务厂商提供的各种字母开头的instance,网络设备带宽的速度和稳定性,磁盘的I/O能力等等。

多数时候我们应当使用更高性能的方案,但有时候甚至要故意去违背它们。最后,以《Effective Java》第一章的一句话结束本系列吧。

首先要学会基本的规则,然后才能知道什么时候可以打破规则。

以上就是本文的全部内容,如果觉得还不错的话欢迎点赞转发关注,感谢支持。


参考:

  • 《高性能JavaScript》 —— Nicholas C. Zakas
  • 《Effective Java》 第三版 —— Joshua Bloch
  • www.brendangregg.com/ —— Brendan Gregg
  • https://colin-scott.github.io/personal_website/research/interactive_latency.html
  • https://stackoverflow.com/questions/23599074/system-calls-overhead
  • https://stackoverflow.com/questions/21887797/what-is-the-overhead-of-a-context-switch
  • jolestar.com/parallel-programming-model-thread-goroutine-actor/
  • https://www.codercto.com/a/57430.html
  • https://segmentfault.com/a/1190000007621011#articleHeader11

推荐阅读:

浏览 51
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报