关于Node.js后端架构的一点后知后觉

3,097 阅读15分钟
原文链接: zhuanlan.zhihu.com

前言

上周有幸和淘宝前端团队的七念老师做了一些NodeJS方面上的交流(实际情况其实是他电话面试了我╮(╯-╰)╭),我们主要聊到了我参与维护的一个线上NodeJS服务,关于它的现状和当下的不足。他向我提出的一些问题带给了我很大启发,尽管回答的不是很好。问题大意是,对于你意识到的这些不足,你将尝试怎样去改进它们?甚至,如果给你一个机会来重新设计这个系统服务,你将如何做?相比现在有什么的改进?

为什么说这些问题对我产生了启发,是因为这些问题是我不曾考虑过的。或者说考虑过,但没有这么严肃的考虑过。这里的“严肃”指的是具体到线上,细节,容灾容错等方面。而在电话之后我重新尝试回答这些问题的过程中又收获了不少新的知识。

这篇文章与以往的文章不同,并不是阐述某一个问题的最佳解决方案,也不会落实到具体的代码上。而是分享在探寻答案过程中收获的心得、留下的困惑还有一点个人的经验。至于这些能否拿来回答最初的那些问题我没有十足的把握,也许能,但肯定不是最佳答案。因为后端架构实在一个很有深度的话题,也是一个极其成熟的技术方向。即使有了理论方面的积累,面对千变万化的业务需求难免还是灵活的对方案进行改进,而无论是理论还是实践经验都是我欠缺的。

这段话本来应该是写在结尾,感觉顺嘴也就挂在了开头。

最后,本文的部分内容和图片参考自图书Node.js design patterns的第七章内容Scalability and Architectural Patterns。其实书中该章中的大部分内容也并非原创,但是它做了很好的汇总和迁移,具体我会在之后说明。所以如有雷同,不是巧合。

正文

一个怎样的后端服务才能算得上优秀?或者放低身段说合格?再把这个问题翻译翻译,优秀或者合格的标准是什么?

假设现在需要你用NodeJS搭建一个http服务,我猜测你会借助express框架用不到10行的代码完成这项工作。不能说这么做是错的,但这样简易的程序是脆弱的:一旦部署上线之后,可能瞬间就被大量涌入的请求击垮,更不要提各种潜在的漏洞危险。退一步说,即使线上程序经过了这一关考验,如果你要更新程序怎么办?不得不让用户中断访问一段时间?

在我看来,后端服务务必要满足两条特性:

  • 能容错(Fault tolerant)
  • 可扩展(Scalability)

当然还有一些其他特性也很重要,比如程序要健壮,接口设计要友好,程序修改起来要灵活等等。但容错性和拓展性才是正常运行的基本保障,至少保证了你的服务是可用的,永远是可用的。而无论实现服务的代码如何优雅,它都是为业务服务的,一旦用户无法访问你的服务了,再优美的代码也无济于事。所以接下来的问题就是,我们后端程序的架构如何的设计以保证满足这两条特性呢?

首先我们说说拓展性(Scalability)。

按照书中的说法,拓展性划分为三类,如下图所示:


  • X轴方向:纯粹的对服务实例进行拓展,例如为了响应更多的请求
  • y轴方向:为服务添加新的功能,功能性拓展
  • z轴方向:按照业务数据对服务进行拓展(这里没搞懂,不知道这么说是否准确)

而通常实际的拓展过程中多维度是同时进行的,例如增添了新的功能也就意味着有跟多的流量进入,也就是意味着需要增加新的服务实例。

实例拓展

我们先谈第一类X轴拓展,增加服务的实例。增加服务实例也分为两类,横向拓展(horizontal scaling)和纵向拓展(vertical scaling),横向表示利用更多的机器,纵向表示在同一台机器上挖掘它的潜力。但其实横向和纵向两者解决问题的思路的差异并不大。

从小到大,先说纵向拓展。

我们都知道NodeJS程序是以单进程形式运行,32位机器上最多也只有1GB内存的实用权限(在64位机器上最大的内存权限扩大到1.7GB)。而目前绝大部分线上服务器的CPU都是多核并且至少16GB起,如此以来Node程序便无法充分发挥机器的潜力。同时NodeJS自己也意识到了这一点,所以它允许程序创建多个子进程用于运行多个实例。

具体技术细节涉及到Cluster模块,详情可以查看NodeJS相关文档: Node.js v7.5.0 Documentation

下图就是对以上所说多进程模式原理的图解:


简单来说,首先我们有一个主进程master,但master主进程并不实际的处理业务逻辑,但除了业务逻辑以外事情它都做:它是manager,负责启动子进程,管理子进程(如果子进程挂了要及时重启),它也扮演router,也就是对该程序的访问请求首先到达主进程,再由主进程分配请求给子进程worker。而子进程才负责处理业务逻辑。

在这个机制下有两条细节需要我们定夺如何处理。

如何把外界的请求平均的分配给不同的worker处理?这里的平均不是指数量上的平均(因为单条请求处理的工作量可能不同),而是既不能让某个子进程太闲,也不能让某个子进程太忙,保证它们始终处于工作的状态即可。这也是我们常说的负载均衡(load-balancing)。 默认情况下Cluster模块采用的是round robin负载均衡算法,说白了就是依次按顺序把请求派给列表上的子进程,派到结尾之后又重头开始。

这个算法只能保证每个子进程收到的请求个数是平均的,和随机算法类似。但如果某个子进程遇到问题,处理变得迟缓了,而后续的请求又源源不断的分配过来,那么这个子进程的压力就大了,这就略显不公了。除此之外我们还要考虑到超时,重做等机制的建立。所以主进程master作为路由时不仅仅是转发请求,还要能智能的分配请求。

另一个问题是状态共享问题,假如某个用户第一次访问该服务时是分配给了线程A上的实例A处理,并且用户在这个实例上进行了登陆,而没有过几秒钟之后当用户第二次访问时分配给了线程B上的实例B处理,如果此时用户在A上的登陆状态没有共享给其他实例的话,那么用户不得不重新登陆一次,这样的用户体验是无法接受的。如下图所示


这个问题的解决办法是把状态进行共享:


也可以新增一个模块用于记录用户第一次访问的实例,并在之后当用户访问服务时始终访问该实例


主进程-子进程的模式思路不仅适用于纵向拓展,还适用于横向拓展。当单台机器已经无法满足你需求的时候,你可以把单实例子进程的概念拓展为单台机器:我们将在多台机器上部署多个进行实例,用户的访问请求也并非直接到达它们,而是先到达前方的代理机器,它也是负责负载均衡的机器,负责将请求转发给部署了应用实例的机器。这样的模式我们也通常称为反向代理模式:



我们仍然能对这个模式持续改进,例如动态的启动或者关闭机器上的实例用于节省资源,甚至想办法移除负载平衡这一环节用于提高通讯的效率。在这里就不延伸开了去了,具体可以参考Node.js design patterns这本书中的内容。

最后在这里要说一件很重要的事情。上面说的负载平衡也好,反向代理也好,都不是新的技术。相反,都是非常非常成熟,有着相当多经验积累的技术。然而为什么我们接触起来却感觉如此的新鲜和陌生?我想原因大概是NodeJS程序员大多是由前端工程师转化而来,而大家此前都只专注于前端代码而很少接触后端知识。然而如果你从入行开始就是一个Java程序员或者运维工程师,相信你对这一切早就耳熟能详并且手到擒来。

几年前看到过一篇文章,(很可惜现在找不到了,如果有哪位同学知道篇文章的麻烦告知一下谢谢),记录的是一位技术人员针对网站访问量增大而做的一系列技术改进。文章的后半部分我记不得了,但是前半部分遇到的问题和改进的思路和我们是一模一样的:请求骤增,增加实例机器和解决session共享问题。我想说的是,虽然NodeJS是新技术,但是我们解决问题的思路和方案可以来自传统软件行业,并且它们在这方面比我们有经验的多。所以我们在学习NodeJS,在寻找一些问题的解决方案时,不要局限于NodeJS本身,而是应该开阔眼界,跨语言包容的去汲取知识。

功能拓展

你也许会问新增功能有什么难点?每个程序员的日常就是不断的进行功能迭代。但在这里我们希望解决一个问题,就是既然我们无法保证功能不会出错,那我们有没有办法保证当一个功能出错之后不会影响整个程序的正常运行?这也是我们所说的容错性。

道理都懂,我们都明白程序需要容错,所以try/catch是从编码上解决这个问题。但问题是try/catch不是万能的,万无一失的程序也是不存在的,所以我们要换个思路解决这个问题,我们允许程序出错,但是要及时把错误隔离,并且不再影响程序的运行。这个就要从架构上解决这个问题。例如使用微服务(Microservices)架构。

在介绍微服务架构之前,我们要了解其它架构为什么没法满足我们的要求。例如我们常用的单体(monolithic)架构。单体架构这个词你可能不熟悉,但几乎我们每天都在和它打交道,大部分的后端服务都归属于单体架构,对它的解释我翻译Martin Fowler的描述:

企业级应用通常分为三个部分:用户界面(包含运行在用户浏览器上的html页面和javascript脚本),数据库(通常是包含许多表的关系数据库),和服务端应用。服务端应用将会处理http请求,执行业务逻辑,从数据库中取得数据,生成html视图返回给浏览器。这样的服务端应用就被称为单体(monolith)——单个具有逻辑性的执行过程。任何针对系统的修改都会导致重新构建和部署一个新版本的服务端应用。

(注:以上这段描述摘自Martin Fowler的文章Microservices,我认为这是对微架构描述最全面的文章,如果想对这一小节做更深入的了解可以把这篇文章细读。 这也是我读到的Martin Fowler所写的文章中最通俗的文章。个人认为Martin Fowler的文章读起来比较晦涩,John Resig紧随其后)

单体架构是一种很自然的搭建应用的方式,它符合我们对业务处理流程的认知。但单体应用也存在问题:任何一处,无论大小的修改都会导致整个应用被重新构建和重新部署。随着应用规模和复杂性的不断增大,参与维护的人数增多,每一轮迭代修改的模块增多,对上线来说是极大的考验,对于内部单个模块的拓展也是极为不利的。例如当图片压缩请求剧增时,需要新增图片压缩模块的实例,但实际上不得不扩展整个单体应用的实例。

微服务架构解决的就是这一系列问题。顾名思义,微服务架构下软件是由多个独立的服务组成。这些服务相互独立互不干预。以拆分上面所说的单体应用为例,我们可以把处理HTTP请求的模块和负责数据库读写的模块分离出来成为独立的服务,这两个模块从功能上看是没有任何交集。这样的好处就是,我们可以独立的部署,拓展,修改这些服务。例如应用需要添加新的接口时,我们只需要修改处理HTTP请求的服务,只公开这部分代码给修改者,只上线这部分服务,拓展时也只需要新添这部分服务的实例。

微服务和我们通常编写的模块(以文件为单位,以命名空间为单位)相比更加独立,更像是一个五脏俱全的“小应用”,如果你读完了我之前推荐的Martin Fowler关于微服务的文章的话,你会对这点更深有感触:微服务除了在运维上独立以外,它还可以拥有独立的数据库,还应该配备独立的团队维护。它甚至可以允许使用其他的语言进行开发,只要对外接口正常即可。

当然微服务也存在不足,例如如何将诸多的微服务在大型架构中组织起来,如何提高不同服务之间的通信效率都是需要在实际工作中解决的问题。

微服务说到底还是解耦思想的实践。从这个意义上来说,React下的Flux架构某种意义上也属于微服务。如果你了解Flux的起源的话,Flux架构其实来源于后端的CQRS,即Command Query Responsibility Segregation,命令与查询职责分离,也就是将数据的读操作和写操作分离开。这么设计的理由有很多,举例说一点:在许多业务场景中,数据的读和写的次数是不平衡,可能上千次的读操作才对应一次写操作,比如机票余票信息的查询和更新。所以把读和写操作分开能够有针对性的分别优化它们。例如提高程序的scalability,scalability意味着我们能够在部署程序时,给读操作和写操作部署不同数量的线上实例来满足实际的需求。



如果你也有Unity编程经验的话会对解耦更有感触,在Unity中我们已经不能称之为解耦,而是自治,这是Unity的设计模式。举个例子,屏幕上少则可能有十几个游戏元素,例如玩家、敌人还有子弹。你必须为它们编写“死亡”的规则,“诞生”的规则,交互的规则。因为你根本无法预料玩家在何时何种位置发射出子弹,也无法预料子弹何时在什么位置碰撞上什么状态敌人。所以你只能让它们在规则下自由发挥。这和微服务有异曲同工之妙:独立,隔离,自治。

总结

实话实说,这篇文章里没有干货,全都是舶来品。但舶来品不是一个贬义词,它是我们学习知识和解决问题的第一手材料。我还是想重申一遍,在后端领域来说Node.js是一个新人,我们应该学习前辈的经验。借用许多年前奔驰广告的一句话:经典是对经典的继承,经典是对经典的背叛。只有站在前人的肩膀上,我们才有可能创新,看的更远。