记一次Node项目的优化

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
简介: 这两天针对一个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. 将串行改为并行(拥抱异步事件)
相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore     ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库 ECS 实例和一台目标数据库 RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
1月前
|
JavaScript 关系型数据库 MySQL
❤Nodejs 第六章(操作本地数据库前置知识优化)
【4月更文挑战第6天】本文介绍了Node.js操作本地数据库的前置配置和优化,包括处理接口跨域的CORS中间件,以及解析请求数据的body-parser、cookie-parser和multer。还讲解了与MySQL数据库交互的两种方式:`createPool`(适用于高并发,通过连接池管理连接)和`createConnection`(适用于低负载)。
25 0
|
7月前
|
开发工具 git
如何运行github上面的node+express项目
如何运行github上面的node+express项目
117 0
|
7月前
|
数据采集 JavaScript 前端开发
Node.js新手在哪儿找小项目练手?
Node.js新手在哪儿找小项目练手?
46 0
|
1月前
|
JavaScript
node.js 项目中执行 npm install 命令后看到的 idealTree inflate 的含义
node.js 项目中执行 npm install 命令后看到的 idealTree inflate 的含义
|
9天前
|
人工智能 JavaScript 前端开发
计算机node项目|nodejs网上书城设计与实现
计算机node项目|nodejs网上书城设计与实现
|
30天前
|
JavaScript
node项目的建立
该文档介绍了如何建立一个Node.js项目,首先通过`npm init -y`进行项目初始化,然后安装Express `npm i express@4.17.1`。在`app.js`中设置服务器监听8080端口。接着,为解决跨域问题,安装CORS `npm i cors@2.8.5`,并在`app.js`中启用。项目包含用户路由,新建路由文件并导入到`app.js`,通过Postman测试验证。最后,将路由处理逻辑抽离到`router_handler/user.js`,在路由模块中引入并调用处理函数。
26 1
node项目的建立
|
17天前
|
JavaScript 前端开发 中间件
Express框架搭建项目 node.js
【6月更文挑战第3天】这篇文章是关于使用Express框架构建Node.js Web应用的教程。Express是一个轻量级、功能丰富的框架,特点包括简洁灵活的核心、强大的中间件支持、灵活的路由系统和模板引擎兼容性。文章介绍了如何安装Express,并通过一个简单的示例展示了如何创建一个基本的Web服务器。最后,鼓励读者继续学习和实践,以充分利用Express和Node.js的能力。
23 1
|
1月前
|
JavaScript Unix Shell
#! /usr/bin/env node 命令与 npm link 建立项目间软连接(一)
#! /usr/bin/env node 命令与 npm link 建立项目间软连接(一)
31 0
|
1月前
|
Web App开发 JavaScript 前端开发
如何使用npm创建Node.js项目?
【2月更文挑战第10天】
91 3
如何使用npm创建Node.js项目?
|
1月前
|
JavaScript
用户安装nodejs设置路径打包项目
用户安装nodejs设置路径打包项目
55 1