本章总结将结合个人搭建 egg 引入公司的一些实践来进行总结,希望能让大家了解到进程管理和集群分发的重要性。
阅读完本章你应该理解以下几点:
- 为什么要使用多进程架构启动服务;
- 经典的 Master-Worker(主从模式) 介绍到在 egg 中的应用;
- 如何使用 child_process 模块与句柄(Handle)传递实现主从模式架构,从而实现启动多个服务监听一个端口;
- 处理高并发场景的集群健壮性问题;
- 使用 Cluster 模块 (本质是对 child_process 模块实现 Master-Work 架构过程的封装) 快速实现进程集群;
一、为什么要使用多进程架构启动服务
首先我们从两个关键性指标开始考虑
- CPU使用率;
- 内存占用率;
首先我们知道 js 是单线程的,所以 NodeJs 也不以外,启动服务时只能运用到单核,即一个 CPU,但如今大家的服务器或pc普遍都已经是 4 cpu 了,这就导致了另外 3 个 cpu 资源的浪费,这导致 node 的计算能力不弱都不行,而且单线程最大的问题还有个健壮性问题,一旦有一个错误没被捕获到,有可能就会导致整个进程的崩溃,而服务自然就断了。
在高并发的场景下,单 cpu 的计算量是远远不够的,即使 node 全异步的概念很擅长处理密集型 io,但也是寡不敌众啊,高并发下仍会导致响应产生延迟与耽误的情况,这时我们就要引入多进程架构来解决这个问题了,利用其它 cpu 同时进行计算处理 io,提高 cpu 的使用率,将并发请求分发到不同进程独立处理响应,从而解决高并发下延迟请求队列积累过长的问题。
多个 cpu 的有没有缺点?当然有,多个 cpu 同时计算是需要内存的,这个大家都懂,我们每次计算的数据结构都会在内存中进行处理,新生代会被立刻释放,需要存储的会被移动到老生代中,前面的章节已经为大家介绍过这个过程了,我就不废话了,所以榨干 cpu 的同时,我们一定要关注内存的占用率,在其中取得一个最佳平衡。这就像是算法一样,时间换空间 或者 空间换时间 选择一个,等价交换。
二、经典的 Master-Worker (主从模式) 架构
由上图我们可以很清晰的看到整个架构的过程,创建一个主进程,然后子进程通过 fork 主进程生成,此时便解决了不充分利用 cpu 的问题了,但是高并发问题仍然没有解决哦,因为请求的分发并没有做处理。
在这个架构中,主进程是不负责具体的业务处理的,而是专门负责管理(socket 的分发与进程间的通信等)和调度(请求分发、进程间的通信分发)工作,是最稳定的一个进程,而子进程则是专门用于进行业务处理的,稳定性也是比较差的,但多进程最大的好处就是健壮性,因为其中一个子进程的崩溃并不会影响到其他子进程继续接受分发,从而保证了主进程服务的健壮性,而后续子进程崩溃后,我们可通过监听进程的全局异常,继而重启 fork 出新的子进程,崩溃多少次则重启多少次,但是重启是需要至少 30ms 的启动时间的,内存也是需要预留至少 10MB,因为每个子进程都是独立的 V8 实例。所以我们不能短时间内频繁重启,而这些就是后话了,后面再详细向大家介绍。
三、如何使用 child_process 模块与句柄(Handle)传递实现实现主从模式架构,从而实现启动多个服务监听一个端口
通过 node 建立过服务的童鞋都应该知道,http 服务我们直接使用 http.createServer 即可创建并直接监听某个端口,但是端口被占用的情况下,如果仍然监听就会引起 EADDRINUSE
异常,那么进程间是如何知道判断我们监听了同一端口呢,答案就是 socket 的套接字,首先大家都需要知道 http 模块本质上是 net 模块封装而来,本质上是 tcp 协议封装的 http 协议,其中仍然是 socket 套接字,当监听同一端口时,tcp 服务发现此时 socket 套接字的文件描述符是不同的,此时就异常了,所以如果我们能够使 2 个服务使用同一个 socket 套接字,那么文件描述符就相同了,这就解决了这个问题,由此我们就引申除了需要解决的 2 个问题
- 所有子进程如何与父进程一样都是用一个 socket 套接字?
- 子进程如何监听到父进程的 socket 的连接事件从而接收到父进程所分发的内容最传递给子进程启动的 http 服务呢?
A1:在进程间通信中通过 IPC 管道将句柄发送 socket 给子进程,从而使子进程也可以对这个 tcp 服务的 connection 进行监听;
A2:node 子进程通过监听 message 事件便可接受到父进程所 send 的句柄,在接受到 socket 句柄后,便可监听到父进程 tcp 服务的 connection 事件了,当父进程的 tcp 服务被访问时,子进程监测并将本次的 socket 通过 emit 触发 http.server 类 的 connection 事件;
在解决方案中,我们引申出了几个具体名词和方法的解析,在以下会对其进行详解,并最终放上实现过程的代码;
- 进程间如何进行普通通信?
- 进程间如何发送 socket 句柄,句柄又是什么东西,是怎么发送的?
- http.server 类继承与 net.server 类,理论上具有 net 所有通过 EventEmitter 类所定义事件;
问题1:进程间如何进行普通通信
// parent.js
var cp = require('child_process')
var child = cp.fork('./child.js')
child.send('hello world')
process.on('message', function (m) {
console.log('parent 进程已接收到 child 进程的' + m)
})
// child.js
process.on('message', function (m) {
console.log('child 进程已接收到 parent 进程的' + m)
})
从 demo 中我们可以看出父进程运行时会复制启动一个新的子进程,并且我们在父进程中向子进程发送了 hello world,当我们运行 node parent.js
时,会发现命令窗打印出了 ‘child 进程已接收到 parent 进程的hello world’ ,此时我们已经实现了父进程 => 子进程的单向通信了。
ps: emmmmmm,我就知道会有童鞋尝试在子进程再次 fork parent.js 然后发送消息,之后再 parent.js 中打印出来= =你以为这是双向通信吗,骚年,这是 fork 了一个新的进程再输出到控制台的而已,不信你自己启动 node 后查看过滤出正在运行的 node 进程列表,正确操作应是在父进程中取出 fork 的子进程再次监听该子进程的 message 事件,子进程