微信公众号:[前端一锅煮]
一点技术、一点思考。
问题或建议,请公众号留言。

Node.js 作为后台服务性能是非常关键的一点,而影响 Node.js 的性能不仅仅要考虑其本身的因素,还应该考虑所在服务器的一些因素。比如网络 I/O 、磁盘 I/O 以及其他内存、句柄等一些问题。下面将详细地分析影响其性能的因素原因,以及部分优化解决方案。

CPU 密集型计算

CPU 负责了程序的运行和业务逻辑的处理,而 CPU 密集型表示的主要是 CPU 承载了比较复杂的运算。

在 Node.js 中由于主线程是单线程的,无论是主线程逻辑,还是回调处理逻辑,最终都是在主线程处理,那么如果该线程一直在处理复杂的计算,其他请求就无法再次进来,也就是单个用户就可以阻塞所有用户的请求。这样就会因为某些用户的复杂运算,而影响到整个系统的请求处理,而且这种复杂运算占用的 CPU 时间越久,就会导致请求堆积,而进一步导致系统处于崩溃状态无法恢复。因此保持主线程的通畅是非常关键的。

在 Node.js 中有以下几种情况,会影响到主线程的运行,应主动避免:

  1. 大的数据循环,比如没有利用好数据流,一次性处理非常大的数组;
  2. 字符串处理转化,比如加解密、字符串序列化等;
  3. 图片、视频的计算处理,比如对图片进行裁剪、缩放或者切割等。

对此我们考虑以下优化方向:

  • 将 CPU 密集型计算使用其他进程来处理;
  • 增加缓存,对于相同响应的返回数据,增加缓存处理,避免不必要的重复计算。

本地磁盘 I/O

I/O(Input/Output)意思是输入输出,其实就是数据传递的一个过程,作为后台服务需要更多地与外部进行数据交互,那么就免不了 I/O 操作。

I/O 分为以下 5 种模型,在介绍分类之前,我们先了解 I/O 在系统层面会有 2 个阶段(以读为例子):

  • 第一个阶段是读取文件,将文件放入操作系统内核缓冲区;
  • 第二阶段是将内核缓冲区拷贝到应用程序地址空间。
  1. 阻塞 I/O

例如读取一个文件,我们必须要等待文件读取完成后,也就是完成上面所说的两个阶段,才能执行其他逻辑,而当前是无法释放 CPU 的,因此无法去处理其他逻辑。

  1. 非阻塞 I/O

非阻塞的意思是,我们发起了一个读取文件的指令,系统会返回正在处理中,然后这时候如果要释放进程中的 CPU 去处理其他逻辑,你就必须间隔一段时间,然后不停地去询问操作系统,使用轮询的判断方法看是否读取完成了。

  1. 多路复用 I/O

这一模型主要是为了解决轮询调度的问题,我们可以将这些 I/O Socket 处理的结果统一交给一个独立线程来处理,当 I/O Socket 处理完成后,就主动告诉业务,处理完成了,这样不需要每个业务都来进行轮询查询了。

它包括目前常见的三种类型:select 、poll 和 epoll。首先 select 是比较旧的,它和 poll 的区别在于 poll 使用的是链表来保存 I/O Socket 数据,而 select 是数组,因此 select 会有上限 1024,而 poll 则没有。select、poll 与 epoll 的区别在于,前两者不会告诉你是哪个 I/O Socket 完成了,而 epoll 会通知具体哪个 I/O Socket 完成了哪个阶段的操作,这样就不需要去遍历查询了。

当然这里有一个重点是这三者只会告知文件读取进入了操作系统内核缓冲区,也就是上面我们所说的第一阶段,但是第二阶段从内核拷贝到应用程序地址空间还是同步等待的。

  1. 信号驱动 I/O

这种模式和多路复用的区别在于不需要有其他线程来处理,而是在完成了读取进入操作系统内核缓冲区后,立马通知,也就是第一阶段可以由系统层面来处理,不需要独立线程来管理,但是第二阶段还是和多路复用一样。

  1. 异步 I/O

和信号驱动不同的是,异步 I/O 是两个阶段都完成了以后,才会通知,并不是第一阶段完成。

我们常说 Node.js 是一个异步 I/O 这个是没有错的。具体来说 Node.js 是其 libv 库自行实现的一种类似异步 I/O 的模型,对于 Node.js 应用来说是一个异步 I/O,因此无须处理两个过程,而在 libv 内部实现,则是多线程的一个 epoll 模型。

在一般情况下磁盘 I/O 不会影响到主线程性能,因为磁盘 I/O 是异步其他线程处理。但是因为服务器磁盘性能是一定的,如果在高并发情况下,磁盘 I/O 压力较大,从而导致磁盘 I/O 的服务性能下降,就会从侧面影响机器性能,导致 Node.js 服务性能受影响。

网络 I/O

在后台服务中常见的网络 I/O 有如下几种类型:

  1. 缓存型,如 MemCache、Redis;
  2. 数据存储型,如 MySQL、MongoDB;
  3. 服务型,如内网 API 服务或者第三方 API。

网络 I/O 的成本是最高的,涉及两个最重要的点:

  • 依赖其他服务的性能;
  • 依赖服务器之间的延时。

对此,我们可以从以下几个方面来考虑优化的策略:

  • 减少与网络 I/O 的交互,比如缓存已获取的内容;
  • 使用更高性能的网络 I/O 替代其他性能较差的、成本更高的网络 I/O 类型,比如数据库读写的 I/O 成本是明显高于缓存型的,因此可以使用缓存型网络 I/O 替换存储型;
  • 降低目标网络 I/O 服务的并发压力,可以采用异步队列方式。

网络 I/O 一般不影响主线程逻辑,其请求的服务往往是瓶颈端,从而影响 Node.js 中涉及该网络服务的请求。但是网络 I/O 堆积较多也会侧面影响:服务器本身的网络模块问题以及Node.js 性能,导致其他服务接口受影响。

缓存问题

缓存是临时的一块存储空间,用于存放访问频次较高的数据,用空间换响应速度,核心是减少用户对数据库的查询压力。但是如果没有应用好缓存,将会导致一些不可见或者说很难定位的问题,主要是三点:缓存雪崩、缓存击穿和缓存穿透。

  1. 缓存雪崩

大部分数据都有一个过期时间的概念,假设我们有一批数据是通过定时服务从数据库写入缓存中,然后我们统一设置了过期时间。当这个时间节点到了,但是由于某种原因数据又没有从数据库写入缓存,导致这时候所有的数据都会前往数据库查询数据,从而引起数据库查询压力,导致数据库并发过大而瘫痪无法正常服务。

那么应该如何应对呢?

  • 避免所有数据都设置同一个过期时间节点,应该按数据类型、数据更新时效性来设置;
  • 数据过期时间应大于数据更新节点时间,并考虑更新时长,同时增加更新失败异常告警提示;
  • 对于一些相对较高频次或者数据库查询压力较大的数据,可不设置过期时间,主动从程序上来控制该数据的移除或者更替。
  1. 缓存击穿

这个概念和缓存雪崩有点类似,但不是大面积的缓存过期失效,而是某个访问频次较高的数据失效了,从而导致这一刻高并发的请求全部穿透到了数据库,从而数据库并发压力较高,响应较慢,也进一步导致数据库异常,影响其他业务。

那么应该如何应对呢?

  • 高频数据、查询较为复杂的数据,可以不设置过期时间,但是需要程序去维护数据的更替删除;
  • 如果需要缓存过期时间,要大于缓存更新时间,避免过期无法找到键;
  • 使用原子操作方案,当多个数据都需要前往数据库查询同一个数据时,告知程序缓存正在生成中,并且告知其他程序可以读取上一次缓存数据,避免同时读取同一份数据。
  1. 缓存穿透

对于访问频繁的数据,这里就会出现一种情况,比如说查询信息一直是空数据,空数据按理不属于访问频繁较高的数据,所以经过了缓存,但是并没有缓存该空数据,而是直接穿透进入了数据库,虽然数据库查询也是空数据,但是还是需要经过数据库的查询,这种现象就是击穿了缓存直接前往了数据库查询。

那么应该如何应对呢?

  • 过滤非正常请求数据,比如一些从参数就可以知道为空的数据,可以直接从程序上处理;
  • 缓存空的结果,为了提升性能,可以将一些查询为空的结果也缓存起来,这样下次用户再进行访问时,可以直接从缓存中判断返回;
  • 由于第 2 种方案在空数据较多时会浪费内存空间,我们可以将这些空数据的键名,使用布隆过滤器来缓存到缓存,这样可以尽可能地减少内存占用,并且更加高效。

多进程 cluster 模式

在多进程 cluster 模式中,因为所有的请求都必须经过 master 进程进行分发,同时接收处理 worker 进程的返回。

因此在实际开发过程中,如果启用了比较多的 worker 进程,而主进程只有一个,从而在单机高并发时(2 万以上的每秒并发请求)会导致 master 进程处理瓶颈,这样就影响到了服务性能,并且这时候你会发现 worker 进程的 CPU 并没有任何压力。

这点非常重要,在生产环境下一般很难发现这类问题,不过应该有这样的一个概念:大概在 2 万以上的并发时,master 进程会存在性能瓶颈。

内存限制

在 32 位服务器上 Node.js 的内存限制是 0.7 G,而在 64 位服务器上则是 1.4 G,而这个限制主要是因为 Node.js 的垃圾回收线程在超过限制内存时,回收时长循环会大于 1s,从而会影响性能问题。

现在我们一般会启用多个进程,如果每个进程损耗 1.4 G,那么加起来可能超出了服务器内存上限,从而导致服务器瘫痪。其次如果内存不会超出服务器上限,而是在达到一定上限时,也就是我们上面说的 0.7 G和 1.4 G,会导致服务器重启,从而会导致接口请求失败的问题。

句柄限制

句柄可以简单理解为一个 ID 索引,通过这个索引可以访问到其他的资源,比如说文件句柄、网络 I/O 操作句柄等等,而一般服务器句柄都有上限。当 Node.js 没有控制好句柄,比如说无限的打开文件并未关闭,就会出现句柄泄漏问题,而这样会导致服务器异常,从而影响 Node.js 服务。


前端一锅煮
849 声望31 粉丝

积极阳光前端一枚,爱学习,爱分享,全栈进行中~