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

记一个Node项目的优化

时间:2023-04-03 21:06:46 Node.js

这两天对一个Node项目进行了一波代码级的优化。从响应时间的角度来看,这是一个非常显着的改进。纯粹为客户端提供接口,不涉及页面渲染的服务。背景首先,这个项目是几年前的项目,期间又增加了新的需求,导致代码逻辑更加复杂,接口响应时间也在上升。之前有针对服务器环境的优化(节点版本升级),性能确实提升了不少,不过本着“青春即逝”的理念,这次又从代码层面进行了优化.相关环境是几年前的项目,所以用的是Express+co。因为早年Node.js版本是4.x,异步处理是使用yield+generator进行的。确实,相对于早期的一些async.waterfall,代码的可读性已经非常高了。关于数据存储,因为是一些实时性要求比较高的数据,所以数据来自于Redis。由于前段时间的升级,现在Node.js的版本是8.11.1,可以让我们合理的使用一些新的语法来简化代码。因为访问量一直在增加,一些早年没有问题的代码,在请求达到一定程度后,就会成为程序变慢的原因之一。本次优化主要是为了填这部分的坑。一些小提示此优化说明不显示任何配置文件。我这次没有靠性能分析来优化,只是简单的加上了界面的响应时间,总结之后对比结果。(异步写入具有开始和结束时间戳的文件appendFile)基于配置文件的优化可以分三个阶段进行。profile主要是用来查找内存泄漏,函数调用栈内存大小等问题,所以本次优化没有考虑profile的使用,个人认为贴几张内存快照没有意义(本次优化),是最好拿出一些优化前后的实际代码对比。这里列出几个优化的地方。本次优化涉及到的地方有:一些不合理的数据结构(使用姿势有问题)串行异步代码(类似于回调地狱的格式)数据结构相关的优化这里说的结构都是Redis相关的,基本上参考部分数据过滤的实现。过滤主要体现在一些列表数据接口上,因为过滤等一些操作需要根据业务逻辑进行:过滤引用来自另一个生成数据集过滤的引用来自于Redis。其实第一种数据也是通过Redis生成的。:)从另一个数据源过滤的优化就像第一种情况,在代码中可能是这样的:letdata1=getData1()//[{id:XXX,name:XXX},...]letdata2=getData2()//[{id:XXX,name:XXX},...]data2=data2.filter(item=>{for(lettargetofdata1){if(target.id===item.id){returnfalse}}returntrue})有两个列表,保证第一个列表中的数据不会出现在第二个列表中当然最优解一定是服务端不处理,由客户端过滤,但是这样就失去了灵活性,很难兼容旧版本。在遍历data2中的每一个元素时,上面的代码都会尝试遍历data1,然后将两者进行比较。这样做的缺点是每次都会重新生成一个迭代器,而且因为判断的是id属性,所以每次都会去查找object属性,所以我们优化代码如下://在外层创建一个用于过滤的数组letfilterData=data1.map(item=>item.id)data2=data2.filter(item=>filterData.includes(item.id))这样,在遍历data2的时候,我们只需要调用filterData对象的include即可搜索,而不是每次都生成一个新的迭代器。当然,其实这方面还是有优化空间的,因为我们上面创建的filterData其实是一个Array,也就是一个List。使用includes,可以认为其时间复杂度为O(N),N为长度。所以我们可以尝试把上面的Array换成Object或者Map对象。因为后两者属于hash结构,所以对于这个结构的查找可以认为时间复杂度为O(1),有无都可以。letfilterData=newMap()data.forEach(item=>filterData.set(item.id,null)//填充空占位符,我们不需要它的实际值)data2=data2.filter(item=>filterData.has(item.id))附言和同事讨论过这个问题,做了一个测试脚本实验,证明了对于大量的数据,Set和Array在判断一个item是否存在的时候表现最差,而Map和Object基本一样。关于从Redis中过滤关于这个过滤,需要考虑的优化Redis数据结构一般是Set和SortedSet。比如Set调用sismember判断一个item是否存在,或者SortedSet调用zscore判断一个item是否存在(是否有对应的score值)这里就是权衡的地方,如果我们在上面的两个方法的循环中使用.是直接在循环外层获取所有item,直接在内存中获取元素是否存在,还是在循环中依次调用Redis获取item是否存在?这里有一点建议供参考。如果是SortedSet,建议在循环中使用zscore来判断(这个时间复杂度是O(1))。如果是Set,如果已知的Set基数基本大于循环次数,建议在循环中使用sismember来判断代码是否会循环很多次,但是Set的基数并不大,可以拿出来在循环外使用(smembers的时间复杂度是O(N),N是集合的底数)还有一点嘛,网络传输成本也需要计入范围我们的权衡,因为sismbers的返回值只有1|0,而smembers将传输整个集合。关于Set的两个实际场景,如果现在有一个列表数据,需要针对某些省份过滤掉一些数据。我们可以选择在循环的外层取出集合中的所有值,然后在循环内部直接通过内存中的对象进行判断过滤。如果列表数据要对用户进行黑名单过滤,考虑到有的用户可能会屏蔽很多人,这个Set的基数很难估计??。这时候推荐使用循环内判断的方法。降低网络传输成本,防止Hash滥用确实,使用hgetall是一件很省心的事情,不管Redis的Hash里面有什么,我都会搞定。但是,这确实会导致一些性能问题。例如,我有一个具有以下数据结构的Hash:{name:'Niko',age:18,sex:1,...}现在这个hash中的name和age字段需要在列表接口中使用。最省心的方法是:letinfo={}letresults=awaitredisClient.hgetall('hash')return{...info,name:results.name,age:results.age}当hash很小的时候,hgetall不会对性能有什么影响,但是当我们有大量的hash时,这样的hgetall就会有很大的影响。hgetall的时间复杂度是O(N),N是hash的大小。且不说上面的时间复杂度,我们其实只用了name和age,其他的值通过网络传输其实是一种浪费,所以我们需要修改类似的代码:letresults=awaitredisClient.hgetall('hash')//==>let[name,age]=awaitredisClient.hmget('hash','name','age')**P.S.如果散列中的项数超过一定数量,则散列的存储结构将发生变化。这时候使用hgetall的性能会比hmget好。可以简单理解为hmgets少于20个hmgets是没有问题的。从async和await开始,Node.js中的异步编程已经变得非常清晰。我们可以这样写异步函数:asyncfunctionfunc(){letdata1=awaitgetData1()letdata2=awaitgetData2()returndata1.concat(data2)}awaitfunc()看起来很舒服吧?你舒服,节目也舒服。程序只有在getData1得到返回值后才会执行getData2请求,然后就陷入等待回调的过程。这是滥用异步函数的一个非常常见的地方。将异步更改为串行失去了Node.js作为异步事件流的优势。像这种无关紧要的异步请求,一个建议:能就合并吧。这种合并并不意味着你需要修改数据提供者的逻辑,而是为了更好地利用异步事件流的优势,同时注册多个异步事件。asyncfunctionfunc(){let[data1,data2]=awaitPromise.all([getData1(),getData2()])}该方法允许同时发送getData1和getData2请求,并处理回调结果均匀地。理想情况下,我们将所有异步请求一起发送,然后等待结果返回。但是,通常不可能实现这一点。像上面的例子,我们可能要循环调用sismember,或者我们的一个数据集依赖于另一个数据集的过滤。这是另一个权衡。就像这个优化的一个例子,有两个数据集,一个是定长数据(个位数),另一个是变长数据。第一个数据集在数据生成后会被裁剪,保证长度是一个固定的数字。第二个数据集是可变长度的,需要根据第一个数据集的元素进行过滤。这时候第一次采集的异步调用会耗费很多时间,而如果我们在第二次采集的数据获取中不按照第一个数据进行过滤,就会造成一些无效的请求(重复获取数据)。但是对比之后还是觉得还是把两者改成并发比较划算。上面说了,第一组的个数大约是个位数,也就是说第二组即使重复,也不会重复很多数据。两者相比,我果断选择了并发。得到两组数据后,用第一组过滤第二组的数据。如果两者的异步执行时间相差不大,这样的优化基本上可以节省40%的时间成本(当然缺点是数据提供者的压力会成倍增加)。串行改为并行的额外好处如果串行执行多个异步操作,任何一个操作的缓慢都会导致整体时间的增加。而如果并行选择多个异步代码,其中一个操作耗时过长,但不一定是整个队列中最长的,所以不会影响整体时间。后记总的来说,这种优化在于以下几点:合理使用数据结构(利用好哈希结构来代替一些列表)减少不必要的网络请求(hgetall到hmget)串行到并行(拥抱异步事件)和一个比较新鲜出炉的接口响应时间图: