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

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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容