@FunC
2017-12-17T21:37:05.000000Z
字数 5939
阅读 2260
Node.js
让 Node.js 应用可伸缩,除了能应对更高的负荷,还能增加应用的可用性以及容错率。
通常使用伸缩立方(scale cube)来描述这三个维度:
沿着 x轴伸缩,简单,时间开销小,效率高。只需要复制应用 n 次,每个应用的负担降为1/n。
沿着 y轴伸缩,意味着应用根据其功能,服务或者使用场景来分割。将其分割为不同的独立的应用。
沿着 z轴伸缩,分割后的每个实例,都只是整个数据的一部分。该技术通常用在数据库中,称为水平分割。只有在 x轴和 y轴都尝试过后,再考虑在 z轴伸缩。
传统的 web 服务器使用多进程,能使用服务器的全部进程的性能。然而Node.js 是单线程,而且默认内存上限是 1.7GB (在 64位的机器上)。所以与传统的 Web 服务器相比,Node.js 服务需要更早地开始扩容。
不过,在早期就需要扩容,能够确保应用不依赖一些无法被多线程或多机器共享的资源。需要共享数据时,可以使用一个共享的数据库。
在 Node.js 中,将应用的负荷分配到多个实例中的最简单的方法,就是使用 cluster
模块来 fork 新实例,负荷就自动完成分配了。如下图所示:
其中 master 进程负责将发来的请求分配到不同的 worker 进程上。
app.js
const http = require('http');
const pid = process.pid;
http.createServer((req, res) => {
for (let i = 1e7; i > 0; i--) {}
console.log(`Handling request from ${pid}`);
res.end(`Hello from ${pid}\n`);
}).listen(8080, () => {
console.log(`Started ${pid}`);
});
这个服务器接收到请求时,先阻塞一段时间(一个空循环),然后返回其 pid。
如果进行压测:
ab -c200 -t10 http://localhost:8080/
会发现 CPU 利用率只有 20%
clusteredApp.js
const cluster = require('cluster');
const os = require('os');
if(cluster.isMaster) {
// 根据 CPU 的核心数 fork 相应数量的进程
const cpus = os.cpus().length;
for (let i = 0; i < cpus; i++) { // [1]
cluster.fork();
}
} else {
// worker 进程
require('./app'); // [2]
}
需要注意的是,每个 worker 都是不同的 Node.js 进程,有着自己的事件循环,内存空间以及加载的模块。
这时再进行同样的压力测试会发现性能提高了 3倍左右,CPU 利用率高达 90%。
尽管我们充分利用了硬件的性能,然而如果其中的一个进程意外终止了,他就永远地终止了。我们来看看怎么利用 cluster 模块来提高系统的弹性和可用性。
首先,我们在 app.js 的最后加上一段代码,来模拟随机崩溃:
//Crash randomly
setTimeout(() => {
throw new Error('Ooops');
}, Math.ceil(Math.random() * 3) * 1000);
然后在 clusteredApp.js 模块中加入以下代码:
// 在进程意外退出时,新 fork 一个进程
cluster.on('exit', (worker, code) => {
if(code != 0 && !worker.suicide) {
console.log('Worker crashed. Starting a new worker');
cluster.fork();
}
});
这时再进行压力测试:
可以看到请求成功率仍有 97.96%。其中失败的部分主要是请求在处理的过程中进程意外退出。
有时代码需要更新,但有的服务即使短时间下线也会造成严重损失,这时我们就需要零下线重启。
核心在于轮流重启每一个实例:
clusteredApp.js
// 省略部分代码
// 收到重启的信号时
process.on('SIGUSR2', () => {
console.log('Restarting workers');
// 获取 workers 列表
const workers = Object.keys(cluster.workers);
// 遍历重启
function restartWorker(i) {
if (i >= workers.length) return;
const worker = cluster.workers[workers[i]];
console.log(`Stopping worker: ${worker.process.pid}`);
worker.disconnect();
worker.on('exit', () => {
if (!worker.suicide) return;
const newWorker = cluster.fork();
newWorker.on('listening', () => {
restartWorker(i + 1);
});
});
}
restartWorker(0);
});
先前提到的负载均衡都是自动,随机完成的。
试想我们现在有一个用户登陆了,在实例 A中处于登陆状态,而下一次的请求被分发到了实例 B,这时就处于未登录状态。导致要再次登陆:
第一个解决方案就是实例间使用共享的数据存储。例如 PostgreSQL, MongoDB, CouchDB和 Redis等:
这种方法唯一的缺点就是有时客观条件不允许:一些依赖的库将通信状态保存在内存上。
另一个解决方案就是始终将同一个会话的请求分配给同一个实例:
这通常能通过 cookie 中的 sessionID 实现。或者通过 hash IP来进行负载均衡(但在漫游时就失效)
这种方式最大的问题就是无法享受“裁减系统“的优势,因为当其中一个实例意外终结然后重新 fork 新的实例后,原来实例的所有会话就失效了。
Cluster 模块不是扩容 Node.js 应用的唯一选择。在传统 web 服务器中,常见的方式是使用反向代理。
(同时 cluster 模块也无法实现多机器的扩容)
应用在不同的端口或机器上运行,反向代理负责把请求分发到不同的机器或端口上,同时不用关系语言和平台的问题。
配合一些 npm 包来启动 Node.js 应用,能实现自动重启的功能(如 forever)
在不同端口启动应用:
forever start app.js 8081
forever start app.js 8082
forever start app.js 8083
forever start app.js 8084
然后对 nginx.conf 文件作相应配置:
http {
upstream nodejs_design_patterns_app {
server 127.0.0.1:8081;
server 127.0.0.1:8082;
server 127.0.0.1:8083;
server 127.0.0.1:8084;
}
server {
listen 80;
location / {
proxy_pass http://nodejs_design_patterns_app;
}
}
}
然后重启 nginx,即可实现 nginx 的负载均衡:nginx -s reload
现在使用云端服务器的一个最大优势,就是可以动态扩容。然而,因为服务对应的实例的数量不确定,导致负载均衡器需要时刻更新当前的可用服务及实例列表。这点可以通过实现一个集中的服务注册表实现:
如图所示,每个实例上线时都需要将自己注册到服务注册表中,而下线时都要取消注册,这样就能保证服务列表时刻处于最新状态。
Npm 上有一些包能协助我们完成任务,如http-proxy, portfinder, consul
app.js
const http = require('http');
const pid = process.pid;
const consul = require('consul')();
const portfinder = require('portfinder');
const serviceType = process.argv[2];
// 找到可用端口
portfinder.getPort((err, port) => {
const serviceId = serviceType+port;
// 注册服务
consul.agent.service.register({
id: serviceId,
name: serviceType,
address: 'localhost',
port: port,
tags: [serviceType]
}, () => {
// 解除注册的 handler
const unregisterService = (err) => {
consul.agent.service.deregister(serviceId, () => {
process.exit(err ? 1 : 0);
});
};
process.on('exit', unregisterService);
process.on('SIGINT', unregisterService);
process.on('uncaughtException', unregisterService);
http.createServer((req, res) => {
for (let i = 1e7; i > 0; i--) {}
console.log(`Handling request from ${pid}`);
res.end(`${serviceType} response from ${pid}\n`);
}).listen(port, () => {
console.log(`Started ${serviceType} (${pid}) on port ${port}`);
});
});
});
loadBalancer.js
const http = require('http');
const httpProxy = require('http-proxy');
const consul = require('consul')();
const routing = [
{
path: '/api',
service: 'api-service',
index: 0
},
{
path: '/',
service: 'webapp-service',
index: 0
}
];
const proxy = httpProxy.createProxyServer({});
http.createServer((req, res) => {
let route;
// 找到匹配的就停止
routing.some(entry => {
route = entry;
//Starts with the route path?
return req.url.indexOf(route.path) === 0;
});
consul.agent.service.list((err, services) => {
const servers = [];
// 筛选出目标服务
Object.keys(services).filter(id => {
if (services[id].Tags.indexOf(route.service) > -1) {
servers.push(`http://${services[id].Address}:${services[id].Port}`)
}
});
if (!servers.length) {
res.writeHead(502);
return res.end('Bad gateway');
}
// 环形负载均衡
route.index = (route.index + 1) % servers.length;
// 将请求代理到相应的服务上
proxy.web(req, res, {target: servers[route.index]});
});
}).listen(8080, () => console.log('Load balancer started on port 8080'));
在调用内部服务时,可以考虑使用端对端的负载均衡,去掉反向代理。有以下优点:
* 通过减少网络节点来减少架构的复杂度
* 经过更少的节点,通信更快
* 更好地扩容,性能不受负载均衡器的上限影响
巨型系统(monolithic systems)通常已经高度模块化,并且在组件间解耦度高。然而因为他们仍是一整个程序,所以其中一个服务挂掉整个系统就挂掉了:
各个组件都在一个应用内,且同属于一个 datestore
然而如果把不同模块拆分成独立的应用,模块间的沟通则会变得更加困难。
Node.js 中写大型程序最重要的模式,就是不要写大型程序。意思就是根据服务、功能将应用分解成一个个独立的小应用。达到高内聚,低耦合的效果。
可见,所有服务都是独立的应用,而且数据也是独立的。虚线代表他们仍需要进行通信。
因为服务之间不共享数据,所以为了维持系统的一致性,需要更多的通信才可以。
微服务中的一个最难的挑战,在于需要连接各个节点,让他们相互协作,同时还要考虑服务的可复用性和扩展性。
本质上是加上一个反向代理层,通过这一层代理屏蔽掉不同服务调用间的差异(然而服务之间的通信问题仍未解决)
将需要调用不同服务的操作组合为一个 API,这样对于调用者来说,不需要考虑如何调用不同服务的不同 API了。与 API 代理的不同之处在于,他不是一个简单直接的代理,而是整合了不同的服务。
上面的模式需要完全了解服务的架构以及每个服务时怎么运行的,这其实是一种反模式,被称为 上帝对象。
解决方式是使用 message broker,通过实现一个集中的发布/订阅模式,用户只需调用其中的一个服务。后续的服务通过事件来连锁触发:
而一切的整合都在背后进行,调用者只需要调用一个 API 即可完成整套相关的操作。
这种模式没有在外部增加实体,也没有“上帝服务”,是解耦服务,降低复杂度的好方法。