«

记一次node程序性能调优

时间:2023-9-1 14:16     作者:Feeling..     分类:


  1. node性能指标
    衡量一个程序的性能无非关注CPU占用、内存占用、磁盘读写等,为了更方便的采集这些指标,我借用了阿里出品的Node.js性能平台,成功接入后就可以看到这样的界面

Node.js性能监控
需要关注的是右下角【抓取性能数据】功能:

【堆快照】通过抓取堆快照可以查看到当前内存都被谁占用了,查内存泄露的利器。
【CPU profile】抓取一段时间内cpu的占用并生成火焰图,性能调优的关键点。
【GC Trace】抓取一段时间内的内存垃圾回收记录。
首先通过压测程序将被压机器的cpu打满,然后点击【CPU profile】,抓取成功后可以在右侧菜单栏【文件】中找到记录文件。

文件列表
点击【转储】-【分析】即可看到对应的火焰图

CPU火焰图
可以看到GC占比达到了惊人的58.3%,此时接口的并发能力只有6000/s,那此时的优化已经有了方向,到底是什么导致了GC如此频繁。

  1. V8的内存分布和垃圾回收
    由于JavaScript是单进程语言,当V8进行垃圾回收时势必会阻塞当前进程,为了减缓对程序运行的影响,V8将内存分为了新生代和老生代,两种类型的内存采用了不同的回收策略。对于新生代内存,垃圾回收通过Scavenge算法进行,Scavenge的优点是速度很快,但缺点是空间的利用率低,只有50%,因为Scavenge将新生代内存一分为二,每部分都叫做Semispace,在同一时间两个Semispace只有一个处于使用状态,处于使用状态的Semispace称为From,未使用的Semispace则称为To。

新生代内存回收流程
老生代内存相对较大,如果仍采用Scavenge算法未免太过奢侈,老生代共有两种回收算法:

标记清除(Mark Sweep)

算法会将需要清除的部分直接释放,但是这样会导致内存碎片化严重。

标记合并(Mark Compact)

为了解决内存碎片化的问题,V8引入了合并算法,算法会将需要保留的内存集中归并到一端,但合并算法效率很低,所以V8引擎仅在剩余空间不足以安置新晋对象时才会触发。

新生代晋升必须满足以下两个条件之一:

对象已经经历过一次新生代内存回收,这次依旧存活。
To空间已经使用了超过25%,则将From中的对象直接复制到老生代。
了解了GC的基本逻辑后,回到最初的问题,我们的CPU到底被哪些GC消耗了?通过性能平台,拿到当时的GC Trach如下图:

GC Trach
可以看到,这段时间内GC次数734次,其中新生代算法Scavenge执行了728次,老生代算法Mark Sweep执行了6次,看来问题就出在新生代内存里了。新生代中都是一些临时对象,首先可以想到通过增加新生代内存大小来解决,但是新生代大小增加后不仅会导致内存利用率降低,还会导致单次GC的时间变长,所以先不着急更改内存大小,先看下自己的代码是否有可以优化的部分,遇到问题首先怀疑自己是个很好的习惯。

  1. 堆内存
    与此同时,我抓取了当时的堆快照,如下图:

堆快照
我注意到上方红色标记的部分,这里是因为代码使用了kafka作为通信组件,消息不停地写入kafka,在高压情况下,就导致了非常频繁的创建和回收消息对象,而且注意到这里的内存占用为78.62MB,已经超过了默认的新生代半空间(默认为16MB),而且这些对象生命周期特别短,不会被转移到老生代中,所以就造成了新生代内存频繁GC。由于业务原因,这里无法变更,所以最终我选择了通过修改新生代大小来解决问题。在启动时添加参数

node --max_semi_space_size=64 app.js
此时将新生代半空间内存调整为了64MB,再次打压观测结果如下:

新生代半空间修改为64MB
GC时间占比几乎不可见,仅为4次,接口并发能力从6000/s提升到了10000/s,至此问题解决。