目录

11案例十压测Cluster的并发负载Node的集群-cluster

本资源由 itjc8.com 收集整理

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

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

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

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

起一个简单的 HTTP Server

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

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

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

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

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

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

# 安装 autocannon 到全局
npm i autocannon -g

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

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

这些参数意思是:

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

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

➜  ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
Running 10s test @ http://127.0.0.1:5000
1000 connections with 10 pipelining factor
# A 接口的延迟程度
Stat    2.5% 50%  97.5%   99%     Avg       Stdev      Max
Latency 0 ms 0 ms 4061 ms 4652 ms 368.29 ms 1248.24 ms 9176.69 ms
# B 每秒能处理的请求数 TPS
Stat      1%     2.5%   50%    97.5%  Avg    Stdev   Min
Req/Sec   1591   1591   1918   1940   1888.1 101.11  1591
# C 每秒返回的字节数
Bytes/Sec 288 kB 288 kB 347 kB 351 kB 342 kB 18.3 kB 288 kB

19k requests in 10.15s, 3.42 MB read
760 errors (730 timeouts)

➜  ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
Stat    2.5% 50%  97.5%   99%     Avg       Stdev      Max
Latency 0 ms 0 ms 4895 ms 4908 ms 373.95 ms 1226.24 ms 5582.86 ms
Stat      1%     2.5%   50%    97.5%  Avg    Stdev   Min
Req/Sec   1641   1641   1910   1970   1872.1 112.94  1641
Bytes/Sec 297 kB 297 kB 346 kB 357 kB 339 kB 20.4 kB 297 kB
19k requests in 10.16s, 3.39 MB read
875 errors (870 timeouts)

➜  ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
Stat    2.5% 50%  97.5%   99%     Avg       Stdev      Max
Latency 0 ms 0 ms 4729 ms 4748 ms 370.43 ms 1208.29 ms 5539.08 ms
Stat      1%     2.5%   50%    97.5%  Avg    Stdev   Min
Req/Sec   1631   1631   1961   1980   1928.1 101.4   1631
Bytes/Sec 295 kB 295 kB 355 kB 358 kB 349 kB 18.3 kB 295 kB
19k requests in 10.14s, 3.49 MB read
600 errors (590 timeouts)

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

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

通过 cluster 启动 HTTP 服务

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

const http = require('http')
// 加载拿到 cluster 模块
const cluster = require('cluster')
// 通过 os 模块拿到当前计算机上的 cpu
const cpus = require('os').cpus()

// cluster 能拿到当前是否是 master 模式
if (cluster.isMaster) {
  // master 下,对每个 cpu 都 fork 一个进程
  // 相当于是把 cpu 个数都吃满,充分利用多核优势
  for (let i = 0; i < cpus.length; i ++) {
    cluster.fork()
  }
} else {
  // 如果不是 master 模式,则每个子进程都会启动这个服务
  // 相当于有多少个 cpu,fork 了多少个进程,这里就会有多少个服务器
  http.Server((req, res) => {
    for (var i = 0; i < 1000000; i++) {}
    res.statusCode = 200
    res.setHeader('Content-Type', 'text/plain')
    res.end('经过一个耗时操作,这是返回的一段文本\n')
  }).listen(5000, () => console.log('服务启动了'))
}

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

➜  ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
Stat    2.5% 50%  97.5%   99%     Avg       Stdev    Max
Latency 0 ms 0 ms 1132 ms 1164 ms 101.92 ms 354.6 ms 9963.98 ms
Stat      1%      2.5%    50%     97.5%   Avg    Stdev   Min
Req/Sec   6371    6371    7263    7383    7187.6 286.23  6368
Bytes/Sec 1.15 MB 1.15 MB 1.31 MB 1.34 MB 1.3 MB 51.9 kB 1.15 MB
72k requests in 10.17s, 13 MB read
640 errors (640 timeouts)

➜  ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
Stat    2.5% 50%  97.5%   99%     Avg      Stdev     Max
Latency 0 ms 0 ms 1195 ms 1274 ms 113.6 ms 376.96 ms 9984.54 ms
Stat      1%      2.5%    50%     97.5%   Avg     Stdev   Min
Req/Sec   6459    6459    7391    7471    7294.4  284.22  6457
Bytes/Sec 1.17 MB 1.17 MB 1.34 MB 1.35 MB 1.32 MB 51.4 kB 1.17 MB
73k requests in 10.17s, 13.2 MB read
560 errors (560 timeouts)

➜  ~ autocannon -c 1000 -p 10 http://127.0.0.1:5000
Stat    2.5% 50%  97.5%   99%     Avg       Stdev     Max
Latency 0 ms 0 ms 1355 ms 1443 ms 119.83 ms 396.95 ms 9926.91 ms
Stat      1%      2.5%    50%     97.5%   Avg    Stdev   Min
Req/Sec   6359    6359    7259    7371    7176.8 280.34  6358
Bytes/Sec 1.15 MB 1.15 MB 1.31 MB 1.33 MB 1.3 MB 50.9 kB 1.15 MB
72k requests in 10.19s, 13 MB read
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 进程杀掉挂掉的时候会被触发,我们来一段代码感受一下:

const cluster = require('cluster')
const http = require('http')

// 通过 if else 区分下主进程和子进程各自的启动逻辑
if (cluster.isMaster) masterProcess()
else childProcess()

function masterProcess () {
  // 可以选择只启动 2 个 worker
  for (let i = 0; i < 2; i ++) {
    let worker = cluster.fork()
  }

  // 进程创建成功 则触发 online 事件
  cluster.on('online', worker => {
    console.log('子进程 ' + worker.process.pid + ' 创建成功')
  })

  // 进程退出 则触发 exit 事件
  cluster.on('exit', (worker, code, signal) => {
    console.log(`子进程 ${worker.process.pid} 退出`)
  })
}

function childProcess () {
  console.log(`子进程开始 ${process.pid} 开始启动服务器...`)

  http.Server((req, res) => {
    res.statusCode = 200
    res.setHeader('Content-Type', 'text/plain')
    console.log('来自子进程 id 为 ' + cluster.worker.id + ' 的响应')
    res.end('Hello Juejin!')
    process.exit(1)
  }).listen(5000, () => {
    console.log('子进程 ' + process.pid + ' 已成功监听 5000 端口')
  })
}

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

~ curl http://127.0.0.1:5000

子进程 16725 创建成功
子进程 16726 创建成功
子进程开始 16726 开始启动服务器...
子进程开始 16725 开始启动服务器...
子进程 16726 已成功监听 5000 端口
子进程 16725 已成功监听 5000 端口
来自子进程 id 为 2 的响应
子进程 16726 退出
# 访问浏览器 http://127.0.0.1:5000 可能会多出一个响应
来自子进程 id 为 1 的响应
子进程 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 这里:

cluster._getServer = function(obj, options, cb) {
  let address = options.address;

  // Resolve unix socket paths to absolute paths
  address = path.resolve(address);

  const indexesKey = [address,
                      options.port,
                      options.addressType,
                      options.fd ].join(':');

  if (indexes[indexesKey] === undefined)
    indexes[indexesKey] = 0;
  else
    indexes[indexesKey]++;

  const message = util._extend({
    act: 'queryServer',
    index: indexes[indexesKey],
    data: null
  }, options);

  message.address = address;

  if (obj._getServerData)
    message.data = obj._getServerData();

  send(message, (reply, handle) => {
    if (typeof obj._setServerData === 'function')
      obj._setServerData(reply.data);

    if (handle)
      shared(reply, handle, indexesKey, cb);  // Shared listen socket.
    else
      rr(reply, indexesKey, cb); // Round-robin.
  });

  obj.once('listening', () => {
    cluster.worker.state = 'listening';
    const address = obj.address();
    message.act = 'listening';
    message.port = address && address.port || options.port;
    send(message);
  });
};

cluster 如果挂了怎么办

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

const cluster = require('cluster')
const http = require('http')

if (cluster.isMaster) masterProcess()
else childProcess()

function masterProcess () {
  // 只启动 1 个 worker
  const worker = cluster.fork()
  cluster.on('exit', (worker, code, signal) => {
    console.log(`子进程 ${worker.process.pid} 挂了`)
  })
}

function childProcess () {
  http.Server((req, res) => {
    console.log('子进程 ' + cluster.worker.id + ' 在响应')
    // 此处发生异常
    throw new Error({})
    res.end('Hello Juejin!')
  }).listen(5000, () => {
    console.log('子进程 ' + process.pid + ' 监听中')
  })
}

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

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

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

const cluster = require('cluster')
const http = require('http')

if (cluster.isMaster) masterProcess()
else childProcess()

function masterProcess () {
  // 只启动 1 个 worker
  cluster.fork()
  cluster.on('exit', (worker, code, signal) => {
    console.log(`子进程 ${worker.process.pid} 挂了`)
    if (code != 0 && !worker.suicide) {
      cluster.fork()
      console.log('再启动一个新的子进程')
    }
  })
}

function childProcess () {
  http.Server((req, res) => {
    console.log('子进程 ' + cluster.worker.id + ' 在响应')
    throw new Error({})
    res.end('Hello Juejin!')
  }).listen(5000, () => {
    console.log('子进程 ' + process.pid + ' 监听中')
  })
}

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

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

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

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

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

pm2 的安装特别简单:

# 安装到全局
npm i pm2 -g

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

pm2 启动服务器

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

pm2 start app.js -i 2

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

~ pm2 ls
┌────┬──┬────┬───────┬──────┬───┬──────┬───────┐
│Name│id│mode│status │↺     │cpu│memory│       │
├────┼──┼────┼───────┼──────┼───┼──────┼───────┤
│app │0 │N/A │cluster│online│0  │19%   │28.4 MB│
│app │1 │N/A │cluster│online│0  │0%    │20.3 MB│
└────┴──┴────┴───────┴──────┴───┴──────┴───────┘
pm2 实时扩容集群

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

pm2 scale app +1
[PM2] Scaling up application
┌─────┬──┬────┬───────┬──────┬───┬──────┬───────┐
│ Name│id│mode│status │↺     │cpu│memory│       │
├─────┼──┼────┼───────┼──────┼───┼──────┼───────┤
│ app │0 │N/A │cluster│online│0  │0%    │33.6 MB│
│ app │1 │N/A │cluster│online│0  │0%    │34.2 MB│
│ app │2 │N/A │cluster│online│0  │0%    │19.9 MB│
└─────┴──┴────┴───────┴──────┴───┴──────┴───────┘

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

pm2 终止某个进程

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

e12-cluster git:(master) ✗ pm2 stop 1
[PM2] Applying action stopProcessId on app [1](ids: 1)
[PM2] [app](1)┌────┬──┬────┬───────┬───────┬───┬──────┬───────┐
│Name│id│mode│status │↺      │cpu│memory│       │
├────┼──┼────┼───────┼───────┼───┼──────┼───────┤
│app │0 │N/A │cluster│online │0  │0%    │33.6 MB│
│app │1 │N/A │cluster│stopped│0  │0%    │0 B    │
│app │2 │N/A │cluster│online │0  │0%    │33.6 MB│
└────┴──┴────┴───────┴───────┴───┴──────┴───────┘

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

pm2 平滑重启进程

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

➜ pm2 reload app
Use --update-env to update environment variables
[PM2] Applying action reloadProcessId on app [app](ids: 0,1,2)
[PM2] [app](1)[PM2] [app](0)[PM2] [app](2)➜ pm2 ls
┌────┬──┬────┬───────┬──────┬───┬──────┬───────┐
│Name│id│mode│status │↺     │cpu│memory│       │ 
├────┼──┼────┼───────┼──────┼───┼──────┼───────┤
│app │0 │N/A │cluster│online│1  │6.8%  │37.4 MB│
│app │1 │N/A │cluster│online│0  │6.8%  │37.4 MB│
│app │2 │N/A │cluster│online│1  │6.4%  │37.5 MB│
└────┴──┴────┴───────┴──────┴───┴──────┴───────┘

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

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

小结

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