[压测 Cluster 的并发负载] Node 的集群 - cluster

  1. 本节目标:【压测 cluster 的集群负载能力】 - 所谓双拳难敌四手,cluster 的集群扩展可以分摊利用多核,健壮可扩展有了可能。

我们都知道 Node 是事件驱动的异步服务模型,高效的同时也很脆弱,因为所有的事情都是在一个单线程中完成的,一旦这个单线程挂了,那么整个服务就挂了,或者有点这个单线程里有个非常耗时的同步任务,那么其他的请求进来也会阻滞在这里了,这时候我们就希望能充分利用计算机的多核优势,多起几个独立的进程,每个进程都像是伏地魔的一个魂,让我们的服务有多条命,就算是一个挂了,整个服务还不至于瘫痪,而且还可以把压力分摊到每个进程上面,整体服务更加健壮,也能支撑更多的并发。

幸运的是,在 Node 里面,提供 cluster 这个模块,来实现服务集群的扩展,具体怎么用呢,我们先起一个简单的服务器来返回一段文本,同时里面放一个略大的数组来阻滞下代码运行。

起一个简单的 HTTP Server

先来实现一个略微耗时的任务:

  1. const t1 = Date.now()
  2. // 来用一个 1 百万长度的数组来模拟耗时操作
  3. for (var i = 0; i < 1000000; i++) {}
  4. const t2 = Date.now()
  5. // 最后打印下耗时操作用时
  6. console.log('耗时', t2 - t1, '毫秒')

我的电脑打印后是这样的结果:耗时 3 毫秒

然后我们起一个 Server,把任务丢进去作为响应返回:

  1. // 通过 http 创建创建一个服务器实例
  2. require('http').createServer((req, res) => {
  3. for (var i = 0; i < 1000000; i++) {}
  4. // 返回一段文本
  5. res.statusCode = 200
  6. res.setHeader('Content-Type', 'text/plain')
  7. res.end('经过一个耗时操作,这是返回的一段文本\n')
  8. }).listen(5000, '127.0.0.1', () => console.log('服务启动了'))

在命令行 node server.js 服务开起来后,我们压测一下,压测的话,大家可以使用 Apache absiegewrk 等等,具体教程大家参考官方文档,我们这里使用一个 Node 的简单压测工具 autocannon,首先把它安装到本地:

  1. # 安装 autocannon 到全局
  2. npm i autocannon -g

安装后,通过 node server.js 开启服务,同时再开一个命令行窗口,输入下面命令运行:

  1. autocannon -c 1000 -p 10 http://127.0.0.1:5000

这些参数意思是:

-c 是并发连接的数量,默认 10,我们指定为 1000
-p 指定每个连接的流水线请求数,默认是 1,我们指定为 10

在我电脑上我压测了 3 次,结果如下:

  1. ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
  2. Running 10s test @ http://127.0.0.1:5000
  3. 1000 connections with 10 pipelining factor
  4. # A 接口的延迟程度
  5. Stat 2.5% 50% 97.5% 99% Avg Stdev Max
  6. Latency 0 ms 0 ms 4061 ms 4652 ms 368.29 ms 1248.24 ms 9176.69 ms
  7. # B 每秒能处理的请求数 TPS
  8. Stat 1% 2.5% 50% 97.5% Avg Stdev Min
  9. Req/Sec 1591 1591 1918 1940 1888.1 101.11 1591
  10. # C 每秒返回的字节数
  11. Bytes/Sec 288 kB 288 kB 347 kB 351 kB 342 kB 18.3 kB 288 kB
  12. 19k requests in 10.15s, 3.42 MB read
  13. 760 errors (730 timeouts)
  14. ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
  15. Stat 2.5% 50% 97.5% 99% Avg Stdev Max
  16. Latency 0 ms 0 ms 4895 ms 4908 ms 373.95 ms 1226.24 ms 5582.86 ms
  17. Stat 1% 2.5% 50% 97.5% Avg Stdev Min
  18. Req/Sec 1641 1641 1910 1970 1872.1 112.94 1641
  19. Bytes/Sec 297 kB 297 kB 346 kB 357 kB 339 kB 20.4 kB 297 kB
  20. 19k requests in 10.16s, 3.39 MB read
  21. 875 errors (870 timeouts)
  22. ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
  23. Stat 2.5% 50% 97.5% 99% Avg Stdev Max
  24. Latency 0 ms 0 ms 4729 ms 4748 ms 370.43 ms 1208.29 ms 5539.08 ms
  25. Stat 1% 2.5% 50% 97.5% Avg Stdev Min
  26. Req/Sec 1631 1631 1961 1980 1928.1 101.4 1631
  27. Bytes/Sec 295 kB 295 kB 355 kB 358 kB 349 kB 18.3 kB 295 kB
  28. 19k requests in 10.14s, 3.49 MB read
  29. 600 errors (590 timeouts)

压测的指标分为三部分,也就是 ABC,延迟越低,TPS 越高,每秒返回的字节数越多,就说明服务的响应能力越好,性能越好。

压测的结果不太稳定,但大体上可以看到,我们单核跑这个服务时候,延迟 4 秒多才能有返回,同时每秒处理的请求个数有 2 千上下,能吞吐响应的字节数,在二三百 KB 之内徘徊,在 10 秒内能响应的请求数有 2 万左右,同时还伴随有几百个超时错误,这样的结果不是太理想,我们改用 cluster 起服务下看看效果。

通过 cluster 启动 HTTP 服务

Node cluster 的用法非常简单,启动服务文件的时候,判断是否是 Master 模式,如果是则直接调用 cluster fork 来创建多个服务实例,如果不是 Master,就直接启动一个服务器实例,我们稍后再来了解这些概念,先看下被 cluster 优化后的代码:

  1. const http = require('http')
  2. // 加载拿到 cluster 模块
  3. const cluster = require('cluster')
  4. // 通过 os 模块拿到当前计算机上的 cpu
  5. const cpus = require('os').cpus()
  6. // cluster 能拿到当前是否是 master 模式
  7. if (cluster.isMaster) {
  8. // master 下,对每个 cpu 都 fork 一个进程
  9. // 相当于是把 cpu 个数都吃满,充分利用多核优势
  10. for (let i = 0; i < cpus.length; i ++) {
  11. cluster.fork()
  12. }
  13. } else {
  14. // 如果不是 master 模式,则每个子进程都会启动这个服务
  15. // 相当于有多少个 cpu,fork 了多少个进程,这里就会有多少个服务器
  16. http.Server((req, res) => {
  17. for (var i = 0; i < 1000000; i++) {}
  18. res.statusCode = 200
  19. res.setHeader('Content-Type', 'text/plain')
  20. res.end('经过一个耗时操作,这是返回的一段文本\n')
  21. }).listen(5000, () => console.log('服务启动了'))
  22. }

然后依然在命令行窗口中执行 node server.js,然后新开一个窗口,进行压测:

  1. ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
  2. Stat 2.5% 50% 97.5% 99% Avg Stdev Max
  3. Latency 0 ms 0 ms 1132 ms 1164 ms 101.92 ms 354.6 ms 9963.98 ms
  4. Stat 1% 2.5% 50% 97.5% Avg Stdev Min
  5. Req/Sec 6371 6371 7263 7383 7187.6 286.23 6368
  6. Bytes/Sec 1.15 MB 1.15 MB 1.31 MB 1.34 MB 1.3 MB 51.9 kB 1.15 MB
  7. 72k requests in 10.17s, 13 MB read
  8. 640 errors (640 timeouts)
  9. ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
  10. Stat 2.5% 50% 97.5% 99% Avg Stdev Max
  11. Latency 0 ms 0 ms 1195 ms 1274 ms 113.6 ms 376.96 ms 9984.54 ms
  12. Stat 1% 2.5% 50% 97.5% Avg Stdev Min
  13. Req/Sec 6459 6459 7391 7471 7294.4 284.22 6457
  14. Bytes/Sec 1.17 MB 1.17 MB 1.34 MB 1.35 MB 1.32 MB 51.4 kB 1.17 MB
  15. 73k requests in 10.17s, 13.2 MB read
  16. 560 errors (560 timeouts)
  17. ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
  18. Stat 2.5% 50% 97.5% 99% Avg Stdev Max
  19. Latency 0 ms 0 ms 1355 ms 1443 ms 119.83 ms 396.95 ms 9926.91 ms
  20. Stat 1% 2.5% 50% 97.5% Avg Stdev Min
  21. Req/Sec 6359 6359 7259 7371 7176.8 280.34 6358
  22. Bytes/Sec 1.15 MB 1.15 MB 1.31 MB 1.33 MB 1.3 MB 50.9 kB 1.15 MB
  23. 72k requests in 10.19s, 13 MB read
  24. 800 errors (800 timeouts)

同样压测了 3 次,发现超时错误的次数依然是几百个,但是服务的整体响应能力,从 10 秒响应的 2 万个,增长到了 7 万多个,翻了 3 倍多,同时延迟时间也从 4 秒降到了 1 秒多,每秒的处理次数也从 2 千增长到了 7 千,响应的字节数从二三百 KB 增长到了 1 MB 多,整体的服务性能改善还是非常可观的,这就是 Node cluster 带给我们的收益,想想还是很激动的。

关于 cluster

在刚才的测试里面,起到关键作用的一句代码就是 cluster.fork(),通过 fork 按照 cpu 的个数,创建了多个子进程,也就是 child process,我们管它叫 worker,这些 worker 会共享同一个服务器端口,也就是 server port,而能做到这一点离不开主进程的调度,也就是 master process。

对于 cluster 模块,它里面有几个事件,其中比较常见的一个是 online 事件,当 worker 被 fork 出来发送 online message,而 exit 会在一个 worker 进程杀掉挂掉的时候会被触发,我们来一段代码感受一下:

  1. const cluster = require('cluster')
  2. const http = require('http')
  3. // 通过 if else 区分下主进程和子进程各自的启动逻辑
  4. if (cluster.isMaster) masterProcess()
  5. else childProcess()
  6. function masterProcess () {
  7. // 可以选择只启动 2 个 worker
  8. for (let i = 0; i < 2; i ++) {
  9. let worker = cluster.fork()
  10. }
  11. // 进程创建成功 则触发 online 事件
  12. cluster.on('online', worker => {
  13. console.log('子进程 ' + worker.process.pid + ' 创建成功')
  14. })
  15. // 进程退出 则触发 exit 事件
  16. cluster.on('exit', (worker, code, signal) => {
  17. console.log(`子进程 ${worker.process.pid} 退出`)
  18. })
  19. }
  20. function childProcess () {
  21. console.log(`子进程开始 ${process.pid} 开始启动服务器...`)
  22. http.Server((req, res) => {
  23. res.statusCode = 200
  24. res.setHeader('Content-Type', 'text/plain')
  25. console.log('来自子进程 id 为 ' + cluster.worker.id + ' 的响应')
  26. res.end('Hello Juejin!')
  27. process.exit(1)
  28. }).listen(5000, () => {
  29. console.log('子进程 ' + process.pid + ' 已成功监听 5000 端口')
  30. })
  31. }

当我们访问服务的时候,可以拿到返回的 Hello Juejin,但同时服务器也退出了,退出的时候,自然 cluster 启动的子进程也会退出,所以打印了如下的这段日志:

  1. ~ curl http://127.0.0.1:5000
  2. 子进程 16725 创建成功
  3. 子进程 16726 创建成功
  4. 子进程开始 16726 开始启动服务器...
  5. 子进程开始 16725 开始启动服务器...
  6. 子进程 16726 已成功监听 5000 端口
  7. 子进程 16725 已成功监听 5000 端口
  8. 来自子进程 id 2 的响应
  9. 子进程 16726 退出
  10. # 访问浏览器 http://127.0.0.1:5000 可能会多出一个响应
  11. 来自子进程 id 1 的响应
  12. 子进程 16725 退出

浏览器请求的时候,可能会多发一个 favicon 的请求,等于是两个请求,第一个子进程退出后,第二个子进程会接管之后而来的其他请求,响应后也会退出,所以会多打印两行日志。

那 worker 负责干活,master 呢?master 在这里的作用,就是启动多个 worker,然后来调度这些 worker,然后在主进程和子进程之间通过 IPC 实现进程间的通信,但是子进程之间的任务怎么分配呢? 我们上面的代码案例中,如果把 process.exit(1) 拿掉后,然后不断的刷新浏览器,会发现实际上真正干活的子进程,一会是 1 一会是 2,并没有什么明显的规律,只是看上去大概符合 1:1 的平均分配,这里的分配就是 cluster 底层做的,用的调度算法是 RR 算法,也就是 Round-Robin 算法,调用的地方在 lib/internal/cluster/child.js 源码 93 行 cluster._getServer 这里:

  1. cluster._getServer = function(obj, options, cb) {
  2. let address = options.address;
  3. // Resolve unix socket paths to absolute paths
  4. address = path.resolve(address);
  5. const indexesKey = [address,
  6. options.port,
  7. options.addressType,
  8. options.fd ].join(':');
  9. if (indexes[indexesKey] === undefined)
  10. indexes[indexesKey] = 0;
  11. else
  12. indexes[indexesKey]++;
  13. const message = util._extend({
  14. act: 'queryServer',
  15. index: indexes[indexesKey],
  16. data: null
  17. }, options);
  18. message.address = address;
  19. if (obj._getServerData)
  20. message.data = obj._getServerData();
  21. send(message, (reply, handle) => {
  22. if (typeof obj._setServerData === 'function')
  23. obj._setServerData(reply.data);
  24. if (handle)
  25. shared(reply, handle, indexesKey, cb); // Shared listen socket.
  26. else
  27. rr(reply, indexesKey, cb); // Round-robin.
  28. });
  29. obj.once('listening', () => {
  30. cluster.worker.state = 'listening';
  31. const address = obj.address();
  32. message.act = 'listening';
  33. message.port = address && address.port || options.port;
  34. send(message);
  35. });
  36. };

cluster 如果挂了怎么办

我们上面代码案例中通过 process.exit 来退出程序了,如果是其他异常导致子进程异常呢,来看如下代码:

  1. const cluster = require('cluster')
  2. const http = require('http')
  3. if (cluster.isMaster) masterProcess()
  4. else childProcess()
  5. function masterProcess () {
  6. // 只启动 1 个 worker
  7. const worker = cluster.fork()
  8. cluster.on('exit', (worker, code, signal) => {
  9. console.log(`子进程 ${worker.process.pid} 挂了`)
  10. })
  11. }
  12. function childProcess () {
  13. http.Server((req, res) => {
  14. console.log('子进程 ' + cluster.worker.id + ' 在响应')
  15. // 此处发生异常
  16. throw new Error({})
  17. res.end('Hello Juejin!')
  18. }).listen(5000, () => {
  19. console.log('子进程 ' + process.pid + ' 监听中')
  20. })
  21. }

我们访问 http://127.0.0.1:5000,会看到如下的服务报错:

  1. 子进程 20739 监听中
  2. 子进程 1 在响应
  3. /Users/black/Downloads/node-10.x/juejin/server.js:18
  4. throw new Error({})
  5. ^
  6. Error: [object Object]
  7. at Server.http.Server (/Users/black/Downloads/node-10.x/juejin/server.js:18:11)
  8. at Server.emit (events.js:182:13)
  9. at parserOnIncoming (_http_server.js:652:12)
  10. at HTTPParser.parserOnHeadersComplete (_http_common.js:109:17)
  11. 子进程 20739 挂了

可以看到,能通过 cluster 的 exit 事件监听到子进程挂掉,那么我们就可以在 exit 的时候,再启动一个进程,改下代码成这样子:

  1. const cluster = require('cluster')
  2. const http = require('http')
  3. if (cluster.isMaster) masterProcess()
  4. else childProcess()
  5. function masterProcess () {
  6. // 只启动 1 个 worker
  7. cluster.fork()
  8. cluster.on('exit', (worker, code, signal) => {
  9. console.log(`子进程 ${worker.process.pid} 挂了`)
  10. if (code != 0 && !worker.suicide) {
  11. cluster.fork()
  12. console.log('再启动一个新的子进程')
  13. }
  14. })
  15. }
  16. function childProcess () {
  17. http.Server((req, res) => {
  18. console.log('子进程 ' + cluster.worker.id + ' 在响应')
  19. throw new Error({})
  20. res.end('Hello Juejin!')
  21. }).listen(5000, () => {
  22. console.log('子进程 ' + process.pid + ' 监听中')
  23. })
  24. }

同样的请求后,我们观察终端打印的日志如下:

  1. 子进程 20956 监听中
  2. 子进程 1 在响应
  3. /Users/black/Downloads/node-10.x/juejin/server.js:22
  4. throw new Error({})
  5. ^
  6. Error: [object Object]
  7. at Server.http.Server (/Users/black/Downloads/node-10.x/juejin/server.js:22:11)
  8. at Server.emit (events.js:182:13)
  9. at parserOnIncoming (_http_server.js:652:12)
  10. at HTTPParser.parserOnHeadersComplete (_http_common.js:109:17)
  11. 子进程 20956 挂了
  12. 再启动一个新的子进程
  13. 子进程 20960 监听中

看到虽然子进程 20956 挂了,但是 子进程 20960 已经跑起来,可以继续接管后续的请求了。

有哪些能实现横向扩展 cluster 的工具

虽然我们知道 cluster 的大概原理,但人肉来维护进程显然不是我们在学习 Node 初期可以深度掌握的技能,需要一些工具的配合,那么这里就给大家推荐两个工具,一个是 pm2,一个是阿里的 Egg 框架自带的 egg-cluster,关于后者我们本册先不涉及,先来看下 pm2。

pm2 的安装特别简单:

  1. # 安装到全局
  2. npm i pm2 -g

pm2 官方文档也特别详尽,大家可以前往学习,我挑几个自己常用的介绍下。

pm2 启动服务器

推荐大家从配置文件启动,配置文件参考官网,从命令行启动非常简单:

  1. pm2 start app.js -i 2

-i 后面跟的 2 表示启动 2 个 server 实例,如果输入 0 的话,则按照当前服务器它实际的 cpu 核数来启动多个 server,启动后,我们通过 pm2 ls 来看看已经启动的实例:

  1. ~ pm2 ls
  2. ┌────┬──┬────┬───────┬──────┬───┬──────┬───────┐
  3. Nameidmodestatus │↺ cpumemory
  4. ├────┼──┼────┼───────┼──────┼───┼──────┼───────┤
  5. app 0 N/A clusteronline0 19% 28.4 MB
  6. app 1 N/A clusteronline0 0% 20.3 MB
  7. └────┴──┴────┴───────┴──────┴───┴──────┴───────┘

pm2 实时扩容集群

如果我们发现线上的服务响应比较吃力,而 cpu 核数没有吃满的话,我们可以实时扩容集群,通过 scale 命令来实现:

  1. pm2 scale app +1
  2. [PM2] Scaling up application
  3. ┌─────┬──┬────┬───────┬──────┬───┬──────┬───────┐
  4. Nameidmodestatus │↺ cpumemory
  5. ├─────┼──┼────┼───────┼──────┼───┼──────┼───────┤
  6. app 0 N/A clusteronline0 0% 33.6 MB
  7. app 1 N/A clusteronline0 0% 34.2 MB
  8. app 2 N/A clusteronline0 0% 19.9 MB
  9. └─────┴──┴────┴───────┴──────┴───┴──────┴───────┘

这里的 +1 就是扩容一个服务实例,其实就是增加一个 cluster 的 worker 子进程,扩容后:

pm2 终止某个进程

有时候如果某个进程明显卡住了,或者线上负载不大,可以杀掉部分进程,通过:

  1. e12-cluster git:(master) pm2 stop 1
  2. [PM2] Applying action stopProcessId on app [1](ids: 1)
  3. [PM2] [app](1)
  4. ┌────┬──┬────┬───────┬───────┬───┬──────┬───────┐
  5. Nameidmodestatus │↺ cpumemory
  6. ├────┼──┼────┼───────┼───────┼───┼──────┼───────┤
  7. app 0 N/A clusteronline 0 0% 33.6 MB
  8. app 1 N/A clusterstopped0 0% 0 B
  9. app 2 N/A clusteronline 0 0% 33.6 MB
  10. └────┴──┴────┴───────┴───────┴───┴──────┴───────┘

可以看到进程 ID 为 2 的 worker 已经是 stopped 状态。

pm2 平滑重启进程

有时候,如果想要某个比较吃内存的进程可以重启,或者想要所有的 worder 都重新启动,但是又希望不影响进程正常处理用户的请求,可以使用 pm2 的 gracefulReload 命令:

  1. pm2 reload app
  2. Use --update-env to update environment variables
  3. [PM2] Applying action reloadProcessId on app [app](ids: 0,1,2)
  4. [PM2] [app](1)
  5. [PM2] [app](0)
  6. [PM2] [app](2)
  7. pm2 ls
  8. ┌────┬──┬────┬───────┬──────┬───┬──────┬───────┐
  9. Nameidmodestatus │↺ cpumemory
  10. ├────┼──┼────┼───────┼──────┼───┼──────┼───────┤
  11. app 0 N/A clusteronline1 6.8% 37.4 MB
  12. app 1 N/A clusteronline0 6.8% 37.4 MB
  13. app 2 N/A clusteronline1 6.4% 37.5 MB
  14. └────┴──┴────┴───────┴──────┴───┴──────┴───────┘

这样所有的子进程又原地满血复活,当然也会存在说,某些进程上面的未处理连接或者任务的确很重,比如有一些大而重的文件 IO 或者数据库 IO 在等待,会导致 reload 失败,这时候也可以指定一个超时时间,命令会退化到 restart 模式,强制杀死进程再重启,或者我们可以在代码中再友好一些,当它收到 pm2 要重启的时候,在程序里面我们把一些任务清空掉然后让服务重启:

  1. // pm2 会发出 SIGINT 事件,我们监听事件
  2. process.on('SIGINT', function() {
  3. // 处理一些任务然后再信号交还给 PM2 来重启服务
  4. db.stop(function(err) {
  5. process.exit(err ? 1 : 0)
  6. })
  7. })

小结

简单总结一下,我们现在了解到 cluster 可以分摊服务器的压力,可以最大的利用多核 CPU 的资源,从而实现并发和整体响应性能的提升,同时在服务的健壮性上,我们也可以通过监听子进程的异常来杀死或者启动一个新的子进程,从而实现了多进程多服务的有效负载。我们在生产环境中,也可以通过 pm2 这样的部署运维工具,来保持服务的自动重启和更简便的集群扩展,甚至可以使用它的高级功能如监控等等,对于一些不太复杂的系统我们就有这样的配套全家桶了。