[关闭]
@FunC 2017-12-17T21:37:05.000000Z 字数 5939 阅读 2260

Node.js Design Patterns | CH10

Node.js


可伸缩性和架构模式

让 Node.js 应用可伸缩(Scaling Node.js applications)

让 Node.js 应用可伸缩,除了能应对更高的负荷,还能增加应用的可用性以及容错率。

可伸缩性的三个维度

通常使用伸缩立方(scale cube)来描述这三个维度:

沿着 x轴伸缩,简单,时间开销小,效率高。只需要复制应用 n 次,每个应用的负担降为1/n。
沿着 y轴伸缩,意味着应用根据其功能,服务或者使用场景来分割。将其分割为不同的独立的应用。
沿着 z轴伸缩,分割后的每个实例,都只是整个数据的一部分。该技术通常用在数据库中,称为水平分割。只有在 x轴和 y轴都尝试过后,再考虑在 z轴伸缩。

克隆和负载均衡

传统的 web 服务器使用多进程,能使用服务器的全部进程的性能。然而Node.js 是单线程,而且默认内存上限是 1.7GB (在 64位的机器上)。所以与传统的 Web 服务器相比,Node.js 服务需要更早地开始扩容。

不过,在早期就需要扩容,能够确保应用不依赖一些无法被多线程或多机器共享的资源。需要共享数据时,可以使用一个共享的数据库。

Cluster 模块

在 Node.js 中,将应用的负荷分配到多个实例中的最简单的方法,就是使用 cluster模块来 fork 新实例,负荷就自动完成分配了。如下图所示:

其中 master 进程负责将发来的请求分配到不同的 worker 进程上。

来写一个简单的 HTTP 服务器吧

app.js

  1. const http = require('http');
  2. const pid = process.pid;
  3. http.createServer((req, res) => {
  4. for (let i = 1e7; i > 0; i--) {}
  5. console.log(`Handling request from ${pid}`);
  6. res.end(`Hello from ${pid}\n`);
  7. }).listen(8080, () => {
  8. console.log(`Started ${pid}`);
  9. });

这个服务器接收到请求时,先阻塞一段时间(一个空循环),然后返回其 pid。
如果进行压测:
ab -c200 -t10 http://localhost:8080/
会发现 CPU 利用率只有 20%

使用 cluster 模块扩容

clusteredApp.js

  1. const cluster = require('cluster');
  2. const os = require('os');
  3. if(cluster.isMaster) {
  4. // 根据 CPU 的核心数 fork 相应数量的进程
  5. const cpus = os.cpus().length;
  6. for (let i = 0; i < cpus; i++) { // [1]
  7. cluster.fork();
  8. }
  9. } else {
  10. // worker 进程
  11. require('./app'); // [2]
  12. }

需要注意的是,每个 worker 都是不同的 Node.js 进程,有着自己的事件循环,内存空间以及加载的模块。
这时再进行同样的压力测试会发现性能提高了 3倍左右,CPU 利用率高达 90%。

通过 cluster 模块提高系统弹性和可用性

尽管我们充分利用了硬件的性能,然而如果其中的一个进程意外终止了,他就永远地终止了。我们来看看怎么利用 cluster 模块来提高系统的弹性和可用性。

首先,我们在 app.js 的最后加上一段代码,来模拟随机崩溃:

  1. //Crash randomly
  2. setTimeout(() => {
  3. throw new Error('Ooops');
  4. }, Math.ceil(Math.random() * 3) * 1000);

然后在 clusteredApp.js 模块中加入以下代码:

  1. // 在进程意外退出时,新 fork 一个进程
  2. cluster.on('exit', (worker, code) => {
  3. if(code != 0 && !worker.suicide) {
  4. console.log('Worker crashed. Starting a new worker');
  5. cluster.fork();
  6. }
  7. });

这时再进行压力测试:

可以看到请求成功率仍有 97.96%。其中失败的部分主要是请求在处理的过程中进程意外退出。

零下线重启(Zero-downtime restart)

有时代码需要更新,但有的服务即使短时间下线也会造成严重损失,这时我们就需要零下线重启。
核心在于轮流重启每一个实例:
clusteredApp.js

  1. // 省略部分代码
  2. // 收到重启的信号时
  3. process.on('SIGUSR2', () => {
  4. console.log('Restarting workers');
  5. // 获取 workers 列表
  6. const workers = Object.keys(cluster.workers);
  7. // 遍历重启
  8. function restartWorker(i) {
  9. if (i >= workers.length) return;
  10. const worker = cluster.workers[workers[i]];
  11. console.log(`Stopping worker: ${worker.process.pid}`);
  12. worker.disconnect();
  13. worker.on('exit', () => {
  14. if (!worker.suicide) return;
  15. const newWorker = cluster.fork();
  16. newWorker.on('listening', () => {
  17. restartWorker(i + 1);
  18. });
  19. });
  20. }
  21. restartWorker(0);
  22. });

处理有状态的通信

先前提到的负载均衡都是自动,随机完成的。
试想我们现在有一个用户登陆了,在实例 A中处于登陆状态,而下一次的请求被分发到了实例 B,这时就处于未登录状态。导致要再次登陆:

在多个实例中共享状态

第一个解决方案就是实例间使用共享的数据存储。例如 PostgreSQL, MongoDB, CouchDB和 Redis等:


这种方法唯一的缺点就是有时客观条件不允许:一些依赖的库将通信状态保存在内存上。

粘性负载均衡

另一个解决方案就是始终将同一个会话的请求分配给同一个实例:


这通常能通过 cookie 中的 sessionID 实现。或者通过 hash IP来进行负载均衡(但在漫游时就失效)

这种方式最大的问题就是无法享受“裁减系统“的优势,因为当其中一个实例意外终结然后重新 fork 新的实例后,原来实例的所有会话就失效了。

通过反向代理扩容

Cluster 模块不是扩容 Node.js 应用的唯一选择。在传统 web 服务器中,常见的方式是使用反向代理。
(同时 cluster 模块也无法实现多机器的扩容)


应用在不同的端口或机器上运行,反向代理负责把请求分发到不同的机器或端口上,同时不用关系语言和平台的问题。

用 Nginx 做负载均衡

配合一些 npm 包来启动 Node.js 应用,能实现自动重启的功能(如 forever)
在不同端口启动应用:

  1. forever start app.js 8081
  2. forever start app.js 8082
  3. forever start app.js 8083
  4. forever start app.js 8084

然后对 nginx.conf 文件作相应配置:

  1. http {
  2. upstream nodejs_design_patterns_app {
  3. server 127.0.0.1:8081;
  4. server 127.0.0.1:8082;
  5. server 127.0.0.1:8083;
  6. server 127.0.0.1:8084;
  7. }
  8. server {
  9. listen 80;
  10. location / {
  11. proxy_pass http://nodejs_design_patterns_app;
  12. }
  13. }
  14. }

然后重启 nginx,即可实现 nginx 的负载均衡:nginx -s reload

使用服务注册表

现在使用云端服务器的一个最大优势,就是可以动态扩容。然而,因为服务对应的实例的数量不确定,导致负载均衡器需要时刻更新当前的可用服务及实例列表。这点可以通过实现一个集中的服务注册表实现:


如图所示,每个实例上线时都需要将自己注册到服务注册表中,而下线时都要取消注册,这样就能保证服务列表时刻处于最新状态。

通过 http-proxy 和 Consul 实现动态负载均衡

Npm 上有一些包能协助我们完成任务,如http-proxy, portfinder, consul
app.js

  1. const http = require('http');
  2. const pid = process.pid;
  3. const consul = require('consul')();
  4. const portfinder = require('portfinder');
  5. const serviceType = process.argv[2];
  6. // 找到可用端口
  7. portfinder.getPort((err, port) => {
  8. const serviceId = serviceType+port;
  9. // 注册服务
  10. consul.agent.service.register({
  11. id: serviceId,
  12. name: serviceType,
  13. address: 'localhost',
  14. port: port,
  15. tags: [serviceType]
  16. }, () => {
  17. // 解除注册的 handler
  18. const unregisterService = (err) => {
  19. consul.agent.service.deregister(serviceId, () => {
  20. process.exit(err ? 1 : 0);
  21. });
  22. };
  23. process.on('exit', unregisterService);
  24. process.on('SIGINT', unregisterService);
  25. process.on('uncaughtException', unregisterService);
  26. http.createServer((req, res) => {
  27. for (let i = 1e7; i > 0; i--) {}
  28. console.log(`Handling request from ${pid}`);
  29. res.end(`${serviceType} response from ${pid}\n`);
  30. }).listen(port, () => {
  31. console.log(`Started ${serviceType} (${pid}) on port ${port}`);
  32. });
  33. });
  34. });

loadBalancer.js

  1. const http = require('http');
  2. const httpProxy = require('http-proxy');
  3. const consul = require('consul')();
  4. const routing = [
  5. {
  6. path: '/api',
  7. service: 'api-service',
  8. index: 0
  9. },
  10. {
  11. path: '/',
  12. service: 'webapp-service',
  13. index: 0
  14. }
  15. ];
  16. const proxy = httpProxy.createProxyServer({});
  17. http.createServer((req, res) => {
  18. let route;
  19. // 找到匹配的就停止
  20. routing.some(entry => {
  21. route = entry;
  22. //Starts with the route path?
  23. return req.url.indexOf(route.path) === 0;
  24. });
  25. consul.agent.service.list((err, services) => {
  26. const servers = [];
  27. // 筛选出目标服务
  28. Object.keys(services).filter(id => {
  29. if (services[id].Tags.indexOf(route.service) > -1) {
  30. servers.push(`http://${services[id].Address}:${services[id].Port}`)
  31. }
  32. });
  33. if (!servers.length) {
  34. res.writeHead(502);
  35. return res.end('Bad gateway');
  36. }
  37. // 环形负载均衡
  38. route.index = (route.index + 1) % servers.length;
  39. // 将请求代理到相应的服务上
  40. proxy.web(req, res, {target: servers[route.index]});
  41. });
  42. }).listen(8080, () => console.log('Load balancer started on port 8080'));

端对端的负载均衡

在调用内部服务时,可以考虑使用端对端的负载均衡,去掉反向代理。有以下优点:
* 通过减少网络节点来减少架构的复杂度
* 经过更少的节点,通信更快
* 更好地扩容,性能不受负载均衡器的上限影响

分解复杂的应用

巨型系统(monolithic systems)通常已经高度模块化,并且在组件间解耦度高。然而因为他们仍是一整个程序,所以其中一个服务挂掉整个系统就挂掉了:

各个组件都在一个应用内,且同属于一个 datestore

然而如果把不同模块拆分成独立的应用,模块间的沟通则会变得更加困难。

微服务架构

Node.js 中写大型程序最重要的模式,就是不要写大型程序。意思就是根据服务、功能将应用分解成一个个独立的小应用。达到高内聚,低耦合的效果。

一个微服务架构的例子

可见,所有服务都是独立的应用,而且数据也是独立的。虚线代表他们仍需要进行通信。
因为服务之间不共享数据,所以为了维持系统的一致性,需要更多的通信才可以。

微服务中的整合模式

微服务中的一个最难的挑战,在于需要连接各个节点,让他们相互协作,同时还要考虑服务的可复用性和扩展性。

API 代理模式


本质上是加上一个反向代理层,通过这一层代理屏蔽掉不同服务调用间的差异(然而服务之间的通信问题仍未解决)

API 组合模式


将需要调用不同服务的操作组合为一个 API,这样对于调用者来说,不需要考虑如何调用不同服务的不同 API了。与 API 代理的不同之处在于,他不是一个简单直接的代理,而是整合了不同的服务。

通过信息经纪人(message broker)整合

上面的模式需要完全了解服务的架构以及每个服务时怎么运行的,这其实是一种反模式,被称为 上帝对象。
解决方式是使用 message broker,通过实现一个集中的发布/订阅模式,用户只需调用其中的一个服务。后续的服务通过事件来连锁触发:

而一切的整合都在背后进行,调用者只需要调用一个 API 即可完成整套相关的操作。
这种模式没有在外部增加实体,也没有“上帝服务”,是解耦服务,降低复杂度的好方法。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注