在构建生产应用程序时,你通常会寻找优化其性能的方法。在这篇文章中,我们使用一种方法,改进 Node.js 应用程序处理工作负载的方式。
Node.js 应用是运行在单线程的,这意味着在一个多核系统(现在大多数计算机都是这样)上,并不是所有的内核都会被应用程序充分利用。为了使用其他可用的内核,可以启动一个 Node.js 进程集群,并在它们之间分配负载。
使用多个线程可以提高服务器的吞吐量(请求/秒),因为可以同时处理多个请求。接下来,我们将使用 Node.js Cluster
模块创建子进程,然后,我们使用 PM2 进程管理器管理集群。
什么是 Cluster ?
Node.js Cluster
模块允许创建子进程(workers),它们可以同时运行
并共享同一服务端口
。每个衍生的子进程都有自己的 event loop、内存和 V8 实例。子进程使用 IPC(inter-process-communication) 与父进程通信。
有多个进程来处理请求意味着可以同时处理多个请求,如果一个 worker 有耗时/阻塞操作,其他 worker 可以继续处理其他传入的请求,应用程序不会停滞不前。
运行多个 worker 也可以在生产中几乎不停机的情况下更新应用程序。你可以修改你的应用程序,每次重启一个工作进程,等待一个子进程完全生成后再重启另一个子进程。这样,在你更新应用程序时,就会一直有工作程序在运行。
传入的请求有两种方式分布给子进程:
- 主进程监听端口上的请求,并以循环的方式在各个 worker 中分发它们。这是所有平台上的默认方法,除了 Windows。
- 主进程创建一个套接字,并将其发送给感兴趣的 worker,后者将能够直接接受传入的请求。
Cluster 应用
为了了解 cluster 带来的性能优势,我们创建一个未使用 cluster 和一个使用了 cluster 的 Node.js 应用进行比较。
const express = require('express');
const app = express();
const port = 3000;
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(`Final count is ${count}`);
})
app.listen(port, () => {
console.log(`App listening on port ${port}`);
})
这看起来有点作,不过我们就是想让它这样。应用包含两个路由,一个返回字符串”Hello World“,另一个入参 n
最终算出 1-n 的累计加和。
该操作是一个 0(n)
操作,因此它为我们提供了一种简单的方法来模拟服务器上的长时间运行的操作(参数 n
需要足够大)。上限是 5,000,000,000
,不要让计算机太过负载。
执行 node app.js
,并传入一个较小的参数 n
(e.g. http://localhost:3000/api/50
),这将很快计算结束并返回结果。
当你传入较大的数字 n
,你就会发现单线程应用的问题。尝试传个 5,000,000,000
(via http://localhost:3000/api/5000000000
)。
应用将需要几秒钟才能返回结果。如果你同时打开另一个浏览器 tab 页,再次发送请求(/
或者 /api/:n
都可以),然后你会看到这也会等几秒钟后才会有结果。单个 CPU 内核必须完成第一个请求,然后才能处理另一个请求。
现在,让我们来使用 cluster
模块衍生出子进程,看一下它是如何提升性能的。
const express = require('express');
const port = 3000;
const cluster = require('cluster');
const totalCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`Number of CPUs is ${totalCPUs}`);
console.log(`Master ${process.pid} is running`);
// Fork workers.
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(`Worker ${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(`Final count is ${count}`);
})
app.listen(port, () => {
console.log(`App listening on port ${port}`);
})
}
这个应用和之前做一样的事,不过这一次,我们创建了几个子进程,它们共享端口 3000
,并且能够处理来自这个端口的请求。工作进程是使用 child_process.fork()
方法生成的,该方法返回一个 ChildProcess
对象,该对象具有内置的通信通道,允许消息在子进程和父进程之间来回传递。
应用程序运行的机器上有多少 CPU 内核,我们就会创建多少个子进程。建议创建的工作进程不要超过计算机上的逻辑内核,因为这可能会导致调度成本方面的开销。之所以会出现这种情况,是因为系统必须对所有创建的进程进行调度,以便每个进程都能在内核上运行。
工作进程是被主进程创建和管理的。当应用启动时,我们判断它是否是主进程 isMaster
。这是由 process.env.NODE_UNIQUE_ID
决定的,如果 process.env.NODE_UNIQUE_ID
是 undefined
,那么 isMaster
就是 true
。
如果是主进程,我们调用 cluster.fork()
来创建几个工作进程,并打印出主进程和工作进程的 process id。运行上面的代码后,你会看到如下的打印日志(我的计算机是 8 核)。当一个工作进程挂掉后,我们会创建一个新的进程来充分利用 CPU 的内核。
Number of CPUs is 8
Master 19981 is running
Worker 19984 started
Worker 19983 started
Worker 19986 started
App listening on port 3000
App listening on port 3000
App listening on port 3000
Worker 19985 started
Worker 19982 started
App listening on port 3000
App listening on port 3000
Worker 19987 started
Worker 19988 started
App listening on port 3000
App listening on port 3000
Worker 19989 started
App listening on port 3000
为了看到集群对性能的提升,我们来进行和之前一样的测试步骤:/api/:n
请求传入大的参数 n
,并且迅速在另一个 tab 页执行另一个请求。可以看到第二个请求会立即返回结果,尽管第一个请求还在执行中,这就说明第二个请求并没有等待第一个请求结束后,串行执行。有了多个可用的工作进程来处理请求,服务器的可用性和吞吐量都得到了改进。
这里我们感知到了集群带来的性能提升,但是没法测量。接下来,我们进行基准测试,来拿数据说话。
性能测试
让我们在这两个应用程序上运行一个负载测试,看看每个应用如何处理大量的连接请求。为此,我们将使用 loadtest npm 包。
loadtest
允许你模拟对 api 大量并发请求,以便你可以度量其性能。
首先,全局安装 loadtest
:
npm install -g loadtest
我们先对无集群的应用进行压测:
loadtest http://localhost:3000/api/5000000 -n 1000 -c 100
上面命令的意思是,发送 100
个并发的 1000
次请求。下面是打印的结果:
[Fri Mar 26 2021 14:33:46 GMT+0800 (GMT+08:00)] INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Target URL: http://localhost:3000/api/5000000
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Max requests: 1000
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Concurrency level: 100
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Agent: none
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Completed requests: 1000
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Total errors: 0
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Total time: 4.949394667000001 s
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Requests per second: 202
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Mean latency: 468.8 ms
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO Percentage of the requests served within a certain time
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO 50% 490 ms
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO 90% 496 ms
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO 95% 499 ms
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO 99% 500 ms
[Fri Mar 26 2021 14:33:51 GMT+0800 (GMT+08:00)] INFO 100% 505 ms (longest request)
对于同样的请求(n
= 5000000
),服务器每秒可处理 202
次,平均延迟为 468.8
毫秒(完成单个请求的平均时间)。
接下来,让我们用这个结果与使用集群的应用进行比较。
停止服务,启动集群应用,进行同样的压测:
[Fri Mar 26 2021 14:42:17 GMT+0800 (GMT+08:00)] INFO Requests: 0 (0%), requests per second: 0, mean latency: 0 ms
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Target URL: http://localhost:3000/api/5000000
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Max requests: 1000
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Concurrency level: 100
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Agent: none
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Completed requests: 1000
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Total errors: 0
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Total time: 1.204361125 s
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Requests per second: 830
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Mean latency: 112.9 ms
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO Percentage of the requests served within a certain time
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO 50% 110 ms
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO 90% 133 ms
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO 95% 142 ms
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO 99% 162 ms
[Fri Mar 26 2021 14:42:18 GMT+0800 (GMT+08:00)] INFO 100% 170 ms (longest request)
可以看到,每秒可处理 830
次请求,平均延迟为 112.9
毫秒。由此证明,集群对性能有很大的提升!
在进入下一节之前,让我们先看看一个场景,在这个场景中集群可能不会提供太大的性能提升。
我们再为两个应用程序运行两个测试,我们将测试那些不是 cpu 密集型
的请求。
先对无集群应用测试,执行以下命令:
loadtest http://localhost:3000/api/5000 -n 1000 -c 100
结果是:
Total time: 0.383569709 s
Requests per second: 2607
Mean latency: 35.5 ms
然后,对集群应用测试,执行同样的命令。
结果是:
Total time: 0.401926125 s
Requests per second: 2488
Mean latency: 37.7 ms
可以看到,性能反而变差了~~ 这是怎么回事?
在上面的测试中,我们用一个较小的值 n
调用我们的 api,这意味着我们代码中的循环将运行的次数相当小。这个操作不是 cpu密集型
的。当涉及到 cpu密集型 任务时,集群发挥了作用。
然而,如果你的应用没有运行大量的 cpu密集型
任务,那么产生这么多 worker 可能就不值得了。请记住,你创建的每个进程都有自己的内存和 V8 实例。由于额外的资源分配,并不总是建议生成大量的子 Node.js 进程。
在我们的示例中,集群应用程序的性能比无集群应用程序要差一些,因为我们要为创建几个没有提供太多优势的子进程付出代价。在实际情况中,你可以确定你的微服务体系结构中的哪些应用程序可以从集群中受益 —— 那就是通过运行测试来检查额外的工作进程是否带来好处。
PM2 管理 Node.js 集群
在我们的应用中,我们使用 cluster
模块手动创建管理我们的工作进程。我们首先确定要生成的 worker 数量(使用 CPU 核数),然后手动生成 worker,最后,监听任何挂掉的 worker,这样我们就可以生成新的 worker。在我们非常简单的应用程序中,我们不得不编写大量代码来处理集群。在生产应用程序中,你或许会编写更多。
有一种工具可以帮助更好地管理进程 —— PM2 进程管理器。PM2 是一个用于 Node.js 应用的生产进程管理器,它带有内置的负载均衡器。当正确配置后,PM2 将自动在集群模式下运行你的应用程序,为你生成 worker,并在 worker 死亡时负责生成新的 worker。PM2 可以很容易地停止、删除和启动进程,它还有一些监控工具,可以帮助你监控和调整应用的性能。
应用 PM2,首先全局安装:
npm install pm2 -g
我们用它来运行我们的应用程序:
const express = require('express');
const app = express();
const port = 3000;
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(`Final count is ${count}`);
})
app.listen(port, () => {
console.log(`App listening on port ${port}`);
})
启动应用程序,执行以下命令:
pm2 start app.js -i 0
-i <number of workers>
告知 PM2 以集群模式(cluster_mode
, 与 fork_mode
相反)启动应用程序。如果 <number of workers>
设为 0
,PM2 将依据你的 CPU内核数 尽可能多的生成 worker。
现在,你的应用程序在集群模式(cluster_mode
)下运行,这不需要修改代码。现在,你可以运行前面部分的相同测试,然后可以得到使用集群的应用程序的相同结果。在幕后,PM2 还使用 Node.js cluster
模块以及其他工具,使进程管理更容易。
在终端中,你会得到一个工作进程的详细信息的表格:
你可以通过以下命令停止应用程序:
pm2 stop app.js
每次运行服务需要执行 pm2 start app.js -i 0
,这看起来很麻烦,你可以把它们存储到配置文件中 -- Ecosystem File。这个文件允许你为不同的应用程序生成各自的配置,这对于微服务应用程序来说很有用。
你可以生成 Ecosystem File:
pm2 ecosystem
这会生成一个名为 ecosystem.config.js
的文件。对于我们现在的应用程序,把它改为:
module.exports = {
apps : [{
name: "app",
script: "app.js",
instances : 0,
exec_mode : "cluster"
}]
}
通过 exec_mode
设置为 cluster
,PM2 就会在应用实例间实现负载均衡。instances
设置为 0
,就和上面 -i 0
,一样,依据服务器 CPU 核数尽可能多的生成工作进程。
-i
或 instances
配置项可设置为:
0
或max
(deprecated) ,将应用程序分布在所有 CPU 上;-1
,将应用程序分布在 CPU核数 - 1 个 CPU 上;number
, 将应用程序分布在 number 个 CPU 上(number <= CPU核数)。
现在,你可以这样启动应用:
pm2 start ecosystem.config.js
应用程序将运行在 cluster mode 下,就和之前一样。
你可以启动、重启、重载、停止和删除应用:
$ pm2 start app_name
$ pm2 restart app_name
$ pm2 reload app_name
$ pm2 stop app_name
$ pm2 delete app_name
# When using an Ecosystem file:
$ pm2 [start|restart|reload|stop|delete] ecosystem.config.js
restart
命令会立即结束进程,然后重启进程。而reload
命令实现了0
秒停机重新加载,即一个接一个地重新启动 worker,等待一个新的 worker 生成,然后再杀死旧的worker。
你还可以检查正在运行的应用程序的状态、日志和指标。
下面命令会列出 PM2 管理的所有应用程序状态:
pm2 ls
下面命令会打印实时的日志:
pm2 logs
下面命令会展示实时的监控面板:
pm2 monit
关于 PM2 和它集群模式(cluster mode)的更多信息,请查看文档。
总结
集群提供了一种通过更有效地利用系统资源来提高 Node.js 应用程序性能的方法。当一个应用被调整为使用集群时,我们看到了吞吐量的显著提高。然后,我们简要介绍了一种工具,它可以帮助你简化管理集群的过程。我希望这篇文章对你有用。有关集群的更多信息,请查看 Node.js cluster 模块文档和 PM2 的文档。对于 cluster 模块的原理,你可以查看 Node.js 多进程之 cluster 模块。
本文中的代码,你可以在 GitHub 中找到。