这两天针对一个Node项目进行了一波代码层面的优化,从响应时间上看,是一次很显著的提高。
一个纯粹给客户端提供接口的服务,没有涉及到页面渲染相关。javascript
首先这个项目是一个几年前的项目了,期间一直在新增需求,致使代码逻辑变得也比较复杂,接口响应时长也在跟着上涨。
以前有过一次针对服务器环境方面的优化(node版本升级),确实性能提高很多,可是本着“青春在于做死”的理念,此次就从代码层面再进行一次优化。java
因为是一个几年前的项目,因此使用的是Express
+co
这样的。
由于早年Node.js
版本为4.x
,遂异步处理使用的是yield
+generator
这种方式进行的。
确实相对于一些更早的async.waterfall
来讲,代码可读性已经很高了。node
关于数据存储方面,由于是一些实时性要求很高的数据,因此数据均来自Redis
。
Node.js
版本因为前段时间的升级,如今为8.11.1
,这让咱们能够合理的使用一些新的语法来简化代码。redis
由于访问量一直在上涨,一些早年没有什么问题的代码在请求达到必定量级之后也会成为拖慢程序的缘由之一,此次优化主要也是为了填这部分坑。编程
本次优化笔记,并不会有什么profile
文件的展现。
我此次作优化也没有依赖于性能分析,只是简单的添加了接口的响应时长,汇总后进行对比获得的结果。(异步的写文件appendFile
了开始结束的时间戳)
依据profile
的优化可能会做为三期来进行。
profile
主要会用于查找内存泄漏、函数调用堆栈内存大小之类的问题,因此本次优化没有考虑profile
的使用
并且我我的以为贴那么几张内存快照没有任何意义(在本次优化中),不如拿出些实际的优化先后代码对比来得实在。数组
这里列出了在本次优化中涉及到的地方:服务器
callback
地狱那种格式的)这里说的结构都是与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)
了,N
为length
。
因此咱们能够尝试将上边的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是否存在的操做时,Set
和Array
表现是最差的,而Map
和Object
基本持平。
关于这个的过滤,须要考虑优化的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
的基数就很难估,这时候就建议使用循环内判断的方式了。确实,使用hgetall
是一件很是省心的事情,无论Redis
的这个Hash
里边有什么,我都会获取到。
可是,这个确实会形成一些性能上的问题。
好比,我有一个Hash
,数据结构以下:
{
name: 'Niko',
age: 18,
sex: 1,
...
}
复制代码
如今在一个列表接口中须要用到这个hash
中的name
和age
字段。
最省心的方法就是:
let info = {}
let results = await redisClient.hgetall('hash')
return {
...info,
name: results.name,
age: results.age
}
复制代码
在hash
很小的状况下,hgetall
并不会对性能形成什么影响,
但是当咱们的hash
数量很大时,这样的hgetall
就会形成很大的影响。
hgetall
时间复杂度为O(N)
,N
为hash
的大小name
和age
,而其余的值经过网络传输过来实际上是一种浪费因此咱们须要对相似的代码进行修改:
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
开始,到如今的async
、await
,在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()
])
}
复制代码
这样的作法可以让getData1
与getData2
的请求同时发出去,并统一处理回调结果。
最理想的状况下,咱们将全部的异步请求一并发出,而后等待返回结果。
然而通常来说不太可能实现这样的,就像上边的几个例子,咱们可能要在循环中调用sismember
,亦或者咱们的一个数据集依赖于另外一个数据集的过滤。
这里就又是一个权衡取舍的地方了,就像本次优化的一个例子,有两份数据集,一个有固定长度的数据(个位数),第二个为不固定长度的数据。
第一个数据集在生成数据后会进行裁剪,保证长度为固定的个数。
第二个数据集长度则不固定,且须要根据第一个集合的元素进行过滤。
此时第一个集合的异步调用会占用不少的时间,而若是咱们在第二个集合的数据获取中不依据第一份数据进行过滤的话,就会形成一些无效的请求(重复的数据获取)。
可是在对比了之后,仍是以为将二者改成并发性价比更高。
由于上边也提到了,第一个集合的数量大概是个位数,也就是说,第二个集合即便重复了,也不会重复不少数据,二者相比较,果断选择了并发。
在获取到两个数据集之后,在拿第一个集合去过滤第二个集合的数据。
若是二者异步执行的时间差不太多的话,这样的优化基本能够节省40%
的时间成本(固然缺点就是数据提供方的压力会增大一倍)。
若是串行执行屡次异步操做,任何一个操做的缓慢都会致使总体时间的拉长。
而若是选择了并行多个异步代码,其中的一个操做时间过长,可是它可能在整个队列中不是最长的,因此说并不会影响到总体的时间。
整体来讲,本次优化在于如下几点:
hash
结构来代替某些list
)hgetall
to hmget
)以及一个新鲜的刚出炉的接口响应时长对比图: