网站分布式改造
一个创业公司起步时很可能就两台机器,一台 Web 服务器,一台数据库服务器,在一个应用系统中集成了所有的功能模块。但随着业务的发展和流量的增长,单应用已不能满足业务需求,分布式成为必由之路。
1.1 为什么要做分布式化?
-
背景
一个网站的技术架构早期很多是 LAMP(Linux + Apache + MySQL + PHP),随着业务扩展和流量增长,该架构下的系统很快达到瓶颈,即便尝试一些高端服务器(如:IOE),除了价格昂贵之外,也阻挡不了瓶颈的到来。分布式改造成为必由之路。
-
什么是分布式改造?
所谓分布式改造,就是尽量让系统无状态化,或者让有状态的信息封装在一定范围内,以免限制应用的横向扩展。简单来说,就是当一个应用的少数服务器宕机后,不影响整体业务的稳定性。
-
实现应用的分布式改造需要解决好哪些问题?
- 应用需要微服务化,即将大量粗粒度的应用逻辑拆小做服务化改造
- 必须建立分布式服务框架。必须具备分布式配置系统、分布式 RPC 框架、异步消息系统、分布式数据层、分布式文件系统、服务的发现、注册和管理。
- 必须解决状态一致性问题
1.2 典型的分布式架构
分布式架构与传统单机架构的最大区别在于,分布式架构可以解决扩展问题:横向扩展和纵向扩展。
什么是横向扩展?
横向扩展,主要解决应用架构上的容量问题。简单说,假如一台机器部署的 WEB 应用可以支持 1亿 PV 量,当我想支持 10亿 PV 量的请求时,可以支持横向扩展机器数量。
什么是纵向扩展?
纵向扩展,主要解决业务的扩展问题。随着业务的扩展,业务的复杂程度也不断提高,架构上也要能根据功能的划分进行纵向层次的划分。比如,Web/API 层只做页面逻辑或展示数据的封装,服务层做业务逻辑的封装等。业务逻辑层还可以划分成更多的层次,以支持更细的业务的组合。
一个典型的分布式网站架构如图1.1所示:
它将用户的请求通过负载均衡随机分配给一台Web机器,Web机器再通过远程调用请求服务层。但是数据层一般都是有状态的,而数据要做到分布式化,就必须保证数据的一致性。要保证数据的一致性,一般都需要对最细粒度的数据做单写控制,因此要记录数据的状态、做好数据的访问控制等
一个有状态的分布式架构如图1.2所示:
分布式集群中一般都有一个Master负责管理集群中所有机器的状态和数据访问的规制等,为了保证高可用Master也有备份,Master通常会把访问的路由规则推给实际的请求发起端,这样Client就可以直接和实际要访问的节点通信了,避免中间再经过一层代理
还有一种分布式架构是非Master-Slave模式而是Leader选举机制,即分布式集群中没有单独的Master角色,每个节点功能都是一样的,但是在集群的初始化时会选取一个Leader 承担Master的功能。一旦该Leader失效,集群会重新选择一个Leader。
这种方式的好处是不用单独考虑Master的节点的可用性,但是也会增加集群维护的复杂度:
-
需要分布式中间件
从前面典型的分布式架构上可以看出,要搭建一个分布式应用系统必须要有支持分布式架构的框架。例如首先要有一个统一的负载均衡系统(LB/LVS)帮助平均分配外部请求的流量,将这些流量分配到后端的多台机器上,这类设备一般都是工作在第四层,只做链路选择而不做应用层解析;应用层的负载均衡可以通过HA来实现,例如可以根据请求的URL或者用户的Cookie 精准地调度流量。
请求到达服务层,就需要解决服务之间的系统调用了。这时,需要在服务层构建一个典型的分布式系统,包括同步调度的分布式RPC框架、异步调度的分布式消息框架和解决静态配置信息的分布式配置框架。这三个分布式框架就像人体的骨骼和经络,把整个服务层连接起来。我们会在后面详细介绍这三个典型的分布式框架(分布式框架的开源产品有很多,例如Dubbo、RocketMQ等)。
-
服务化和分布式化
我们在网站升级中一般会接触到两个概念:
- 服务化改造;
- 分布式化改造。
它们是一回事吗?
服务化改造更多是从业务架构的角度出发,目的是将业务做更细粒度的功能拆分,使业务逻辑更加清晰、边界更加清楚且易于维护;服务化的另一个好处是收敛业务逻辑,通过接口标准化提供统一的访问方式。
分布式化更多是从系统架构层面的角度出发,更多是看请求的访问路径,即一个请求必须先访问什么再访问什么、一次访问要经过哪些步骤才能最终有结果等……因此,这是两个不同层面的工作。
1.3 分布式配置框架
分布式配置框架可以说是其他分布式框架的基础,因为在分布式系统中要做到所有机器节点都完全对等几乎是不可能的,必然有某些机器或者集群存在某些差异,但同时又要保证程序代码是一份,所以解决这些差异的唯一办法就是差异化配置——配置框架就承担着这些个性化的定制功能:它把差异性封装到配置框架的后台中,使集群中的每台机器节点的代码看起来都是一致的,只是某些配置数据有差异。
配置框架的原理非常简单,即向一个控制台服务端同步最新的一个K/V集合、JSON/XML或者任意一个文件,配置信息可以是在内存中的也可以是持久化的文件。
它的最大难点在于集群管理机器数量的能力和配置下发的延时率。
如图1.3所示,分布式配置框架一般有以下两种管理方式:
-
拉取模式
拉取模式就是Client集群主动向 ConfigServer机器询问配置信息是否有更新,如果有则拉取最新的信息更新自己。这种模式对配置集群来说比较简单,不需要知道Client的状态也不需要管理它们,只需处理Client的请求就行了,是典型的C/s模式。缺点是ConfigServer 没法主动及时更新配置信息,必须等Client请求时再更新。一般Client都会设置一个定时更新周期,通常是几秒钟。
-
推送模式
这种模式是当ConfigServer感知到配置信息变化时主动把信息推送给每个Client。它需要ConfigServer感知到每个Client的存在,需要保持和它们之间的心跳,这使得ConfigServer的管理难度增加了,当Client的数据很大时,ConfigServer 有可能会成为瓶颈。
在实际的应用场景中,一般而言,如果对配置下发延时率比较敏感而且Client数据不是太大(千级别)时,推荐使用推送模式,像ZooKeeper就是这种框架(我们在后面的小节会介绍几种分布式管理的场景);而当Client 数据在万级以上时推荐使用拉取模式。不过,不管运用哪种模式我们都要考虑以下两个问题:
- 由于是典型的Master/Slaver模式,要求在数据下发时要控制节奏,不要让ConfigServer的网卡成为瓶颈,尤其是当下发的数据量较大时。
- 要保证配置下发的到达率,即ConfigServer 需要精确地知道每个Client是否已经是最新的配置数据。一般解决这个问题的办法是给每次配置信息的更新增加一个版本号,然后Client在每次要更新时都用这个版本号和ConfigServer的最新数据比较,如果小于ConfigServer再自我更新。
分布式配置框架是最简单的管理Client机器及相应配置下发功能的框架,因此,它也很容易发展成带有这两种属性的其他的工具平台,如名字服务、开关系统等。
1.4 分布式 RPC 框架
应用系统要做分布式改造,必须先要有分布式RPC框架,否则将事倍功半,为什么呢?这就像盖楼一样,如果没有先搭好骨架的话,很可能就是给自己“埋坑”。要做好分布式RPC框架需要实现服务的注册、服务发现、服务调度和负载均衡、统一的SDK封装。当前Java环境中分布式RPC框架如Dubbo、HSF都是比较成熟的框架,但是其他语言像PHP、C++还不多。
一个通常意义上的RPC框架一般包含如图1.4所示的结构:
-
服务注册
服务要能被发现,必须先注册。服务的注册对Java程序来说非常简单,只需要在应用启动时调用RPC框架直接向服务端注册就行,一般需要传递类名、方法名、参数类型以及版本号。
由于语言上的一些限制,用PHP来做服务的注册和发现相比之下要困难很多。由于PHP不像Java那样很容易地支持长连接,所以在PHP文件启动时很难像Java程序那样在初始化函数里完成注册。对PHP来说目前有两个办法:第一个办法是写一个FPM的扩展,在第一次FPM初始化时完成服务的注册。但是如果一个FPM部署了多个服务模块,那么如何区分也是一个难题;第二个办法是在PHP模块里手动配置一个要注册的服务名录列表,在PHP打包发布时进行注册。
服务注册后,服务发布者所在的机器需要和注册中心保持心跳以维持自己处于一直可以提供服务的状态,否则注册中心就会踢掉机器。对PHP来说,维持RPC连接是个麻烦事,所以一般会在本机另外再起一个proxy agent或者直接发送HTTP请求,但是这样做比较消耗性能。
-
服务发现
服务发布方注册服务后,服务调用方就要能够发现服务。服务发现(如图1.5所示)最重要的一个目标就是要把服务和提供服务的对应机器解耦,而不是通过机器的IP寻找服务。
服务调用方只需要关心服务名,不用关心该服务由谁提供、在哪儿提供、是否可用……服务发现的组件会把这些信息封装好。
对Java语言来说。服务发现就是把对应的服务提供者当前能够存活的机器列表推给服务调用者的机器的内存,真正发起调用时随机选取一个IP就可以发起RPC调用。对PHP来说,如何把服务发布者的机器列表推给调用方也很麻烦,目前的解决方案更倾向于在本机的Agent中完成地址列表的更新,然后真正调用时再查询Agent更新的本地文件,并从本地文件中查找相应的服务。
-
服务调度和负载均衡
服务注册和发现完成后,就要处理服务的调度和负载均衡了(如图1.6所示)。服务调用有两个关键点:一是要摘除故障节点,二是负载均衡。
- 摘除故障节点。这对Java来说比较容易处理:一旦有机器下线后,很容易更新地址列表。但对PHP来说就只能定期从Agent中拉取最新的地址列表,做不到像Java那样实时。
- 负载均衡。负载均衡需要将调用方的请求平均分布到不同的服务提供者的机器上,一个最简单的算法就是随机选取,做得复杂一点可以给每个提供者IP设置一个权重,然后根据权重选取。
-
统一的 SDK 封装
服务框架需要提供一个统一的客户端和服务端的标准接口规范,这样可以减少业务开发的重复工作量,例如SDK会统一封装通信协议、失败重试以及封装一些隐式参数传递(trace信息)。
Java通过提供一个统一的jar包,封装了服务发布和调用的接口,业务层只要做些简单的配置就能方便地调用服务,例如Java一般都会配置一个Spring的Bean。
对其他语言来说,运用IDL规范是个好选择。在thrift的基础上,修改code-gen,生成struct(class)的read和write,生成Client和server插件框架,并基于thrift的lib提供Binary Protocol、TCP Transport。
1.5 分布式消息框架
一个分布式应用系统中,除了RPC调用之外,还需要在应用之间传递一些消息数据,这时分布式消息中间件就成为必需品。消息中间件主要用于异步和一个Provider多个Consumer的场景中。消息可分为实时消息和延时消息。
-
实时消息
实时消息就是当消息发送后,接受者能实时消费的消息(如图1.7所示),很多开源的消息中间件如开源的RocketMQ,Apache Kafka等都是比较成熟的消息中间件。
-
- 异步解耦
异步解耦的好处体现在多个方面:可以分开调用者和被调用者的处理逻辑,降低系统耦合,解决处理语言之间的差异、数据结构之间的差异以及生产消息和消费者的速度差异(削峰填谷)。
通过中间的消息队列服务,可以做很多事情,但是要保证以下两点:
- 最终的一致性。一致性要求不能丢消息,保证消息最终可达,如果最终不可达要反馈给发送方
- 消息的有序性。有序性是指消息的先后顺序,先后的顺序是按消息的发送时间排序
-
- 多消费端
一个消息被多个订阅者消费是典型的一种应用场景(如图1.9所示),多消费端非常适合用在单一事情触发的场景中。例如当一个订单产生时,下游会对这个订单做很多额外的处理,而消息的生产者对这些消息的消费者根本不会关心,非常适合用在一个大型的异构系统中。
在此场景中我们会遇到下面这些典型问题:
- 确认消息是否被消费。当出现多个消费者时,需要确定哪些消息发送成功,哪些消息发送失败,是否要重新发送……所以消息队列中需要增加消费确认标识,以确定哪些消费端已经成功消费了消息,而投递失败的需要重新投递
- 消息队列需要有容错能力。当某个消息失败后,需要消息队列有容错能力,保证消费端恢复后能重新投递消息,所以消息队列要有能力保存一定的消息量
-
延时消息
除了实时消息外,延时消息用得也比较广泛。典型的例子比如一张电影票的订单产生后,用户在15分钟后仍然没有付款,那么系统会要求在15分钟后取消该订单,释放座位(如图1.10所示)。这种场景非常适合用延时消息队列来处理。
延时消息在技术实现上比实时消息队列要更难,因为它需要增加一个触发事件,而这个触发事情有时候不一定是时间触发事件,还有可能是其他消息事件触发,这样导致它所承担的业务逻辑会更重,架构也会更复杂。
延时消息的核心是需要有一个延时事情的触发器,此外还必须解决消息的持久化存储问题,其他方面和实时消息队列差不多。总体来说,所有的消息队列都必须要解决最终一致性、高性能和高可靠性问题。
-
几种常见的消息中间件
在 http://queues.io/ 上几乎列出了当前大部分开源的消息队列,每个产品各有特点,适用于不同应用场景,适合的才是最好的。
1.6 分布式数据层
分布式数据层主要解决数据的分库分表、主备切换以及读写分离等问题,统一封装数据库的访问细节,如建立连接中的用户名和密码、连接数、数据类型的转换等信息。
-
分库分表
分布式数据层最重要的功能是对数据做分库分表处理(如图1.11所示),尤其对互联网公司来说,数据量的不断增长要求切分数据是相当平常的任务。
当一条或者一批SQL提交给分布式数据层,并根据某些标识进行规制运算后,应该将这些SQL分发给规则被命中的机器去执行并返回结果。这里最重要的是数据分片的规制要对开发透明,即写SQL的同学不用关心他要请求的数据到底在哪台机器上,当我们改变数据分片规制时,只需要修改路由规则而无须修改SQL。所以很显然该分布式数据层需要解析用户的SQL,并且有可能会重写SQL(例如修改表名或者增加一些 HINT 等信息)
-
主备读写分离
数据库的读写分离是常见的操作,如图1.12所示。由于数据库资源非常宝贵,为了保证数据库的高可用一般都会设置一主多备的架构;为了充分利用数据库资源,都会进行读写分离,即写主库读从库。在同机房的场景下,数据库主从复制的延迟非常低,对应用层没有什么影响。
在原理上主从的读写分离比较简单,就是拆开用户的写请求和读请求,并分别路由到不同的DataSource上。在这种场景下要注意读写一致性的问题。在某些场景如双11抢单时,用户下完单立即查询下单是否成功,如果查询从库延时会比较大,用户很可能看不到下单成功界面从而重复下单,要有保障机制避免此类问题发生。
1.7 分布式文件系统
有些应用需要读写文件数据时,如果只写本机的话那么就会和本机绑定,这样这个应用就成为有状态的应用,那么就很难方便地对这个应用进行迁移,水平扩展也变得困难。
不仅是文件数据,一些缓存数据也存在类似问题。现在很多的分布式缓存系统Redis、Memcache等就是用于解决数据的分布式存储问题的。
当前开源的分布式文件系统很多,像开源的TFS、FastDFS、GFS等,它们主要解决的是数据的高可用性和高性能问题,下面我们介绍一个分布式文件系统Seaweedfs的设计,它的设计比较巧妙,很有启发性(如图1.13所示):
Seaweedfs 文件系统有3个非常重要的组成部分,分别是Master、VolumeServer和Volume:
- Master 管理多个VolumeServer,它的工作是分配某文件到VolumeServer中,并维护文件的副本,它通过Raft协议保证一致性。
- VolumeServer 管理多个Volume。每个Volume包含固定大小的存储空间,每个Volume可存储多个文件直到Volume的整个存储空间被写满为止。Master、VolumeServer和Volume只是逻辑上的划分,可以运行在同一台机器上。
- 每个Volume表示一个固定大小的存储空间。
假设一个Volume有30GB的容量,被分配一个唯一的32bit的VolumelD;每个VolumeServer 维护多个Volume和每个Volume剩余可写的存储空间,并上报给Master;Master维护整个集群中当前可写的Volume列表,一旦Volume写满就标识为只读。如果整个集群中没有可写的Volume,那么整个集群都将不可写,只能通过扩容增加新的VolumeServer。
Client 要上传一个文件首先需要向Master申请一个fid,Master会从当前可写的Volume列表中随机选择一个。和大多数分布式文件系统一样,这个fid就是表示文件在集群中的存储地址,最重要的是fid的前32bit,代表的是VolumeID,表示该文件具体存储在哪个Volume中并返回此fid应该存储的VolumeServer的机器地址。
典型的一个上传文件请求如curl-F"file=@/tmp/test.jpg""192.168.0.1:9333/submit",返回{"fid":"3,01f83e45ff","fileName":"test.jpg","fileUrl":"192.168.0.1:8081/3,01f83e45ff",
"size":12315}。Client拿到fileUrl再将文件实际上传到192.168.0.1:8081的VolumeServer中。
文件的多副本也是在Master上管理的,巧妙的地方在于文件的多副本不是以单个用户的文件为单位而是以Volume为单位进行管理。例如上面的“3,01f83e45ff”这个fid,它是存在3的Volume中,那么这个3Volume会有一个副本,即在其他的VolumeServer上也有一个3的Volume,那么当test.jpg上传到192.168.0.1:8081时,它会查询Master这个Volume的副本在哪台机器上,然后由这台机器把文件copy到对应的机器上,再返回结果给用户。目前还是采用强一致性来保证多副本的一致性,如果某个副本上传失败则返回用户失败的结果。
扩容比较简单,当集群中所有的Volume都写满时,再增加一些VolumeServer并增加若干Volume,Master会收集新增的VolumeServer中空闲的Volume并加入到可写的Volume列表中。
如果有机器挂掉的话,由于Volume是有备份Volume的,所以只要存在一份Volume,Master都会保证能够返给用户正确的请求。这里需要注意的是Volume的多备份管理并没有主次的概念,每次Master都会在可用的多备份中随机选择一个返回。假设3这个Volume的副本分别在192.168.0.2:8081和192.168.0.3:8081上,那么如果192.168.0.3:8081机器“宕”掉,那么这个3对应的Volume就会被设置为只读,实际上192.168.0.3:8081上所有对应的Volume都会被设置为只读——只要某个Volume的副本数减少,都会禁止再写。
综上,这个文件系统的设计思路可以总结成以下两点:
- 通过Volume作为基本的存储管理单元,类似作为一个“集装箱”,一旦“集装箱”装满了,就不再写入而转为只读。每个存入的文件都被放在一个Volume中,并且返回一个文件名;存储系统不再维护原始文件名与生成的文件名的对应关系。文件系统的一致性是以Volume的单位进行保证的,并且每个Volume可以独立存储和移动。
- 分布式文件的路由信息是由Master来管理和分配的,而且每次请求都需要强依赖Master,Master的高可用则由Raft来协调。
到目前为止,该文件系统0.7的版本还不是太完善,表现在没有一个很完善的Client程序来处理Master到VolumeServer之间的跳转,一般需要自己写。但是它最吸引人的地方就是Volume(集装箱式的设计),这个设计比较简单和独立,尤其是管理比较方便。当然,大部分分布式系统的设计也都有相似之处。
1.8 应用的服务化改造
解决好跨应用的连接和数据访问后,我们的应用也要做好相应的改造,如应用分层的设计、接口服务化拆分等。
-
应用分层设计
应用分层设计很有必要。例如最起码要把对数据库的访问统一抽象出来形成数据层,而不是直接在代码里写SQL——这会使重构应用和水平拆分数据库非常困难。
我们通常从垂直方向划分应用,分成服务层、业务逻辑层和数据层,每一层尽量做到解耦:上层依赖下层,而下层不要反向依赖上层。
应用分层最核心的目的是每个层都会封装一些信息、完成一些特定的功能需求,层与层之间通过接口交互,而且交互的数据是清晰和固定的,做到隔离和交互。可以从以下两个方向判断分层是否合理。
- 如果我要增加一些新需求或者修改某些需求时,是否能清楚地知道要到哪个层去完成,换句话说,这些分层的职责是否清晰
- 如果每个层对我的接口不变,那么每个层内部的修改是否会导致其他层也发生修改,即每个层是否做到了收敛
分层设计中最怕的就是在接口中设计一些超级数据结构,如传递一个对象,然后把这个对象一直传递下去,而且每个层都可能修改这个对象。这种做法导致两个问题:一是一旦该对象更改,所有层都要随之更改;二是无法知道该对象的数据在哪个层被修改,在排查问题时会比较复杂。因此,在设计层接口时要尽量使用原生数据类型如String、Integer和Long等。
-
微服务化
微服务化,是从水平划分的角度尽量把服务分得更细,每个业务只负责一个功能单元,这样可以把这些微服务组合成更大的功能模块。也就是有目的地拆小应用,形成单一职责从而提升系统可维护性、扩展性和开发效率。
图1.14所示是基于Spring Boot构建的一个典型的微服务架构,它按照不同功能将大的会员服务和商品服务拆成更小原子的服务,将重要稳定的服务独立出来,以免经常更新的服务发布影响这些重要稳定的服务。
1.9 分布式化遇到的典型问题
在大型分布式互联网系统中,Session问题是典型的分布式化过程中会遇到的难题。因为Session数据必须在服务端的机器中共享,并要保证状态的一致性。该问题在《深入分析Java Web技术内幕(修订版)》的第10章中有详细的介绍。
ZooKeeper是一个分布式的,开放源码的分布式应用程序协调服务,它是一个为分布式应用提供一致性服务的软件,所提供的功能包括:配置维护、域名服务、分布式同步、组服务等。下面我们介绍一下典型的分布式环境下遇到的一些典型问题的解决办法。
-
集群管理(Group Membership)
ZooKeeper 能够很容易地实现集群管理的功能,如图1.15所示。如果多台Server组成一个服务集群,那么必须有一个“总管”知道当前集群中每台机器的服务状态,一旦有机器不能提供服务,就必须知会集群中的其他集群,并重新分配服务策略。同样,当集群的服务能力增加时,就会增加一台或多台Server,这些也必须让“总管”知道。
ZooKeeper不仅能够维护当前集群中机器的服务状态,而且能够选出一个“总管”,让“总管”来管理集群——这就是ZooKeeper的另一个功能Leader Election。它的实现方式是在ZooKeeper 上创建一个EPHEMERAL类型的目录节点,然后每个Server在它们创建目录节点的父目录节点上调用getChildren(String path,Boolean watch)方法并设置watch为true。由于是EPHEMERAL目录节点,当创建它的Server死去时,这个目录节点也随之被删除,所以Children将会变化;这时getChildren上的Watch将会被调用,通知其他Server某台Server已死了。新增Server也是同样的原理。
那么,ZooKeeper如何实现Leader Election,也就是选出一个Master Server呢?
和前面的一样,每台Server创建一个EPHEMERAL目录节点,不同的是它还是一个SEQUENTIAL目录节点,所以它是个EPHEMERAL_SEQUENTIAL目录节点。之所以它是EPHEMERAL SEQUENTIAL目录节点,是因为我们可以给每台Server编号—我们可以选择当前最小编号的Server为Master,假如这个最小编号的Server死去,由于它是EPHEMERAL节点,死去的Server对应的节点也被删除,所以在当前的节点列表中又出现一个最小编号的节点,我们就选择这个节点为当前Master。这样就实现了动态选择Master,避免传统上单Master容易出现的单点故障问题。
-
共享锁
在同一个进程中,共享锁很容易实现,但是在跨进程或者不同Server的情况下就不好实现了。然而ZooKeeper能很容易地实现这个功能,它的实现方式也是通过获得锁的Server创建一个EPHEMERAL_SEQUENTIAL 目录节点,再通过调用getChildren方法,查询当前的目录节点列表中最小的目录节点是否是自己创建的目录节点,如果是自己创建的,那么它就获得了这个锁;如果不是,那么它就调用exists(String path,Boolean watch)方法,并监控ZooKeeper上目录节点列表的变化,直到使自己创建的节点是列表中最小编号的目录节点,从而获得锁。释放锁很简单,只要删除前面它自己所创建的目录节点即可,如图1.16所示。
- 队列管理
Zoo Keeper 可以处理以下两种类型的队列
- 同步队列,即当一个队列的成员都聚齐时,这个队列才可用,否则一直等待所有成员到达
- 队列按照FIFO方式进行入队和出队操作,例如实现生产者和消费者模型。
用ZooKeeper 实现同步队列的实现思路如下:
- 创建一个父目录/synchronizing,每个成员都监控标志(Set Watch)位目录/synchronizing/start是否存在,然后每个成员都加入这个队列
- 加入队列的方式就是创建/synchronizing/member_i的临时目录节点,之后每个成员获取/synchronizing目录的所有目录节点,也就是member_i
- 判断i的值是否已经是成员的个数,如果小于成员个数等待/synchronizing/start的出现,如果已经相等就创建/synchronizing/start
我们用图1.17的流程图来直观地展示该过程:
用ZooKeeper实现FIFO队列的思路如下:
在特定的目录下创建SEQUENTIAL类型的子目录/queue_i,这样就能保证所有成员加入队列时都是有编号的;出队列时通过getChildren()方法返回当前所有队列中的元素,再消费其中最小的一个,这样就能保证FIFO。
1.10 分布式消息通道的服务设计
分布式消息通道广泛应用在很多公司,尤其是在移动App和服务端需要上传、推送大量的数据和消息时。比如打车App每天要上传大量的位置信息,服务端也有很多订单要及时推送给司机;此外,由于司机是在高速移动过程中,所以网络连接的稳定性也不是很好——这类场景给消息通道的高可用设计带来很大的挑战。
如图1.18所示是一个典型的移动App的消息通道的设计架构图,这种设计比较适合上传数据量大,并且高速移动导致网络不太稳定的链路。
链路1是Client和整个服务端的长连接链路,一般采用私有协议的TCP请求。如果是第一次请求还会通过2做链接认证,认证通过后会把该Client和接入集群的某个服务器做个K/V对,并记录到路由表里一—这可以方便下发消息时找到该链接。
经过链路4,上行消息处理集群会将TCP请求转成普通的HTTP请求,再调用后端业务执行具体的业务逻辑,或者只是上传一个数据而已,不做任何响应。如果业务有数据需要下发,会经过链路6,把消息推送到消息下发处理集群,由它把消息推送给Client。
消息下发集群会查询链接路由表,确定当前Client的链接在哪台机器上,再通过该服务器把消息推送下去。这里常见的问题是当前Client的网络不可达,导致消息无法推送。在这种情况下,消息下发处理集群会保持该消息,并定时尝试再推送;如果Client 重新建立连接,连接的服务器也会随之变化,那么消息下发集群会去查询链接路由表再重新连接新的K/V对。
链路9是为了处理Client端的一些同步请求而设计的。例如Client需要发送一个HTTP请求并且期望能返回结果,这时Client中的业务层可能直接请求HTTP,再经过Client中的网络模块转成私有TCP协议,在上行长链请求集群转成HTTP请求,调用后端业务并将HTTP的response转成消息发送到消息下发处理集群,异步下发给Client,到达Client 再转成业务的HTTP response。这种设计的主要考虑是当HTTP响应返回时,如果长链已经断掉,该响应就没法再推送回去。因此,这种上行同步请求而下行异步推送是一种更高可用的设计。
从整体架构上看,只有接入集群是有状态的,其他集群都是无状态的,这也保证了集群的扩展性。如果接入点在全国有多个点,并且这些点与服务端有专线网络服务,接入集群还可以做到就近接入。
1.11 典型的分布式集群设计思路
当前的分布式集群管理中通常有两种设计思路:
- 一种是 Maser/Slaver 模式(如图1.19所示)
- 一种是对等的设计思路(如图1.20所示)
两种思路各有优缺点:
- Master/Slaver 模式
-
- 优点:
Master 节点是固定集中式的,管理着所有其他节点,统一指挥、统一调度,所有信息的一致性都由它控制,不容易出错,是一种典型的集权式管理。
-
- 缺点:
- 一旦Master挂了,整个集群就容易崩溃
- 由于它控制了所有的信息,所以也容易成为性能瓶颈
图示如下:
- 对等集群管理模式
-
- 优点:
- 每个节点的功能都是一样的,所以每个节点都有能力成为 Master 节点
- 整个集群中所有机器的状态都保持一致
-
- 缺点:
要达到整个集群中所有机器的状态都保持一致,需要节点之间充分的信息交换,这会导致:
- 机器之间交互控制的信息过多
- 集群越大信息越多,管理越复杂,在出现 bug 时不太容易排查
例如,对等集群管理模式中,最典型就是 Cassandra 的集群管理。Cassandra 利用了 Gossip 协议(谣言协议)达到集群中所有机器的状态都保持一致。
Gossip 协议(谣言协议)是指:一个节点状态发生变化很快被传播到机器中的所有节点,于是每个节点发生相应的变更知识发散:路由器路由表维护所涉及的 RIP 动态路由协议原理。在RIP中,每个路由器都周期地向其直通的邻居路由器发送自己完全的路由表,并且也从自己直通的邻居路由器接收路由更新信息。因为每个路由器都是从自己的邻居路由器了解路由信息,因此也将其称为“谣言”路由。
图示如下:
下面我们以开源的Tair 集群管理为例着重介绍Maser/Slaver的一种管理模式(如图1.21所示),它的设计比较巧妙:
从集群的架构上可以看出通常有3个角色:Client、ConfigServer和DataNode,整个集群通过一个路由信息对照表来管理,如下面路由对照表所示:
Bucket | Node |
---|---|
0 | 192.168.0.1 |
1 | 192.168.0.2 |
2 | 192.168.0.1 |
3 | 192.168.0.2 |
4 | 192.168.0.1 |
5 | 192. 168.0.2 |
Bucket是DataNode上数据管理的基本单位,通过Bucket可以将用户的数据划分成若干个集合。上表中分成6个Bucket,那么所有用户的数据可以对6取模,这样每条数据都会存储在其中的一个Bucket中,而每个Bucket也会对应一台DataNode。只要控制这张列表就可以控制用户数据的分布。
ConfigServer与DataNode保持心跳,并根据DataNode的状态生成对照表,Client主动向 ConfigServer 请求最新的对照表并缓存。ConfigServer最重要的责任就是维护对照表,但从实际的数据交互角度看,它并不是强依赖——正常的数据请求不需要和ConfigServer 交互,即使ConfigServer 挂掉也不会立即影响整个集群的工作。原因在于对照表在Client 或者DataNode上都有备份,因此ConfigServer不是传统意义上的Master 节点,也就不会成为集群的瓶颈。
下面介绍一下它们如何解决集群中的状态变更:扩容和容灾
-
扩容
假如要扩容一台机器192.168.0.3,那么整个集群的对照表需要重新分配,而重新分配对照表必然也会伴随着数据在DataNode之间的移动。数据的重新分配必须基于两个原则:尽可能地保持现有的对照关系,均衡地分布到所有节点上。新的对照表如下表所示:
Bucket Node 0 192.168.0.1 1 192.168.0.2 2 192.168.0.1 3 192.168.0.2 4 192.168.0.3 5 192.168.0.3 此时只需将4和5Bucket数据移动到新机器上。
-
容灾
容灾模式比扩容更复杂一些,除了上面两个原则以外,还需要考虑数据的备份情况。假如保存了3份数据,则对照表如下表所示:
Bucket Node Node Node 0 192.168.0.1 192.168.0.2 192.168.0.3 1 192.168.0.2 192.168.0.3 192.168.0.1 2 192.168.0.1 192.168.0.2 192.168.0.3 3 192.168.0.2 192.168.0.1 192.168.0.3 4 192.168.0.3 192.168.0.1 192.168.0.2 5 192.168.0.3 192.168.0.1 192.168.0.2 第一列的Node作为主节点,如果主节点挂掉,那么第二列的备份节点就会升级为主节点;如果备份节点挂掉则不会受影响,而是再重新分配一个备份节点以保证数据的备份数。
当DataNode节点发生故障时,ConfigServer要重新生成对照表,并把新的对照表同步给所有的DataNode。
Client是如何获取最新对照表的呢?
每份对照表都有一个版本号,每次Client向DataNode请求数据时,DataNode都会把自己对照表的版本号返回给Client,如果Client发现自己的版本低,则会从ConfigServer拉取最新的对照表。
这种方式会产生一个问题:当对照表发生变更时,Client有可能会更新不及时导致请求失败。
为何ConfigServer不把对照表主动推送给Client呢?
当然可以,但这会导致ConfigServer保持对每个Client的心跳,加重ConfigServer的负担,尤其当Client数量非常大的时候,容易给ConfigServer造成管理瓶颈。
因此,上面的设计其实是取中考虑,即在Client的数量和DataNode发生故障的概率之间选择一个。
综上,集群管理中最大的困难就是当DataNode数量发生变化、涉及的数据发生迁移时,既要保证数据的一致性,又要保证高可用。
1.12 总结
网站的分布式改造,核心是要解决以下问题:
- 纵向业务逻辑的分层拆分,要方便不同工种的程序开发人员的协作,如前端和后端开发人员的协作效率,业务开发人员与偏技术的中间件开发人员的分工等。
- 横向不同业务系统的拆分,如商品和会员系统独立,交易与支付系统的独立等。这种拆分更多是业务领域知识的专业化,同时也为系统的稳定性和扩展性提供更好的支持。一般而言,业务系统的拆分也会引起组织结构的变化。
- 系统的横向和纵向的拆分必须首先解决好系统之间的连接问题,而这些系统之间如何连接就是分布式系统必须解决的问题。
最后我们用图 1.22 、图 1.23 来总结单应用系统向分布式系统演进的过程:
单应用集群架构:
演进后典型的分布式集群架构:
说明
本文内容源自于许令波著的《大型网站技术架构演进与性能优化》一书的第一章。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。