不同于单机系统,分布式系统在很多方面遇到了新的挑战和难题。为了保证分布式系统能正确工作,需要一个分布式协调系统来调度工作,ZooKeeper也应运而生。
ZooKeeper为分布式系统提供了稳定而高效的分布式协调服务,提供了保证分布式数据一致性的基础设施,为分布式锁、命名服务、配置管理的分布式基础服务的构建提供了工具。
ZooKeeper的由来
随着大数据时代的到来,人们张口闭口都是Hadoop、Hbase与Spark等名词,似乎不懂这些都不好意思打招呼。但实际上,Hadoop技术栈中略显低调的ZooKeeper,对于开发人员来说是更加有用的项目。那么ZooKeeper是用干什么的呢?
ZooKeeper是Apache Hadoop项目的组成部分,现在已发展成为Apache基金会的顶级项目。早期雅虎有许多分布式系统的项目都存在数据不一致的问题,于是雅虎的工程师开发了一套用来解决分布式环境下协调项目,使得开发人员能够专注于业务逻辑上,而不用去解决复杂的分布式问题。
由于雅虎内部有很多用动物命名的项目,例如Pig、Hive等,就像一个动物园一样。在ZooKeeper发布之后,人们吐槽说动物名称的项目太多了,正好ZooKeeper是被开发来协调各个不同分布式服务,所以人们就用“动物园管理员”的名称来命名它。
为什么需要ZooKeeper
分布式环境下,开发人员面临着以下的困难和挑战:
- 节点故障。分布式环境中的每个节点都面临着崩溃的风险。根据分布式理论,随着运行时间和节点数量的增加,节点故障几乎是不可避免的。
- 通信异常。单机环境下调用另外一个程序,结果只有成功或者失败两种情况。但在分布式环境下调用别的程序,可能由于消息在网络中丢失,也有可能被调用方在执行中崩溃。总之,调用方无法等到结果的返回,调用发生超时。在这种情况下,被调用方的执行结果可能成功,也可能失败,调用方无法感知。
- 网络分区。由于采用网络传输信息,通信的延迟远大于单机环境。当通信延时不断增大,会出现部分节点之间可以通信,部分节点之间无法通信的情况,导致网络分区(脑裂)的出现。网络分区对分布式环境下的数据一致性有很大的影响。
由于诸多问题,分布式环境下的服务的开发比单机环境下会更加困难。为了保证服务的可用性,开发人员不可避免的要解决以上问题。如果每一个项目都要去解决这些底层问题,必须去单独开发一套分布式协调的程序。不但造轮子的过程会十分低效而繁琐,往往会有很多BUG隐藏其中。ZooKeeper就是为了解决上述问题而被开发,使得开发人员可以聚焦于业务逻辑。
ZooKeeper是一个开源的分布式协调框架,是Google的Chubby项目的开源实现。ZooKeeper提供了一套用来构建分布式系统的原语集合,解决了各种本需开发人员解决的分布式难题,包装成简单易用的接口,极大的简化了分布式环境下的开发工作。
ZooKeeper与CAP理论
早期的分布式系统设计,总是试图寻找一个完美的方案,不但系统能够7*24小时不间断的对外提供服务,而且数据还能够保持完美的分布式一致性。但是,实际的分布式系统实践结果不断的打人们的脸,如何兼顾一致性与可用性,似乎成为了困扰在无数工程师心中无解难题。
2000年7月,加州大学伯克利分校的Eric Brewer教授提出了著名的CAP理论。CAP理论主要内容是:分布式系统不可能同时满足一致性(Consistency)、可用性(Availability)以及分区容错性(Partition tolerance),而最多只能满足两项。
- 一致性。一致性是指在不断的读写操作中,分布式系统中不同节点的数据能否保持相同的状态。典型的例子是Redis主从同步,如果刚刚在主节点更新了数据,Redis的Master节点还未来得及将数据同步到Slaver节点中。此时读取Slaver节点的数据,可能就会读到老数据,这就是典型的分布式数据不一致的问题。严格的数据一致性是很难做到的,部分系统采取了降级方案,即最终一致性。最终一致性是指即随着时间的推移,分布式系统的不同节点之间总能达到一致的状态。
- 可用性。可用性是指系统在长时间的运行过程中,对用户的每一次请求,都能够在有限的时间内做出回应。我们常用系统提供服务的时间占比来衡量可用性,也就是通常采用的几个“九”说法。
可用性有两个关键点:“有限的时间”和“做出回应”。一般来说,“有限的时间”对于不同的系统的含义是不相同的。例如,我们查询Google来搜索关键词,我们希望能够在秒级左右检索到相关网页。而对于一个大数据处理平台,几十秒的响应时间是完全可以接受的。 - 分区容错性。分区容错性是指,分布式系统不会退化为一个个互相隔离的子网络。如果发生了最严重的分区故障,分布式网络中每一个节点都互相隔离,那么分布式系统就退化为一个个单机系统。分布式系统对于分区容错性的要求十分严苛,分区故障对于分布式系统来说是不可接受的故障。
CAP理论说明了,一致性、可用性与分区容错性是无法全部保证的。因此不同的分布式系统,根据设计目标的不同,会在可用性和一直性上面做出取舍。ZooKeeper的设计目标是:通过保证不同ZooKeeper节点具有相同的状态,从而保证其协调下的服务保持同步、一致。这就不难理解,ZooKeeper选择了一致性、分区容错性作为其核心目标,并被设计为满足CP要求的分布式系统。
ZooKeeper的数据模型
ZNode树
每一个ZooKeeper节点,具有一个树状的层次化数据库,称之为ZNode树。Znode树上的每一个节点成为ZNode节点。与Linux文件系统不同,ZNode节点没有目录和文件的概念,每一个ZNode节点既可以挂载子节点,也可以存储数据。
ZNode主要有四种类型:
- 持久节点(PERSISTENT )。一旦被创建,永远存在与ZNode树,即使重启ZooKeeper节点也不会消失。除非客户端主动调用删除接口,不然持久节点会一直存在。
- 临时节点(EPHEMERAL)。临时节点由客户端创建,其生命期相同于客户端会话的生命期。一旦客户端断开连接,ZooKeeper节点在清除客户端的会话时,会删除该会话创建的所有临时节点。
- 持久顺序节点(PERSISTENT_SEQUENTIAL)。生命期等同于持久节点,但是其节点名称是自动创建的,一般是父节点目录下上一个持久节点的序号加1。
- 临时顺序节点(EPEMERAL_SEQUENTIAL )。生命期等同于临时节点,但是其节点名称是自动创建的,一般是父节点目录下上一个临时节点的序号加1。分布式基础服务中的分布式锁,通常采用临时顺序节点来实现。
ZooKeeper集群中任意节点都具有ZNode树,无论客户端连接的是哪一个节点,其看到的服务端数据模型都是一致的。
内存数据库
为了提高对外服务的性能,ZooKeeper采用了内存数据库ZKDatabase来存储数据,并管理节点上所有的会话、节点与日志。ZKDatabase会定期生成快照文件snapchat和事务日志到磁盘上,当ZooKeeper节点重启的时候,可以利用快照文件snapchat和事务日志,迅速地恢复并重建内存数据库ZKDatabase。因此,磁盘的读写性能和大小对,ZooKeeper集群的性能具有较大影响,一般我们会把日志文件和快照文件的目录挂载在一个单独的SSD磁盘上。
ZooKeeper集群
为了提高ZooKeeper的可用性和容灾性,防止单点故障,ZooKeeper通常采用集群的方式进行部署。只要有不超过半数的节点崩溃,集群仍然可以对外正常提供服务。
节点角色
ZooKeeper节点可以分为三种角色,分别是Leader、Follower与Observer。
- Leader对外提供读、写的服务,负责投票的发起和决议,并发起指令更新整个集群的状态,保证数据在整个集群内部的一致性。Leader是集群内部通过选举产生的。
- Follower对外提供读的服务,如果是写服务则转发给Leader。在Leader崩溃后,Follower们迅速会开展选举,并产生新的Leader。
- Observer对外提供读的服务,如果是写服务则转发给Leader。Observer不会参与选举Leader的投票,也不对集群的容灾性能造成影响。ZooKeeper3.3单纯为了提高读性能而引入Observer角色。
节点个数
节点数一般是奇数。ZooKeeper集群被部署为奇数的原因很简单:ZooKeeper集群为了能够保证选举的正常举行,需要至少存活半数以上节点,即要保证Quorum的存在。
假设ZooKeeper集群中存在2*N+1个节点,那么集群至多能挂掉N个节点。举个例子,如果有两个ZooKeeper集群,它们的节点个数分别为5个和6个,那么这两个集群都至多只能挂掉2个节点。换而言之,部署数量为5台和6台服务器的集群的容灾能力是一样的,为了节约成本,ZooKeeper集群部署为奇数个节点更加合适。
读写数据
写操作
ZooKeeper集群中任意角色都会接受客户端连接。如果Follower接受了写请求,会将该请求转发给Leader,由Leader完成实际的写入操作。Leader会协调整个集群,采用二阶段写的方式写入集群所有的节点(原子广播)。当整个写入操作完成后,Leader会通知Follower操作完成,Follower再通知客户端写入完成。
读操作
相对于写操作,读操作会非常简单。由于ZooKeeper集群的数据一致性要求,其每一次写操作都会保证成功写入每一个节点。因此在任何一个节点上面都可以进行读操作,并且返回的数据是全局一致的。
一致性原理
paxos算法
为了保证分布式系统的数据一致性,需要有一种解决方案来规范读写过程,从而保证数据在全局的一致性。比较有名的一致性解决方案有Paxos算法。Paxos算法由Lamport在1990年提出,给出了理论上的分布式环境一致性解决方案,并且在数学层面上面给出了证明。Paxos算法比较晦涩难懂,这里就不展开了。
2PC算法
ZooKeeper没有直接采用Paxos算法来解决一致性的问题,而是采用了一种简化的方案:ZAB算法(ZooKeeper Atomic Broadcast)。ZAB算法是一个类似于二阶段提交协议(2PC)的算法,需要依赖一个Leader节点来进行协调。要了解ZAB算法,我们需要先来了解一下2PC算法。
协议说明
2PC将事务的提交过程分为两个阶段:提交事务与执行事务。
阶段1: 提交事务
- Leader向所有Follower传输事务内容,询问Follower是否可以进行事务提交,并等待Follower相应。
- Follower执行事务操作,并将事务作为Undo与Redo写入事务日志。
- Follower返回事务执行结果,执行成返回YES,失败返回NO。
阶段2: 执行事务
正常流程:所有Follower返回YES
- Leader向所有Follower发送事务提交请求。
- Follwer收到事务提交请求后,执行事务提交操作。
- Follower完成事务提交操作后,向Leader发送ACK消息。
- Leader接受到所有参与者反馈的ACK消息后,完成事务。
异常流程:任一Follower返回NO
- Leader向所有Follower发送回滚请求。
- Follower通过之前的Undo日志,执行回滚请求。
- Follower回滚完成后,向Leader发送ACK消息。
- Leader接受到所有ACK消息后,完成事务回滚,中断事务。
ZAB算法
2PC是有缺陷的,在异常情况下会影响分布式事务的完成。2PC在以下几个方面存在缺陷:
- 同步阻塞。在2PC的第一、第二阶段,Leader需要等待所有Follower反馈结果。在等待过程中,整个集群处于阻塞的状态,严重影响了事务执行的性能。更为严重的是,如果某个Follower崩溃或者消息超时,整个集群将处于无限期的等待下去。
- 单点问题。Leader在2PC中非常重要,存在单点故障的风险。如果Leader在任意过程中崩溃,那么整个2PC过程都无法进行了,整个集群也随之崩溃。如果在事务提交阶段,Leader崩溃了,那么可能只有部分Follower收到了提交请求,就会出现数据不一致的情况。
- 时序问题。由于网路的延迟等问题,Follower难以严格按照发出的时序来接受Leader的命令。例如,命令A -> B ->C是一个命令序列,其中命令C的执行依赖于A和B的执行结果。因此Follower执行事务的顺序,会对数据一致性造成影响。
针对以上问题,ZAB提出了自己的解决方案:
- 针对同步阻塞问题。ZAB为所有节点设置了超时时间。如果Follower等待Leader的信息超时,那么就会抛弃现有Leader,进入“发现”阶段,重新寻找Leader。而Leader不会去等待所有Follower反馈ACK,只要有Quorum(半数个)Follower反馈ACK,就会进行事务的提交。这样,在保证数据一致性的前提下,ZAB能够提交事务处理的性能。
- 针对时序问题。ZAB提出了zxid(ZooKeeper)来解决时序问题。每次Leader开始写操作时,在第一阶段开始,会为该次事务提交成一个全局唯一的事务id。zxid的数值越大,代表这次事务在时序上越靠后。Follwer接受到Leader的多个事务提交,会用zxid来排序事务提交并依次执行。
- 针对单点问题。ZAB的思路是在发生了Leader崩溃后,能够迅速找到一个新的节点来担起Leader的职责,并且保证数据一致性。
因此,ZooKeeper中最为关键的部分为解决下面两个问题:
a. 如何在Leader崩溃后,能够进行选举,并保证整体的数据一致性。
b. 由于选举期间ZooKeeper集群不可用,如何快速进行选举。
状态机
Zookeeper通过崩溃恢复来解决Leader崩溃后数据一致性的问题。
节点具有三种状态:发现、同步与广播。集群中的节点在刚启动时,会进入发现状态来寻找Leader,接着在同步状态来同步Leader的数据。完成数据同步后,节点会进入广播状态,能够正常对外提供服务。
下面两种情况,节点会进入发现状态:
- 节点刚刚启动,并进入一个新的集群当中,会进入发现状态。
- 集群中Leader崩溃,其他Follower会进入发现状态。
节点的状态在三个发现、同步与广播只中循环,完成保证了数据的一致性。这样一个循环称为主进程周期,主进程周期是一个序号,存在zxid的高位。
发现状态
发现阶段是要进行准Leader的选举与生成新的主进程周期。我们后面讨论准Leader选举的过程,先看一下后面的流程。
生成新的主进程周期
- 各个Follower向准Leader发送自己的最后接受事务的
CEPOCH(F.p)
,即主进程周期。 - 准Leader在接受到过半Follower的EPOCH后,会生成新的主进程周期
NEWEPOCH
,其中NEWEPOCH=max(EPOCH(F.p))+1
,再发回给这些Follower。 - 当Follower收到准Leader发来的
NEWEPOCH
后,检查自己的CEPOCH(F.p)
是否小于NEWEPOCH
。如果小于,则更新自己的主进程周期为NEWEPOCH
,并向准Leader发送ACK消息。 - 当准Leader L接收到来自半数或以上的Follower的ACK-E消息后,准Leader L会从这半数或以上的Follower中选取一个Follower F,并将其
hf
作为初始化历史记录。
那么Follower F是怎么选出来方法为:选取F,那么∀F'∈Quorum
,满足下面的其中1个条件
a.CEPOCH(F'.p) < CEPOCH(F.p)
b.CEPOCH(F'.p) == CEPOCH(F.p) && (F'.zxid ≺ F.zxid || F'.zxid = =F.zxid )
同步阶段
同步阶段主要是个集群中所有Follower完成数据同步,同时准Leader晋升为Leader。
- 准Leader向Quorum中Follower发送消息,包含新的主进程周期
NEWEPOCH
与事务序列I
。 - Follwer接受到消息后,用
NEWEPOCH
与CEPOCH(F.p)
进行判断:
2.1 如果NEWEPOCH≠CEPOCH(F.p)
,说明Follwer还处在上一个主进程周期(没有在发现阶段更新自己的主进程周期)。这种情况,Follwer直接跳过进入发现阶段,无法进行本来的同步。
2.2 如果NEWEPOCH=CEPOCH(F.p)
,那么说明Follwer可以进行同步操作。Follwer会一次读取Leader发送过来的事务序列I
,并依序执行其中的事务操作:∀<v,z>∈Ie'
,Follower
都会接受<e',<v,z>>
。最后Follwer会向Leader返回ACK消息。 - Leader接受到半数的ACK后,会向Follwer发送Commit消息。
- Follower接受到commit消息后,提交事务,并返回ACK消息。
经过同步阶段,准Leader晋升为Leader,并且集群中所有节点的数据保持一致。
广播阶段
广播阶段是节点的正常工作阶段,其流程已在上面提及,不再赘述了。