通过集群提高 Node.js 应用程序的性能

2022-07-29  本文已影响0人  一蓑烟雨任平生_cui

Node.js 实例在单线程中运行,这意味着在多核系统(如今大多数计算机都是多核)上,应用程序不会使用所有内核。要利用其他可用内核,可以启动 Node.js 进程集群并在它们之间分配负载。

通过多个进程来处理请求可以提高服务器的吞吐量(请求数/秒),因为可以同时处理多个服务。

集群

Node.js 集群模块支持创建并同时运行多个子进程,进程之间共享相同的端口。每个生成的子进程都拥有自己的事件循环、内存和 V8 实例。子进程使用 IPC(进程间通信)与主进程进行通信。

通过多个进程来处理传入的请求意味着可以同时处理多个请求,如果一个工作进程的有长时间运行/阻塞操作,其他工作进程可以继续处理其他传入请求,不会使应用程序阻塞。

传入的连接有两种方式分布在子进程中:

  1. 主进程侦听端口上的连接,并以循环方式将它们分配给工作进程(这是除 Windows 之外的所有平台上的默认方法)
  2. 主进程创建一个侦听套接字并将其发送给子进程,然后这些工作进程将直接接受传入的连接

先测试没有使用集群的情况:

const express = require('express')
const app = express()

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.get('/api/:n', function (req, res) {
  let n = parseInt(req.params.n)
  let count = 0

  if (n > 5000000000) n = 5000000000

  for (let i = 0; i <= n; i++) {
    count += i
  }

  res.send(`count is ${count}`)
})

app.listen(3000, () => {
  console.log(`runing...`)
})

/api/:n 为动态路由,根据传入的参数执行 for 循环,其时间复杂度为 O(n),浏览器访问 http://localhost:3000/api/50),将快速执行并立即返回响应。当 n 传入很大的值时,http://localhost:3000/api/5000000000,程序需要几秒才能完成请求。如果同时再打开一个浏览器选项卡并向服务器发送另一个 n 为 50 的请求,该请求仍要等待几秒钟才能完成,因为单个线程忙于处理第一个耗时的请求。单个 CPU 内核必须先完成第一个请求,才能处理另一个请求。

使用集群

const express = require('express')
const cluster = require('cluster')
const totalCPUs = require('os').cpus().length

if (cluster.isMaster) {
  console.log(`CPU 总核数: ${totalCPUs}`)
  console.log(`主进程 ${process.pid} is running`)

  // Fork 与内核数量相同的工作进程
  for (let i = 0; i < totalCPUs; i++) {
    cluster.fork()
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`)
    console.log("Let's fork another worker!")
    cluster.fork()
  })
} else {
  const app = express()
  console.log(`工作进程 ${process.pid} started`)

  app.get('/', (req, res) => {
    res.send('Hello World!')
  })

  app.get('/api/:n', function (req, res) {
    let n = parseInt(req.params.n)
    let count = 0

    if (n > 5000000000) n = 5000000000

    for (let i = 0; i <= n; i++) {
      count += i
    }

    res.send(`count is ${count}`)
  })

  app.listen(3001, () => {
    console.log(`runing...`)
  })
}

工作进程由主进程创建和管理。当程序第一次运行时,首先检查是否是主进程(isMaster),由 process.env.NODE_UNIQUE_ID 变量决定的。如果 process.env.NODE_UNIQUE_IDundefined,那么 isMaster 将是 true。然后调用 cluster.fork() 生成多个工作进程。当工作进程退出时,紧接着生成一个新进程以继续利用可用的 CPU 内核。

工作进程之间共享 3000 端口,并且都能够处理发送到该端口的请求。工作进程使用 child_process.fork() 方法生成。该方法返回一个 ChildProcess 具有内置通信通道的对象,该通道允许消息在子进程和父进程之间传递。

CPU 总核数: 6
主进程 id 40727 is running
工作进程 id 40733 started
工作进程 id 40729 started
工作进程 id 40732 started
工作进程 id 40730 started
工作进程 id 40731 started
runing
runing
runing
runing
runing
工作进程 id 40734 started
runing

测试集群效果,首先访问 http://localhost:3000/api/100000000000000000,紧接着在另一个选项卡访问 http://localhost:3000/api/50,发现后一个请求立即响应,第一个仍要等待几秒完成。

由于有多个工作进程可以处理请求,服务器的可用性和吞吐量都得到了提高。但是通过浏览器的访问来衡量请求处理以及集群的优势并不是正确可靠的方法,要更好的了解集群的性能需要通过工具测量。

性能指标

使用 loadtest模块对以上两个程序进行负载测试,看看每个程序如何处理大量传入的请求。

loadtest 可模拟大量的并发连接,以便测量其性能。

安装 loadtest

yarn add loadtest -D

基本使用:

loadtest [-n requests] [-c concurrency] [-k] URL

参数说明:

-n requests 要发送的请求数量

-c concurrency 并发数

--rps requestsPerSecond 控制每秒发送的请求数。也可以是小数,比如 --rps 0.5,每两秒发送一个请求。

URL 可以是 http、https、ws。

打开新的终端对第一个程序进行负载测试:

npx loadtest http://localhost:3000/api/5000000 -n 1000 -c 100

结果:

INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
INFO Requests: 726 (73%), requests per second: 145, mean latency: 646.4 ms
INFO
INFO Target URL:          http://localhost:3000/api/5000000
INFO Max requests:        1000
INFO Concurrency level:   100
INFO Agent:               none
INFO
INFO Completed requests:  1000
INFO Total errors:        0
INFO Total time:          6.7272657559999995 s
INFO Requests per second: 149
INFO Mean latency:        640.3 ms
INFO
INFO Percentage of the requests served within a certain time
INFO   50%      626 ms
INFO   90%      631 ms
INFO   95%      770 ms
INFO   99%      1010 ms
INFO  100%      1070 ms (longest request)
✨  Done in 1.21s.

总耗时 6.7272657559999995 s
平均延时(完成单个请求所需的时间) 640.3 ms
RPS(可以处理的并发量) 149

n 再加一个数量级测试:

npx loadtest http://localhost:3000/api/50000000 -n 1000 -c 100

结果:

INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
INFO Requests: 89 (9%), requests per second: 18, mean latency: 2510.7 ms
INFO Requests: 177 (18%), requests per second: 18, mean latency: 5618.9 ms
INFO Requests: 265 (27%), requests per second: 18, mean latency: 5691.5 ms
INFO Requests: 353 (35%), requests per second: 18, mean latency: 5679.5 ms
INFO Requests: 441 (44%), requests per second: 18, mean latency: 5663.3 ms
INFO Requests: 528 (53%), requests per second: 17, mean latency: 5686.3 ms
INFO Requests: 614 (61%), requests per second: 17, mean latency: 5808.5 ms
INFO Requests: 700 (70%), requests per second: 17, mean latency: 5828.4 ms
INFO Requests: 785 (79%), requests per second: 17, mean latency: 5800.2 ms
INFO Requests: 872 (87%), requests per second: 17, mean latency: 5845.6 ms
INFO Requests: 959 (96%), requests per second: 17, mean latency: 5713.4 ms
INFO
INFO Target URL:          http://localhost:3000/api/50000000
INFO Max requests:        1000
INFO Concurrency level:   100
INFO Agent:               none
INFO
INFO Completed requests:  1000
INFO Total errors:        0
INFO Total time:          57.321694236000006 s
INFO Requests per second: 17
INFO Mean latency:        5446.2 ms
INFO
INFO Percentage of the requests served within a certain time
INFO   50%      5703 ms
INFO   90%      5842 ms
INFO   95%      5852 ms
INFO   99%      5873 ms
INFO  100%      5879 ms (longest request)
✨  Done in 57.94s.

总耗时 57.321694236000006 s,平均延时 5446.2 ms,RPS 17。

下面同样的测试在集群下测试

n = 500000:

INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
INFO
INFO Target URL:          http://localhost:3000/api/5000000
INFO Max requests:        1000
INFO Concurrency level:   100
INFO Agent:               none
INFO
INFO Completed requests:  1000
INFO Total errors:        0
INFO Total time:          1.288627536 s
INFO Requests per second: 776
INFO Mean latency:        120.6 ms
INFO
INFO Percentage of the requests served within a certain time
INFO   50%      121 ms
INFO   90%      132 ms
INFO   95%      136 ms
INFO   99%      149 ms
INFO  100%      160 ms (longest request)

总耗时 1.288627536 s,平均延时 120.6 ms,RPS 776

速度快了 5 倍多。

n = 50000000:

INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
INFO Requests: 436 (44%), requests per second: 87, mean latency: 1047.2 ms
INFO Requests: 988 (99%), requests per second: 110, mean latency: 902.9 ms
INFO
INFO Target URL:          http://localhost:3000/api/50000000
INFO Max requests:        1000
INFO Concurrency level:   100
INFO Agent:               none
INFO
INFO Completed requests:  1000
INFO Total errors:        0
INFO Total time:          10.130369751 s
INFO Requests per second: 99
INFO Mean latency:        966.2 ms
INFO
INFO Percentage of the requests served within a certain time
INFO   50%      904 ms
INFO   90%      1084 ms
INFO   95%      1529 ms
INFO   99%      1882 ms
INFO  100%      1937 ms (longest request)

总耗时 10.130369751 s
平均延时 966.2 ms
RPS 99

差了将近 5.7 倍。

以上测试的是计算量很大的 CPU 密集型运算。下面测试计算量小运行速度快的请求。

测试单进程 n = 50 :

INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
INFO
INFO Target URL:          http://localhost:3000/api/50
INFO Max requests:        1000
INFO Concurrency level:   100
INFO Agent:               none
INFO
INFO Completed requests:  1000
INFO Total errors:        0
INFO Total time:          0.836801992 s
INFO Requests per second: 1110
INFO Mean latency:        76.8 ms
INFO
INFO Percentage of the requests served within a certain time
INFO   50%      90 ms
INFO   90%      95 ms
INFO   95%      96 ms
INFO   99%      96 ms
INFO  100%      97 ms (longest request)

总耗时 0.836801992 s,平均延时 76.8 ms,RPS 1110

n = 5000:

INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
INFO
INFO Target URL:          http://localhost:3000/api/5000
INFO Max requests:        1000
INFO Concurrency level:   100
INFO Agent:               none
INFO
INFO Completed requests:  1000
INFO Total errors:        0
INFO Total time:          0.877875636 s
INFO Requests per second: 1095
INFO Mean latency:        81.3 ms
INFO
INFO Percentage of the requests served within a certain time
INFO   50%      89 ms
INFO   90%      94 ms
INFO   95%      94 ms
INFO   99%      95 ms
INFO  100%      99 ms (longest request)

总耗时 0.877875636 s,平均延时 81.3 ms,RPS 1095

集群模式下测试 n = 50:

INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
INFO
INFO Target URL:          http://localhost:3000/api/50
INFO Max requests:        1000
INFO Concurrency level:   100
INFO Agent:               none
INFO
INFO Completed requests:  1000
INFO Total errors:        0
INFO Total time:          0.9260376199999999 s
INFO Requests per second: 1080
INFO Mean latency:        86 ms
INFO
INFO Percentage of the requests served within a certain time
INFO   50%      90 ms
INFO   90%      96 ms
INFO   95%      97 ms
INFO   99%      99 ms
INFO  100%      99 ms (longest request)

总耗时 0.9260376199999999 s,平均延时 86 ms,RPS 1080

n = 5000

 INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
INFO
INFO Target URL:          http://localhost:3000/api/5000
INFO Max requests:        1000
INFO Concurrency level:   100
INFO Agent:               none
INFO
INFO Completed requests:  1000
INFO Total errors:        0
INFO Total time:          0.872461731 s
INFO Requests per second: 1146
INFO Mean latency:        80.9 ms
INFO
INFO Percentage of the requests served within a certain time
INFO   50%      84 ms
INFO   90%      96 ms
INFO   95%      97 ms
INFO   99%      99 ms
INFO  100%      99 ms (longest request)

总耗时 0.872461731 s,平均延时 80.9 ms,RPS 1146

可以看出在非 CPU 密集型场景下,单进程和集群模式相比集群跟单进程差不多甚至不如单进程,并没有对程序的性能有所提升。事实上,与不使用集群的应用相比,集群应用的性能确实要差一些。

以上面测试为例,当用一个相当小的值调用 API 时代码中的计算量很小,不会占用大量 CPU,而集群模式下创建的每个进程都会有自己的内存和 V8 实例,造成额外的资源分配,所以在非密集型运算场景下集群反而不占优势。所以不建议总是创建子进程。

但处理 CPU 密集型任务时集群是有很大优势的。

所以,在实际项目中需要评估项目是否是 CPU 密集型的以确定是否启用集群模式。

使用 PM2 管理 Node.js 集群

以上程序中使用 Node 的 cluster 模块创建和管理子进程。根据 cpu 核数确定创建子进程的数量。然后监听进程的状态,一旦 died 就立马新创建一个。

PM2 是一个守护进程管理器,内置负载均衡器,自动在集群模式下运行,创建工作进程并在工作进程 died 时创建新工作进程。可以停止、删除和启动进程,0 秒停机重载,还有一些监控功能可以帮助监控和调整应用程序的性能。

全局安装

npm i -g pm2

使用第一个程序就行,不需要开发创建集群。

const express = require('express')
const app = express()

app.get('/', (req, res) => {
  res.send('Hello World!')
})
app.get('/api/:n', function (req, res) {
  let n = parseInt(req.params.n)
  let count = 0

  if (n > 5000000000) n = 5000000000

  for (let i = 0; i <= n; i++) {
    count += i
  }

  res.send(`count is ${count}`)
})

app.listen(3000, () => {
  console.log(`runing`)
})

运行:

pm2 start server.js -i 0

-i 表示 PM2 在 cluster_mode(而不是 fork_mode)中启动应用程序。如果设置为 0,PM2 将自动生成与 CPU 内核数量一样多的工作线程。

终端中输入如下表格:


image.png

设置 exec_mode 的值为 cluster 让 PM2 在每个实例之间进行负载平衡

instances 代表工作进程的数量;0 代表和内核相同数量,-1 表示 CPU - 1;或者指定特定值(别大于 CPU 核数)

使用 pm2 stop server.js 终止程序。终端输出所有进程的 stopped 状态

image.png

除了 -i 参数还有别的,最好是通过配置文件管理。使用 pm2 ecosystem 生成配置文件。该文件还可以为不同的应用程序设置特定的配置。这种对微服务程序特别有用。

修改配置文件:

module.exports = {
  apps: [
    { name: 'app', script: 'server.js', instances: 0, exec_mode: 'cluster' }
  ]
}

运行:

pm2 start ecosystem.config.js
image.png

仍然在集群模式下运行。

PM2 还有其他命令:

pm2 start app_name
pm2 restart app_name
pm2 reload app_name
pm2 stop app_name
pm2 delete app_name

# 使用配置文件时

pm2 [start|restart|reload|stop|delete] ecosystem.config.js

restart 命令立即终止并重新启动进程,实现了 0 秒的停机时间,工作进程会一个接一个重新启动。

还可以检查程序的状态、日志和指标。

状态:

pm2 ls

实时日志:

pm2 logs

终端显示仪表盘:

pm2 monit
上一篇下一篇

猜你喜欢

热点阅读