最后,选择哪种计算模型取决于应用程序的特定要求,我们必须根据项目的规模和要求来决定选择哪种计算模型,以便更好地实现预期的目标。比较分布式和集群的主要区别,我们可以得出结论,分布式计算更适合实现大规模的并行任务,而集群计算更适合实现密集型任务,以获得更快的计算速度和更高的可扩展性。
1 概述
“分布式系统”是我们经常遇到的一个概念,而“集群”也是。但是很多时候,大家会将这些概念混淆,一个原因是:分布式系统的定义本身就不明确。
学术界中,对分布式系统的定义并不统一。
有的学者将分布式系统定义为“一个其硬件或软件组件分布在联网的计算机上,组件之间通过传递消息进行通信和动作协调的系统[1]”;
有的学者将分布式系统定义为“若干独立计算机的集合,这些计算机对于用户来说就像是单个相关系统[2]”。
显然,这些定义都可以涵盖分布式系统,但又过于宽泛和模糊,与软件开发者日常讨论的分布式系统的概念相去甚远。
工程界中,分布式系统的概念也是模糊的。例如我们会说ZooKeeper是分布式系统,也会说微服务系统是分布式系统。但实际上,两类系统的差别很大(后面分析了两个系统的具体归类,这里我们给出结果:两者都属于分布式系统。而准确地说,ZooKeeper是属于分布式系统中的信息一致的节点集群,而微服务应用本身就是分布式系统的一个子类)。
那我们平时所说的分布式系统到底是什么,其判断标准是怎样的?
接下来我们就要回答上述问题。我们会从应用演化历程的角度介绍应用如何一步步从单体发展到分布式。然后在此基础上,给出分布式系统的确切定义。
2 应用的演进历程
在这一章节我们要了解应用如何从单体结构逐渐演变为分布式结构,并详细介绍演变过程中出现的各种结构的优势与缺陷。
2.1 单体应用
单体应用是最简单和最纯粹的应用形式,它就是部署在一台机器上的单一应用。单体应用中可以包含很多功能模块,模块之间会互相调用,但这些调用都在应用内展开,十分方便。因此,单体应用是一个高度内聚的个体,其内部的各个模块间是高度耦合的。
单体应用的开发、维护、部署成本低廉,适合实现功能简单、并发数低、容量小的应用。当应用的功能、并发数、容量不断提升时,单体应用的规模会不断增大。这会带来两个方面的挑战:
硬件方面。庞大的单体应用需要与之对应的服务器提供支持,这种服务器被称为“大型机”,其购买、维护费用都极其高昂。
软件方面。单体应用内模块间是高度耦合的,应用规模的增大使得这种耦合变得极为复杂。这使得应用软件的开发维护变得困难。
这里要单独说一点,很多时候从程序员的角度看,往往觉着“软件方面”的因素是单体应用分布化的主要推动力,其实不是的。从历史角度和宏观角度看,“硬件方面”才是!
因此,当应用的功能、并发数、容量增加到一定程度时,需要对单体应用进行拆分,以便于对功能、并发数、容量进行分散。这就演变成了集群应用。
2.2 集群应用
集群应用可以对应用的并发数、容量进行分散。集群应用包含多个同质的应用节点,这些节点组成集群共同对外提供服务。这里说的“同质”是指每个应用节点运行同样的程序、有着同样的配置,它们像是从一个模板中复制出来的一样。
为了让集群应用中的每个节点都承担一部分并发数和容量,可以通过反向代理等手段将外界请求分散到应用的多个节点上。集群应用的结构如图所示。
但集群应用也带来了一些新的问题。一个最明显的问题是同一个用户发出的多个请求可能会落在不同的节点上,打破了服务的连贯性。
例如用户发出R1、R2两个请求,且R2的执行要依赖R1的信息(例如R1会触发一个任务,而R2用来查询任务的执行结果)。如果R1和R2被分配到不同的节点上,则R2的操作可能无法正常执行。
为了解决上述问题,演化出以下几种集群方案。
2.2.1 无状态的节点集群
无状态应用是最容易从单体形式扩展到集群形式的一类应用。所谓无状态应用是说假设用户先后发出R1、R2两个请求,则应用无论是否在之前接收过请求R1,总对请求R2返回同样的结果。即应用给出的任何一个请求的结果都和应用之前收到的请求无关。
要想让应用满足无状态,必须保证应用的状态不会因为接口的调用而发生变化。查询接口能满足这点。
无状态节点集群设计简单,可以方便地进行扩展,较少遇到协作问题。但只适合无状态应用,有很大的局限性。
很多应用是有状态的。例如某个节点接收到外部请求后修改了某对象的属性,那后面的请求再查询对象属性时便应该读取到修改后的结果。如果后面的请求落到了其他节点上,则可能读取到修改前的结果。对于这类应用,无法扩展为无状态的节点集群。
2.2.2 单一服务的节点集群
许多服务是有状态的,用户的历史请求在应用中组成了上下文,应用必须结合用户上下文对用户的请求进行回复。例如聊天应用中,用户之前的对话(通过过去的请求实现)便是上下文;在游戏应用中,用户之前购买的装备、晋升的等级(也是通过过去的请求实现)便是上下文。
有状态应用必须要在处理用户的每个请求时读取和修改用户的上下文信息。这在单体应用中是容易实现的,但在节点集群中,这一切就变得复杂起来。其中一个最简单的办法是在节点和用户之间建立对应关系:
任意用户都有一个对应的节点,该节点上保存有该用户的上下文信息
用户的请求总是落在与之对应的节点上
这种集群的典型特点就是各个节点是完全隔离的。这些节点运行同样的代码,有着同样的配置,然而却保存了不同用户的上下文信息,各自服务自身对应的用户。
虽然集群包含多个节点,但是从用户角度看服务某个用户的始终是同一个节点,因此我们将这种集群称为单一服务的节点集群。
实现单一服务节点集群要解决的一个问题是如何建立和维护用户与节点之间的对应关系。具体的实现有很多种,我们列举常用的几种:
在用户注册时由用户自由选择节点。很多游戏服务就采用这种方式,让用户自由选择账户所在的区。
在用户注册时根据用户所处的网络分配节点。一些邮件服务采用这种方式。
在用户注册时根据用户id随机分配节点。许多聊天应用采用这种方式。
在用户登录时随机或者使用规则分配节点,然后将分配结果写入cookie,接下来根据请求中的cookie将用户请求分配到指定节点。
其中最后一种方式与前几种方式略有不同。前几种方式能保证用户对应的节点在整个用户周期内不改变,而后一种方式则只保证用户对应的节点在一次会话周期内不改变。后一种方式适合用在两次会话之间无上下文关系的场景,例如一些登录应用、权限应用等,它只需要维护用户这次会话内的上下文信息。
无论采用了哪种方式,用户的请求都会被路由到其对应的节点上。根据应用分流方案的不同,该路由操作可以由反向代理、网关等组件完成。
单一服务节点集群能够解决有状态服务的问题。但因为各个节点之间是隔离的,无法互相备份。当某个服务节点崩溃时,会使得该节点对应的用户失去服务。因此,这种设计方案的容错性比较差。
2.2.3 共享信息池的节点集群
有一种方案可以解决有状态服务问题,并且不会因为某个服务节点崩溃而造成某些用户失去服务,那就是共享信息池的节点集群。在这种集群中,所有节点连接到一个公共的信息池上,并在这个信息池中存储所有用户的上下文信息。该应用的结构如图所示。
这是一种常见的将单体应用扩展为多节点应用的方式。通常我们会将服务进程在不同的机器上启动多份,并将它们连接到同一个信息池,便可以获得这种形式的集群。
任何一个节点接收到用户请求,都从信息池中读取该用户的上下文信息,然后进行请求处理。处理结束后,立刻将新的用户状态写回信息池中。信息池不仅可以是传统数据库,也可以采用其他类型。例如可以使用Redis作为共享内存,存储用户的Session信息。
在共享信息池的节点集群中,每个节点都从同一个信息池中读写信息,因此对于用户而言,每个节点都是等价的。用户的请求落在任意一个节点上都会得到相同的结果。
在这种集群中,节点之间可以基于信息池进行通信,进而开展协作。
共享信息池的节点集群通过增加服务节点而提升了集群的计算能力、容错能力。但因为多个节点共享信息池,受到信息池容量、读写性能的影响,应用在数据存储容量、数据吞吐能力等方面的提升并不明显。并且信息池也成了应用中的故障单点。
2.2.4 信息一致的节点集群
为了避免信息池成为整个应用的瓶颈,我们可以创建多个信息池,分散信息池压力的同时也避免单点故障。
为了继续保证应用提供有状态的服务,我们必须确保各个信息池中的信息是一致的,这就组成了下图所示的信息一致的节点集群。
通常,我们会让每个节点独立拥有信息池,并且将信息池看作节点的一部分,即演化为下图的形式。这是一种更为常见的形式。
在这种形式的应用中,每个节点都具有独立的信息池,保证了容量和读写性能。同时,因为各个节点的信息池中的数据是一致的,任何一个节点宕机都不会导致整个应用瘫痪。
应用中的任何一个节点接收到外界变更请求后,都需要将变更同步到所有的节点上。这一同步工作的实施成本是巨大的。因此,信息一致的节点集群适合用在读多写少的场景中。在这种场景中,较少发生节点间的信息同步,又能充分发挥多个信息池的吞吐能力优势。
2.3 狭义分布式应用
应用从诞生之初便不断发展,在这个发展过程中,应用的边界可能会扩展、应用的功能可能会增加,进而包含越来越多的模块。最终,应用的规模(注意,这里的规模是应用自身的规模,而不是外部请求压力的规模)不断增大。
应用规模的增大会带来诸多问题:
硬件成本提升:应用规模的增加会增加对CPU资源、内存资源、IO资源的需求,这需要更为昂贵的硬件设备来满足。
应用性能下降:当硬件资源无法满足众多模块的资源需求时,会引发性能下降。
业务逻辑复杂:应用中包含了众多功能模块,而每个模块都可能和其他模块存在耦合。应用开发者必须了解应用所有模块的业务逻辑后才可以进行开发。这给开发者,尤其是团队的新开发者带来了挑战。
变更维护复杂:应用中任何一个微小的变动与升级都必须要重新部署整个应用,随之而来的还有各种回归测试等工作。
可靠性变差:任何一个功能模块的异常都可能导致整个应用不可用。应用模块众多又使得应用很难在短时间内恢复。
以上这些问题都不能够通过扩展为集群应用来解决。因为集群应用只能减少应用的并发数和容量,并不能够缩减应用自身的规模。
为了解决以上问题,我们可以将单体应用拆分成为多个子应用,让每个子应用部署到单独的机器上,然后让这些子应用共同协作完成原有单体应用的功能。这时,单体应用变成了狭义分布式应用,如图所示。
我们将其称为狭义分布式应用,是为了和后面讨论的概念进行区分。
与集群应用不同,狭义分布式应用中的不同节点上可能运行着不同的应用程序,因此各个节点是异质的。
通过将单体应用拆分为子应用,狭义分布式应用既能将原本集中在一个应用、机器上的压力分散到多个应用、机器上,还便于单体应用内部模块之间的解耦,使得这些子应用可以独立地开发、部署、升级、维护。
在实际生产中,建议优先对大的单体应用进行拆分,将其拆分为狭义分布式应用,然后再对各个子应用分别扩展集群。而不是一上来就对单体应用扩展集群。
当拆分后的狭义分布式应用遇到性能或容量瓶颈时,再有针对性地将并发数过高的子应用按需扩展为集群