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 原理
-
Master、Worker进程通信
master 进程通过
cluster.fork()来创建 worker进程。cluster.fork()内部 是通过child_process.fork()来创建子进程。所以 master进程、worker进程是父、子进程的关系。Master 进程、Woker进程之间通过IPC通道进行通信。 -
实现端口共享
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); } } -
分发多个请求到worker
每当worker进程创建server实例来监听请求,都会通过IPC通道,在master上进行注册。当客户端请求到达,master会负责将请求转发给对应的worker。
具体转发给哪个worker?这是由转发策略决定的。可以通过环境变量
NODE_CLUSTER_SCHED_POLICY设置,也可以在cluster.setupMaster(options)时传入。默认的转发策略是轮询(SCHED_RR):当有客户请求到达,master会轮询一遍worker列表,找到第一个空闲的worker,然后将该请求转发给该worker。
-
发送内部消息
当发送的消息包含
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 进程。
| 类型 | 进程数量 | 作用 | 稳定性 | 是否运行业务代码 |
|---|---|---|---|---|
| Master | 1 | 进程管理,进程间消息转发 | 非常高 | 否 |
| Agent | 1 | 后台运行工作(长连接客户端) | 高 | 少量 |
| 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 |
| <------------------------------------------------ +
| |
- 在通讯端口上 Leader 启动一个 Local Server,所有的 Leader/Follower 通讯都经过 Local Server。
- Follower 连接上 Local Server 后,首先发送一个 register channel 的 packet(引入 channel 的概念是为了区别不同类型的客户端)。
- Local Server 会将 Follower 分配给指定的 Leader(根据客户端类型进行配对)。
- Follower 向 Leader 发送订阅、发布请求。
- Leader 在订阅数据变更时通过 subscribe result packet 通知 Follower。
- Follower 向 Leader 发送调用请求,Leader 收到后执行相应操作后返回结果。
使用方法
-
客户端接口约定,例如:
// 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; -
使用
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'); }); }; -
第三步,使用
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 是如何解决多进程模式下相关问题的
本文详细介绍了Node.js的Cluster模块,用于在多核环境下提升项目性能。Eggjs在此基础上构建了多进程模型,包括错误处理、Agent机制以及进程间通讯。文中特别强调了Agent的作用,它是处理公共事务的独立进程,增强了系统的健壮性。此外,还讨论了负载均衡的Sticky Mode,确保请求被定向到相同的Worker,解决了Session问题。最后,探讨了Eggjs如何通过建立Agent和Worker的直接连接,优化了多进程研发模式。
 多进程实践&spm=1001.2101.3001.5002&articleId=105571341&d=1&t=3&u=48ffc037bc1b4b4ba4a024f55350532b)
707

被折叠的 条评论
为什么被折叠?



