目录
一. Zookeeper应⽤场景
(一)数据发布/订阅
1. 配置存储
2. 配置获取
3. 配置变更
(二)命名服务
(三)集群管理
1. 基于Agent的分布式集群管理
2. Zookeeper的两⼤特性
3. 分布式⽇志收集系统
(四)Master选举
(五)分布式锁
1. 排他锁
2. 共享锁
3. ⽺群效应
(六)分布式队列
二. Zookeeper深⼊进阶
(一)ZAB协议
1. 概念
2. ZAB核⼼
3. ZAB协议介绍
4. 基本特性
5. 数据同步
6. 运⾏时状态分析
7. ZAB与Paxos的联系和区别
(二)服务器⻆⾊
1. Leader
2. Follower
3. Observer
(三)服务器启动
1. 服务端整体架构图
2. 单机版服务器启动
3. 集群服务器启动
(五)leader选举
1. 服务器启动时期的Leader选举
2. 服务器运⾏时期的Leader选举
一. Zookeeper应⽤场景
ZooKeeper
是⼀个典型的发布
/
订阅模式的分布式数据管理与协调框架,我们可以使⽤它来进⾏分布式数据的发布与订阅。另⼀⽅⾯,通过对
ZooKeeper
中丰富的数据节点类型进⾏交叉使⽤,配合
Watcher
事件通知机制,可以⾮常⽅便地构建⼀系列分布式应⽤中都会涉及的核⼼功能,如数据发布
/
订阅、命名
服务、集群管理、
Master
选举、分布式锁和分布式队列等。那接下来就针对这些典型的分布式应⽤场景
来做下介绍。
(一)数据发布/订阅
数据发布
/
订阅(
Publish/Subscribe
)系统,即所谓的配置中⼼,顾名思义就是发布者将数据发布到ZooKeeper
的⼀个或⼀系列节点上,供订阅者进⾏数据订阅,进⽽达到动态获取数据的⽬的,实现配置
信息的集中式管理和数据的动态更新。
发布
/
订阅系统⼀般有两种设计模式,分别是
推(
Push
)
模式和
拉(
Pull
)
模式。在推模式中,服务端主动将数据更新发送给所有订阅的客户端;⽽拉模式则是由客户端主动发起请求来获取最新数据,通常
客户端都采⽤定时进⾏轮询拉取的⽅式。
ZooKeeper
采⽤的是推拉相结合的⽅式:客户端向服务端注册⾃⼰需要关注的节点,⼀旦该节点的数据发⽣变更,那么服务端就会向相应的客户端发送
Watcher
事件通知,客户端接收到这个消息通知之后,
需要主动到服务端获取最新的数据。
如果将配置信息存放到
ZooKeeper
上进⾏集中管理,那么通常情况下,应⽤在启动的时候都会主动到ZooKeeper
服务端上进⾏⼀次配置信息的获取,同时,在指定节点上注册⼀个
Watcher
监听,这样⼀
来,但凡配置信息发⽣变更,服务端都会实时通知到所有订阅的客户端,从⽽达到实时获取最新配置信
息的⽬的。
下⾯我们通过⼀个
“
配置管理
”
的实际案例来展示
ZooKeeper
在
“
数据发布
/
订阅
”
场景下的使⽤⽅式。
在我们平常的应⽤系统开发中,经常会碰到这样的需求:系统中需要使⽤⼀些通⽤的配置信息,例如机器列表信息、运⾏时的开关配置、数据库配置信息等。这些全局配置信息通常具备以下
3
个特性。
- 数据量通常⽐较⼩。
- 数据内容在运⾏时会发⽣动态变化。
- 集群中各机器共享,配置⼀致。
接下来我们就以⼀个“数据库切换”的应⽤场景展开,看看如何使⽤ZooKeeper来实现配置管理:
1. 配置存储
在进⾏配置管理之前,⾸先我们需要将初始化配置信息存储到
Zookeeper
上去,⼀般情况下,我们可以在
Zookeeper
上选取⼀个数据节点⽤于配置信息的存储,例如:
/app1/database_confifig
配置管理的zookeeper节点示意图
我们将需要管理的配置信息写⼊到该数据节点中去,例如:
#
数据库配置信息
#DBCP
dbcp.driverClassName
=
com.mysql.jdbc.Driver
dbcp.dbJDBCUrl
=
jdbc
:
mysql
:
//127.0.0.1
:
3306/lagou-test
dbcp.username
=
zm
dbcp.password
=
1234
dbcp.maxActive
=
30
dbcp.maxIdle
=
10
2. 配置获取
集群中每台机器在启动初始化阶段,⾸先会从上⾯提到的
ZooKeeper
配置节点上读取数据库信息,同时,客户端还需要在该配置节点上注册⼀个数据变更的
Watcher
监听,⼀旦发⽣节点数据变更,所有订
阅的客户端都能够获取到数据变更通知。
3. 配置变更
在系统运⾏过程中,可能会出现需要进⾏数据库切换的情况,这个时候就需要进⾏配置变更。借助
ZooKeeper
,我们只需要对
ZooKeeper
上配置节点的内容进⾏更新,
ZooKeeper
就能够帮我们将数据变更的通知发送到各个客户端,每个客户端在接收到这个变更通知后,就可以重新进⾏最新数据的获取。
(二)命名服务
命名服务(
Name Service
)也是分布式系统中⽐较常⻅的⼀类场景,是分布式系统最基本的公共服务之⼀。在分布式系统中,被命名的实体通常可以是集群中的机器、提供的服务地址或远程对象等
——
这些
我们都可以统称它们为名字(
Name
),其中较为常⻅的就是⼀些分布式服务框架(如
RPC
、
RMI
)中
的服务地址列表,通过使⽤命名服务,客户端应⽤能够根据指定名字来获取资源的实体、服务地址和提
供者的信息等。
ZooKeeper
提供的命名服务功能能够帮助应⽤系统通过⼀个资源引⽤的⽅式来实现对资源的定位与使⽤。另外,⼴义上命名服务的资源定位都不是真正意义的实体资源
——
在分布式环境中,上层应⽤仅仅
需要⼀个全局唯⼀的名字,类似于数据库中的唯⼀主键。
所以接下来。我们来看看如何使⽤
ZooKeeper
来实现⼀套分布式全局唯⼀
ID
的分配机制
所谓
ID
,就是⼀个能够唯⼀标识某个对象的标识符。在我们熟悉的关系型数据库中,各个表都需要⼀个主键来唯⼀标识每条数据库记录,这个主键就是这样的唯⼀
ID
。在过去的单库单表型系统中,通常可以
使⽤数据库字段⾃带的
auto_increment
属性来⾃动为每条数据库记录⽣成⼀个唯⼀的
ID
,数据库会保证
⽣成的这个
ID
在全局唯⼀。但是随着数据库数据规模的不断增⼤,分库分表随之出现,⽽
auto_increment
属性仅能针对单⼀表中的记录⾃动⽣成
ID
,因此在这种情况下,就⽆法再依靠数据库的
auto_increment
属性来唯⼀标识⼀条记录了。于是,我们必须寻求⼀种能够在分布式环境下⽣成全局唯
⼀
ID
的⽅法。
⼀说起全局唯⼀
ID
,相信⼤家都会联想到
UUID
。没错,
UUID
是通⽤唯⼀识别码(
Universally
Unique Identififier
)的简称,是⼀种在分布式系统中⼴泛使⽤的⽤于唯⼀标识元素的标准 确实,
UUID是⼀个⾮常不错的全局唯⼀
ID
⽣成⽅式,能够⾮常简便地保证分布式环境中的唯⼀性。⼀个标准的
UUID
是⼀个包含
32
位字符和
4
个短线的字符串,例如
“e70f1357-f260-46ffff-a32d-53a086c57ade”
。
UUID
的优势⾃然不必多说,我们重点来看看它的缺陷。
(1)⻓度过⻓
UUID
最⼤的问题就在于⽣成的字符串过⻓。显然,和数据库中的
INT
类型相⽐,存储⼀个
UUID
需要花费更多的空间。
(2)
含义不明
上⾯我们已经看到⼀个典型的
UUID
是类似于
“e70f1357-f260-46ffff-a32d-53a086c57ade”
的⼀个字符串。根据这个字符串,开发⼈员从字⾯上基本看不出任何其表达的含义,这将会⼤⼤影响问题排查和开
发调试的效率。
所以接下来,我们结合⼀个分布式任务调度系统来看看如何使⽤
ZooKeepe
来实现这类全局唯⼀
ID
的⽣成。 之前我们已经提到,通过调⽤
ZooKeeper
节点创建的
API
接⼝可以创建⼀个顺序节点,并且在
API
返
回值中会返回这个节点的完整名字。利⽤这个特性,我们就可以借助
ZooKeeper
来⽣成全局唯⼀的
ID
了,如下图:
全局唯⼀
ID
⽣成的
ZooKeeper
节点示意图
说明,对于⼀个任务列表的主键,使⽤
ZooKeeper
⽣成唯⼀
ID
的基本步骤:
1.
所有客户端都会根据⾃⼰的任务类型,在指定类型的任务下⾯通过调⽤
create
()接⼝来创建⼀个
顺序节点,例如创建
“job-”
节点。
2.
节点创建完毕后,
create
()接⼝会返回⼀个完整的节点名,例如
“job-0000000003”
。
3.
客户端拿到这个返回值后,拼接上
type
类型,例如
“type2-job-0000000003”
,这就可以作为⼀个
全局唯⼀的
ID
了。
在
ZooKeeper
中,每⼀个数据节点都能够维护⼀份⼦节点的顺序顺列,当客户端对其创建⼀个顺序⼦节点的时候
ZooKeeper
会⾃动以后缀的形式在其⼦节点上添加⼀个序号,在这个场景中就是利⽤了
ZooKeeper
的这个特性。
(三)集群管理
1. 基于Agent的分布式集群管理
随着分布式系统规模的⽇益扩⼤,集群中的机器规模也随之变⼤,那如何更好地进⾏集群管理也显得越来越重要了。所谓集群管理,包括集群监控与集群控制两⼤块,前者侧重对集群运⾏时状态的收集,后
者则是对集群进⾏操作与控制。
在⽇常开发和运维过程中,我们经常会有类似于如下的需求:
- 如何快速的统计出当前⽣产环境下⼀共有多少台机器
- 如何快速的获取到机器上下线的情况
- 如何实时监控集群中每台主机的运⾏时状态
在传统的基于
Agent
的分布式集群管理体系中,都是通过在集群中的每台机器上部署⼀个
Agent
,由这个
Agent
负责主动向指定的⼀个监控中⼼系统(监控中⼼系统负责将所有数据进⾏集中处理,形成⼀系
列报表,并负责实时报警,以下简称
“
监控中⼼
”
)汇报⾃⼰所在机器的状态。在集群规模适中的场景
下,这确实是⼀种在⽣产实践中⼴泛使⽤的解决⽅案,能够快速有效地实现分布式环境集群监控,但是
⼀旦系统的业务场景增多,集群规模变⼤之后,该解决⽅案的弊端也就显现出来了。
(1)⼤规模升级困难
以客户端形式存在的
Agent
,在⼤规模使⽤后,⼀旦遇上需要⼤规模升级的情况,就⾮常麻烦,在升级成本和升级进度的控制上⾯临巨⼤的挑战。
(2)
统⼀的
Agent
⽆法满⾜多样的需求
对于机器的
CPU
使⽤率、负载(
Load
)、内存使⽤率、⽹络吞吐以及磁盘容量等机器基本的物理状态,使⽤统⼀的
Agent
来进⾏监控或许都可以满⾜。但是,如果需要深⼊应⽤内部,对⼀些业务状态进⾏监
控,例如,在⼀个分布式消息中间件中,希望监控到每个消费者对消息的消费状态;或者在⼀个分布式
任务调度系统中,需要对每个机器上任务的执⾏情况进⾏监控。很显然,对于这些业务耦合紧密的监控
需求,不适合由⼀个统⼀的
Agent
来提供。
(3)
编程语⾔多样性
随着越来越多编程语⾔的出现,各种异构系统层出不穷。如果使⽤传统的
Agent
⽅式,那么需要提供各种语⾔的
Agent
客户端。另⼀⽅⾯,
“
监控中⼼
”
在对异构系统的数据进⾏整合上⾯临巨⼤挑战。
2. Zookeeper的两⼤特性
1.
客户端如果对
Zookeeper
的数据节点注册
Watcher
监听,那么当该数据节点的内容或是其⼦节点
列表发⽣变更时,
Zookeeper
服务器就会向订阅的客户端发送变更通知。
2.
对在
Zookeeper
上创建的临时节点,⼀旦客户端与服务器之间的会话失效,那么临时节点也会被
⾃动删除
利⽤其两⼤特性,可以实现集群机器存活监控系统,若监控系统在
/clusterServers
节点上注册⼀个Watcher
监听,那么但凡进⾏动态添加机器的操作,就会在
/clusterServers
节点下创建⼀个临时节
点:
/clusterServers/[Hostname]
,这样,监控系统就能够实时监测机器的变动情况。
下⾯通过分布式⽇志收集系统这个典型应⽤来学习
Zookeeper
如何实现集群管理。
3. 分布式⽇志收集系统
分布式⽇志收集系统的核⼼⼯作就是收集分布在不同机器上的系统⽇志,在这⾥我们重点来看分布
式⽇志系统(以下简称
“
⽇志系统
”
)的收集器模块。
在⼀个典型的⽇志系统的架构设计中,整个⽇志系统会把所有需要收集的⽇志机器(我们以
“
⽇志源机器
”
代表此类机器)分为多个组别,每个组别对应⼀个收集器,这个收集器其实就是⼀个后台机器(我们
以
“
收集器机器
”
代表此类机器),⽤于收集⽇志。
对于⼤规模的分布式⽇志收集系统场景,通常需要解决两个问题:
在⽣产环境中,伴随着机器的变动,每个应⽤的机器⼏乎每天都是在变化的(机器硬件问题、扩容、机房迁移或是⽹络问题等都会导致⼀个应⽤的机器变化),也就是说每个组别中的⽇志源机器通常是在不断变化
的
⽇志收集系统⾃身也会有机器的变更或扩容,于是会出现新的收集器机器加⼊或是⽼的收集器机器退出的情况。
⽆论是⽇志源机器还是收集器机器的变更,最终都可以归结为如何快速、合理、动态地为每个收集器分配对应的⽇志源机器。这也成为了整个⽇志系统正确稳定运转的前提,也是⽇志收集过程中最⼤的
技术挑战之⼀,在这种情况下,我们就可以引⼊
zookeeper
了,下⾯我们就来看
ZooKeeper
在这个场景
中的使⽤。
使⽤
Zookeeper
的场景步骤如下
①
注册收集器机器
使⽤
ZooKeeper
来进⾏⽇志系统收集器的注册,典型做法是在
ZooKeeper
上创建⼀个节点作为收集器的根节点,例如
/logs/collector
(下⽂我们以
“
收集器节点
”
代表该数据节点),每个收集器机器在启动的时
候,都会在收集器节点下创建⾃⼰的节点,例如
/logs/collector/[Hostname]
②
任务分发
待所有收集器机器都创建好⾃⼰对应的节点后,系统根据收集器节点下⼦节点的个数,将所有⽇志源机器分成对应的若⼲组,然后将分组后的机器列表分别写到这些收集器机器创建的⼦节点(例
如
/logs/collector/host1
)上去。这样⼀来,每个收集器机器都能够从⾃⼰对应的收集器节点上获取⽇志源机器列表,进⽽开始进⾏⽇志收集⼯作。
③
状态汇报
完成收集器机器的注册以及任务分发后,我们还要考虑到这些机器随时都有挂掉的可能。因此,针对这个问题,我们需要有⼀个收集器的状态汇报机制:每个收集器机器在创建完⾃⼰的专属节点后,还需要
在对应的⼦节点上创建⼀个状态⼦节点,例如
/logs/collector/host1/status
,每个收集器机器都需要定
期向该节点写⼊⾃⼰的状态信息。我们可以把这种策略看作是⼀种⼼跳检测机制,通常收集器机器都会
在这个节点中写⼊⽇志收集进度信息。⽇志系统根据该状态⼦节点的最后更新时间来判断对应的收集器
机器是否存活。
④
动态分配
如果收集器机器挂掉或是扩容了,就需要动态地进⾏收集任务的分配。在运⾏过程中,⽇志系统始终关注着
/logs/collector
这个节点下所有⼦节点的变更,⼀旦检测到有收集器机器停⽌汇报或是有新的收集
器机器加⼊,就要开始进⾏任务的重新分配。⽆论是针对收集器机器停⽌汇报还是新机器加⼊的情况,
⽇志系统都需要将之前分配给该收集器的所有任务进⾏转移。为了解决这个问题,通常有两种做法:
·
全局动态分配
这是⼀种简单粗暴的做法,在出现收集器机器挂掉或是新机器加⼊的时候,⽇志系统需要根据新的收集器机器列表,⽴即对所有的⽇志源机器重新进⾏⼀次分组,然后将其分配给剩下的收集器机器。
·
局部动态分配
全局动态分配⽅式虽然策略简单,但是存在⼀个问题:⼀个或部分收集器机器的变更,就会导致全局动态任务的分配,影响⾯⽐较⼤,因此⻛险也就⽐较⼤。所谓局部动态分配,顾名思义就是在⼩范围内进
⾏任务的动态分配。在这种策略中,每个收集器机器在汇报⾃⼰⽇志收集状态的同时,也会把⾃⼰的负
载汇报上去。请注意,这⾥提到的负载并不仅仅只是简单地指机器
CPU
负载(
Load
),⽽是⼀个对当前
收集器任务执⾏的综合评估,这个评估算法和
ZooKeeper
本身并没有太⼤的关系,这⾥不再赘述。
全局动态分配⽅式虽然策略简单,但是存在⼀个问题:⼀个或部分收集器机器的变更,就会导致全局动态任务的分配,影响⾯⽐较⼤,因此⻛险也就⽐较⼤。所谓局部动态分配,顾名思义就是在⼩范围内进
⾏任务的动态分配。在这种策略中,每个收集器机器在汇报⾃⼰⽇志收集状态的同时,也会把⾃⼰的负
载汇报上去。请注意,这⾥提到的负载并不仅仅只是简单地指机器
CPU
负载(
Load
),⽽是⼀个对当前
收集器任务执⾏的综合评估,这个评估算法和
ZooKeeper
本身并没有太⼤的关系,这⾥不再赘述。
在这种策略中,如果⼀个收集器机器挂了,那么⽇志系统就会把之前分配给这个机器的任务重新分配到那些负载较低的机器上去。同样,如果有新的收集器机器加⼊,会从那些负载⾼的机器上转移部分任务
给这个新加⼊的机器。
上述步骤已经完整的说明了整个⽇志收集系统的⼯作流程,其中有两点注意事项:
①
节点类型
在
/logs/collector
节点下创建临时节点可以很好的判断机器是否存活,但是,若机器挂了,其节点会被删除,记录在节点上的⽇志源机器列表也被清除,所以需要选择持久节点来标识每⼀台机器,同时在节
点下分别创建
/logs/collector/[Hostname]/status
节点来表征每⼀个收集器机器的状态,这样,既能实
现对所有机器的监控,同时机器挂掉后,依然能够将分配任务还原。
②
⽇志系统节点监听
在
/logs/collector
节点下创建临时节点可以很好的判断机器是否存活,但是,若机器挂了,其节点会被删除,记录在节点上的⽇志源机器列表也被清除,所以需要选择持久节点来标识每⼀台机器,同时在节
点下分别创建
/logs/collector/[Hostname]/status
节点来表征每⼀个收集器机器的状态,这样,既能实
现对所有机器的监控,同时机器挂掉后,依然能够将分配任务还原。
②
⽇志系统节点监听
若采⽤
Watcher
机制,那么通知的消息量的⽹络开销⾮常⼤,需要采⽤⽇志系统主动轮询收集器节点的策略,这样可以节省⽹络流量,但是存在⼀定的延时。
(四)Master选举
Master
选举是⼀个在分布式系统中⾮常常⻅的应⽤场景。分布式最核⼼的特性就是能够将具有ᇿ⽴计算能⼒的系统单元部署在不同的机器上,构成⼀个完整的分布式系统。⽽与此同时,实际场景中往往也需
要在这些分布在不同机器上的ᇿ⽴系统单元中选出⼀个所谓的
“
⽼⼤
”
,在计算机中,我们称之为
Master
。
在分布式系统中,
Master
往往⽤来协调集群中其他系统单元,具有对分布式系统状态变更的决定权。例如,在⼀些读写分离的应⽤场景中,客户端的写请求往往是由
Master
来处理的;⽽在另⼀些场景中,
Master
则常常负责处理⼀些复杂的逻辑,并将处理结果同步给集群中其他系统单元。
Master
选举可以
说是
ZooKeeper
最典型的应⽤场景了,接下来,我们就结合
“
⼀种海量数据处理与共享模型
”
这个具体例
⼦来看看
ZooKeeper
在集群
Master
选举中的应⽤场景。
在分布式环境中,经常会碰到这样的应⽤场景:集群中的所有系统单元需要对前端业务提供数据,⽐如⼀个商品
ID
,或者是⼀个⽹站轮播⼴告的⼴告
ID
(通常出现在⼀些⼴告投放系统中)等,⽽这些商品
ID
或是⼴告
ID
往往需要从⼀系列的海量数据处理中计算得到
——
这通常是⼀个⾮常耗费
I/O
和
CPU
资源
的过程。鉴于该计算过程的复杂性,如果让集群中的所有机器都执⾏这个计算逻辑的话,那么将耗费⾮
常多的资源。⼀种⽐较好的⽅法就是只让集群中的部分,甚⾄只让其中的⼀台机器去处理数据计算,⼀
旦计算出数据结果,就可以共享给整个集群中的其他所有客户端机器,这样可以⼤⼤减少重复劳动,提
升性能。 这⾥我们以⼀个简单的⼴告投放系统后台场景为例来讲解这个模型。
整个系统⼤体上可以分成客户端集群、分布式缓存系统、海量数据处理总线和 ZooKeeper四个部分。
⾸先我们来看整个系统的运⾏机制。图中的
Client
集群每天定时会通过
ZooKeeper
来实现
Master
选举。选举产⽣
Master
客户端之后,这个
Master
就会负责进⾏⼀系列的海量数据处理,最终计算得到⼀个数
据结果,并将其放置在⼀个内存
/
数据库中。同时,
Master
还需要通知集群中其他所有的客户端从这个
内存
/
数据库中共享计算结果。
接下去,我们将重点来看
Master
选举的过程,⾸先来明确下
Master
选举的需求:在集群的所有机器中选举出⼀台机器作为
Master
。针对这个需求,通常情况下,我们可以选择常⻅的关系型数据库中的主
键特性来实现:集群中的所有机器都向数据库中插⼊⼀条相同主键
ID
的记录,数据库会帮助我们⾃动
进⾏主键冲突检查,也就是说,所有进⾏插⼊操作的客户端机器中,只有⼀台机器能够成功
——
那么,
我们就认为向数据库中成功插⼊数据的客户端机器成为
Master
。
借助数据库的这种⽅案确实可⾏,依靠关系型数据库的主键特性能够很好地保证在集群中选举出唯⼀的⼀个
Master
。但是我们需要考虑的另⼀个问题是,如果当前选举出的
Master
挂了,那么该如何处理?
谁来告诉我
Master
挂了呢?显然,关系型数据库没法通知我们这个事件。那么,如果使⽤
ZooKeeper
是
否可以做到这⼀点呢? 那在之前,我们介绍了
ZooKeeper
创建节点的
API
接⼝,其中⼀个重要特性便
是:利⽤
ZooKeeper
的强⼀致性,能够很好保证在分布式⾼并发情况下节点的创建⼀定能够保证全局唯
⼀性,即
ZooKeeper
将会保证客户端⽆法重复创建⼀个已经存在的数据节点。也就是说,如果同时有多
个客户端请求创建同⼀个节点,那么最终⼀定只有⼀个客户端请求能够创建成功。利⽤这个特性,就能
很容易地在分布式环境中进⾏
Master
选举了。
在这个系统中,⾸先会在 ZooKeeper 上创建⼀个⽇期节点,例如“2020-11-11
客户端集群每天都会定时往
ZooKeeper
上创建⼀个临时节点,例如
/master_election/2020-11-
11/binding
。在这个过程中,只有⼀个客户端能够成功创建这个节点,那么这个客户端所在的机器就成为了
Master
。同时,其他没有在
ZooKeeper
上成功创建节点的客户端,都会在节
点
/master_election/2020-11-11
上注册⼀个⼦节点变更的
Watcher
,⽤于监控当前的
Master
机器是否存活,⼀旦发现当前的
Master
挂了,那么其余的客户端将会重新进⾏
Master
选举。
从上⾯的讲解中,我们可以看到,如果仅仅只是想实现
Master
选举的话,那么其实只需要有⼀个能够保证数据唯⼀性的组件即可,例如关系型数据库的主键模型就是⾮常不错的选择。但是,如果希望能够快
速地进⾏集群
Master
动态选举,那么就可以基于
ZooKeeper
来实现。
(五)分布式锁
分布式锁是控制分布式系统之间同步访问共享资源的⼀种⽅式。如果不同的系统或是同⼀个系统的不同主机之间共享了⼀个或⼀组资源,那么访问这些资源的时候,往往需要通过⼀些互斥⼿段来防⽌彼此之
间的⼲扰,以保证⼀致性,在这种情况下,就需要使⽤分布式锁了。
在平时的实际项⽬开发中,我们往往很少会去在意分布式锁,⽽是依赖于关系型数据库固有的排他性来实现不同进程之间的互斥。这确实是⼀种⾮常简便且被⼴泛使⽤的分布式锁实现⽅式。然⽽有⼀个不争
的事实是,⽬前绝⼤多数⼤型分布式系统的性能瓶颈都集中在数据库操作上。因此,如果上层业务再给
数据库添加⼀些额外的锁,例如⾏锁、表锁甚⾄是繁重的事务处理,那么就会让数据库更加不堪重负
下⾯我们来看看使⽤
ZooKeeper
如何实现分布式锁,这⾥主要讲解排他锁和共享锁两类分布式锁。
1. 排他锁
排他锁(
Exclusive Locks
,简称
X
锁),⼜称为写锁或ᇿ占锁,是⼀种基本的锁类型。如果事务
T1
对数据对象
O1
加上了排他锁,那么在整个加锁期间,只允许事务
T1
对
O1
进⾏读取和更新操作,其他任
何事务都不能再对这个数据对象进⾏任何类型的操作
——
直到
T1
释放了排他锁
从上⾯讲解的排他锁的基本概念中,我们可以看到,排他锁的核⼼是如何保证当前有且仅有⼀个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到。
下⾯我们就来看看如何借助
ZooKeeper
实现排他锁:
①
定义锁
在通常的
Java
开发编程中,有两种常⻅的⽅式可以⽤来定义锁,分别是
synchronized
机制和
JDK5
提供的ReentrantLock
。然⽽,在
ZooKeeper
中,没有类似于这样的
API
可以直接使⽤,⽽是通过
ZooKeeper
上的数据节点来表示⼀个锁,例如
/exclusive_lock/lock
节点就可以被定义为⼀个锁,如图:
②
获取锁
在需要获取排他锁时,所有的客户端都会试图通过调⽤
create
()接⼝,在
/exclusive_lock
节点下创建临时⼦节点
/exclusive_lock/lock
。在前⾯,我们也介绍了,
ZooKeeper
会保证在所有的客户端中,最
终只有⼀个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,所有没有获取到锁的客户
端就需要到
/exclusive_lock
节点上注册⼀个⼦节点变更的
Watcher
监听,以便实时监听到
lock
节点的变
更情况。
③
释放锁
在
“
定义锁
”
部分,我们已经提到,
/exclusive_lock/lock
是⼀个临时节点,因此在以下两种情况下,都有可能释放锁。
·
当前获取锁的客户端机器发⽣宕机,那么
ZooKeeper
上的这个临时节点就会被移除。
·
正常执⾏完业务逻辑后,客户端就会主动将⾃⼰创建的临时节点删除。 ⽆论在什么情况下移除了
lock
节
点,
ZooKeeper
都会通知所有在
/exclusive_lock
节点上注册了⼦节点变更
Watcher
监听的客户端。这些
客户端在接收到通知后,再次重新发起分布式锁获取,即重复
“
获取锁
”
过程。整个排他锁的获取和释放
流程,如下图:
2. 共享锁
共享锁(Shared Locks,简称S锁),⼜称为读锁,同样是⼀种基本的锁类型。
如果事务
T1
对数据对象
O1
加上了共享锁,那么当前事务只能对
O1
进⾏读取操作,其他事务也只能对这个数据对象加共享锁
——
直到该数据对象上的所有共享锁都被释放。
共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对⼀个事务可⻅,⽽加上共享锁后,数据对所有事务都可⻅。
下⾯我们就来看看如何借助
ZooKeeper
来实现共享锁。
①
定义锁
和排他锁⼀样,同样是通过
ZooKeeper
上的数据节点来表示⼀个锁,是⼀个类似于
“/shared_lock/[Hostname]-
请求类型
-
序号
”
的临时顺序节点,例如
/shared_lock/host1-R-
0000000001
,那么,这个节点就代表了⼀个共享锁,如图所示:
② 获取锁
在需要获取共享锁时,所有客户端都会到
/shared_lock
这个节点下⾯创建⼀个临时顺序节点,如果当前是读请求,那么就创建例如
/shared_lock/host1-R-0000000001
的节点;如果是写请求,那么就创建例
如
/shared_lock/host2-W-0000000002
的节点。
判断读写顺序
通过
Zookeeper
来确定分布式读写顺序,⼤致分为四步
1.
创建完节点后,获取
/shared_lock
节点下所有⼦节点,并对该节点变更注册监听。
2.
确定⾃⼰的节点序号在所有⼦节点中的顺序。
3.
对于读请求:若没有⽐⾃⼰序号⼩的⼦节点或所有⽐⾃⼰序号⼩的⼦节点都是读请求,那么表
明⾃⼰已经成功获取到共享锁,同时开始执⾏读取逻辑,若有写请求,则需要等待。对于写请求:若⾃⼰不
是序号最⼩的⼦节点,那么需要等待。
4.
接收到
Watcher
通知后,重复步骤
1
③
释放锁
,其释放锁的流程与ᇿ占锁⼀致。
3. ⽺群效应
上⾯讲解的这个共享锁实现,⼤体上能够满⾜⼀般的分布式集群竞争锁的需求,并且性能都还可以
——这⾥说的⼀般场景是指集群规模不是特别⼤,⼀般是在
10
台机器以内。但是如果机器规模扩⼤之后,会
有什么问题呢?我们着重来看上⾯
“
判断读写顺序
”
过程的步骤
3
,结合下⾯的图,看看实际运⾏中的情况。
针对如上图所示的情况进⾏分析
1. host1
⾸先进⾏读操作,完成后将节点
/shared_lock/host1-R-00000001
删除。
2.
余下
4
台机器均收到这个节点移除的通知,然后重新从
/shared_lock
节点上获取⼀份新的⼦节
点列表。
3.
每台机器判断⾃⼰的读写顺序,其中
host2
检测到⾃⼰序号最⼩,于是进⾏写操作,余
下的机器则继续等待。
4.
继续
...
可以看到,
host1
客户端在移除⾃⼰的共享锁后,
Zookeeper
发送了⼦节点更变
Watcher
通知给所有机器,然⽽除了给
host2
产⽣影响外,对其他机器没有任何作⽤。⼤量的
Watcher
通知和⼦节点列表获取
两个操作会重复运⾏,这样不仅会对
zookeeper
服务器造成巨⼤的性能影响影响和⽹络开销,更为严重
的是,如果同⼀时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,
ZooKeeper
服务器
就会在短时间内向其余客户端发送⼤量的事件通知,这就是所谓的
⽺群效应
。
上⾯这个
ZooKeeper
分布式共享锁实现中出现⽺群效应的根源在于,没有找准客户端真正的关注点。我们再来回顾⼀下上⾯的分布式锁竞争过程,它的核⼼逻辑在于:判断⾃⼰是否是所有⼦节点中序号最⼩
的。于是,很容易可以联想到,每个节点对应的客户端只需要关注⽐⾃⼰序号⼩的那个相关节点的变更
情况就可以了
——
⽽不需要关注全局的⼦列表变更情况。
可以有如下改动来避免⽺群效应。
改进后的分布式锁实现:
⾸先,我们需要肯定的⼀点是,上⾯提到的共享锁实现,从整体思路上来说完全正确。这⾥主要的改动在于:每个锁竞争者,只需要关注
/shared_lock
节点下序号⽐⾃⼰⼩的那个节点是否存在即可,具体实
现如下。
1.
客户端调⽤
create
接⼝常⻅类似于
/shared_lock/[Hostname]-
请求类型
-
序号的临时顺序节点。
2.
客户端调⽤
getChildren
接⼝获取所有已经创建的⼦节点列表(不注册任何
Watcher
)。
3.
如果⽆法获取共享锁,就调⽤
exist
接⼝来对⽐⾃⼰⼩的节点注册
Watcher
。对于读请求:向⽐⾃⼰序号⼩的最后⼀个写请求节点注册
Watcher
监听。对于写请求:向⽐⾃⼰序号⼩的最后⼀个节点注
册
Watcher
监听。
4.
等待
Watcher
通知,继续进⼊步骤
2
。
此⽅案改动主要在于:每个锁竞争者,只需要关注
/shared_lock
节点下序号⽐⾃⼰⼩的那个节点是否存在即可。
注意
相信很多同学都会觉得改进后的分布式锁实现相对来说⽐较麻烦。确实如此,如同在多线程并发编程实践中,我们会去尽量缩⼩锁的范围
——
对于分布式锁实现的改进其实也是同样的思路。那么对于开
发⼈员来说,是否必须按照改进后的思路来设计实现⾃⼰的分布式锁呢?答案是否定的。在具体的实际
开发过程中,我们提倡根据具体的业务场景和集群规模来选择适合⾃⼰的分布式锁实现:在集群规模不
⼤、⽹络资源丰富的情况下,第⼀种分布式锁实现⽅式是简单实⽤的选择;⽽如果集群规模达到⼀定程
度,并且希望能够精细化地控制分布式锁机制,那么就可以试试改进版的分布式锁实现。
(六)分布式队列
分布式队列可以简单分为两⼤类:
⼀种是常规的
FIFO
先⼊先出队列模型
,还有⼀种是 等待队列元素聚集后统⼀安排处理执⾏的
Barrier
模型
。
①
FIFO
先⼊先出
FIFO
(
First Input First Output
,先⼊先出),
FIFO
队列是⼀种⾮常典型且应⽤⼴泛的按序执⾏的队列模型:先进⼊队列的请求操作先完成后,才会开始处理后⾯的请求。
使⽤
ZooKeeper
实现
FIFO
队列,和之前提到的共享锁的实现⾮常类似。
FIFO
队列就类似于⼀个全写的共享锁模型,⼤体的设计思路其实⾮常简单:所有客户端都会到
/queue_fififo
这个节点下⾯创建⼀个临时
顺序节点,例如如
/queue_fififo/host1-00000001
。
创建完节点后,根据如下4个步骤来确定执⾏顺序。
1.
通过调⽤
getChildren
接⼝来获取
/queue_fififo
节点的所有⼦节点,即获取队列中所有的元素。
2.
确定⾃⼰的节点序号在所有⼦节点中的顺序。
3.
如果⾃⼰的序号不是最⼩,那么需要等待,同时向⽐⾃⼰序号⼩的最后⼀个节点注册
Watcher
监
听。
4.
接收到
Watcher
通知后,重复步骤
1
。
② Barrier:分布式屏障
Barrier
原意是指障碍物、屏障,⽽在分布式系统中,特指系统之间的⼀个协调条件,规定了⼀个队列的元素必须都集聚后才能统⼀进⾏安排,否则⼀直等待。这往往出现在那些⼤规模分布式并⾏计算的应⽤
场景上:最终的合并计算需要基于很多并⾏计算的⼦结果来进⾏。这些队列其实是在
FIFO
队列的基础
上进⾏了增强,⼤致的设计思想如下:开始时,
/queue_barrier
节点是⼀个已经存在的默认节点,并且
将其节点的数据内容赋值为⼀个数字
n
来代表
Barrier
值,例如
n=10
表示只有当
/queue_barrier
节点下的
⼦节点个数达到
10
后,才会打开
Barrier
。之后,所有的客户端都会到
/queue_barrie
节点下创建⼀个临
时节点,例如
/queue_barrier/host1
,如图所示。
创建完节点后,按照如下步骤执⾏。
1.
通过调⽤
getData
接⼝获取
/queue_barrier
节点的数据内容:
10
。
2.
通过调⽤
getChildren
接⼝获取
/queue_barrier
节点下的所有⼦节点,同时注册对⼦节点变更的
Watcher
监听。
3.
统计⼦节点的个数。
4.
如果⼦节点个数还不⾜
10
个,那么需要等待。
5.
接受到
Wacher
通知后,重复步骤
2
二. Zookeeper深⼊进阶
(一)ZAB协议
1. 概念
在深⼊了解
zookeeper
之前,很多同学可能会认为
zookeeper
就是
paxos
算法的⼀个实现,但事实上,zookeeper
并没有完全采⽤
paxos
算法,⽽是使⽤了⼀种称为
Zookeeper Atomic Broadcast
(
ZAB
,
Zookeeper
原⼦消息⼴播协议)的协议作为其数据⼀致性的核⼼算法。
ZAB
协议并不像
Paxos
算法那样 是⼀种通⽤的分布式⼀致性算法,它是⼀种特别为
zookeeper
专⻔设计的⼀种
⽀持崩溃恢复的原⼦⼴播协议
在
zookeeper
中,主要就是依赖
ZAB
协议来实现分布式数据的⼀致性,基于该协议,
Zookeeper
实现了⼀种主备模式的系统架构来保持集群中各副本之间的数据的⼀致性,表现形式就是 使⽤⼀个单⼀的主进
程来接收并处理客户端的所有事务请求,并采⽤
ZAB
的原⼦⼴播协议,将服务器数据的状态变更以事务
Proposal
的形式⼴播到所有的副本进程中,
ZAB
协议的主备模型架构保证了同⼀时刻集群中只能够有⼀
个主进程来⼴播服务器的状态变更,因此能够很好地处理客户端⼤量的并发请求。但是,也要考虑到主
进程在任何时候都有可能出现崩溃退出或重启现象,因此
,ZAB
协议还需要做到当前主进程当出现上述异
常情况的时候,依旧能正常⼯作。
2. ZAB核⼼
ZAB协议的核⼼是定义了对于那些会改变Zookeeper服务器数据状态的事务请求的处理⽅式
即:所有事务请求必须由⼀个全局唯⼀的服务器来协调处理,这样的服务器被称为
Leader
服务器,余下的服务器则称为
Follower
服务器,
Leader
服务器负责将⼀个客户端事务请求转化成⼀个事务
Proposal
(提
议),并将该
Proposal
分发给集群中所有的
Follower
服务器,之后
Leader
服务器需要等待所有
Follower
服务器的反馈,⼀旦超过半数的
Follower
服务器进⾏了正确的反馈后,那么
Leader
就会再次向
所有的
Follower
服务器分发
Commit
消息,要求其将前⼀个
Proposal
进⾏提交。
3. ZAB协议介绍
ZAB协议包括两种基本的模式:崩溃恢复和消息⼴播
进⼊崩溃恢复模式:
当整个服务框架启动过程中,或者是
Leader
服务器出现⽹络中断、崩溃退出或重启等异常情况时,
ZAB协议就会进⼊崩溃恢复模式,同时选举产⽣新的
Leader
服务器。当选举产⽣了新的
Leader
服务器,同
时集群中已经有过半的机器与该
Leader
服务器完成了状态同步之后,
ZAB
协议就会退出恢复模式,其
中,所谓的
状态同步
就是指数据同步,⽤来保证集群中过半的机器能够和
Leader
服务器的数据状态保
持⼀致。
进⼊消息⼴播模式:
当集群中已经有过半的
Follower
服务器完成了和
Leader
服务器的状态同步,那么整个服务框架就可以进⼊
消息⼴播模式,
当⼀台同样遵守
ZAB
协议的服务器启动后加⼊到集群中,如果此时集群中已经存在⼀
个
Leader
服务器在负责进⾏消息⼴播,那么加⼊的服务器就会⾃觉地进⼊
数据恢复模式:找到
Leader
所在的服务器,并与其进⾏数据同步,然后⼀起参与到消息⼴播流程中去。
Zookeeper
只允许唯⼀的⼀。
个
Leader
服务器来进⾏事务请求的处理,
Leader
服务器在接收到客户端的事务请求后,会⽣成对应的事务提议并发起⼀轮⼴播协议,⽽如果集群中的其他机器收到客户端的事务请求后,那么这些⾮
Leader
服务器会⾸先将这个事务请求转发给
Leader
服务器。
接下来我们就重点讲解⼀下
ZAB
协议的消息⼴播过程和崩溃恢复过程
①
消息⼴播
ZAB
协议的消息⼴播过程使⽤原⼦⼴播协议,类似于⼀个⼆阶段提交过程,针对客户端的事务请求,Leader
服务器会为其⽣成对应的事务
Proposal
,并将其发送给集群中其余所有的机器,然后再分别收集
各⾃的选票,最后进⾏事务提交。
在
ZAB
的⼆阶段提交过程中,移除了中断逻辑,所有的
Follower
服务器要么正常反馈
Leader
提出的事务Proposal
,要么就抛弃
Leader
服务器,同时,
ZAB
协议将⼆阶段提交中的中断逻辑移除意味着我们可以
在过半的
Follower
服务器已经反馈
Ack
之后就开始提交事务
Proposal
了,⽽不需要等待集群中所有的
Follower
服务器都反馈响应,但是,在这种简化的⼆阶段提交模型下,⽆法处理因
Leader
服务器崩溃退
出⽽带来的数据不⼀致问题,因此
ZAB
采⽤了崩溃恢复模式来解决此问题,另外,整个消息⼴播协议是
基于具有
FIFO
特性的
TCP
协议来进⾏⽹络通信的,因此能够很容易保证消息⼴播过程中消息接受与发送
的顺序性。
在整个消息⼴播过程中,
Leader
服务器会为每个事务请求⽣成对应的
Proposal
来进⾏⼴播,并且在⼴播事务
Proposal
之前,
Leader
服务器会⾸先为这个事务
Proposal
分配⼀个全局单调递增的唯⼀
ID
,称之
为事务
ID
(
ZXID
),由于
ZAB
协议需要保证每个消息严格的因果关系,因此必须将每个事务
Proposal
按
照其
ZXID
的先后顺序来进⾏排序和处理。
具体的过程:在消息⼴播过程中,
Leader
服务器会为每⼀个
Follower
服务器都各⾃分配⼀个单ᇿ的队列,然后将需要⼴播的事务
Proposal
依次放⼊这些队列中去,并且根据
FIFO
策略进⾏消息发送。每⼀
个
Follower
服务器在接收到这个事务
Proposal
之后,都会⾸先将其以事务⽇志的形式写⼊到本地磁盘中
去,并且在成功写⼊后反馈给
Leader
服务器⼀个
Ack
响应。当
Leader
服务器接收到超过半数
Follower
的
Ack
响应后,就会⼴播⼀个
Commit
消息给所有的
Follower
服务器以通知其进⾏事务提交,同时
Leader
⾃身也会完成对事务的提交,⽽每⼀个
Follower
服务器在接收到
Commit
消息后,也会完成对事务的提
交。
②
崩溃恢复
ZAB
协议的这个基于原⼦⼴播协议的消息⼴播过程,在正常情况下运⾏⾮常良好,但是⼀旦在
Leader
服务器出现崩溃,或者由于⽹络原因导致
Leader
服务器失去了与过半
Follower
的联系,那么就会进⼊崩溃
恢复模式。在
ZAB
协议中,为了保证程序的正确运⾏,整个恢复过程结束后需要选举出⼀个新的
Leader
服务器,因此,
ZAB
协议需要⼀个⾼效且可靠的
Leader
选举算法,从⽽保证能够快速地选举出新的
Leader
,同时,
Leader
选举算法不仅仅需要让
Leader
⾃身知道已经被选举为
Leader
,同时还需要让集
群中的所有其他机器也能够快速地感知到选举产⽣出来的新
Leader
服务器。
4. 基本特性
根据上⾯的内容,我们了解到,
ZAB
协议规定了如果⼀个事务
Proposal
在⼀台机器上被处理成功,那么应该在所有的机器上都被处理成功,哪怕机器出现故障崩溃。接下来我们看看在崩溃恢复过程中,可能
会出现的两个数据不⼀致性的隐患及针对这些情况
ZAB
协议所需要保证的特性。
(1)
ZAB
协议需要确保那些已经在
Leader
服务器上提交的事务最终被所有服务器都提交
假设⼀个事务在
Leader
服务器上被提交了,并且已经得到过半
Folower
服务器的
Ack
反馈,但是在它将
Commit
消息发送给所有
Follower
机器之前,
Leader
服务器挂了,如图所示
图中的消息
C2
就是⼀个典型的例⼦:在集群正常运⾏过程中的某⼀个时刻,
Server1
是
Leader
服务器,其先后⼴播了消息
P1
、
P2
、
C1
、
P3
和
C2
,其中,当
Leader
服务器将消息
C2
(
C2
是
Commit Of
Proposal2
的缩写,即提交事务
Proposal2
)发出后就⽴即崩溃退出了。针对这种情况,
ZAB
协议就需要
确保事务
Proposal2
最终能够在所有的服务器上都被提交成功,否则将出现不⼀致。
(2)
ZAB
协议需要确保丢弃那些只在
Leader
服务器上被提出的事务
如果在崩溃恢复过程中出现⼀个需要被丢弃的提案,那么在崩溃恢复结束后需要跳过该事务
Proposal
,如图所示。
在图所示的集群中,假设初始的
Leader
服务器
Server1
在提出了⼀个事务
Proposal3
之后就崩溃退出了,从⽽导致集群中的其他服务器都没有收到这个事务
Proposal3
。于是,当
Server1
恢复过来再次加
⼊到集群中的时候,
ZAB
协议需要确保丢弃
Proposal3
这个事务。
结合上⾯提到的这两个崩溃恢复过程中需要处理的特殊情况,就决定了
ZAB
协议必须设计这样⼀个
Leader
选举算法:能够确保提交已经被
Leader
提交的事务
Proposal
,同时丢弃已经被跳过的事务
Proposal
。针对这个要求,如果让
Leader
选举算法能够保证新选举出来的
Leader
服务器拥有集群中所
有机器最⾼编号(即
ZXID
最⼤)的事务
Proposal
,那么就可以保证这个新选举出来的
Leader
⼀定具有
所有已经提交的提案。更为重要的是,如果让具有最⾼编号事务
Proposal
的机器来成为
Leader
,就可
以省去
Leader
服务器检查
Proposal
的提交和丢弃⼯作的这⼀步操作了。
5. 数据同步
完成
Leader
选举之后,在正式开始⼯作(即接收客户端的事务请求,然后提出新的提案)之前,
Leader
服务器会⾸先确认事务⽇志中的所有
Proposal
是否都已经被集群中过半的机器提交了,即是否完成数据同步。下⾯我们就来看看
ZAB
协议的数据同步过程。
所有正常运⾏的服务器,要么成为
Leader
,要么成为
Follower
并和
Leader
保持同步。
Leader
服务器需要确保所有的
Follower
服务器能够接收到每⼀条事务
Proposal
,并且能够正确地将所有已经提交了的
事务
Proposal
应⽤到内存数据库中去。具体的,
Leader
服务器会为每⼀个
Follower
服务器都准备⼀个队
列,并将那些没有被各
Follower
服务器同步的事务以
Proposal
消息的形式逐个发送给
Follower
服务器,
并在每⼀个
Proposal
消息后⾯紧接着再发送⼀个
Commit
消息,以表示该事务已经被提交。等到
Follower
服务器将所有其尚未同步的事务
Proposal
都从
Leader
服务器上同步过来并成功应⽤到本地数
据库中后,
Leader
服务器就会将该
Follower
服务器加⼊到真正的可⽤
Follower
列表中,并开始之后的其
他流程。
6. 运⾏时状态分析
在ZAB协议的设计中,每个进程都有可能处于如下三种状态之⼀
· LOOKING
:
Leader
选举阶段。
· FOLLOWING
:
Follower
服务器和
Leader
服务器保持同步状态。
· LEADING
:
Leader
服务器作为主进程领导状态。
所有进程初始状态都是
LOOKING
状态,此时不存在
Leader
,接下来,进程会试图选举出⼀个新的
Leader
,之后,如果进程发现已经选举出新的
Leader
了,那么它就会切换到
FOLLOWING
状态,并开始和
Leader
保持同步,处于
FOLLOWING
状态的进程称为
Follower
,
LEADING
状态的进程称为
Leader
,当
Leader
崩溃或放弃领导地位时,其余的
Follower
进程就会转换到
LOOKING
状态开始新⼀轮的
Leader
选
举。
⼀个
Follower
只能和⼀个
Leader
保持同步,
Leader
进程和所有的
Follower
进程之间都通过⼼跳检测
机制来感知彼此的情况。若
Leader
能够在超时时间内正常收到⼼跳检测,那么
Follower
就会⼀直与该Leader
保持连接,⽽如果在指定时间内
Leader
⽆法从过半的
Follower
进程那⾥接收到⼼跳检测,或者
TCP
连接断开,那么
Leader
会放弃当前周期的领导,并转换到
LOOKING
状态,其他的
Follower
也会选择
放弃这个
Leader
,同时转换到
LOOKING
状态,之后会进⾏新⼀轮的
Leader
选举。
7. ZAB与Paxos的联系和区别
联系:
① 都存在⼀个类似于
Leader
进程的⻆⾊,由其负责协调多个
Follower
进程的运⾏。
②
Leader
进程都会等待超过半数的
Follower
做出正确的反馈后,才会将⼀个提议进⾏提交。
③ 在
ZAB
协议中,每个
Proposal
中都包含了⼀个
epoch
值,⽤来代表当前的
Leader
周期,在
Paxos算法中,同样存在这样的⼀个标识,名字为
Ballot
。
区别:
Paxos
算法中,新选举产⽣的主进程会进⾏两个阶段的⼯作,第⼀阶段称为读阶段,新的主进程和其他进程通信来收集主进程提出的提议,并将它们提交。第⼆阶段称为写阶段,当前主进程开始提出⾃
⼰的提议。
ZAB
协议在
Paxos
基础上添加了同步阶段,此时,新的
Leader
会确保 存在过半的
Follower
已经提交了之前的
Leader
周期中的所有事务
Proposal
。这⼀同步阶段的引⼊,能够有效地保证
Leader
在新的周
期中提出事务
Proposal
之前,所有的进程都已经完成了对之前所有事务
Proposal
的提交。
总的来说,
ZAB
协议和
Paxos
算法的本质区别在于,两者的设计⽬标不太⼀样,
ZAB
协议主要⽤于构建⼀个⾼可⽤的分布式数据主备系统,⽽
Paxos
算法则⽤于构建⼀个分布式的⼀致性状态机系统。
(二)服务器⻆⾊
1. Leader
Leader服务器是Zookeeper集群⼯作的核⼼,其主要⼯作有以下两个:
(1)
事务请求的唯⼀调度和处理者,保证集群事务处理的顺序性。
(2)
集群内部各服务器的调度者。
请求处理链
使⽤责任链来处理每个客户端的请求是
Zookeeper
的特⾊,
Leader
服务器的请求处理链如下:
可以看到,从
prepRequestProcessor
到
FinalRequestProcessor
前后⼀共
7
个请求处理器组成了
leader服务器的请求处理链
(1) PrepRequestProcessor
。请求预处理器,也是
leader
服务器中的第⼀个请求处理器。在
Zookeeper中,那些会改变服务器状态的请求称为事务请求(创建节点、更新数据、删除节点、创建会话等),
PrepRequestProcessor
能够识别出当前客户端请求是否是事务请求。对于事务请求,
PrepRequestProcessor
处理器会对其进⾏⼀系列预处理,如创建请求事务头、事务体、会话检查、
ACL
检查和版本检查等。
(2) ProposalRequestProcessor
。事务投票处理器。也是
Leader
服务器事务处理流程的发起者,对于⾮事务性请求,
ProposalRequestProcessor
会直接将请求转发到
CommitProcessor
处理器,不再做
任何处理,⽽对于事务性请求,处理将请求转发到
CommitProcessor
外,还会根据请求类型创建对应的
Proposal
提议,并发送给所有的
Follower
服务器来发起⼀次集群内的事务投票。同时,
ProposalRequestProcessor
还会将事务请求交付给
SyncRequestProcessor
进⾏事务⽇志的记录。
(3) SyncRequestProcessor
。事务⽇志记录处理器。⽤来将事务请求记录到事务⽇志⽂件中,同时
会触发
Zookeeper
进⾏数据快照。
(4) AckRequestProcessor
。负责在
SyncRequestProcessor
完成事务⽇志记录后,向
Proposal
的投票收集器发送
ACK
反馈,以通知投票收集器当前服务器已经完成了对该
Proposal
的事务⽇志记录。
(5) CommitProcessor
。事务提交处理器。对于⾮事务请求,该处理器会直接将其交付给下⼀级处理器处理;对于事务请求,其会等待集群内 针对
Proposal
的投票直到该
Proposal
可被提交,利⽤
CommitProcessor
,每个服务器都可以很好地控制对事务请求的顺序处理。
(6) ToBeCommitProcessor
。该处理器有⼀个
toBeApplied
队列,⽤来存储那些已经被
CommitProcessor
处理过的可被提交的
Proposal
。其会将这些请求交付给
FinalRequestProcessor
处理器处理,待其处理完后,再将其从
toBeApplied
队列中移除。
(7) FinalRequestProcessor
。⽤来进⾏客户端请求返回之前的操作,包括创建客户端请求的响应,针对事务请求,该处理器还会负责将事务应⽤到内存数据库中。
2. Follower
Follower服务器是Zookeeper集群状态中的跟随者,其主要⼯作有以下三个:
(1)
处理客户端⾮事务性请求(读取数据),转发事务请求给
Leader
服务器。
(2)
参与事务请求
Proposal
的投票。
(3)
参与
Leader
选举投票。
和
leader
⼀样,
Follower
也采⽤了责任链模式组装的请求处理链来处理每⼀个客户端请求,由于不
需要对事务请求的投票处理,因此
Follower
的请求处理链会相对简单,其处理链如下。
和
Leader
服务器的请求处理链最⼤的不同点在于,
Follower
服务器的第⼀个处理器换成了
FollowerRequestProcessor
处理器,同时由于不需要处理事务请求的投票,因此也没有了
ProposalRequestProcessor
处理器。
(1) FollowerRequestProcessor
其⽤作识别当前请求是否是事务请求,若是,那么
Follower
就会将该请求转发给
Leader
服务器,
Leader
服务器在接收到这个事务请求后,就会将其提交到请求处理链,按照正常事务请求进⾏处理。
(2) SendAckRequestProcessor
其承担了事务⽇志记录反馈的⻆⾊,在完成事务⽇志记录后,会向
Leader
服务器发送
ACK
消息以表明⾃身完成了事务⽇志的记录⼯作。
3. Observer
Observer
是
ZooKeeper
⾃
3.3.0
版本开始引⼊的⼀个全新的服务器⻆⾊。从字⾯意思看,该服务器充当了⼀个观察者的⻆⾊
——
其观察
ZooKeeper
集群的最新状态变化并将这些状态变更同步过来。
Observer
服务器在⼯作原理上和
Follower
基本是⼀致的,对于⾮事务请求,都可以进⾏ᇿ⽴的处理,⽽对于事务请求,则会转发给
Leader
服务器进⾏处理。和
Follower
唯⼀的区别在于,
Observer
不参与任
何形式的投票,包括事务请求
Proposal
的投票和
Leader
选举投票。简单地讲,
Observer
服务器只提供
⾮事务服务,通常⽤于在不影响集群事务处理能⼒的前提下提升集群的⾮事务处理能⼒。
另外,
Observer
的请求处理链路和
Follower
服务器也⾮常相近
,
其处理链如下
另外需要注意的⼀点是,虽然在图中可以看到,
Observer
服务器在初始化阶段会将
SyncRequestProcessor
处理器也组装上去,但是在实际运⾏过程中,
Leader
服务器不会将事务请求投票发送给
Observer
服务器。
(三)服务器启动
1. 服务端整体架构图
Zookeeper服务器的启动,⼤致可以分为以下五个步骤
1.
配置⽂件解析
2.
初始化数据管理器
3.
初始化⽹络
I/O
管理器
4.
数据恢复
5.
对外服务
2. 单机版服务器启动
单机版服务器的启动其流程图如下:
上图的过程可以分为预启动和初始化过程。
(1) 预启动
1.
统⼀由
QuorumPeerMain
作为启动类。⽆论单机或集群,在
zkServer.cmd
和
zkServer.sh中都配置了
QuorumPeerMain
作为启动⼊⼝类。
2.
解析配置⽂件
zoo.cfg
。
zoo.cfg
配置运⾏时的基本参数,如
tickTime
、
dataDir
、
clientPort
等参数。
3.
创建并启动历史⽂件清理器
DatadirCleanupManager
。对事务⽇志和快照数据⽂件进⾏定时清理。
4.
判断当前是集群模式还是单机模式启动。若是单机模式,则委托给
ZooKeeperServerMain
进⾏启动。
5.
再次进⾏配置⽂件
zoo.cfg
的解析。
6.
创建服务器实例
ZooKeeperServer
。
Zookeeper
服务器⾸先会进⾏服务器实例的创建,然后对该服务器实例进⾏初始化,包括连接器、内存数据库、请求处理器等组件的初始化。
(2)初始化
1.
创建服务器统计器
ServerStats
。
ServerStats
是
Zookeeper
服务器运⾏时的统计器。
2.
创建
Zookeeper
数据管理器
FileTxnSnapLog
。
FileTxnSnapLog
是
Zookeeper
上层服务器和底层数据存储之间的对接层,提供了⼀系列操作数据⽂件的接⼝,如事务⽇志⽂件和快照数据⽂件。
Zookeeper
根据
zoo.cfg
⽂件中解析出的快照数据⽬录
dataDir
和事务⽇志⽬录
dataLogDir
来创建FileTxnSnapLog
。
3.
设置服务器
tickTime
和会话超时时间限制。
4.
创建
ServerCnxnFactory
。通过配置系统属性
zookeper.serverCnxnFactory
来指定使⽤
Zookeeper
⾃⼰实现的
NIO
还是使⽤
Netty
框架作为
Zookeeper
服务端⽹络连接⼯⼚。
5.
初始化
ServerCnxnFactory
。
Zookeeper
会初始化
Thread
作为
ServerCnxnFactory
的主线程,然后再初始化
NIO
服务器。
6.
启动
ServerCnxnFactory
主线程。进⼊
Thread
的
run
⽅法,此时服务端还不能处理客户端请求。
7.
恢复本地数据。启动时,需要从本地快照数据⽂件和事务⽇志⽂件进⾏数据恢复。
8.
创建并启动会话管理器。
Zookeeper
会创建会话管理器
SessionTracker
进⾏会话管理。
9.
初始化
Zookeeper
的请求处理链。
Zookeeper
请求处理⽅式为责任链模式的实现。会有多个请求处理器依次处理⼀个客户端请求,在服务器启动时,会将这些请求处理器串联成⼀个请求处理链。
10.
注册
JMX
服务。
Zookeeper
会将服务器运⾏时的⼀些信息以
JMX
的⽅式暴露给外部。
11.
注册
Zookeeper
服务器实例。将
Zookeeper
服务器实例注册给
ServerCnxnFactory
,之后
Zookeeper
就可以对外提供服务。
⾄此,单机版的
Zookeeper
服务器启动完毕。
3. 集群服务器启动
单机和集群服务器的启动在很多地⽅是⼀致的,其流程图如下:
上图的过程可以分为
预启动、初始化、
Leader
选举、
Leader
与
Follower
启动期交互、
Leader
与
Follower
启动
等过程。
(1)预启动
1. 统⼀由QuorumPeerMain作为启动类。
2. 解析配置⽂件zoo.cfg。
3.
创建并启动历史⽂件清理器
DatadirCleanupFactory
。
4.
判断当前是集群模式还是单机模式的启动。在集群模式中,在
zoo.cfg
⽂件中配置了多个服务器
地址,可以选择集群启动。
(2)初始化
1.
创建
ServerCnxnFactory
。
2.
初始化
ServerCnxnFactory
。
3.
创建
Zookeeper
数据管理器
FileTxnSnapLog
。
4.
创建
QuorumPeer
实例。
Quorum
是集群模式下特有的对象,是
Zookeeper
服务器实例
(
ZooKeeperServer
)的托管者,
QuorumPeer
代表了集群中的⼀台机器,在运⾏期间,
QuorumPeer
会不断检测当前服务器实例的运⾏状态,同时根据情况发起
Leader
选举。
5.
创建内存数据库
ZKDatabase
。
ZKDatabase
负责管理
ZooKeeper
的所有会话记录以及
DataTree
和事务⽇志的存储。
6.
初始化
QuorumPeer
。将核⼼组件如
FileTxnSnapLog
、
ServerCnxnFactory
、
ZKDatabase
注册到
QuorumPeer
中,同时配置
QuorumPeer
的参数,如服务器列表地址、
Leader
选举算法和会话超时时间限制等。
7.
恢复本地数据。
8.
启动
ServerCnxnFactory
主线程
(3)Leader选举
1.
初始化
Leader
选举。
集群模式特有,
Zookeeper
⾸先会根据⾃身的服务器
ID
(
SID
)、最新的
ZXID
(
lastLoggedZxid
)和当前的服务器
epoch
(
currentEpoch
)来⽣成⼀个初始化投票,在
初始化过程中,每个服务器都会给⾃⼰投票。然后,根据
zoo.cfg
的配置,创建相应
Leader
选举算法
实现,
Zookeeper
提供了三种默认算法(
LeaderElection
、
AuthFastLeaderElection
、
FastLeaderElection
),可通过
zoo.cfg
中的
electionAlg
属性来指定,但现只⽀持
FastLeaderElection
选举算法。在初始化阶段,
Zookeeper
会创建
Leader
选举所需的⽹络
I/O
层
QuorumCnxManager
,同时启动对
Leader
选举端⼝的监听,等待集群中其他服务器创建连接。
2.
注册
JMX
服务。
3.
检测当前服务器状态
运⾏期间,
QuorumPeer
会不断检测当前服务器状态。在正常情况下,
Zookeeper
服务器的状态
在
LOOKING
、
LEADING
、
FOLLOWING/OBSERVING
之间进⾏切换。在启动阶段,
QuorumPeer
的初始
状态是
LOOKING
,因此开始进⾏
Leader
选举。
4. Leader
选举
ZooKeeper
的
Leader
选举过程,简单地讲,就是⼀个集群中所有的机器相互之间进⾏⼀系列投
票,选举产⽣最合适的机器成为
Leader
,同时其余机器成为
Follower
或是
Observer
的集群机器⻆
⾊初始化过程。关于
Leader
选举算法,简⽽⾔之,就是集群中哪个机器处理的数据越新(通常我们根
据每个服务器处理过的最⼤
ZXID
来⽐较确定其数据是否更新),其越有可能成为
Leader
。当然,如
果集群中的所有机器处理的
ZXID
⼀致的话,那么
SID
最⼤的服务器成为
Leader
,其余机器称为
Follower
和
Observer
(4) Leader和Follower启动期交互过程
到这⾥为⽌,
ZooKeeper
已经完成了
Leader
选举,并且集群中每个服务器都已经确定了⾃⼰的⻆⾊
——通常情况下就分为
Leader
和
Follower
两种⻆⾊。下⾯我们来对
Leader
和
Follower
在启动期间的交互
进⾏介绍,其⼤致交互流程如图所示。
1.
创建
Leader
服务器和
Follower
服务器。完成
Leader
选举后,每个服务器会根据⾃⼰服务器的⻆⾊创建相应的服务器实例,并进⼊各⾃⻆⾊的主流程。
2. Leader
服务器启动
Follower
接收器
LearnerCnxAcceptor
。运⾏期间,
Leader
服务器需要和所有其余的服务器(统称为
Learner
)保持连接以确集群的机器存活情况,
LearnerCnxAcceptor
负责接
收所有⾮
Leader
服务器的连接请求。
3. Learner
服务器开始和
Leader
建⽴连接。所有
Learner
会找到
Leader
服务器,并与其建⽴连接。
4. Leader
服务器创建
LearnerHandler
。
Leader
接收到来⾃其他机器连接创建请求后,会创建⼀个LearnerHandler
实例,每个
LearnerHandler
实例都对应⼀个
Leader
与
Learner
服务器之间的连
接,其负责
Leader
和
Learner
服务器之间⼏乎所有的消息通信和数据同步。
5.
向
Leader
注册。
Learner
完成和
Leader
的连接后,会向
Leader
进⾏注册,即将
Learner
服务器的基本信息(
LearnerInfo
),包括
SID
和
ZXID
,发送给
Leader
服务器。
6. Leader
解析
Learner
信息,计算新的
epoch
。
Leader
接收到
Learner
服务器基本信息后,会解析出该
Learner
的
SID
和
ZXID
,然后根据
ZXID
解析出对应的
epoch_of_learner
,并和当前
Leader
服务器
的
epoch_of_leader
进⾏⽐较,如果该
Learner
的
epoch_of_learner
更⼤,则更新
Leader
的
epoch_of_leader = epoch_of_learner + 1
。然后
LearnHandler
进⾏等待,直到过半
Learner
已经
向
Leader
进⾏了注册,同时更新了
epoch_of_leader
后,
Leader
就可以确定当前集群的
epoch
了。
7.
发送
Leader
状态。计算出新的
epoch
后,
Leader
会将该信息以⼀个
LEADERINFO
消息的形式发送给
Learner
,并等待
Learner
的响应。
8. Learner
发送
ACK
消息。
Learner
接收到
LEADERINFO
后,会解析出
epoch
和
ZXID
,然后向
Leader反馈⼀个
ACKEPOCH
响应。
9.
数据同步。
Leader
收到
Learner
的
ACKEPOCH
后,即可进⾏数据同步。
10.
启动
Leader
和
Learner
服务器。当有过半
Learner
已经完成了数据同步,那么
Leader
和
Learner
服务器实例就可以启动了
(5)Leader和Follower启动
1.
创建启动会话管理器。
2.
初始化
Zookeeper
请求处理链,集群模式的每个处理器也会在启动阶段串联请求处理链。
3.
注册
JMX
服务。
⾄此,集群版的Zookeeper服务器启动完毕
(五)leader选举
Leader选举概述
Leader选举是zookeeper最重要的技术之⼀,也是保证分布式数据⼀致性的关键所在。
当
Zookeeper
集群中的⼀台服务器出现以下两种情况之⼀时,需要进⼊
Leader
选举。
(1)
服务器初始化启动。
(2)
服务器运⾏期间⽆法和
Leader
保持连接。
下⾯就两种情况进⾏分析讲解。
1. 服务器启动时期的Leader选举
若进⾏
Leader
选举,则⾄少需要两台机器,这⾥选取
3
台机器组成的服务器集群为例。在集群初始化阶段,当有⼀台服务器
Server1
启动时,其单ᇿ⽆法进⾏和完成
Leader
选举,当第⼆台服务器
Server2
启动
时,此时两台机器可以相互通信,每台机器都试图找到
Leader
,于是进⼊
Leader
选举过程。选举过程
如下
(1)每个Server发出⼀个投票
由于是初始情况,
Server1
(假设
myid
为
1
)和
Server2
假设
myid
为
2
)都会将⾃⼰作为
Leader
服务器来进⾏投票,每次投票会包含所推举的服务器的
myid
和
ZXID
,使⽤
(myid, ZXID)
来表示,此时
Server1
的
投票为
(1, 0)
,
Server2
的投票为
(2, 0)
,然后各⾃将这个投票发给集群中其他机器
(2)接受来⾃各个服务器的投票
集群的每个服务器收到投票后,⾸先判断该投票的有效性,如检查是否是本轮投票、是否来⾃
LOOKING
状态的服务器。
(3)处理投票
针对每⼀个投票,服务器都需要将别⼈的投票和⾃⼰的投票进⾏
PK
,
PK
规则如下
·
优先检查
ZXID
。
ZXID
⽐较⼤的服务器优先作为
Leader
。
·
如果
ZXID
相同,那么就⽐较
myid
。
myid
较⼤的服务器作为
Leader
服务器。
现在我们来看
Server1
和
Server2
实际是如何进⾏投票处理的。对于
Server1
来说,它⾃⼰的投票是(
1
,
0
),⽽接收到的投票为(
2
,
0
)。⾸先会对⽐两者的
ZXID
,因为都是
0
,所以⽆法决定谁是
Leader
。接下来会对⽐两者的
myid
,很显然,
Server1
发现接收到的投票中的
myid
是
2
,⼤于⾃⼰,于
是就会更新⾃⼰的投票为(
2
,
0
),然后重新将投票发出去。⽽对于
Server2
来说,不需要更新⾃⼰的
投票。
(4)统计投票
每次投票后,服务器都会统计所有投票,判断是否已经有过半的机器接收到相同的投票信息。对于Server1
和
Server2
服务器来说,都统计出集群中已经有两台机器接受了(
2
,
0
)这个投票信息。这⾥我们需要对
“
过半
”
的概念做⼀个简单的介绍。所谓
“
过半
”
就是指⼤于集群机器数量的⼀半,即⼤于或等于
(
n/2+1
)。对于这⾥由
3
台机器构成的集群,⼤于等于
2
台即为达到
“
过半
”
要求。
那么,当
Server1
和
Server2
都收到相同的投票信息(2
,
0
)的时候,即认为已经选出了
Leader
。
(5)改变服务器状态
⼀旦确定了
Leader
,每个服务器就会更新⾃⼰的状态:如果是
Follower
,那么就变更为
FOLLOWING
,如果是
Leader
,那么就变更为
LEADING
。
2. 服务器运⾏时期的Leader选举
在
ZooKeeper
集群正常运⾏过程中,⼀旦选出⼀个
Leader
,那么所有服务器的集群⻆⾊⼀般不会再发⽣变化
——
也就是说,
Leader
服务器将⼀直作为集群的
Leader
,即使集群中有⾮
Leader
机器挂了或是有
新机器加⼊集群也不会影响
Leader
。但是⼀旦
Leader
所在的机器挂了,那么整个集群将暂时⽆法对外
服务,⽽是进⼊新⼀轮的
Leader
选举。服务器运⾏期间的
Leader
选举和启动时期的
Leader
选举基本过
程是⼀致的。
我们还是假设当前正在运⾏的
ZooKeeper
机器由
3
台机器组成,分别是
Server1
、
Server2
和
Server3
,当前的
Leader
是
Server2
。假设在某⼀个瞬间,
Leader
挂了,这个时候便开始了
Leader
选举。
(1)
变更状态
Leader
挂后,余下的⾮
Observer
服务器都会将⾃⼰的服务器状态变更为
LOOKING
,然后开始进⼊
Leader
选举过程。
(2)
每个
Server
会发出⼀个投票
在运⾏期间,每个服务器上的
ZXID
可能不同,此时假定
Server1
的
ZXID
为
123
,
Server3
的
ZXID
为
122
;在第⼀轮投票中,
Server1
和
Server3
都会投⾃⼰,产⽣投票
(1, 123)
,
(3, 122)
,然后各⾃将投票发送给
集群中所有机器。
(3)
接收来⾃各个服务器的投票,与启动时过程相同
(4)
处理投票。与启动时过程相同,此时,
Server1
将会成为
Leader
(5)
统计投票。与启动时过程相同
(6)
改变服务器的状态。与启动时过程相同
本文为拉钩教育课堂笔记整理。