当前位置: 首页 > 后端技术 > Node.js

你不知道的Node.js性能优化,看了之后水平上来了

时间:2023-04-03 11:12:31 Node.js

你不能叫我马上写,我需要干货来做,写一些老生常谈然后加很多特别的效果,Node.js的性能好像Duang之后立马就上去了~,那读者肯定会骂我,Node.js根本就没有这样的性能优化,都是假的。”------Stark成龙大王1.使用最新版本的Node.js,只需简单升级Node.js版本即可轻松获得性能提升,因为几乎任何新版本的Node.js都会比旧版本表现得更好,为什么呢?Node.js各个版本的更新主要来自两个方面:V8的版本更新;Node.js内部代码的更新和优化,比如最新的V87.1,闭包在某些情况下的逃逸分析是optimized,提高了Array的一些方法的性能:随着版本的升级,Node.js的内部代码也会有明显的优化。比如下图是require随着Node.js版本升级的性能变化:每一个提交给Node.js的PR都会在review时考虑是否会导致当前性能下降。同时,还有一个专门的基准测试团队来监控性能变化。你可以在这里看到每个版本的Node.js的性能变化:https://benchmarking.nodejs.org/所以,你可以充分了解新版本的Node.js不用担心性能。如果您发现新版本下有任何性能下降,请提交问题。如何选择Node.js的版本?这里科普一下Node.js的版本攻略:Node.js版本主要分为Current和LTS;Current是仍在开发中的最新版本的Node.js;LTS是会长期维护的稳定版本;Node.js每六个月(每年4月和10月)会发布一次大版本升级,大版本会带来一些不兼容的升级;每年4月发布的版本(版本号为偶数,如v10)是LTS版本,即长期支持的版本。从发布年的10月开始,社区将继续维护它18+12个月(ActiveLTS+MaintainceLTS);每年10月份发布的版本(版本号为奇数,比如现在的v11)只有8个月的维护期。例如,现在(2018年11月),Node.js当前版本为v11,LTS版本为v10和v8。较旧的v6处于维护LTS状态,从明年4月起将不再维护。去年10月发布的v9版本,今年6月结束维护。对于生产环境,Node.js官方推荐使用最新的LTS版本,现在是v10.13.0。2、使用fast-json-stringify加速JSON序列化在JavaScript中,生成JSON字符串非常方便:constjson=JSON.stringify(obj)但是很少有人会认为这里还有性能优化的空间,即使用JSONSchema来加速序列化。在序列化JSON时,我们需要识别大量的字段类型。比如对于字符串类型,我们需要在两边加上"。对于数组类型,我们需要遍历数组。序列化每个对象之后,用.隔开,然后在两边加上[和]等等。但是如果事先已经通过Schema知道了每个字段的类型,那么就不需要遍历和识别字段类型,而是可以直接序列化对应的字段,大大减少了这就是fast-json-stringfy的原理.根据项目中的基准测试,在某些情况下它甚至可以比JSON.stringify快近10倍!一个简单的例子:constfastJson=require('fast-json-stringify')conststringify=fastJson({title:'ExampleSchema',type:'object',properties:{name:{type:'string'},age:{type:'integer'},books:{type:'array',items:{type:'string',uniqueItems:true}}}})console.log(stringify({name:'Starkwang',age:23、books:['C++Primer','风け!ユーフォニアム~']}))//=>{"name":"Starkwang","age":23,"books":["C++Primer",“风け!ユーフォニアム~”]}在Node.js的中间件业务中,通常有大量数据使用JSON,而这些JSON的结构非常相似(尤其是如果你使用TypeScript的话),这种场景是非常适合使用JSONSchema进行优化。3、提升Promise的性能Promise是解决回调嵌套地狱的灵丹妙药,尤其是自从async/await被广泛普及之后,它们的组合无疑成为了JavaScript异步编程的终极解决方案,现在已经有大量的项目开始了使用这种模式。但优雅的语法也隐藏了性能损失。我们可以使用github上已有的正在运行的项目进行测试。以下是测试结果:filetime(ms)memory(MB)callbacks-baseline.js38070.83promises-bluebird.js55497.23promises-bluebird-generator.js58597.05async-bluebird.js593105.43promises-es2015-util.promisify.js1203219.04promises-es2015-native.js1257227.03async3。-es2017-util.promisify.js1550228.74Platforminfo:Darwin18.0.0x64Node.JS11.1.0V87.0.276.32-node.7Intel(R)Core(TM)i5-5257UCPU@2.70GHz×4我们可以从结果如上所示,原生async/await+Promise的性能比callback差很多,内存占用也高很多。对于异步逻辑比较多的中间件项目,这里的性能开销是不容忽视的。通过对比可以发现,性能损失主要来自Promise对象本身的实现。V8原生实现的Promise比bluebird等第三方实现的Promise库慢很多。async/await语法不会带来太大的性能损失。因此对于异步逻辑量大、计算轻量级的中间件项目,可以在代码中将全局的Promise换成bluebird的实现:global.Promise=require('bluebird');4、正确编写异步代码使用async/await后,项目的异步代码会很好看:constfoo=awaitdoSomethingAsync();constbar=awaitdoSomethingElseAsync();但正因为如此,有时候我们也会忘记使用Promise带给我们的其他能力,比如Promise.all()并行能力://badasyncfunctiongetUserInfo(id){constprofile=awaitgetUserProfile(id);constrepo=awaitgetUserRepo(id)return{profile,repo}}//goodasyncfunctiongetUserInfo(id){const[profile,repo]=awaitPromise.all([getUserProfile(id),getUserRepo(id)])return{profile,repo}}和Promise.any()(ES6Promise标准中没有这个方法,你也可以使用标准的Promise.race()代替),我们可以用它轻松实现更可靠、更快速的调用:asyncfunctiongetServiceIP(name){//从DNS和ZooKeeper中获取服务IP,以先成功返回者为准//和Promise.race的区别在于,只有当两个调用都reject时,才会抛出错误returnawaitPromise.any([getIPFromDNS(name),getIPFromZooKeeper(name)])}5.优化V8GC关于V8的垃圾回收机制,已经有很多类似的文章,这里不再赘述。推荐两篇文章:V8GCLog解读(一):Node.js应用背景及GC基础知识V8GCLog解读(二):堆内外内存划分和GC算法我们日常开发代码的时候,更容易踩到下面的坑:坑一:使用大对象作为缓存导致旧空间垃圾回收变慢示例:constcache={}asyncfunctiongetUserInfo(id){if(!cache[id]){cache[id]=awaitgetUserInfoFromDatabase(id)}returncache[id]}这里我们使用了一个变量cache作为缓存来加速用户信息的查询。经过多次查询,缓存对象会进入老年代,变??得异常庞大,而老年代采用三色标记+DFS进行GC。大对象会直接导致GC花费的时间增加(同时也有内存泄漏的风险)。解决方案是:使用像Redis这样的外部缓存。其实像Redis这样的内存数据库就非常适合这种场景;限制本地缓存对象的大小,比如使用FIFO、TTL等机制清理对象中的缓存。坑2:新生代空间不足,导致频繁GC的坑会比较隐蔽。Node.js默认为新生代分配64MB内存(64位机,下同),但是由于新生代GC使用了Scavenge算法,实际可以使用的内存只有一半,即32MB.当业务代码频繁产生大量小对象时,这个空间很容易被填满,从而触发GC。虽然新生代的GC比老年代快很多,但是频繁的GC还是会对性能有很大的影响。在极端情况下,GC甚至可以占用总计算时间的30%左右。解决办法是修改新生代的内存上限,减少启动Node.js时的GC次数:node--max-semi-space-size=128app.js当然,肯定有人会问是不是新一代的内存是越大越好?随着内存的增加,GC次数会减少,但是每次GC所需的时间也会增加,所以越大越好,具体值需要根据业务概况来衡量,以确定分配多少新生代内存为最佳。但一般根据经验,分配64MB或128MB比较合理。6、正确使用StreamStream是Node.js最基本的概念之一。Node.js中大部分与IO相关的模块,例如http、net、fs和repl,都是构建在各种Streams之上的。下面这个经典的例子应该是大多数人都知道的。对于大文件,我们不需要将其完全读入内存,而是使用Stream发送出去:consthttp=require('http');constfs=require('fs');//badhttp.createServer(function(req,res){fs.readFile(__dirname+'/data.txt',function(err,data){res.end(data);});});//goodhttp.createServer(function(req,res){conststream=fs.createReadStream(__dirname+'/data.txt');stream.pipe(res);});在业务代码中合理使用Stream当然可以大大提升性能,但是在实际业务中我们很可能会忽略这一点,比如使用React服务端渲染的项目,我们可以使用renderToNodeStream:constReactDOMServerrequire('react-dom/server')consthttp=require('http')constfs=require('fs')constapp=require('./app')//badconstserver=http.createServer((req,res)=>{constbody=ReactDOMServer.renderToString(app)res.end(body)});//好的constserver=http.createServer(function(req,res){conststream=ReactDOMServer.renderToNodeStream(app)stream.pipe(res)})server.listen(8000)使用pipeline来管理stream以前的Node.js,处理stream很麻烦,比如:source.pipe(a).pipe(b).pipe(c).pipe(dest)一旦source,a,b,c,dest中的任何一个stream出现错误或者被关闭,整个pipeline就会停止。有时我们需要手动销毁所有流,这在代码层面是非常麻烦的,因此社区中出现了pump等库来自动控制流的销毁。并且Node.jsv10.0增加了一个新特性:stream.pipeline,它可以替代pump来帮助我们更好的管理stream。官方示例:const{pipeline}=require('stream');constfs=require('fs');constzlib=require('zlib');pipeline(fs.createReadStream('archive.tar'),zlib.createGzip(),fs.createWriteStream('archive.tar.gz'),(err)=>{if(err){console.error('管道失败',err);}else{console.log('流水线成功');}});自己实现高性能的Stream业务上也可能自己实现一个Stream,可读可写或者双向流,可以参考文档:implementingReadablestreamsimplementingWritablestreamsStream虽然很牛逼,但是自己实现Stream也可能存在隐藏的性能问题,例如:classMyReadableextendsReadable{_read(size){while(null!==(chunk=getNextChunk())){this.push(chunk);}}}当我们调用newMyReadable().pipe(xxx)时,getNextChunk()得到的chunk会被推出,直到读取结束。但是如果此时pipeline的下一步处理速度慢,数据就会堆积在内存中,导致内存占用变大,GC速度变慢。正确的做法应该是根据this.push()的返回值来选择正确的行为。当返回值为false时,表示此时累积的chunk已满,应停止读取。classMyReadableextendsReadable{_read(size){while(null!==(chunk=getNextChunk())){if(!this.push(chunk)){returnfalse}}}}这个问题在Node.js官方一篇文章有??详细介绍:BackpressuringinStreams7,C++extensionmustbefasterthanJavaScript?Node.js非常适合IO密集型的应用,而对于计算密集型的业务,很多人会想到通过写C++Addon来优化性能。但实际上,C++扩展并不是万能的,V8的性能也没有想象中的那么差。比如我在今年9月将Node.js的net.isIPv6()从C++迁移到JS,这样大部分测试用例都实现了10%到250%的性能提升(具体PR可以看这里)。JavaScript在V8上比C++扩展运行得更快。这种情况多发生在字符串和正则表达式相关的场景,因为V8内部使用的正则表达式引擎是irregexp,比boost中的自动正则表达式引擎速度更快。带有(boost::regex)的引擎要快得多。另外值得注意的是,Node.js的C++扩展在执行类型转换时可能会消耗大量性能。如果不注意C++代码的细节,性能会大打折扣。这里有一篇文章比较C++和JS在相同算法下的性能(翻墙):HowtogetaperformanceboostusingNode.jsnativeaddons。值得注意的结论是,C++代码将参数中的字符串转换后(String::Utf8Valuetostd::string),性能甚至不及JS实现的一半。只有使用了NAN提供的类型封装,才能达到比JS更高的性能。也就是说,C++是否比JavaScript更高效,需要具体情况具体分析。在某些情况下,C++扩展不一定比原生JavaScript更高效。如果你对自己的C++水平没那么自信的话,其实更推荐用JavaScript来实现,因为V8的性能比你想象的要好很多。8、使用node-clinic快速定位性能问题说了这么多,有没有什么开箱即用,五分钟见效的?当然有。node-clinic是NearForm开源的一款Node.js性能诊断工具,可以非常快速的定位性能问题。使用npmi-gclinicnpmi-gautocannon时,先启动服务进程:clinicdoctor--nodeserver.js然后我们可以用任何压测工具进行压测,比如用同一个作者的autocannon(当然你也可以使用ab、curl等工具进行压力测试):autocannonhttp://localhost:3000压力测试完成后,我们ctrl+c关闭clinic打开的进程,会有一个报告自动生成。比如下面是我们的一个中间件服务的性能报告:从CPU使用率曲线可以看出,这个中间件服务的性能瓶颈不是它自己的内部计算,而是I/O速度太慢。该诊所还在上面告诉我们,已检测到潜在的I/O问题。接下来,我们使用clinicbubbleprof来检测I/O问题:clinicbubbleprof--nodeserver.js再次压测后,我们得到了一个新的报告:在这个报告中,我们可以看到http.Server在整个程序运行过程中,96%的时间处于pending状态。点击之后,我们会发现调用栈中有大量的空帧,也就是说,由于网络I/O的限制,CPU中有大量的空闲。很常见,也给我们指出了优化方向不在服务内部,而是服务器的网关和依赖服务的相应速度。如果你想知道如何阅读clinicbubbleprof生成的报告,你可以在这里阅读:https://clinicjs.org/bubblepr...同样,clinic也可以检测服务内部的计算性能问题。接下来,我们要做一些“破坏”,让这个服务的性能瓶颈出现在CPU计算上。我们添加了CPU密集型“破坏性”代码,例如在中间件中空转1亿次:(ctx,next)=>{sleep()//......returnnext()}然后使用诊所医生重复以上步骤生成性能报告:这是一个非常典型的同步“案例”计算阻塞异步队列,即在主线程上进行大量计算,导致JavaScript异步回调无法及时触发,EventLoop延迟极高。对于此类应用,我们可以继续使用clinicflame来判断密集计算发生在什么地方:clinicflame--nodeapp。火焰图看起来没有那么极端):从这张图中,我们可以清楚地看到顶部的大白条,它代表睡眠功能空转所消耗的CPU时间。根据这样的火焰图,我们很容易看出CPU资源的消耗情况,从而定位到代码中计算密集的地方,找到性能瓶颈。本文已获作者授权发布于腾讯云+社区