Eggjs学习系列(七) 多进程实践

JavaScript 是单线程的,只能运行在一个CPU上,这样不能充分发挥计算机的性能。为了更好地利用多核环境,Node.js 提供了 Cluster 模块,可以方便的创建多个子进程,提高项目运行效率。

Cluster 模块

Cluster 模块将进程分为 Master 进程和 Worker 进程:

  • 负责启动其他进程的叫做 Master 进程,只负责启动其他进程。
  • 其他被启动的叫 Worker 进程,接收请求,对外提供服务。
  • Worker 进程的数量一般根据服务器的 CPU 核数来定,这样就可以完美利用多核资源。

范例代码:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
// 判断是不是 Master 进程
if (cluster.isMaster) {
  // 创建子进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', function(worker, code, signal) {
    console.log('worker ' + worker.process.pid + ' died');
  });
} else {
  // Worker 进程能够共用TCP连接
  http.createServer(function(req, res) {
    res.writeHead(200);
    res.end("hello world\n");
  }).listen(8000);
}

Cluster 原理

  1. Master、Worker进程通信

    master 进程通过 cluster.fork() 来创建 worker进程。cluster.fork() 内部 是通过 child_process.fork()来创建子进程。所以 master进程、worker进程是父、子进程的关系。Master 进程、Woker进程之间通过IPC通道进行通信。

  2. 实现端口共享

    net模块中,对 listen() 方法进行了特殊处理。如果当前进程是 Master 进程则不做处理,如果是 Worker 进程则创建server实例。然后通过IPC通道,向 Master 进程发送消息,**让 Master 进程也创建 server 实例,并在该端口上监听请求。**当请求进来时,master进程将请求转发给worker进程的server实例。

    // lib/net.js 源代码
    function listen(self, address, port, addressType, backlog, fd, exclusive) {
      exclusive = !!exclusive;
      // 引入 cluster 模块
      if (!cluster) cluster = require('cluster');
      // 判断当前进程是不是 Master 进程
      if (cluster.isMaster || exclusive) {
        self._listen2(address, port, addressType, backlog, fd);
        return;
      }
      // _getServer 向 Master 进程注册该 Worker,若master进程是第一次接收到监听此端口/描述符下的Worker,则起一个内部TCP服务器,来承担监听该端口/描述符的职责,随后在master中记录下该worker。
      // Hack掉worker进程中的net.Server实例的listen方法,使其不再监听接口。
      cluster._getServer(self, {
        address: address,
        port: port,
        addressType: addressType,
        fd: fd,
        flags: 0
      }, cb);
    
      function cb(err, handle) {
        // ...
        self._handle = handle;
        self._listen2(address, port, addressType, backlog, fd);
      }
    }
    
  3. 分发多个请求到worker

    每当worker进程创建server实例来监听请求,都会通过IPC通道,在master上进行注册。当客户端请求到达,master会负责将请求转发给对应的worker。

    具体转发给哪个worker?这是由转发策略决定的。可以通过环境变量NODE_CLUSTER_SCHED_POLICY设置,也可以在cluster.setupMaster(options)时传入。

    默认的转发策略是轮询(SCHED_RR):当有客户请求到达,master会轮询一遍worker列表,找到第一个空闲的worker,然后将该请求转发给该worker。

  4. 发送内部消息

    当发送的消息包含cmd字段,且改字段以NODE_作为前缀,则该消息会被视为内部保留的消息,不会通过message事件抛出,但可以通过监听’internalMessage’捕获。

    // woker进程
    const message = {
      cmd: 'NODE_CLUSTER',
      act: 'queryServer'
    };
    process.send(message);
    // master进程接收方式
    worker.process.on('internalMessage', fn);
    

Eggjs 多进程模型

错误处理

在多进程的情况下,一个重要的要求就是保证程序出错的时候还能够正常运行,这就叫健壮性(又叫鲁棒性)。Eggjs 框架在这方面也提供了相关的保证。

Node.js 进程退出的两大情况

  • 未捕获异常 Node.js 提供了 process.on('uncaughtException', handler) 接口来捕获未被处理的异常。Eggjs 则采用关闭异常 Worker 进程所有的 TCP Server, Master 进程立刻 fork 一个新的 Worker 进程来保证服务正常进行。异常 Worker 在处理完请求后退出
  • OOM、系统异常 Eggjs 让当前进程直接退出,Master 立刻 fork 一个新的 Worker。

Agent 机制

Eggjs 把常用的事务,例如日志操作等从 Worker 进程中抽离出来,提供单独的 Agent 进程进行统一处理。Agent 进程专门处理一些公共事务,提高了效率。

注意:

  • Worker 进程依赖于 Agent,所以 Agent 进程先初始化。
  • Agent 要保持相对稳定,不能用于处理复杂事务。

Agent 使用

在应用或插件根目录下的 agent.ts 中实现你自己的逻辑

// agent.ts
import { Agent } from 'egg';

export default (agent: Agent) => {
  agent.messenger.on('egg-ready', () => {
    const data = { time: new Date() };
    agent.messenger.sendToApp('agent_action', data);
  });
};
// app.ts
import { Application, IBoot } from 'egg';
// 声明周期
export default class AppBoot implements IBoot {
  // ...
  async willReady() {
    // All plugins have started, can do some thing before app ready.
    // 设置 room
    const room = await this.app.redis.get('room:demo');
    if (!room) {
      await this.app.redis.set('room:demo', 'demo');
    }
    this.app.messenger.on('agent_action', data => {
      console.log('date:', data);
    });
  }
}

运行项目,会打印出:

date: { time: '2020-04-16T13:35:15.525Z' }

只有当我们遇到一些场景,只想让代码运行在一个进程上的时候,才会用到 Agent 进程。

由于 Agent 只有一个,而且会负责许多维持连接的脏活累活,因此它不能轻易挂掉和重启,所以 Agent 进程在监听到未捕获异常时不会退出,但是会打印出错误日志,我们需要对日志中的未捕获异常提高警惕。而 Worker 进程常用于负责处理真正的用户请求和定时任务的处理。所以出错的时候会退出, Master 进程重启一个 Worker 进程。

类型进程数量作用稳定性是否运行业务代码
Master1进程管理,进程间消息转发非常高
Agent1后台运行工作(长连接客户端)少量
Worker一般设置为 CPU 核数执行业务代码一般

进程间通讯(IPC)

Node.js IPC通讯范例:

const cluster = require('cluster');
if (cluster.isMaster) {
    const worker = cluster.fork();
    worker.send('hi there');
    // Master 进程接收消息
    worker.on('message', msg => {
        console.log(`msg: ${msg} from worker#${worker.id}`);
    });
} else if (cluster.isWorker) {
    // Worker 进程接收消息
    process.on('message', (msg) => {
        process.send(msg);
    });
}

而 Eggjs 提供了一个 messager 对象,对象挂在 app / agent 实例上,提供一系列友好的 API。

发送API:

// 发送广播, 包括自己
app.messenger.broadcast(action, data)

// 发送给所有的 app 进程
app.messenger.sendToApp(action, data) 

// 发送给 agent 进程(由 master 来控制发送给谁)
app.messenger.sendToAgent(action, data)

// agent 随机发送消息给一个 app 进程(由 master 控制)
agent.messenger.sendRandom(action, data)

// 发送给指定进程
app.messenger.sendTo(pid, action, data)

接收API

app.messenger.on(action, data => {});
app.messenger.once(action, data => {});

负载均衡 Sticky Mode

背景:最早 Session 等状态信息是保存在 Worker 内存里的,所以一旦用户的多次请求打到不同的Worker上的时候,必然会出现登录态失效的问题。
当项目需要创建多个 WebSocket连接时,需要在多进程的情况下保证多个WebSocket通讯,这时就需要开始 sticky 模式了,例如:egg-socket.io 插件需要配置 --sticky 指令才能正常运行。

解决方案:通过一定的方式保证同一个用户的请求打到同一个 Worker 上,Sticky Mode 就是为了解决这个问题

转发实现
如果启用了 sticky 模式,在 master 当中会分配一个 stickyWorkerPort

// master.js 
detectPorts() {
 return GetFreePort()
   // 省略中间一段设置主端口的代码
   .then(port => {
     // 判断是否开启 sticky
     if (this.options.sticky) {
       this.options.stickyWorkerPort = port;
     }
 })
}

同时,会启动一个内部的 net.Server,用来做消息转发给Worker

if (this.options.sticky) {
 this.startMasterSocketServer(err => {
       // 省略
 });
}
startMasterSocketServer(cb) {
 // 内部 net server
 require('net').createServer({
   pauseOnConnect: true,
 }, connection => {
   if (!connection.remoteAddress) {
     connection.destroy();
   } else {
     // 选出一个worker
     const worker = this.stickyWorker(connection.remoteAddress);
     worker.send('sticky-session:connection', connection);
   }
 }).listen(this[REAL_PORT], cb);
}

在 worker 当中,如果是有 配置 sticky,就会使用该 stickyWorkerPort 端口进行监听,同时只监听 父进程(也就是master)转发过来的 sticky-session:connection消息

if (options.sticky) {
 server.listen(options.stickyWorkerPort, '127.0.0.1');
 process.on('message', (message, connection) => {
   if (message !== 'sticky-session:connection') {
     return;
   }
   server.emit('connection', connection);
   connection.resume();
 });
}
// 省略正常监听的代码

转发策略
转发是通过 stickyWorker 函数实现的,本质上就是把 remoteAddress 对 Worker 数量取余数,作为索引去 Worker 列表里随机取一个 Worker

stickyWorker(ip) {
   const workerNumbers = this.options.workers;
   // ws是一组pid列表
   const ws = this.workerManager.listWorkerIds();

   let s = '';
   // IP处理:127.0.0.1 -> 127001
   for (let i = 0; i < ip.length; i++) {

     // 这个判断可以过滤掉字母和符号,这样就可以兼容IPv4和IPv6
     if (!isNaN(ip[i])) {
       s += ip[i];
     }
   }
   s = Number(s);
   // 取余数
   const pid = ws[s % workerNumbers];
   return this.workerManager.getWorker(pid);
 }

多进程研发模式增强

Eggjs提供一种新的模式来降低这类客户端封装的复杂度。通过建立 Agent 和 Worker 的 socket 直连跳过 Master 的中转。Agent 作为对外的门面维持多个 Worker 进程的共享连接。

核心思想

客户端会被区分为两种角色:

  • Leader: 负责和远程服务端维持连接,对于同一类的客户端只有一个 Leader。
  • Follower: 会将具体的操作委托给 Leader,常见的是订阅模型(让 Leader 和远程服务端交互,并等待其返回)

框架里面我们采用的是强制指定模式(框架指定某一个 Leader,其余的就是 Follower),Leader 只能在 Agent 里面创建,框架启动的时候 Master 会随机选择一个可用的端口作为 Cluster Client 监听的通讯端口,并将它通过参数传递给 Agent 和 App Worker。

  • Leader 和 Follower 之间通过 socket 直连(通过通讯端口),不再需要 Master 中转。

异常处理

  • Leader 如果“死掉”会触发新一轮的端口争夺,争夺到端口的那个实例被推选为新的 Leader。
  • 为保证 Leader 和 Follower 之间的通道健康,需要引入定时心跳检查机制,如果 Follower 在固定时间内没有发送心跳包,那么 Leader 会将 Follower 主动断开,从而触发 Follower 的重新初始化。

调用过程

+----------+             +---------------+          +---------+
| Follower |             |  Local Server |          |  Leader |
+----------+             +---------------+          +---------+
     |     register channel     |       assign to        |
     + -----------------------> |  --------------------> |
     |                          |                        |
     |                                subscribe          |
     + ------------------------------------------------> |
     |                                 publish           |
     + ------------------------------------------------> |
     |                                                   |
     |       subscribe result                            |
     | <------------------------------------------------ +
     |                                                   |
     |                                 invoke            |
     + ------------------------------------------------> |
     |          invoke result                            |
     | <------------------------------------------------ +
     |                                                   |
  1. 在通讯端口上 Leader 启动一个 Local Server,所有的 Leader/Follower 通讯都经过 Local Server。
  2. Follower 连接上 Local Server 后,首先发送一个 register channel 的 packet(引入 channel 的概念是为了区别不同类型的客户端)。
  3. Local Server 会将 Follower 分配给指定的 Leader(根据客户端类型进行配对)。
  4. Follower 向 Leader 发送订阅、发布请求。
  5. Leader 在订阅数据变更时通过 subscribe result packet 通知 Follower。
  6. Follower 向 Leader 发送调用请求,Leader 收到后执行相应操作后返回结果。

使用方法

  1. 客户端接口约定,例如:

    // registry_client.js
    const URL = require('url');
    const Base = require('sdk-base');
    class RegistryClient extends Base {
      // ... 
    
      /**
       * 订阅
       */
      subscribe(reg, listener) {
        const key = reg.dataId;
        this.on(key, listener); // 绑定监听事件
        const data = this._registered.get(key); // 获取 key 对应缓存 map 中的配置信息
        if (data) { // 如果有配置,在下个事件循环执行监听事件
          process.nextTick(() => listener(data)); 
        }
      }
    
      /**
       * 发布
       */
      publish(reg) {
        const key = reg.dataId;
        let changed = false;
    	// 判断是否已经配置过
        if (this._registered.has(key)) {
          const arr = this._registered.get(key);
          if (arr.indexOf(reg.publishData) === -1) {
            changed = true;
            arr.push(reg.publishData);
          }
        } else { // 如果没有配置过,添加配置
          changed = true;
          this._registered.set(key, [reg.publishData]);
        }
        if (changed) {// 如果配置发生改变,则触发监听事件
          this.emit(key, this._registered.get(key).map(url => URL.parse(url, true)));
        }
      }
    }
    
    module.exports = RegistryClient;
    
  2. 使用 agent.cluster 接口对 RegistryClient 进行封装:

    // agent.js
    const RegistryClient = require('registry_client');
    
    module.exports = agent => {
      // 对 RegistryClient 进行封装和实例化
      agent.registryClient = agent.cluster(RegistryClient)
        // delegate 代理方式: 将 sub 代理到 subscribe 逻辑上
        // .delegate('sub', 'subscribe')
        // create 方法的参数就是 RegistryClient 构造函数的参数
        .create({});
    
      agent.beforeStart(async () => {
        await agent.registryClient.ready();
        agent.coreLogger.info('registry client is ready');
      });
    };
    
  3. 第三步,使用 app.cluster 接口对 RegistryClient 进行封装:

    // app.js
    const RegistryClient = require('registry_client');
    module.exports = app => {
      app.registryClient = app.cluster(RegistryClient).create({});
      app.beforeStart(async () => {
        await app.registryClient.ready();
        app.coreLogger.info('registry client is ready');
    
        // 调用 subscribe 进行订阅
        app.registryClient.subscribe({
          dataId: 'demo.DemoService',
        }, val => {
          // ...
        });
    
        // 调用 publish 发布数据
        app.registryClient.publish({
          dataId: 'demo.DemoService',
          publishData: 'xxx',
        });
    
        // 调用 getConfig 接口
        const res = await app.registryClient.getConfig('demo.DemoService');
        console.log(res);
      });
    };
    

对于有读取缓存数据等同步 API 需求的模块,采用 APIClient 来实现这些与远程服务端交互无关的 API,暴露给用户使用到的是这个 APIClient 的实例。为了方便封装 APIClient,在 cluster-client 模块中提供了一个 APIClientBase 基类

const APIClientBase = require('cluster-client').APIClientBase;
const RegistryClient = require('./registry_client');

class APIClient extends APIClientBase {
  // 返回原始的客户端类
  get DataClient() {
    return RegistryClient;
  }

  // 用于设置 cluster-client 相关参数,等同于 cluster 方法的第二个参数
  get clusterOptions() {
    return {
      responseTimeout: 120 * 1000,
    };
  }

  subscribe(reg, listener) {
    this._client.subscribe(reg, listener);
  }

  publish(reg) {
    this._client.publish(reg);
  }
  // 读取缓存数据等
  get(key) {
    return this._cache[key];
  }
}

总结

+------------------------------------------------+
| APIClient                                      |
|       +----------------------------------------|
|       | ClusterClient                          |
|       |      +---------------------------------|
|       |      | RegistryClient                  |
+------------------------------------------------+
  • RegistryClient - 负责和远端服务通讯,实现数据的存取,只支持异步 API,不关心多进程模型。
  • ClusterClient - 通过 cluster-client 模块进行简单 wrap 得到的 client 实例,负责自动抹平多进程模型的差异。
  • APIClient - 内部调用 ClusterClient 做数据同步,无需关心多进程模型,用户最终使用的模块。API 都通过此处暴露,支持同步和异步。

参考资料

Node.js进阶:cluster模块深入剖析
多进程模型和进程间通讯
多进程研发模式增强
EggCluster 是如何解决多进程模式下相关问题的

©️2020 CSDN 皮肤主题: 1024 设计师:上身试试 返回首页