愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

《深入浅出NodeJS》读书笔记之集群的稳定

在搭建好集群之后,我们就可以充分利用多核CPU资源了,但是在用于实际生产环境之前,我们仍然需要考虑一些问题,典型的问题如下:

  • 性能问题
  • 多个工作进程的存活状态管理
  • 工作进程的平滑重启
  • 配置或静态数据的动态重新载入

我们至少需要解决上述问题,集群的稳定性才能有所保障。

一、进程事件

对于子进程对象,它除了message事件外,还有其他事件如下:

  • error:子进程无法复制创建、无法被杀死、无法发送消息时触发
  • exit:子进程退出时触发。若子进程是正常退出,则回调的第一个参数为退出码(否则为null);若是使用kill()方法杀死的,那么会得到第二个参数,表示杀死进程时的信号
  • close:子进程的标准输入输出流终止时触发,参与与exit事件相同
  • disconnect:在父进程或子进程中调用disconnect()时触发,调用后将关闭监听IPC通道

父进程除了可以使用send()方法给子进程发送消息外,还可以使用kill()给子进程发送信号。但kill()并不是直接杀死子进程,而是给子进程发送系统信号
默认情况下,kill()给子进程发送的是SIGTERM信号:

// 在父进程中
child.kill([signal])
// 在进程自身中
process.kill(pid, [signal])

可以使用$ kill -l命令,来查看当前系统中详细的信号列表。Node中提供了信号对应的信号事件,因此当进程收到响应信号时,应当做出约定的行为,示例如下:

process.on('SIGTERM', () => {
    console.log('Got a SIGTERM, exiting...')
    process.exit(1)
})
console.log('server running with PID:', process.pid)
process.kill(process.pid, 'SIGTERM')


二、自动重启

由于我们已经知道进程挂了的时候,我们可以获得通知。那么接下来,就需要有一个机制来对子进程进行管理,如:当一个子进程挂掉后,主进程能够知道,并且立即重新启动一个工作进程来进行服务。一个简单的处理如下:

// master.js
const fork = require('child_process').fork
const cpus = require('os').cpus()
const server = require('net').createServer()
server.listen(1337)

const workers = {}
const createWorker = () => {
    const worker = fork(__dirname + '/worker.js')
    worker.on('exit', () => {
        console.log(`Worker ${worker.pid} exited.`)
        delete workers[worker.pid]
        createWorker()
    })
    worker.send('server', server)
    workers[worker.pid] = worker
    console.log(`Create worker. pid: ${worker.pid}`)
}
for (let i = 0; i < cpus.length; ++i) {
    createWorker()
}
process.on('exit', () => {
    for (let pid in workers) {
        workers[pid].kill()
    }
})

接下来,执行$ node master.js,结果如下:

Create worker. pid: 21877
Create worker. pid: 21878
Create worker. pid: 21879
Create worker. pid: 21880

为了看到效果,我们手动模拟杀死一个进程,执行:$ kill 21880,会发现控制台输出:

Worker 21880 exited.
Create worker. pid: 21894

所以我们的重启逻辑起作用了。但实际业务中,可能会有隐藏的BUG导致工作进程退出,所以我们更细致的处理如下:

// worker.js
const http = require('http')
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' })
    res.end(`handled by child, pid is ${process.pid}\n`)
})
let worker
process.on('message', (m, tcp) => {
    if (m === 'server') {
        worker = tcp
        worker.on('connection', (socket) => {
            server.emit('connection', socket)
        })
    }
})
process.on('uncaughtException', () => {
    // 停止接收新的连接
    worker.close(() => {
        // 所有连接断开后,退出进程
        process.exit(1)
    })
})

如此,一旦工作进程中有未捕获的异常,那么工作进程就会停止接收连接,并且之后退出进程,主进程便收到停止,然后立即重启一个进程为用户服务。

1、平滑重启

然而,极端情况下,会出现所有工作进程都在等待连接中断,然后再退出进程,而此时主进程又需要等到工作进程退出后才进行自动重启过程,此时就会存在没有可用工作进程的情况,如果有新的连接到来,那么将没有工作进程可为止服务。因此为了解决这个问题,需要引入工作进程自杀机制。当工作进程知道自己需要退出时,就提前告诉主进程,然后主进程就立即重启一个进程,如此便可在工作进程退出前,有新的进程可用替代它的工作,这便是平滑重启,修改如下:

// worker.js
// ...
let worker
process.on('message', (m, tcp) => {
    if (m === 'message') {
        worker = tcp
        // ...
    }
})
process.on('uncaughtException', () => {
    process.send({ act: 'suicide' })
    worker.close(() => {
        process.exit(1)
    })
})

// master.js
// ...
const createWorker = () => {
    const worker = fork(__dirname + './worker.js')
    worker.on('message', (m) => {
        if (m.act === 'suicide') {
            createWorker()
        }
    })
    worker.on('exit', () => {
        console.log(`Worker ${worker.pid} exited.\n`)
        delete workers[worker.pid]
    })
    // ...
}
// ...

其正确工作效果如下:

Create worker. pid: 22328
Create worker. pid: 22329
Create worker. pid: 22330
Create worker. pid: 22331
# 此时发生了未捕获异常
Create worker. pid: 22349
Worker 22331 exited.

2、限量重启

虽然平滑重启策略可以使得系统中总是有进程可用,但是依然有极端情况,如:工作进程无限制地频繁重启(启动过程就发生错误、启动后接到连接就报错等),那么极有可能是程序出现了BUG,因此为了消除这种无意义的重启,我们应当做出处理,当重启过于频繁时,就进行报警。如下:

let limit = 10
let during = 60000
let restart = []
const isTooFrequently = () => {
    let time = Date.now()
    let length = restart.push(time)
    if (length > limit) {
        restart = restart.slice(limit * -1)
    }
    return restart.length >= limit && restart[restart.length - 1] - restart[0] < during
}
let workers = {}
let createWorker = () => {
    if (isTooFrequently()) {
        process.emit('giveup', length, during)
        return
    }
    // ...
}

在这种情况下,giveup事件是比uncaughtException事件更为严重的异常事件,为了健壮性考虑,当出现giveup事件时,应该添加重要级别的日志,并且让监控系统监视到错误并报警。


三、负载均衡

通过把请求分配到多个工作进程上,可以更好地调度起CPU资源,从而保证多个处理单元之间公平地工作,这种策略称之为负载均衡
Node默认提供的机制是让闲着的工作进程对到来的请求进行争抢,即为抢占式策略,但是这种策略有可能出现饥饿现象。
因此,Node从v0.11起,提供了一种新的负载均衡策略,即为Round-Robin(轮询调度)。这种策略则是在N个工作进程中,每次选择第i = (i + 1) mod N个进程来工作。在cluster模块中,可以如此启用和关闭:

// 启用
cluster.schedulingPolicy = cluster.SCHED_RR
// 关闭
cluster.schedulingPolicy = cluster.SCHED_NONE

也可以通过环境变量指定,如:

NODE_CLUSTER_SCHED_POLICY=rr
NODE_CLUSTER_SCHED_POLICT=none


四、状态共享

Node进程中不宜存放过多数据,否则会加重垃圾回收的负担从而影响性能;同时,Node也不允许多个进程之间共享数据,但实际的业务中往往需要共享一些数据,如:配置数据。因此,解决策略如下:

1、第三方数据共享

将数据存放于数据库、磁盘文件、缓存服务(如redis)中,但是这种情况下我们还需要通知机制,当数据更新时,各个子进程能够得到通知。通知机制可以是轮询,也可以是主动通知

2、轮询

轮询是工作进程定期向第三方进行轮询,以获取最新的数据。但是这种情况下,如果轮询时间过密,会造成浪费并加重工作进程负担,而若轮询时间过长,则会带来延迟。

3、主动通知

主动通知策略,是当数据发生改变时,就主动通知工作进程,进行通知的进程称为通知进程。但是通知进程仍然也需要定期去轮询第三方,只是当数据变更时,就可以及时通知给各个工作进程,减少了轮询进程的数量,其示意图如下:
9-7.png
但是这种推送机制在跨多台服务器时,会无效。所以可以考虑采用TCP或UDP的方案。进程在第一次启动时,从通知服务除了读取第一次数据外,还可以将进程信息注册到通知服务处。这样子一旦通过轮询发现有数据更新后,就可以根据注册信息,将更新后的数据发送给工作进程。
由于工作进程不需要自己去轮询,所以涉及太多进程时,工作进程压力也不大。而由于通知进程负责的主要是轮询,故可以将轮询时间调的较短,一旦发现更新就实时地推送到各个子进程中。