这两天针对一个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. 将串行改为并行(拥抱异步事件)

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

  1. 记一次 node 项目重构改进

    摘要:经常听到有祖传的代码一说,就是一些项目经过了很长时间的维护,经过了很多人之手,业务逻辑堆叠的越来越多,然后就变成了一个越来越难以维护. 经常听到有祖传的代码一说,就是一些项目经过了很长时间的维护 ...

  2. 性能测试——记XX银行保全项目性能问题分析优化

    记XX银行保全项目性能问题分析优化 数据库问题也许是大部分性能问题的关注点,但是JAVA应用与数据库交互的关节,JDBC 就像是我们人体的上半身跟下半身的腰椎,支持上半身,协调下半身运动的重要支撑点. ...

  3. 【Vuejs】335-(超全) Vue 项目性能优化实践指南

    点击上方"前端自习课"关注,学习起来~ 前言 Vue 框架通过数据双向绑定和虚拟 DOM 技术,帮我们处理了前端开发中最脏最累的 DOM 操作部分, 我们不再需要去考虑如何操作 D ...

  4. 一个node项目的框架搭建流程

    项目服务端编程语言node,前端js,数据库mongodb, 开发工具用webstorm. 使用express应用生成器,生成项目雏形. 安装应用生成器工具,命令是npm install expres ...

  5. Express创建并运行node项目(Jade和EJS模版引擎)

    1.创建Node项目 [Jade模板] > express nodeJade express创建项目若不显示指定模板,默认使用Jade,以下写法都可以: express -jade nodeJa ...

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

    仿百度壁纸客户端(六)--完结篇之Gallery画廊实现壁纸预览已经项目细节优化 百度壁纸系列 仿百度壁纸客户端(一)--主框架搭建,自定义Tab + ViewPager + Fragment 仿百度 ...

  7. node项目自动化部署--基于Jenkins,Docker,Github(1)安装Jenkins

    前言 每次项目代码更新后都要重新部署,如果只有一台服务器还好. 但是如果是分布式系统,动不动就很多台服务器,所以代码的自动部署就显得十分重要了. 这里用几篇文章来记录一下如何使用Jenkins,Doc ...

  8. 记一次SSM项目小结(一)

    记一次SSM项目小结(一) ssm框架 环境配置 服务器配置 解决方法  拦截器重定向到localhost nginx和tomcat中session失效 mybatis的xml文件不生效 数据库用户创 ...

  9. Linux(Centos)服务器配置node项目

    以阿里云服务器,CentOS系统为例 上一节已经提到怎么安装nodejs,以下是以vue项目为例 步骤: (1)首先安装vue脚手架@vue/cli, 官网参考 vue-cli3.x [root@lu ...

随机推荐

  1. 某一线互联网公司前端面试题js部分总结

    js部分 1,使用严格模式的优点 - 消除Javascript语法的一些不合理.不严谨之处,减少一些怪异行为; - 消除代码运行的一些不安全之处,保证代码运行的安全: - 提高编译器效率,增加运行速度 ...

  2. Python爬虫requests判断请求超时并重新发送请求

     下面是简单的一个重复请求过程,更高级更简单的请移步本博客: https://www.cnblogs.com/fanjp666888/p/9796943.html  在爬虫的执行当中,总会遇到请求连接 ...

  3. WPF布局间的切换方法

    效果图,两种效果间的切换

  4. IOI 98 (POJ 1179)Polygon(区间DP)

    很容易想到枚举第一步切掉的边,然后再计算能够产生的最大值. 联想到区间DP,令dp[i][l][r]为第一步切掉第i条边后从第i个顶点起区间[l,r]能够生成的最大值是多少. 但是状态不好转移,因为操 ...

  5. Oracle 转义字符

    id sfds_V_SF ASD_V_DSAF SD_V_DSAD   下划线是Oracle特殊字符,需要转移,如下    select * from systab t where t.id like ...

  6. 【Codeforces Round #404 (Div. 2)】题解

    A. Anton and Polyhedrons 直接统计+答案就可以了. #include<cstdio> #include<cstring> #include<alg ...

  7. BZOJ4942 & UOJ314:[NOI2017]整数——题解

    https://www.lydsy.com/JudgeOnline/problem.php?id=4942 http://uoj.ac/problem/314 https://www.luogu.or ...

  8. BZOJ3521 [Poi2014]Salad Bar 【线段树 + 单调栈】

    题目链接 BZOJ3521 题解 容易想到用前缀和搞 如果我们令\(p\)为\(1\),\(j\)为\(-1\),记前缀和为\(s[i]\) 我们就是要找到一段区间\([l,r]\),使得 \[\fo ...

  9. jenkins实现maven项目自动化部署tomcat

    最近公司有用到jenkins实现自动化部署,这里我对新的东西也是比较感兴趣,就用了点时间尝试了一下,虽然网上有很多这种例子,但是可能有些细节我也走了一些弯路.在这里记录一下,方便下次用到. 实现环境: ...

  10. HDU 2655 主席树

    Kth number Time Limit: 15000/5000 MS (Java/Others)    Memory Limit: 32768/32768 K (Java/Others)Total ...