node-cluster-adv

Node 进程集群之稳定

node-process文章中,我们初步了解如何搭建集群。搭建好了集群,似乎就可以充分利用多核CPU迎接客户端的大量的请求了?不,我们还有一些细节需要考虑。比如性能问题、工作进程的平滑重启等

进程事件

子进程除了message事件外,还有如下事件:

  • error:当子进程无法被复制创建、无法被杀死、无法发送消息时会出发该事件
  • exit:子进程退出时出发该事件
  • close:在子进程的标准输入输出流中止时触发该事件
  • disconnect:在父进程或子进程调用disconnect方法时触发该事件

上述这些事件是父进程能监听到的与子进程相关的事件。出了send方法外,还能通过kill方法给子进程发送消息。kill方法并不能真正的将通过IPC相连的子进程杀死,它只是给子进程发送一个系统信号。默认情况下,父进程通过kill方法给子进程发送一个SIGTERM(软件中止)信号。在node中我们可以这样监听处理:

1
2
3
4
process.on('SIGTERM', function () {
console.log('Got a SIGTERM, exiting');
process.exit(1);
});

自动重启

了解了父子进程之间的相关事件后,我们就可以通过监听子进程的exit事件来获知其退出的消息,接着node-process文章中的多进程架构,我们可以试着在主进程上加入一些子进程的管理机制,比如重新启动一个工作进程来继续服务,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// Master.js
let cp = require('child_process');
let os = require('os');
let cpus = os.cpus();

let net = require('net');
let server = net.createServer();
server.on('connection', function (socket) {
socket.end('handle by parent!\n');
});
server.listen(8000);


let workers = {};

function createWorker() {
let child = cp.fork('./Worker.js');
child.on('exit', function (code, signal) {
console.log('child ' + child.pid + ' exited');
delete workers[child.id];
createWorker();
});

// 句柄转发
child.send('server', server);
workers[child.id] = child;

console.log('create child pid ' + child.pid);
}

// 进程自己退出时,让所有工作进程退出
process.on('exit', function (code, signal) {
for (let pid of workers)
workers[pid].kill();
});

for (let i = 0; i < cpus.length; i++)
createWorker();

// Worker.js
let http = require('http');
let server = http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('handled by child ,pid is ' + process.pid + '!\n');
});

let worker = null;
process.on('message', function (m, tcp) {
console.log('msg from parent', m == 'server');
if ('server' == m)
worker = tcp;
tcp.on('connection', function (socket) {
server.emit('connection', socket);
});
});

process.on('uncaughtException', function (err) {
// 停止接收新的连接
worker.close(function () {
// 所有已有连接断开后,退出进程
process.exit(1);
});
});

测试下上述代码,如下:

$ node Master.js
create child pid 11172
create child pid 11173
create child pid 11174
create child pid 11175
create child pid 11176
create child pid 11177
create child pid 11178
create child pid 11179

我们通过kill命令杀死某个进程试试,如下

kill 11172

结果11172进程退出后,自动创建了新的工作进程11188,总体进程数量并没有发生变化,如下:

child 11172 exited
create child pid 11188

上述代码在极端的情况下会存在问题。当所有进程停止接收新的连接,全部处在等待退出的状态,在等到进程完全退出才重启的过程中时,所有新来的请求可能存在没有工作进程为新用户服务的情景,这样会丢失大量请求。

为此需要改进这个方法,不能等到所有工作进程退出才重新启动新的工作进程。我们可以再退出的过程中增加一个自杀信号。工作进程得知要退出时,向主进程发送一个自杀信号,然后停止接收新的连接,当所有连接断开才退出。主进程接收到自杀信号后,立即创新新的工作进程。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// Master.js
let cp = require('child_process');
let os = require('os');
let cpus = os.cpus();

let net = require('net');
let server = net.createServer();
server.on('connection', function (socket) {
socket.end('handle by parent!\n');
});
server.listen(8000);


let workers = {};

function createWorker() {
let child = cp.fork('./Worker.js');
child.on('message', function (message) {
console.log('master message ' + message.act);
if (message.act == 'suicide')
createWorker();
});
child.on('exit', function (code, signal) {
console.log('child ' + child.pid + ' exited');
delete workers[child.id];
});

// 句柄转发
child.send('server', server);
workers[child.id] = child;

console.log('create child pid ' + child.pid);
}

// 进程自己退出时,让所有工作进程退出
process.on('exit', function (code, signal) {
for (let pid of workers)
workers[pid].kill();
});

for (let i = 0; i < cpus.length; i++)
createWorker();

// Worker.js
let http = require('http');
let server = http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('handled by child ,pid is ' + process.pid + '!\n');
// 模拟未捕获的异常
throw new Error('throw exception');
});

let worker = null;
process.on('message', function (m, tcp) {
console.log('msg from parent', m == 'server');
if ('server' == m)
worker = tcp;
tcp.on('connection', function (socket) {
server.emit('connection', socket);
});
});

process.on('uncaughtException', function (err) {
process.send({act: 'suicide'});
// 停止接收新的连接
worker.close(function () {
// 所有已有连接断开后,退出进程
process.exit(1);
});
});

我们启动进程,curl测试,如下:

1
2
$ curl http://localhost:8000
handled by child ,pid is 11263!

重启输出信息如下:

master message suicide
create child pid 11267
child 11263 exited

至此我们已经完成了进程的平滑重启,一旦异常出现,主进程就会创建新的工作进程来为用户服务,旧的进程一旦处理完已有连接就自动断开。但是如果我们的连接是长连接,等待长连接断开可能需要很久的时间。为此我们为已有连接的断开设置一个超时时间是必要的,在限定时间里强行退出,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
process.on('uncaughtException', function (err) {
process.send({act: 'suicide'});
// 停止接收新的连接
worker.close(function () {
// 所有已有连接断开后,退出进程
process.exit(1);
});

// 如果是长连接,5s后退出进程
setTimeout(function () {
process.exit(1);
}, 5000)
});

另外,进程中如果出现未能捕获的异常,我们也可以通过日志记录下来。

上述通过自杀信号通知主进程可以使得新连接总是有进程服务,但工作进程不能无限制的被重启,如果启动的过程中发生了错误,或者启动后接到连接就收到错误,会导致进程被频繁重启。

为了消除这种无意义的重启,我们应当设置一定的规则,避免反复重启。比如在单位时间内规定只能重启多少次,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Master.js
// ...

const limit = 10;
const during = 60000;
let restart = [];

function isTooFrequently() {
// 记录重启时间
let time = new Date().getTime();
let length = restart.push(time);
if (length > limit) {
// 取出最后10个记录
restart = restart.splice(limit * -1);
}
// 最后一次重启到前10次重启之间的时间间隔
return restart.length >= limit && restart[restart.length - 1] - restart[0] < during;
}

let workers = {};

function createWorker() {
// 检查是否太过频繁
if (isTooFrequently()){
console.log('restart too frequently');
return;
}
let child = cp.fork('./Worker.js');
child.on('message', function (message) {
console.log('master message ' + message.act);
if (message.act == 'suicide')
createWorker();
});
child.on('exit', function (code, signal) {
console.log('child ' + child.pid + ' exited');
delete workers[child.id];
});

// 句柄转发
child.send('server', server);

workers[child.id] = child;

console.log('create child pid ' + child.pid);
}
// ...

负载均衡

Node默认提供的机制是采用操作系统的抢占式策略。所谓的抢占式就是再一堆工作进程中,闲着的进程对到来的请求进行争抢,谁抢到谁服务。

一般而言,这种抢占式策略是对大家公平的,各个进程可以根据自己的繁忙度来进行抢占。但是对于Node而言,需要分清的是它的繁忙是有CPU、I/O两部分组成,影响抢占的是CPU的繁忙度。对不同的业务,可能存在I/O繁忙,而CPU较为清闲的情况,这可能造成某个进程能够抢到更多请求,形成负载不均衡的情况。

为此,Node提供了Round-Robin(轮叫调度),使得负载均衡更合理。它的工作方式是由主进程接受连接,将其依次分发给工作进程。分发的策略是在N个工作进程中,每次选择第i = (i + 1)mod n个进程来发送连接。在cluster模块中,启动它的方式如下:

1
2
3
4
// 启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_RR;
// 不启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_NONE;

或者在环境变量中设置NODE_CLUSTER_SCHED_POLICY

export NODE_CLUSTER_SHCED_POLICY=rr
export NODE_CLUSTER_SCHED_POLICY=none

假设服务器组所有服务器有着相同的配置,不关心每台服务器当前连接数和响应速度。当请求服务间隔时间比较大时,这种策略会出现负载不凭衡。

轮叫调度适合服务器组所有服务器中有着相同的软硬件配置并且平均服务相对均衡的情况。