[译]扩展 Node.js 应用

扩展 Node.js 应用

你应该知道的在 Node.js 内置模块的应用于扩展的工具

来自 Pluralsight 课程中的截图 - Node.js 进阶html

可扩展性在 Node.js 并非过后添加的概念,这一律念在前期就已经体现出其核心地位。Node 之因此被命名为 Node 的缘由就是强调一个想法:每个 Node 应用应该由多个小型的分散 Node 应用相互联系来构成。前端

你曾经在你的 Node 应用上运行多个 Node 应用吗?你曾经试过让生产环境上的机器的每一个 CPU 运行一个 Node 程序,而且对全部的请求进行负载均衡处理吗?你知道 Node 有一个内置模块能作上述事情吗?node

Node 的 cluster 模块不仅是提供一个黑箱的解决方案来充分利用机器中的 CPU,同时它也能帮助你提升 Node 应用的可用性,提供一个瞬时重启整个应用的选项,这篇文章将阐述其中的全部好处。react

这篇文章是 Pluralsight Node.js 课程 中的一部分,我从视频中整理出了相关的内容。android

实现扩展的策略

咱们扩展一个应用的最主要的缘由是应用的负载,可是不仅是这一个缘由。咱们同时经过让应用具有可扩展性来提升应用的可用性和容错性。ios

咱们能够经过三种主流的方式来拓展应用:git

1 — 克隆

扩展一个大型应用最简单的方法就是屡次克隆它,并让每个克隆实例处理一部分工做(例如,使用负载均衡器)。这种作法不会占用开发周期太多时间,而且真的很管用。想要在最低限度上实现扩展,你可使用这种方法,Node.js 有个内置模块 cluster 来让你在一个单一的服务器上更简单地实现克隆方法。github

2 — 分解

同时咱们也能够经过 分解 来扩展一个应用,这种方法取决于应用的函数和服务。这意味着咱们有多个不一样的应用,各有着不一样的基架代码,有时还会有其独自的数据库和用户接口。web

这个策略通常和微服务联系在一块儿,其中的微是指每一个服务应该越小越好,但实际上,服务的规模可有可无,为的是强迫人们解耦和让服务之间高内聚。实现这个策略并不容易,并有可能带来一系列预想不到的问题,可是其益处也是很显著的。算法

3 — 分离

咱们同时也能够把应用分红多个实例,每一个实例只负责应用的一部分数据。这个方法在数据库领域内一般被称为横向分割碎片化。数据分割要求每一步操做前都须要查找当前在使用哪个实例。例如,咱们也许想要根据用户所在的国家或者所用的语言进行分区,首先咱们须要查找相关信息。

成功扩展一个大型应用最终应该实现这三个策略。Node.js 让这一切变得简单,所以这篇文章我将会把注意力集中在克隆策略上,看看 Node.js 有什么可用的内置工具来实现这个策略。

请注意到在读这篇文章前你须要理解好 Node.js 的子进程。若是你不太了解,我建议你能够先读这篇文章:

cluster 模块

想要在同一环境下多个 CPU 的状况开启负载均衡,咱们可使用 cluster 模块。这基于子进程模块的 fork 方法,基本上它容许咱们屡次 fork 主应用并用在多个 CPU 上。而后它接管全部的子进程,并将全部对主进程的请求负载均衡到子进程中去。

Node.js 的 cluster 模块帮助咱们实现可拓展性克隆策略,可是这只适用于在只有一台服务器上的状况。若是你有一台能够储存着大量的资源的服务器,或者在一台服务器上添加资源比增添新服务器更容易和便宜时,采用 cluster 模块来快速执行克隆策略是一个不错的选择。

即便是一个小型的服务器一般也会有多个内核,甚至若是你不担忧 Node 服务器负载太重的话,能够任意开启 cluster 模块来提升服务器的可用性和容错性。执行这一步操做很简单,当你使用像 PM2 这样的进程管理器,你要作的就只是简单地给启动命令提供一个参数而已!

接着让我来跟你讲讲该如何使用原生的 cluster 模块,而且我会解释它是怎么工做的。

cluster 模块的结构很简单,咱们建立一个 master 进程,而且让这个 master 进程 fork 多个 worker 进程并管理它们,每个 worker 进程表明须要可拓展的应用的实例。全部请求都由 master 进程处理,这个进程会给每一个 worker 进程分配其中一部分须要处理的请求。

Pluralsight 课程上的截图 — Node.js 进阶

master 进程的工做很简单,实际上它只是使用轮替算法来挑选 worker 进程。除了 Windows 之外的操做系统都默认开启了这个算法,而且它能经过全局修改来让操做系统自己来处理负载均衡。

轮替算法让负载轮流地均匀分布在可用进程。第一个请求会指向第一个 worker 进程,第二个请求指向列表上的下一个进程,以此类推。当列表已经遍历完,算法会从头开始。

这是其中一种最简易而且也是最经常使用的负载均衡算法,可是并非只有这一个。还有不少各具特点的算法能分配优先级和抽选负载最小或者响应速度最快的服务器。

HTTP 服务器上的负载均衡

让咱们克隆一个简单的 HTTP 服务器并经过 cluster 模块实现负载均衡。这是一个简单的 Node hello-word 例子,咱们修改一下让它模拟响应前的 CPU 工做。

// server.js

const http = require('http');
const pid = process.pid;

http.createServer((req, res) => {
  for (let i=0; i<1e7; i++); // simulate CPU work
  res.end(`Handled by process ${pid}`);
}).listen(8080, () => {
  console.log(`Started process ${pid}`);
});复制代码

为了检验负载均衡器咱们须要建立一些东西来让它工做,我已经在 HTTP 响应中引进了程序 pid 来识别目前正在处理请求的应用的实例。

在咱们使用 cluster 模块把服务器中的主进程克隆成多个 worker 进程以前,咱们应该先调查下服务器每秒可以处理多少个请求。咱们能够用 Apache 基准测试工具 来作这件事。在运行 server.js 以前,咱们先执行 ab 命令:

ab -c200 -t10 http://localhost:8080/复制代码

这个命令会在 10 秒内发起 200 个并发链接来测试服务器的负载性能。

来自 Pluralsight 课程中的截图 — Node.js 进阶

在个人服务器上,单独一个 node 服务器每秒能够处理 51 个请求。固然,结果会随着平台的不一样而有所变化,这只是一个很是简化的性能测试,并不能保证结果 100% 准确,可是它将会清晰地显示 cluster 模块给多核的应用环境所带来的不一样。

既然咱们有了一个参照的基准,咱们就能够经过 cluster 模块来实现克隆策略,以此来拓展一个应用的规模。

server.js 的同级目录上,咱们能够建立一个名叫 cluster.js 的新文件,用来提供 master 进程:

// cluster.js

const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  const cpus = os.cpus().length;

  console.log(`Forking for ${cpus} CPUs`);
  for (let i = 0; i<cpus; i++) {
    cluster.fork();
  }
} else {
  require('./server');
}复制代码

cluster.js 文件里,咱们首先引入 clusteros 模块,咱们须要 os 模块里的 os.cpus() 方法来获得 CPU 的数量。

cluster 模块给了咱们一个便利的 Boolean 参数 isMaster 来肯定 cluster.js 是否正在被 master 进程读取。当咱们第一次执行这个文件时,咱们会执行在 master 进程上,所以 isMaster 为 true。在这种状况下,咱们让 master 进程屡次 fork 咱们的服务器,直到 fork 的次数达到 CPU 的数量。

如今咱们只是经过 os 模块来读取 CPU 的数量,而后对这个数字进行一个 for 循环,在循环内部调用 cluster.fork 方法。for 循环将会简单地建立和 CPU 数量同样多的 worker 进程,以此来充分利用服务器可用的计算能力。

cluster.fork 这一行在 master 进程中被执行时,当前的 cluster.js 文件会再运行一次,可是这一次是在 worker 进程,其中的 isMaster 参数为 false。实际上在这种状况下,另一个参数将为 true,这个参数是 isWorker 参数

当应用运行在 worker 进程上,它开始作实际的工做。咱们就在这里定义服务器的业务逻辑,例如,咱们能够经过请求已经有的 server.js 文件来实现业务逻辑。

基本就是这样了。这样就能简单地充分利用服务器的计算能力。想要测试 cluster,运行 cluster.js 文件:

来自 Pluralsight 课程中的截图 — Node.js 进阶

个人服务器有 8 核所以我要开启 8 个进程。其中重要的是要理解它们和 Node.js 里的进程彻底不一样。每一个 worker 进程有其独自的事件循环和内存空间。

当咱们屡次请求网络服务器,这些请求将会由不一样的 worker 进程处理,worker 进程的 id 也各不相同。序列里的 worker 进程不会准确地进行轮换,由于 cluster 模块在挑选下一个处理请求的 worker 进程时进行了一些优化,负载会分布在不一样的 worker 进程中。

咱们一样可使用先前的 ab 命令来测试 cluster 中的进程的负载:

来自 Pluralsight 课程中的截图 — Node.js 进阶

一样是单独的 node 服务器,建立 cluster 后服务器每秒可以处理 181 个请求,没用 cluster 模块以前每秒只能处理 51 个请求。咱们只是增长了几行代码,应用的性能就提升了 3 倍。

广播全部 Worker 进程

master 进程与 worker 进程之间可以简单地进行通讯,由于 cluster 模块有个 child_process.fork 的 api,这意味着 master 进程与每一个 worker 进程之间进行通讯是可能的。

基于 server.js/cluster.js 的例子,咱们能够用 cluster.workers 获取一个包含全部 worker 对象的列表,该列表持有全部 worker 的引用,并能够经过这个引用来读取 worker 的信息。有了让 master 进程和 worker 进程通讯的方法后,想要广播每一个 worker 进程,咱们只须要简单地遍历全部的 worker。例如:

Object.values(cluster.workers).forEach(worker => {
  worker.send(`Hello Worker ${worker.id}`);
});复制代码

经过 Object.values 能够从 cluster.workers 对象里简单地来获取一个包含全部 worker 的数组。而后对于每一个 worker,咱们使用 send 函数来传递任意咱们要传的值。

在一个 worker 文件里,在咱们的例子中 server.js 要读取从 master 进程中收到的消息,咱们能够在全局 process 对象中给 message 事件注册一个 handler。

process.on('message', msg => {
  console.log(`Message from master: ${msg}`);
});复制代码

当我在 cluster/server 上测试这两项新加的东西时所看到:

来自 Pluralsight 课程中的截图 — Node.js 进阶

每一个 worker 都收到了来自 master 进程的消息。注意到 worker 的启动是乱序的。

此次咱们让通讯的内容变得更实际一点。此次咱们想要服务器返回数据库中用户的数量。咱们将会建立一个 mock 函数来返回数据库中用户的数量,而且每次当它被调用时对这个值进行平方处理(理想状况下的增加):

// **** 模拟 DB 调用
const numberOfUsersInDB = function() {
  this.count = this.count || 5;
  this.count = this.count * this.count;
  return this.count;
}
// ****复制代码

每次 numberOfUsersInDB 被调用,咱们会假设已经链接数据库。咱们想要避免屡次数据库的请求,所以咱们会根据必定时间对调用进行缓存,例如每 10 秒缓存一次。然而,咱们仍然不想让 8 个 forked worker 使用独自的数据库链接和每 10 秒关闭 8 个数据库链接。咱们可让 master 进程只请求一次数据库链接,而后经过通讯接口告诉这 8 个 worker 用户数量的最新值。

例如,在 master 进程模式中,咱们一样能够遍历全部 worker 来广播用户数量的值:

// 在 isMaster=true 的状态下进行 fork 循环后

const updateWorkers = () => {
  const usersCount = numberOfUsersInDB();
  Object.values(cluster.workers).forEach(worker => {
    worker.send({ usersCount });
  });
};

updateWorkers();
setInterval(updateWorkers, 10000);复制代码

这里第一次咱们调用了 updateWorkers,而后经过 setInterval 每 10 秒调用这个方法。这样的话,每 10 秒全部的 worker 会以通讯的形式收到用户数量的值,而且咱们只须要建立一次数据库链接。

在服务端的代码,咱们能够从一样的 message 事件 handler 中拿到 usersCount 的值。咱们简单地用一个模块全局变量缓存这个值,这样咱们在任何地方都能使用它。

例如:

const http = require('http');
const pid = process.pid;

let usersCount;

http.createServer((req, res) => {
  for (let i=0; i<1e7; i++); // simulate CPU work
  res.write(`Handled by process ${pid}\n`);
  res.end(`Users: ${usersCount}`);
}).listen(8080, () => {
  console.log(`Started process ${pid}`);
});

process.on('message', msg => {
  usersCount = msg.usersCount;
});复制代码

上面的代码让 worker 的 web 服务器用缓存的 usersCount 进行响应。若是你如今测试 cluster 的代码,前 10 秒你会从全部的 worker 里获得用户数量为 “25”(同时只建立了一个数据库链接)。而后 10 秒事后,全部的 worker 开始报告当前的用户数量,625(一样只建立了一个数据库链接)。

得力于 master 进程和 worker 之间通讯的方法的存在,咱们可以作到这一切。

提升服务器的可用性

咱们在运行单独一个 Node 应用的实例时有一个问题,就是当这个实例崩溃时,咱们必须重启整个应用。这意味着崩溃后的重启之间会存在一个时间差,即便咱们让这项操做自动执行也是同样的。

同理当服务器想要部署新代码就必须重启。只有一个实例,为此所形成的时间差会影响系统的可用性。

而若是咱们有多个实例的话,只需添加寥寥数行代码就能够提升系统的可用性。

为了在服务器中模拟随机崩溃,咱们经过一个 timer 来调用 process.exit,让它随机执行。

// 在 server.js 文件

setTimeout(() => {
  process.exit(1) // 随时退出进程
}, Math.random() * 10000);复制代码

当一个 worker 进程因崩溃而退出,cluster 对象里的 exit 事件会通知 master 进程。咱们能够给这个事件注册一个 handler,而且当其余 worker 进程还存在时让它 fork 一个新的 worker 进程。

例如:

// 在 isMaster=true 的状态下进行 fork 循环后

cluster.on('exit', (worker, code, signal) => {
  if (code !== 0 && !worker.exitedAfterDisconnect) {
    console.log(`Worker ${worker.id} crashed. ` +
                'Starting a new worker...');
    cluster.fork();
  }
});复制代码

这里咱们添加一个 if 条件来保证 worker 进程真的崩溃了而不是手动断开链接或者被 master 进程杀死了。例如,咱们使用了太多的资源超出了负载的上限,所以 master 进程决定杀死一部分 worker。所以咱们调用 disconnect 方法给任意 worker,这样 exitedAfterDisconnect flag 就会设为 true。if 语句会保证不会所以而 fork 新的 worker。

若是咱们带着上面的 handler 运行 cluster(同时 server.js 里有随机的崩溃的代码),在随机数秒事后,worker 会开始崩溃,master 进程会马上 fork 新的 worker 来提升系统的可用性。你一样能够用 ab 命令来衡量可用性,看看服务器有多少的请求没有处理(由于有一些请求会不走运地遇到没法避免的崩溃)。

当我测试这段代码,10 秒内请求 1800 次,其中有 200 次并发请求,最后只有 17 次请求失败。

来自 Pluralsight 课程中的截图 — Node.js 进阶

这有 99% 以上的可用性。只是添加数行代码,如今咱们再也不担忧进程崩溃了。master 守护将会替咱们关注这些进程的状况。

瞬时重启

那当咱们想要部署新代码,而不得不重启全部的 worker 进程时该怎么办呢?

咱们有多个实例在运行,因此与其让它们一块儿重启,不如每次只重启一个,这样的话即便重启也能保证其余的 worker 进程可以继续处理请求。

用 cluster 模块能简单地实现这一想法。当 master 进程开始运行以后咱们就不想重启它,咱们须要想办法传递重启 worker 的指令给 master 进程。在 Linux 系统上这样作很容易由于咱们能监听一个进程的信号像 SIGUSR2,当 kill 命令里面带有进程 id 和信号时这个监听事件将会触发:

// 在 Node 里面
process.on('SIGUSR2', () => { ... });

// 触发信号
$ kill -SIGUSR2 PID复制代码

这样,master 进程不会被杀死,咱们就可以在里面进行一系列操做了。SIGUSR2 信号适合这种状况,由于咱们要执行用户指令。若是你想知道为何不用 SIGUSR1,那是由于这个信号用在 Node 的调试器上,咱们为了不冲突因此不用它。

不幸的是,在 Windows 里面的进程不支持这个信号,咱们要找其余方法让 master 进程作这件事。有几种代替方案。例如,咱们能够用标准输入或者 socket 输入。或者咱们能够监控 process.id 文件的删除事件。可是为了让这个教程更容易,咱们仍是假定服务器运行在 Linux 平台上。

在 Windows 上 Node 运行良好,可是我认为让做为产品的 Node 应用在 Linux 平台上运行会更安全。这和 Node 自己无关,只是由于在 Linux 上有更多稳定的生产工具。这只是个人我的看法,最好仍是根据本身的状况选择平台。

顺带一提,在最近的 Windows 版本里,实际上你能够在里面使用 Linux 子系统。我本身测试过了,没有什么特别明显的缺点。若是你在 Windows 上开发 Node 应用,能够看看 [Bash on Windows](msdn.microsoft.com/en-us/comma…) 并尝试一下。

在咱们的例子中,当 master 进程收到 SIGUSR2 信号,就意味着是时候重启 worker 了,可是咱们想要每次只重启一个 worker。所以 master 进程应该等到当前的 worker 已经重启完后再重启下一个 worker。

咱们须要用 cluster.workers 对象来获得当前全部 worker 的引用,而后咱们简单地把它存进一个数组中:

const workers = Object.values(cluster.workers);复制代码

而后,咱们建立 restartWorker 函数来接受要重启的 worker 的 index。这样当下一个 worker 能够重启时,咱们让函数调用当前 worker,直到最后重启整个序列里的 worker。这是须要调用的 restartWorker 函数(解释在后面):

const restartWorker = (workerIndex) => {
  const worker = workers[workerIndex];
  if (!worker) return;

  worker.on('exit', () => {
    if (!worker.exitedAfterDisconnect) return;
    console.log(`Exited process ${worker.process.pid}`);

    cluster.fork().on('listening', () => {
      restartWorker(workerIndex + 1);
    });
  });

  worker.disconnect();
};

restartWorker(0);复制代码

restartWorker 函数里面,咱们获得了要重启的 worker 的引用,而后咱们会根据序列递归调用这个函数,咱们须要一个结束递归的条件。当没有 worker 须要重启,咱们就直接 return。基本上咱们想让这个 worker 断开链接(使用 worker.disconnect),可是在重启下一个 worker 以前,咱们须要 fork 一个新的 worker 来代替当前断开链接的 worker。

当目前要断开链接的 worker 还存在时,咱们能够用 worker 自己的 exit 事件来 fork 一个新的 worker,可是咱们要确保在日常的断开链接调用后 exit 动做就会被触发。咱们能够用 exitedAfetrDisconnect flag,若是 flag 不为 true,那么是由于其余缘由而致使的 exit,这种状况下咱们什么都不作就直接 return。可是若是 flag 为 true,咱们就继续执行下去,fork 一个新的 worker 来代替当前要断开链接的那个。

当新的 fork worker 进程准备好了,咱们就要重启下一个。然而,记住 fork 的过程不是同步的,因此咱们不能在调用完 fork 后就直接重启下个 worker。咱们要在新的 fork worker 上监听 listening 事件,这个事件告诉咱们这个 worker 已经链接并准备好了。当咱们触发这个事件,咱们就能够安全地重启下个在序列里 worker 了。

这就是咱们为了实现瞬时重启要作的东西。要测试它,你要知道须要发送 SIGUSR2 信号的 master 进程的 id:

console.log(`Master PID: ${process.pid}`);复制代码

开启 cluster,复制 master 进程的 id,而后用 kill -SIGUSR2 PID 命令重启 cluster。一样你能够在重启 cluster 时用 ab 命令来看看重启时的可用性。剧透一下,没有请求失败:

来自 Pluralsight 课程中的截图 — Node.js 进阶

像 PM2 这样的进程监控器,我我的把它用在生产环境上,它让咱们实现上述工做变得异常简单,同时它还有许多功能来监控 Node.js 应用的健壮度。例如,用 PM2,想要在任意应用上启动 cluster,你只须要用 -i 参数:

pm2 start server.js -i max复制代码

想要瞬时重启你只须要使用这个神奇的命令:

pm2 reload all复制代码

然而,我以为在使用这些命令以前先理解其背后的实现是有帮助的。

共享状态和粘性负载均衡

好东西老是须要付出代价。当咱们对一个 Node 应用进行负载均衡,咱们也失去了一些只能在单进程适用的功能。这个问题在其余语言上被称为线程安全,它和在线程之间共享数据有关。在咱们的案例中,问题则在于如何在 worker 进程之间共享数据。

例如,设立了 cluster 后,咱们就不能在内存上缓存东西了,由于每一个 worker 有其独立的内存空间,若是咱们在其中一个 worker 的内存里缓存东西,其余的 worker 就没办法拿到它。

若是咱们须要在 cluster 里缓存东西,咱们要从全部 worker 那里分离实体和读取/写入实体的 API。实体要存放在数据库服务器,或者若是你想用内存来缓存,你可使用像 Redis 这样的服务器,或者建立一个专一于读取/写入 API 的 Node 进程供全部 worker 使用。

来自 Pluralsight 课程中的截图 — Node.js 进阶

这个作法有个好处,当你的应用为了缓存而分离了实体,实际上这是分解的一部分,能让你的应用更具可拓展性。即便你运行在一个单核服务器,你也应该这样作。

除了缓存外,当咱们运行 cluster,整体来讲状态之间的交流成为了一个问题。咱们不能确保交流发生在同一个 worker 上,所以不能在任何一个 worker 上建立一个状态相关的交流通道。

一个最多见的例子是用户认证。

来自 Pluralsight 课程中的截图 — Node.js 进阶

用 cluster,验证的请求分配到 master 进程,而这个进程把请求分配给一个 worker,假定分配给 A。

来自 Pluralsight 课程中的截图 — Node.js 进阶

如今 Worker A 认出了用户的状态。可是,当一样的用户进行另一个请求,最终负载均衡器会把它分配给其余 worker,而这些 worker 尚未验证这个用户。在单独一个实例的内存上持有验证用户的引用并无论用。

有不少方法处理这个问题。经过在共享数据库或者 Redis node 上对会话信息进行排序,咱们能够在 worker 之间共享状态。然而,实现这个策略须要改变一些代码,这不是最好的方法。

若是你不想修改代码就实现一个会话的共享存储仓库,有个入侵性低但效率不高的策略。你能够用粘性负载均衡。和让普通的负载均衡器实现上述策略相比,它更为简单。想法很简单,当 worker 的实例要验证用户,咱们在负载均衡器上记录相关的关系。

来自 Pluralsight 课程中的截图 — Node.js 进阶

而后,当一样的用户发送新的请求,咱们就检查记录,发现服务器里已经有验证的会话,而后把这个会话发送给服务器,而不是执行普通的验证操做。用这个方法不须要改变服务器里的代码,但同时咱们不会获得用负载均衡器来验证用户的好处,因此只有别无选择时才用粘性负载均衡。

实际上 cluster 模块并不支持粘性负载均衡,可是大多数负载均衡器能够默认设置为粘性负载均衡。


感谢阅读。若是你以为这篇文章对你有帮助,请点击下面的 💚。关注我来获得更多有关 Node.js 和 JavaScript 的文章。

我为 PluralsightLynda 建立了网络课程。最近个人课程是 Advanced React.jsAdvanced Node.jsLearning Full-stack JavaScript

同时我也为 JavaScript,Node.js,React.js,和 GraphQL 的水平在初级与进阶之间的人们建立了在线 training。若是你想要找一位导师,能够 发邮件给我。如对这篇文章或者其余我写的文章有任何疑问,请在 这个 slack 用户 上找到我而且在 #question 空间上提问。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索