Node.js 应用开发实战 - 高级前端开发工程师 - 拉勾教育

前几讲我们都使用了一种非常简单暴力的方式(node app.js)启动 Node.js 服务器,而在线上我们要考虑使用多核 CPU,充分利用服务器资源,这里就用到多进程解决方案,所以本讲介绍 PM2 的原理以及如何应用一个 cluster 模式启动 Node.js 服务。

单线程问题

《01 | 事件循环:高性能到底是如何做到的?》中我们分析了 Node.js 主线程是单线程的,如果我们使用 node app.js 方式运行,就启动了一个进程,只能在一个 CPU 中进行运算,无法应用服务器的多核 CPU,因此我们需要寻求一些解决方案。你能想到的解决方案肯定是多进程分发策略,即主进程接收所有请求,然后通过一定的负载均衡策略分发到不同的 Node.js 子进程中。如图 1 的方案所示:

05 | 多进程解决方案:cluster 模式以及 PM2 工具的原理介绍 - 图1

这一方案有 2 个不同的实现:

  • 主进程监听一个端口,子进程不监听端口,通过主进程分发请求到子进程;
  • 主进程和子进程分别监听不同端口,通过主进程分发请求到子进程。

在 Node.js 中的 cluster 模式使用的是第一个实现。

cluster 模式

cluster 模式其实就是我们上面图 1 所介绍的模式,一个主进程多个子进程,从而形成一个集群的概念。我们先来看看 cluster 模式的应用例子。

应用

我们先实现一个简单的 app.js,代码如下:

  1. const http = require('http');
  2. *
  3. * 创建 http 服务,简单返回
  4. */
  5. const server = http.createServer((req, res) => {
  6. res.write(`hello world, start with cluster ${process.pid}`);
  7. res.end();
  8. });
  9. *
  10. * 启动服务
  11. */
  12. server.listen(3000, () => {
  13. console.log('server start http://127.0.0.1:3000');
  14. });
  15. console.log(`Worker ${process.pid} started`);

这是最简单的一个 Node.js 服务,接下来我们应用 cluster 模式来包装这个服务,代码如下:

  1. const cluster = require('cluster');
  2. const instances = 2;
  3. if (cluster.isMaster) {
  4. for(let i = 0;i<instances;i++) {
  5. cluster.fork();
  6. }
  7. } else {
  8. require('./app.js');
  9. }

首先判断是否为主进程:

  • 如果是则使用 cluster.fork 创建子进程;
  • 如果不是则为子进程 require 具体的 app.js。

然后运行下面命令启动服务。

启动成功后,再打开另外一个命令行窗口,多次运行以下命令:

  1. curl "http://127.0.0.1:3000/"

你可以看到如下输出:

  1. hello world, start with cluster 4543
  2. hello world, start with cluster 4542
  3. hello world, start with cluster 4543
  4. hello world, start with cluster 4542

后面的进程 ID 是比较有规律的随机数,有时候输出 4543,有时候输出 4542,4543 和 4542 就是我们 fork 出来的两个子进程,接下来我们看下为什么是这样的。

原理

首先我们需要搞清楚两个问题:

  • Node.js 的 cluster 是如何做到多个进程监听一个端口的;
  • Node.js 是如何进行负载均衡请求分发的。

多进程端口问题

在 cluster 模式中存在 master 和 worker 的概念,master 就是主进程worker 则是子进程,因此这里我们需要看下 master 进程和 worker 进程的创建方式。如下代码所示:

  1. const cluster = require('cluster');
  2. const instances = 2;
  3. if (cluster.isMaster) {
  4. for(let i = 0;i<instances;i++) {
  5. cluster.fork();
  6. }
  7. } else {
  8. require('./app.js');
  9. }

这段代码中,第一次 require 的 cluster 对象就默认是一个 master,这里的判断逻辑在源码中,如下代码所示:

  1. 'use strict';
  2. const childOrPrimary = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'primary';
  3. module.exports = require(`internal/cluster/${childOrPrimary}`);

通过进程环境变量设置来判断:

  • 如果没有设置则为 master 进程;
  • 如果有设置则为子进程。

因此第一次调用 cluster 模块是 master 进程,而后都是子进程。

主进程和子进程 require 文件不同:

  • 前者是 internal/cluster/primary;
  • 后者是 internal/cluster/child。

我们先来看下 master 进程的创建过程,这部分代码在这里

可以看到 cluster.fork,一开始就会调用 setupPrimary 方法,创建主进程,由于该方法是通过 cluster.fork 调用,因此会调用多次,但是该模块有个全局变量 initialized 用来区分是否为首次,如果是首次则创建,否则则跳过,如下代码:

  1. if (initialized === true)
  2. return process.nextTick(setupSettingsNT, settings);
  3. initialized = true;

接下来继续看 cluster.fork 方法,源码如下:

  1. cluster.fork = function(env) {
  2. cluster.setupPrimary();
  3. const id = ++ids;
  4. const workerProcess = createWorkerProcess(id, env);
  5. const worker = new Worker({
  6. id: id,
  7. process: workerProcess
  8. });
  9. worker.on('message', function(message, handle) {
  10. cluster.emit('message', this, message, handle);
  11. });

在上面代码中第 2 行就是创建主进程,第 4 行就是创建 worker 子进程,在这个 createWorkerProcess 方法中,最终是使用 child_process 来创建子进程的。在初始化代码中,我们调用了两次 cluster.fork 方法,因此会创建 2 个子进程,在创建后又会调用我们项目根目录下的 cluster.js 启动一个新实例,这时候由于 cluster.isMaster 是 false,因此会 require 到 internal/cluster/child 这个方法。

由于是 worker 进程,因此代码会 require (‘./app.js’) 模块,在该模块中会监听具体的端口,代码如下:

  1. *
  2. * 启动服务
  3. */
  4. server.listen(3000, () => {
  5. console.log('server start http://127.0.0.1:3000');
  6. });
  7. console.log(`Worker ${process.pid} started`);

这里的 server.listen 方法很重要,这部分源代码在这里,其中的 server.listen 会调用该模块中的 listenInCluster 方法,该方法中有一个关键信息,如下代码所示:

  1. if (cluster.isPrimary || exclusive) {
  2. server._listen2(address, port, addressType, backlog, fd, flags);
  3. return;
  4. }
  5. const serverQuery = {
  6. address: address,
  7. port: port,
  8. addressType: addressType,
  9. fd: fd,
  10. flags,
  11. };
  12. cluster._getServer(server, serverQuery, listenOnPrimaryHandle);

上面代码中的第 6 行,判断为主进程,就是真实的监听端口启动服务,而如果非主进程则调用 cluster._getServer 方法,也就是 internal/cluster/child 中的 cluster._getServer 方法。

接下来我们看下这部分代码:

  1. obj.once('listening', () => {
  2. cluster.worker.state = 'listening';
  3. const address = obj.address();
  4. message.act = 'listening';
  5. message.port = (address && address.port) || options.port;
  6. send(message);
  7. });

这一代码通过 send 方法,如果监听到 listening 发送一个消息给到主进程,主进程也有一个同样的 listening 事件,监听到该事件后将子进程通过 EventEmitter 绑定在主进程上,这样就完成了主子进程之间的关联绑定,并且只监听了一个端口。而主子进程之间的通信方式,就是我们常听到的 IPC 通信方式

负载均衡原理

既然 Node.js cluster 模块使用的是主子进程方式,那么它是如何进行负载均衡处理的呢,这里就会涉及 Node.js cluster 模块中的两个模块。

  • round_robin_handle.js(非 Windows 平台应用模式),这是一个轮询处理模式,也就是轮询调度分发给空闲的子进程,处理完成后回到 worker 空闲池子中,这里要注意的就是如果绑定过就会复用该子进程,如果没有则会重新判断,这里可以通过上面的 app.js 代码来测试,用浏览器去访问,你会发现每次调用的子进程 ID 都会不变。
  • shared_handle.js( Windows 平台应用模式),通过将文件描述符、端口等信息传递给子进程,子进程通过信息创建相应的 SocketHandle / ServerHandle,然后进行相应的端口绑定和监听、处理请求。

以上就是 cluster 的原理,总结一下就是 cluster 模块应用 child_process 来创建子进程,子进程通过复写掉 cluster._getServer 方法,从而在 server.listen 来保证只有主进程监听端口,主子进程通过 IPC 进行通信,其次主进程根据平台或者协议不同,应用两种不同模块(round_robin_handle.js 和 shared_handle.js)进行请求分发给子进程处理。接下来我们看一下 cluster 的成熟的应用工具 PM2 的应用和原理。

PM2 原理

PM2 是守护进程管理器,可以帮助你管理和保持应用程序在线。PM2 入门非常简单,它是一个简单直观的 CLI 工具,可以通过 NPM 安装,接下来我们看下一些简单的用法。

应用

你可以使用如下命令进行 NPM 或者 Yarn 的安装:

  1. $ npm install pm2@latest -g
  2. # or
  3. $ yarn global add pm2

安装成功后,可以使用如下命令查看是否安装成功以及当前的版本:

接下来我们使用 PM2 启动一个简单的 Node.js 项目,进入本讲代码的项目根目录,然后运行下面命令:

运行后,再执行如下命令:

可以看到如图 2 所示的结果,代表运行成功了。

05 | 多进程解决方案:cluster 模式以及 PM2 工具的原理介绍 - 图2

图 2 pm2 list 运行结果

PM2 启动时可以带一些配置化参数,具体参数列表你可以参考官方文档。在开发中我总结出了一套最佳的实践,如以下配置所示:

  1. module.exports = {
  2. apps : [{
  3. name: "nodejs-column",
  4. script: "./app.js",
  5. instances: 2,
  6. exec_mode: 'cluster',
  7. env_development: {
  8. NODE_ENV: "development",
  9. watch: true,
  10. },
  11. env_testing: {
  12. NODE_ENV: "testing",
  13. watch: false,
  14. },
  15. env_production: {
  16. NODE_ENV: "production",
  17. watch: false,
  18. },
  19. log_date_format: 'YYYY-MM-DD HH:mm Z',
  20. error_file: '~/data/err.log',
  21. out_file: '~/data/info.log',
  22. max_restarts: 10,
  23. }]
  24. }

在上面的配置中要特别注意 error_fileout_file,这里的日志目录在项目初始化时要创建好,如果不提前创建好会导致线上运行失败,特别是无权限创建目录时。其次如果存在环境差异的配置时,可以放置在不同的环境下,最终可以使用下面三种方式来启动项目,分别对应不同环境。

  1. $ pm2 start pm2.config.js --env development
  2. $ pm2 start pm2.config.js --env testing
  3. $ pm2 start pm2.config.js --env production

原理

接下来我们来看下是如何实现的,由于整个项目是比较复杂庞大的,这里我们主要关注进程创建管理的原理

首先我们来看下进程创建的方式,整体的流程如图 3 所示。

05 | 多进程解决方案:cluster 模式以及 PM2 工具的原理介绍 - 图3

图 3 PM2 源码多进程创建方式

这一方式涉及五个模块文件。

  • CLI(lib/binaries/CLI.js)处理命令行输入,如我们运行的命令:
  1. pm2 start pm2.config.js --env development
  • API(lib/API.js)对外暴露的各种命令行调用方法,比如上面的 start 命令对应的 API->start 方法。
  • Client (lib/Client.js)可以理解为命令行接收端,负责创建守护进程 Daemon,并与 Daemon(lib/Daemon.js)保持 RPC 连接。
  • God (lib/God.js)主要负责进程的创建和管理,主要是通过 Daemon 调用,Client 所有调用都是通过 RPC 调用 Daemon,然后 Daemon 调用 God 中的方法。
  • 最终在 God 中调用 ClusterMode(lib/God/ClusterMode.js)模块,在 ClusterMode 中调用 Node.js 的 cluster.fork 创建子进程。

图 3 中首先通过命令行解析调用 API,API 中的方法基本上是与 CLI 中的命令行一一对应的,API 中的 start 方法会根据传入参数判断是否是调用的方法,一般情况下使用的都是一个 JSON 配置文件,因此调用 API 中的私有方法 _startJson。

接下来就开始在 Client 模块中流转了,在 _startJson 中会调用 executeRemote 方法,该方法会先判断 PM2 的守护进程 Daemon 是否启动,如果没有启动会先调用 Daemon 模块中的方法启动守护进程 RPC 服务,启动成功后再通知 Client 并建立 RPC 通信连接。

成功建立连接后,Client 会发送启动 Node.js 子进程的命令 prepare,该命令传递 Daemon,Daemon 中有一份对应的命令的执行方法,该命令最终会调用 God 中的 prepare 方法。

在 God 中最终会调用 God 文件夹下的 ClusterMode 模块,应用 Node.js 的 cluster.fork 创建子进程,这样就完成了整个启动过程。

综上所述,PM2 通过命令行,使用 RPC 建立 Client 与 Daemon 进程之间的通信,通过 RPC 通信方式,调用 God,从而应用 Node.js 的 cluster.fork 创建子进程的。以上是启动的流程,对于其他命令指令,比如 stop、restart 等,也是一样的通信流转过程,你参照上面的流程分析就可以了,如果遇到任何问题,都可以在留言区与我交流。

以上的分析你需要参考PM2 的 GitHub 源码

总结

本讲主要介绍了 Node.js 中的 cluster 模块,并深入介绍了其核心原理,其次介绍了目前比较常用的多进程管理工具 PM2 的应用和原理。学完本讲后,需要掌握 Node.js cluster 原理,并且掌握 PM2 的实现原理。

接下来我们将开始讲解一些关于 Node.js 性能相关的知识,为后续的高性能服务做一定的准备,其次也在为后续性能优化打下一定的技术基础。

下一讲会讲解,目前我们在使用的 Node.js cluster 模式存在的性能问题。


05 | 多进程解决方案:cluster 模式以及 PM2 工具的原理介绍 - 图4

《大前端高薪训练营》

对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!