当我们谈论cluster时我们在谈论什么

Node.js诞生之初就遭到不少这样的吐槽,当然这些都早已不是问题了。

1、可靠性低。
2、单进程,单线程,只支持单核CPU,不能充分的利用多核CPU服务器。一旦这个进程崩掉,那么整个web服务就崩掉了。

Node.js被这样吐槽与它最初的设计单线程模型密不可分,不像php每个request都在单独的线程中处理,即使某一个请求发生很严重的错误也不会影响到其它请求。但由于Node.js是单线程,如果处理某个请求时产生一个没有被捕获到的异常将导致整个进程的退出,已经接收到的其它连接全部都无法处理,对一个web服务器来说,这是致命的灾难。

当应用部署到多核服务器时,为了充分利用多核CPU资源一般启动多个Node.js进程提供服务,这时就会使用到Node.js内置的cluster模块了。相信大多数Node.js的开发者可能都没有直接使用到cluster,cluster模块对child_process模块提供了一层封装,可以说是为了发挥服务器多核优势而量身定做的。简单的一个fork,不需要开发者修改任何的应用代码便能够实现多进程部署,当下最热门的带有负载均衡功能的Node应用的进程管理器pm2便是最好的一个例子,开发的时候完全不需要关注多进程场景,剩余的一切都交给pm2处理,与开发者的应用代码完美的分离。

pm2确实非常强大,但本文不讲解pm2的工作原理,而是从更底层的进程通信讲起,为大家揭秘使用Node.js开发web应用时,使用cluster模块实现多进程部署的原理。

fork

fork是cluster模块中非常重要的一个方法,多个子进程便是通过在master进程中不断的调用cluster.fork方法构造出来。下面的结构图大家应该非常熟悉了。


上面的图非常的粗糙, 并没有体现出master与worker到底是如何分工协作的。Node.js在这块做过比较大的改动,下面就细细的剖析开来。

惊群

继续讲解之前先解释下什么是惊群现象,和多进程有啥关系。

举一个很简单的例子,当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到食物的鸽子只好回去继续睡觉,等待下一块食物到来。这样,每扔一块食物,都会惊动所有的鸽子,即为惊群。对于操作系统来说,多个进程/线程在等待同一资源是,也会产生类似的效果,其结果就是每当资源可用,所有的进程/线程都来竞争资源,造成的后果:
1)系统对用户进程/线程频繁的做无效的调度、上下文切换,系统系能大打折扣。
2)为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。

Node.js最初的多进程模型就是这样的,master进程创建socket,绑定到某个地址以及端口后自身不调用listen来监听连接以及accept连接,而是将该socket的fd传递到fork出来的worker进程,worker接收到fd后再调用listen,accept新的连接。但实际一个新到来的连接最终只能被某一个worker进程accpet再做处理,至于是哪个worker能够accept到,开发者完全无法预知以及干预。这势必就导致了当一个新连接到来时,多个worker进程会产生竞争,最终由胜出的worker获取连接。

之所以可以这么做,最核心的一点是文件描述符可以在进程间进行传递,对底层感兴趣的同学可以看下我收集的一些技术文章《进程通信》

为了进一步加深对这种模型的理解,我编写了一个非常简单的demo。

master进程

const net = require('net');
const fork = require('child_process').fork;

var handle = net._createServerHandle('0.0.0.0', 3000);

for(var i=0;i<4;i++) {
   fork('./worker').send({}, handle);
}

worker进程

const net = require('net');
process.on('message', function(m, handle) {
  start(handle);
});

var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

function start(server) {
    server.listen();
    server.onconnection = function(err,handle) {
        console.log('got a connection on worker, pid = %d', process.pid);
        var socket = new net.Socket({
            handle: handle
        });
        socket.readable = socket.writable = true;
        socket.end(res);
    }
}

保存后直接运行node master.js启动服务器,在另一个终端多次运行ab -n10000 -c100 http://127.0.0.1:3000/

各个worker进程统计到的请求数分别为

worker 63999  got 14561 connections
worker 64000  got 8329  connections
worker 64001  got 2356  connections
worker 64002  got 4885  connections

相信到这里大家也应该知道这种多进程模型比较明显的问题了

  • 多个进程之间会竞争accpet一个连接,产生惊群现象,效率比较低。
  • 由于无法控制一个新的连接由哪个进程来处理,必然导致各worker进程之间的负载非常不均衡。

round-robin

上面的多进程模型存在诸多问题,于是就出现了基于round-robin的另一种模型。主要思路是master进程创建socket,绑定好地址以及端口后再进行监听。该socket的fd不传递到各个worker进程,当master进程获取到新的连接时,再决定将accept到的客户端socket fd传递给指定的worker处理。我这里使用了指定, 所以如何传递以及传递给哪个worker完全是可控的,round-robin只是其中的某种算法而已,当然可以换成其他的。

同样基于这种模型我也写来一个简单的demo。

master进程

const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for(var i=0;i<4;i++) {
   workers.push(fork('./worker'));
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function(err,handle) {
    var worker = workers.pop();
    worker.send({},handle);
    workers.unshift(worker);
}

woker进程

const net = require('net');
process.on('message', function(m, handle) {
  start(handle);
});

var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

function start(handle) {
    console.log('got a connection on worker, pid = %d', process.pid);
    var socket = new net.Socket({
        handle: handle
    });
    socket.readable = socket.writable = true;
    socket.end(res);
}

这种模型由于只有master进程接收客户端连接,并且能够按照特定的算法进行分配, 很好的解决了上面提出的多进程竞争导致各进程负载不均衡的硬伤。

nginx proxy

利用nginx强大的反向代理功能,可以启动多个独立的Node.js进程,分别绑定不同的端口,最后由nginx接收请求然后进行分配。

http {
  upstream cluster {
      server 127.0.0.1:3000;
      server 127.0.0.1:3001;
      server 127.0.0.1:3002;
      server 127.0.0.1:3003;
  }
  server {
       listen 80;
       server_name www.domain.com;
       location / {
            proxy_pass http://cluster;
       }
  }
}

这种方式缺点也非常明显了,一般不会被使用到

  • node进程没有守护,稳定性得不到保障。
  • 进程不好管理,不方便扩容。

进程守护

文章开头提到Node.js被吐槽稳定性差,进程发生未捕获到的异常就会退出,所以很需要一个强大的守护神来守护着这些worker进程,某个worker进程一旦退出就fork出一个新的子进程顶替上去。

这一切cluster模块都已经帮我做好处理了,当某个worker进程发生异常退出或者与master进程失去联系(disconnect)时,master进程都会收到相应的事件通知。

cluster.on('exit', function(){
    clsuter.fork();
});

cluster.on('disconnect', function(){
    clsuter.fork();
});

推荐使用第三方模块recluster,已经处理的很成熟了。

这样一来整个应用的稳定性重任就落在master进程上了,所以一定不要给master太多其它的任务,百分百保证它的健壮性,一旦master进程挂掉你的应用也就玩完了。

ipc

讲了这么多,到最本质的地方了,要用多进程模型就一定会涉及到ipc(进程间通信)了。Node.js中ipc都是在父子进程之间进行,按有无发送fd分为2种方式。linux系统进程间通信有多种方式,详细的可参考这里https://github.com/hustxiaoc/node.js/issues/5

发送fd

当进程间需要发生文件描述符fd时,libuv底层采用消息队列来实现ipc。其实还比较复杂,c语言层面的就不在这说了,上面提到的文章中有详细介绍。

不发送fd

这种情况父子进程之间只是发送简单的字符串,并且它们之间的通信是双向的。虽然pipe能够满足父子进程间的消息传递,但由于pipe是半双工的,也就是说必须得创建2个pipe才可以实现双向的通信,这无疑使得程序逻辑更复杂。

libuv底层采用socketpair来实现全双工的进程通信,父进程fork子进程之前会调用socketpair创建2个fd,下面是一个最简单的也最原始的利用socketpair来实现父子进程间双向通信的demo

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <sys/socket.h>
#include <stdlib.h>

#define BUF_SIZE 100

int main(){
    int s[2];
    int w,r;
    char * buf = (char*)calloc(1 , BUF_SIZE);
    pid_t pid;

    if( socketpair(AF_UNIX,SOCK_STREAM,0,s) == -1 ){
        printf("create unnamed socket pair failed:%s\n",strerror(errno) );
        exit(-1);
    }

    if( ( pid = fork() ) > 0 ){
        printf("Parent process's pid is %d\n",getpid());
        close(s[1]);
    char *messageToChild = "a message to child  process!";
        if( ( w = write(s[0] , messageToChild , strlen(messageToChild) ) ) == -1 ){
            printf("Write socket error:%s\n",strerror(errno));
            exit(-1);
        }
        sleep(1);
        if( (r = read(s[0], buf , BUF_SIZE )) == -1){
            printf("Pid %d read from socket error:%s\n",getpid() , strerror(errno) );
            exit(-1);
    }
        printf("Pid %d read string : %s \n",getpid(),buf);

    }else if(pid == 0){
        printf("Fork child process successed\n");
        printf("Child process's pid is :%d\n",getpid());
        close(s[0]);
    char *messageToParent = "a message to parent process!";
    if( ( w = write(s[1] , messageToParent , strlen(messageToParent) ) ) == -1 ){
            printf("Write socket error:%s\n",strerror(errno));
            exit(-1);
        }
       sleep(1);
       if( (r = read(s[1], buf , BUF_SIZE )) == -1){
            printf("Pid %d read from socket error:%s\n",getpid() , strerror(errno) );
            exit(-1);
       }
       printf("Pid %d read string : %s \n",getpid(),buf);
    }else{
        printf("Fork failed:%s\n",strerror(errno));
        exit(-1);
    }

    exit(0);
}

保存为socketpair.c后运行 gcc socketpair.c -o socket && ./socket 输出

Parent process's pid is 52853
Fork child process successed
Child process's pid is :52854
Pid 52854 read string : a message to child  process!
Pid 52853 read string : a message to parent process!

Node.js中的ipc

上面从libuv底层方面讲解了父子进程间双向通信的原理,在上层Node.js中又是如何实现的呢,让我们来一探究竟。

Node.js中父进程调用fork产生子进程时,会事先构造一个pipe用于进程通信,new process.binding('pipe_wrap').Pipe(true), 构造出的pipe最初还是关闭的状态,或者说底层还并没有创建一个真实的pipe,直至调用到libuv底层的uv_spawn, 利用socketpair创建的全双工通信管道绑定到最初Node.js层创建的pipe上。

管道此时已经真实的存在了,父进程保留对一端的操作,通过环境变量将管道的另一端文件描述符fd传递到子进程。

options.envPairs.push('NODE_CHANNEL_FD=' + ipcFd);

子进程启动后通过环境变量拿到fd

var fd = parseInt(process.env.NODE_CHANNEL_FD, 10);

并将fd绑定到一个新构造的pipe上

var p = new Pipe(true);
p.open(fd);

于是父子进程间用于双向通信的所有基础设施都已经准备好了。说了这么多可能还是不太明白吧? 没关系,我们还是来写一个简单的demo感受下。

Node.js构造出的pipe被存储在进程的_channel属性上

master.js

const WriteWrap = process.binding('stream_wrap').WriteWrap;
var cp = require('child_process');

var worker = cp.fork(__dirname + '/worker.js');
var channel = worker._channel;

channel.onread = function(len, buf, handle){
    if(buf){
        console.log(buf.toString())
        channel.close()
    }else{
        channel.close()
        console.log('channel closed');
    }
}

var message = { hello: 'worker',  pid: process.pid }
var req = new WriteWrap();
var string = JSON.stringify(message) + '\n';
channel.writeUtf8String(req, string, null);

worker.js

const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;

channel.ref()
channel.onread = function(len, buf, handle){
    if(buf){
        console.log(buf.toString())
    }else{
        process._channel.close()
        console.log('channel closed');
    }
}

var message = { hello: 'master',  pid: process.pid }
var req = new WriteWrap();
var string = JSON.stringify(message) + '\n';
channel.writeUtf8String(req, string, null);

运行node master.js 输出

{"hello":"worker","pid":58731}
{"hello":"master","pid":58732}
channel closed

what is disconnect

在多进程服务器中,为了保障整个web应用的稳定性,master进程需要监控worker进程的exit以及disconnect事件,收到相应事件通知后重启worker进程。

exit事件不用说,disconnect事件可能有的人就不太明白了。其实就是父子进程用于通信的channel关闭了,此时父子进程之间失去了联系,自然是无法传递客户端接收到的连接了。失去联系不表示会退出,worker进程有可能仍然在运行,但此时已经无力接收请求了。所以当master进程收到某个worker disconnect的事件时,先需要kill掉worker,然后再fork一个worker。

下面是一个触发disconnect事件的简单demo

master.js

const WriteWrap = process.binding('stream_wrap').WriteWrap;
const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for(var i=0;i<4;i++) {
    var worker = fork(__dirname + '/worker.js');
    worker.on('disconnect', function() {
        console.log('[%s] worker %s is disconnected', process.pid, worker.pid);
    });
   workers.push(worker);
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function(err,handle) {
    var worker = workers.pop();
    var channel = worker._channel;
    var req = new WriteWrap();
    channel.writeUtf8String(req, 'dispatch handle', handle);
    workers.unshift(worker);
}

worker.js

const net = require('net');
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

channel.ref() //防止进程退出
channel.onread = function(len, buf, handle){
    console.log('[%s] worker %s got a connection', process.pid, process.pid);
    var socket = new net.Socket({
        handle: handle
    });
    socket.readable = socket.writable = true;
    socket.end(res);
    console.log('[%s] worker %s is going to disconnect', process.pid, process.pid);
    channel.close();
}

运行node master.js启动服务器后,在另一个终端执行多次curl http://127.0.0.1:3000,下面是输出的内容

[63240] worker 63240 got a connection
[63240] worker 63240 is going to disconnect
[63236] worker 63240 is disconnected

最简单的round-robin server

还记得前面讲的round-robin多进程服务器模型吧,用于通信的channel除了可以发送简单的字符串数据外,还可以发送文件描述符,

channel.writeUtf8String(req, string, null);

最后一个参数便是要传递的fd。round-robin多进程服务器模型的核心也正式依赖于这个特性。 在上面的demo基础上,我们再稍微加工一下,还原在Node.js中最原始的处理。

master.js

const WriteWrap = process.binding('stream_wrap').WriteWrap;
const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for(var i=0;i<4;i++) {
   workers.push(fork(__dirname + '/worker.js'));
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function(err,handle) {
    var worker = workers.pop();
    var channel = worker._channel;
    var req = new WriteWrap();
    channel.writeUtf8String(req, 'dispatch handle', handle);
    workers.unshift(worker);
}

worker.js

const net = require('net');
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

channel.ref()
channel.onread = function(len, buf, handle){
    var socket = new net.Socket({
        handle: handle
    });
    socket.readable = socket.writable = true;
    socket.end(res);
}

运行 node master.js, 一个简单的多进程Node.js web服务器便跑起来了。

小结

到此整个Node.js的多进程服务器模型,以及底层进程间通信原理就讲完了。也为大家揭开了cluster的神秘面纱, 相信大家对cluster有了更深刻的认识,在Node.js的开发旅途上玩得更愉快!

参考文档

该文章来自于阿里巴巴技术协会(ATA)

作者:淘杰

时间: 2024-11-01 01:24:04

当我们谈论cluster时我们在谈论什么的相关文章

浅谈 Node.js 和 PHP 进程管理

所周知,PHP 占据了服务端编程语言的半壁江山,正如汪峰在音乐圈的地位一般.随着 Node.js 逐渐走上服务端编程的舞台,关于 PHP 和 Node.js 孰优孰劣的争论也不曾间断. 垄断性的市场份额足以佐证 PHP 的优秀.并且 HHVM 虚拟机.PHP 7 的革新,也给 PHP 带来了跨越式的性能突破.然而,当我们为语言层面的性能差异喋喋不休时,却往往忽略了 Web 模型在性能表现中的权重. 从 CGI 到 FastCGI 早期的 Web 服务,是基于传统的 CGI 协议实现的.每个发送到

7mall电商:创业者不能忽略的一个因素—情感成本

创业生活中的点点滴滴都是对创业者的一种不断磨炼.很少有人能真正体会创业的旅程,直到我们在创业的征程上走了很远.可能在我们的认知里,创业的各种成本有人力成本.资本成本.用户获取成本和机会成本.但是,我们很少谈论创业中最重要的一项成本:情感成本.假如我们可以将我们所拥有的情感资本存放在银行里,包括我们的耐力.积极性.身体健康程度.幸福度.人际关系等.创业公司创始人在创业中所耗费的不仅仅是银行账户里的金融资本,还有我们的情感资本.然而,情感资本的耗费并不是我们谈论创业时经常会谈论到的话题,这也是问题所

MySQL Cluster(MySQL集群)初试

MySQL Cluster 是MySQL适合于分布式计算环境的高实用.高冗余版本.它采用了NDB Cluster 存储引擎,允许在1个 Cluster 中运行多个MySQL服务器.在MyQL 5.0及以上的二进制版本中.以及与最新的Linux版本兼容的RPM中提供了该存储引擎.(注意,要想获得MySQL Cluster 的功能,必须安装 mysql-server 和 mysql-max RPM). 目前能够运行MySQL Cluster 的操作系统有Linux.Mac OS X和Solaris(

饿了么Redis Cluster集群化演进

2017运维/DevOps在线技术峰会上,饿了么运维负责人程炎岭带来题为"饿了么Redis Cluster集群化演进"的演讲.本文主要从数据和背景开始谈起,并对redis的治理进行分析,接着分享了redis cluster的优缺点,重点分析了corvus,包括commands.逻辑架构和物理部署等,最后分享了redis的运维和开发,并作了简要总结,一起来瞧瞧吧.   以下是精彩内容整理: 近几个月,运维事件频发.从"炉石数据被删"到"MongoDB遭黑客勒

Galera Cluster:一种新型的高一致性MySQL集群架构

1. 何谓Galera Cluster 何谓Galera Cluster?就是集成了Galera插件的MySQL集群,是一种新型的,数据不共享的,高度冗余的高可用方案,目前Galera Cluster有两个版本,分别是Percona Xtradb Cluster及MariaDB Cluster,都是基于Galera的,所以这里都统称为Galera Cluster了,因为Galera本身是具有多主特性的,所以Galera Cluster也就是multi-master的集群架构,如图1所示: 图1

关于Numba你可能不了解的七个方面

更多深度文章,请关注:https://yq.aliyun.com/cloud 我最喜欢的事情之一是与人们谈论GPU计算和Python. Python的生产力和互动性与GPU的高性能结合是科学和工程中许多问题的杀手. 有几种使用GPU加速Python的方法,但我最熟悉的是Numba,它是Python函数的即时编译器. Numba在标准的Python翻译器中运行,因此您可以直接以Python语法编写CUDA内核,并在GPU上执行它们. NVIDIA开发者博客最近推出了一篇对Numba的介绍,我建议阅

环信首席架构师一乐 :煎饼果子与架构模式

煎饼的故事 有一段时间住在花园路,最难忘的就是路边的煎饼果子.老板每天晚上出来,正好是我加班回去的时间. 一勺面糊洒在锅上,刮子转一圈,再打一个蛋,依然刮平.然后啪的一下反过来,涂上辣酱,撒上葱花.空出手来,剥一根火腿肠.最后放上薄脆,咔咔咔三铲子断成三边直的长方形,折起来正好握在手中.烫烫的,一口咬下去,蛋香.酱辣.肠鲜,加上薄脆的声音和葱花的惊喜,所有的疲劳都一扫而光. 这种幸福感让我如此迷恋,以至于会在深宅的周末,穿戴整齐跑出去,就为了吃上一个.也因为理工科的恶习,我也情不自禁地开始思考这

解析MySQL是否为完全免费软件

MySQL虽然功能未必很强大,但因为它的开源.广泛传播,导致很多人都了解到这个数据库.在当今世界是很受欢迎的开源数据库,有人说MySQL是完全免费软件,这种说法对不对啊,接下来将为大家解开这个谜团. MySQL是世界上最受欢迎的开源数据库.MySQL在中国也越来越受欢迎并被广泛关注.但是中国部分用户对于MySQL认识还存在一个误区,当提起MySQL时,许多用户第一反应是:"MySQL不是完全免费的软件吗?".然而,通过仔细研读MySQL所遵循的GPLv2协议,得到的结论是:MySQL是

Web设计核心问题3:为用户设计(1)

web|设计|问题   正如在第1章中所讨论的,各个Web站点经常是根据各自特定的哲学观点来进行设计的.有时这种观点是以内容为中心,有时它又是以技术为中心.更经常的是,它是以视觉效果为中心.但是,设计Web站点时的真正重点应该是用户.时刻想着用户,并千方百计地满足他们的要求是以用户为中心的设计的关键.但是理解用户并不是一件很容易的事.虽然所有的用户都有一些共同的能力如记忆力和响应时间,但不同的用户仍然是不同的个体.网站应该为共同的用户进行设计,而不是为个别的新用户或老用户.网站应该能被所有的人所