记一次Node项目的优化

这两天针对一个Node项目进行了一波代码层面的优化,从响应时间上看,是一次很显著的提升。
一个纯粹给客户端提供接口的服务,没有涉及到页面渲染相关。

背景

首先这个项目是一个几年前的项目了,期间一直在新增需求,导致代码逻辑变得也比较复杂,接口响应时长也在跟着上涨。
之前有过一次针对服务器环境方面的优化(node版本升级),确实性能提升不少,但是本着“青春在于作死”的理念,这次就从代码层面再进行一次优化。

相关环境

由于是一个几年前的项目,所以使用的是Express+co这样的。
因为早年Node.js版本为4.x,遂异步处理使用的是yield+generator这种方式进行的。
确实相对于一些更早的async.waterfall来说,代码可读性已经很高了。

关于数据存储方面,因为是一些实时性要求很高的数据,所以数据均来自Redis
Node.js版本由于前段时间的升级,现在为8.11.1,这让我们可以合理的使用一些新的语法来简化代码。

因为访问量一直在上涨,一些早年没有什么问题的代码在请求达到一定量级以后也会成为拖慢程序的原因之一,这次优化主要也是为了填这部分坑。

一些小提示

本次优化笔记,并不会有什么profile文件的展示。
我这次做优化也没有依赖于性能分析,只是简单的添加了接口的响应时长,汇总后进行对比得到的结果。(异步的写文件appendFile了开始结束的时间戳)
依据profile的优化可能会作为三期来进行。
profile主要会用于查找内存泄漏、函数调用堆栈内存大小之类的问题,所以本次优化没有考虑profile的使用
而且我个人觉得贴那么几张内存快照没有任何意义(在本次优化中),不如拿出些实际的优化前后代码对比来得实在。

几个优化的地方

这里列出了在本次优化中涉及到的地方:

  1. 一些不太合理的数据结构(用的姿势有问题)
  2. 串行的异步代码(类似callback地狱那种格式的)

数据结构相关的优化

这里说的结构都是与Redis相关的,基本上是指部分数据过滤的实现
过滤相关的主要体现在一些列表数据接口中,因为要根据业务逻辑进行一些过滤之类的操作:

  1. 过滤的参考来自于另一份生成好的数据集
  2. 过滤的参考来自于Redis

其实第一种数据也是通过Redis生成的。:)

过滤来自另一份数据源的优化

就像第一种情况,在代码中可能是类似这样的:

let data1 = getData1()
// [{id: XXX, name: XXX}, ...]

let data2 = getData2()
// [{id: XXX, name: XXX}, ...]

data2 = data2.filter(item => {
  for (let target of data1) {
    if (target.id === item.id) {
      return false
    }
  }

  return true
})

有两个列表,要保证第一个列表中的数据不会出现在第二个列表中
当然,这个最优的解决方案一定是服务端不进行处理,由客户端进行过滤,但是这样就失去了灵活性,而且很难去兼容旧版本
上面的代码在遍历data2中的每一个元素时,都会尝试遍历data1,然后再进行两者的对比。
这样做的缺点在于,每次都会重新生成一个迭代器,且因为判断的是id属性,每次都会去查找对象属性,所以我们对代码进行如下优化:

// 在外层创建一个用于过滤的数组
let filterData = data1.map(item => item.id)

data2 = data2.filter(item =>
  filterData.includes(item.id)
)

这样我们在遍历data2时只是对filterData对象进行调用了includes进行查找,而不是每次都去生成一个新的迭代器。
当然,其实关于这一块还是有可以再优化的地方,因为我们上边创建的filterData其实是一个Array,这是一个List,使用includes,可以认为其时间复杂度为O(N)了,Nlength
所以我们可以尝试将上边的Array切换为Object或者Map对象。
因为后边两个都是属于hash结构的,对于这种结构的查找可以认为时间复杂度为O(1)了,有或者没有

let filterData = new Map()
data.forEach(item =>
  filterData.set(item.id, null) // 填充null占位,我们并不需要它的实际值
)

data2 = data2.filter(item =>
  filterData.has(item.id)
)

P.S. 跟同事讨论过这个问题,并做了一个测试脚本实验,证明了在针对大量数据进行判断item是否存在的操作时,SetArray表现是最差的,而MapObject基本持平。

关于来自Redis的过滤

关于这个的过滤,需要考虑优化的Redis数据结构一般是SetSortedSet
比如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两种实际的场景
  1. 如果现在有一个列表数据,需要针对某些省份进行过滤掉一些数据。
    我们可以选择在循环外层取出集合中所有的值,然后在循环内部直接通过内存中的对象来判断过滤。
  2. 如果这个列表数据是要针对用户进行黑名单过滤的,考虑到有些用户可能会拉黑很多人,这个Set的基数就很难估,这时候就建议使用循环内判断的方式了。

降低网络传输成本

杜绝Hash的滥用

确实,使用hgetall是一件非常省心的事情,不管Redis的这个Hash里边有什么,我都会获取到。
但是,这个确实会造成一些性能上的问题。
比如,我有一个Hash,数据结构如下:

{
  name: ‘Niko‘,
  age: 18,
  sex: 1,
  ...
}

现在在一个列表接口中需要用到这个hash中的nameage字段。
最省心的方法就是:

let info = {}
let results = await redisClient.hgetall(‘hash‘)

return {
  ...info,
  name: results.name,
  age: results.age
}

hash很小的情况下,hgetall并不会对性能造成什么影响,
可是当我们的hash数量很大时,这样的hgetall就会造成很大的影响。

  1. hgetall时间复杂度为O(N)Nhash的大小
  2. 且不说上边的时间复杂度,我们实际仅用到了nameage,而其他的值通过网络传输过来其实是一种浪费

所以我们需要对类似的代码进行修改:

let results = await redisClient.hgetall(‘hash‘)
// == >
let [name, age] = await redisClient.hmget(‘hash‘, ‘name‘, ‘age‘)

P.S. 如果hash的item数量超过一定量以后会改变hash的存储结构,
此时使用hgetall性能会优于hmget,可以简单的理解为,20个以下的hmget都是没有问题的

异步代码相关的优化

co开始,到现在的asyncawait,在Node.js中的异步编程就变得很清晰,我们可以将异步函数写成如下格式:

async function func () {
  let data1 = await getData1()
  let data2 = await getData2()

  return data1.concat(data2)
}

await func()

看起来是很舒服对吧?
你舒服了程序也舒服,程序只有在getData1获取到返回值以后才会去执行getData2的请求,然后又陷入了等待回调的过程中。
这个就是很常见的滥用异步函数的地方。将异步改为了串行,丧失了Node.js作为异步事件流的优势。

像这种类似的毫无相关的异步请求,一个建议:
能合并就合并,这个合并不是指让你去修改数据提供方的逻辑,而是要更好的去利用异步事件流的优势,同时注册多个异步事件。

async function func () {
  let [
    data1,
    data2
  ] = await Promise.all([
    getData1(),
    getData2()
  ])
}

这样的做法能够让getData1getData2的请求同时发出去,并统一处理回调结果。

最理想的情况下,我们将所有的异步请求一并发出,然后等待返回结果。

然而一般来讲不太可能实现这样的,就像上边的几个例子,我们可能要在循环中调用sismember,亦或者我们的一个数据集依赖于另一个数据集的过滤。
这里就又是一个权衡取舍的地方了,就像本次优化的一个例子,有两份数据集,一个有固定长度的数据(个位数),第二个为不固定长度的数据。

第一个数据集在生成数据后会进行裁剪,保证长度为固定的个数。
第二个数据集长度则不固定,且需要根据第一个集合的元素进行过滤。

此时第一个集合的异步调用会占用很多的时间,而如果我们在第二个集合的数据获取中不依据第一份数据进行过滤的话,就会造成一些无效的请求(重复的数据获取)。
但是在对比了以后,还是觉得将两者改为并发性价比更高。
因为上边也提到了,第一个集合的数量大概是个位数,也就是说,第二个集合即使重复了,也不会重复很多数据,两者相比较,果断选择了并发。
在获取到两个数据集以后,在拿第一个集合去过滤第二个集合的数据。
如果两者异步执行的时间差不太多的话,这样的优化基本可以节省40%的时间成本(当然缺点就是数据提供方的压力会增大一倍)。

将串行改为并行带来的额外好处

如果串行执行多次异步操作,任何一个操作的缓慢都会导致整体时间的拉长。
而如果选择了并行多个异步代码,其中的一个操作时间过长,但是它可能在整个队列中不是最长的,所以说并不会影响到整体的时间。

后记

总体来说,本次优化在于以下几点:

    1. 合理利用数据结构(善用hash结构来代替某些list
    2. 减少不必要的网络请求(hgetall to hmget
    3. 将串行改为并行(拥抱异步事件)

原文地址:https://www.cnblogs.com/jiasm/p/9157922.html

时间: 2024-10-11 14:11:42

记一次Node项目的优化的相关文章

你不知道的Node.js性能优化,读了之后水平直线上升

本文由云+社区发表 "当我第一次知道要这篇文章的时候,其实我是拒绝的,因为我觉得,你不能叫我写马上就写,我要有干货才行,写一些老生常谈的然后加上好多特技,那个 Node.js 性能啊好像 Duang~ 的一下就上去了,那读者一定会骂我,Node.js 根本没有这样搞性能优化的,都是假的." ------ 斯塔克·成龙·王 1.使用最新版本的 Node.js 仅仅是简单的升级 Node.js 版本就可以轻松地获得性能提升,因为几乎任何新版本的 Node.js 都会比老版本性能更好,为什么

【夯实Mysql基础】记一次mysql语句的优化过程!

1. [事件起因] 今天在做项目的时候,发现提供给客户端的接口时间很慢,达到了2秒多,我第一时间,抓了接口,看了运行的sql,发现就是 2个sql慢,分别占了1秒多. 一个sql是 链接了5个表同时使用了 2个 order by和 1个limit的分页 sql. 一个sql是上一个sql的count(*),即链接了5个表,当然没有limit了(取总数). 2. [着手优化] 1)[优化思路] 第一条是 做client调用 service层的数据缓存 第二条就是 优化sql本身. 这里着重讲一下

请问这个git上开源的node项目怎样才能在windows用Npm跑起来

这个项目https://github.com/wechaty/we...以前都是用人家弄好的手脚架搞得es6,搞了2天搞起了es6还报错,错误信息在下面,然后我想请教大神:1我到底应该怎么弄才能在windows上跑起来,2还有如果要学Docker,有没有什么好的学习docker的中文教程?3还有里面都是.ts结尾的是什么鬼?要学会如何用这个应该去学什么东西?在哪里学比较好? E:\weixin\wechaty>npm run dev [email protected] dev E:\weixin

【开发模式】项目过早优化现象:处女座专属鸡汤

最近在Coursera上看机器学习,顺便梳理了下算法体系. 其中Andrew Ng就有提到一个"过早优化"的观点非常喜欢:         与其将大把时间花费在挑选学习算法.更换模型上,然后花费6.7个月收集数据,(潜台词:这是愚蠢的做法,bad idea) 不如         凭直觉先随意挑个算法.用少量数据在1,2天内进行实现,然后通过学习曲线.误差分析来调整这个学习算法,并判断特征是否足够区分,是否需要加入新的特征变量,直到有证据表明目前特征适合且只欠缺样本量/数据后再开始收集

使用 Jasmine 测试 Node 项目

在上一篇文章(Jasmine Introdunction)中,我们已经介绍了如何在浏览器中运行 Jasmine 测试框架.对于浏览器端的 JS 代码来说,这无疑是很方便的.那么 Jasmine 能否用来对服务端的代码进行测试呢?答案当然是可以.本文中,我们就将介绍如何在基于 Node 的项目中,方便快捷地运行 Jasmine 测试. 同上篇文章一样,我们依旧新建一个待测试的 JS 文件 js/Hello.js function Hello() {}; Hello.prototype.foo =

node项目

我在打开node项目的时候,在运行 npm start的时候报这个错 图的意思是说, node的版本问题,他好像叫你rebuild,于是我的解决方案是:先 npm uninstall node-sass 然后再npm install node-sass最后成功的模样是:这个时候再执行npm start这样项目就启动成功了.

Node项目通过cluster API充分利用多核CPU

默认的一个Node项目只有一个Master进程,通过cluster API来管理此进程,可以为每核增加一个worker,就叫分支进程吧 示例 var cluster = require('cluster'); var http = require('http'); //获取服务器的CPU核数 var numCPUs = require('os').cpus().length; if (cluster.isMaster) { for (var i = 0; i < numCPUs; i++) {

仿百度壁纸client(六)——完结篇之Gallery画廊实现壁纸预览已经项目细节优化

仿百度壁纸client(六)--完结篇之Gallery画廊实现壁纸预览已经项目细节优化 百度壁纸系列 仿百度壁纸client(一)--主框架搭建,自己定义Tab + ViewPager + Fragment 仿百度壁纸client(二)--主页自己定义ViewPager广告定时轮播图 仿百度壁纸client(三)--首页单向,双向事件冲突处理,壁纸列表的实现 仿百度壁纸client(四)--自己定义上拉载入实现精选壁纸墙 仿百度壁纸client(五)--实现搜索动画GestureDetector

android加载速度优化,通过项目的优化过程分析

通过这么长时间的盒子开发以及之前手机项目的经验,总体感觉两种不同设备还是有很多不同的地方的,首先一点不同的就是,手机项目和电视项目默认启动页面加载速度有重要区别 对于手机:手机加载网络数据,由于屏幕小,如果主页有网络图片的情况下,基本都是显示默认图片,这也是由于网速的限制,更重要的是手机上基本是图文混排,用户没看到图片可能焦点就在文本上了. 对于电视:如果应用首页加载使用默认图,会感觉特别丑,因为屏幕大,重要信息都是图片,如果没有图片,那用户看到的都是空白,用户的焦点没有了,只有等待和抱怨. 因