28 彻底掌握二阶段提交三阶段提交算法原理

在本节课的开篇中,我们已经提到过 ZooKeeper 在分布式系统环境中主要解决的是分布式一致性问题。而为什么会发生数据不一致的问题呢?是因为当网络集群处理来自客户端的请求时,其中的事务性会导致服务器上数据状态的变更。

为了保证数据变更请求在整个分布式环境下正确地执行,不会发生异常中断,从而导致请求在某一台服务器执行失败而在集群中其他服务器上执行成功,在整个分布式系统处理数据变更请求的过程中,引入了分布式事务的概念。

分布式事务

对于事务操作我们并不陌生,最为熟悉的就是数据库事务操作。当多个线程对数据库中的同一个信息进行修改的时候,为保证数据的原子性、一致性、隔离性、持久性,需要进行本地事务性操作。而在分布式的网络环境下,也会面临多个客户端的数据请求服务。在处理数据变更的时候,需要保证在分布式环境下的数据的正确完整,因此在分布式环境下也引入了分布式事务。

二阶段提交

二阶段提交(Two-phase Commit)简称 2PC ,它是一种实现分布式事务的算法。二阶段提交算法可以保证分布在不同网络节点上的程序或服务按照事务性的方式进行调用。

底层实现

正如算法的名字一样,二阶段提交的底层实现主要分成两个阶段,分别是询问阶段提交阶段。具体过程如下图所示:

整个集群服务器被分成一台协调服务器,集群中的其他服务器是被协调的服务器。在二阶段算法的询问阶段,分布式集群服务在接收到来自客户端的请求的时候,首先会通过协调者服务器,针对本次请求能否正常执行向集群中参与处理的服务器发起询问请求。集群服务器在接收到请求的时候,会在本地机器上执行会话操作,并记录执行的相关日志信息,最后将结果返回给协调服务器。

image-20240602194748445

【简单来说,先搞懂这是分布式事务,本质还是事务,只不过在分布式环境中。就是先问你事务能不能执行,能执行就执行】

在协调服务器接收到来自集群中其他服务器的反馈信息后,会对信息进行统计。如果集群中的全部机器都能正确执行客户端发送的会话请求,那么协调者服务器就会再次向这些服务器发送提交命令。在集群服务器接收到协调服务器的提交指令后,会根据之前处理该条会话操作的日志记录在本地提交操作,并最终完成数据的修改。

虽然二阶段提交可以有效地保证客户端会话在分布式集群中的事务性,但是该算法自身也有很多问题,主要可以归纳为以下几点:效率问题、单点故障、异常中断。

性能问题

首先,我们先来介绍一下性能问题。如我们上面介绍的二阶段算法,在数据提交的过程中,所有参与处理的服务器都处于阻塞状态,如果其他线程想访问临界区的资源,需要等待该条会话请求在本地执行完成后释放临界区资源。因此,采用二阶段提交算法也会降低程序并发执行的效率。

单点问题

此外,还会发生单点问题。单点问题也叫作单点服务器故障问题,它指的是当作为分布式集群系统的调度服务器发生故障时,整个集群因为缺少协调者而无法进行二阶段提交算法。单点问题也是二阶段提交最大的缺点,因此使用二阶段提交算法的时候通常都会进行一些改良,以满足对系统稳定性的要求。

异常中断

异常中断问题指的是当统计集群中的服务器可以进行事务操作时,协调服务器会向这些处理事务操作的服务器发送 commit 提交请求。如果在这个过程中,其中的一台或几台服务器发生网络故障,无法接收到来自协调服务器的提交请求,导致这些服务器无法完成最终的数据变更,就会造成整个分布式集群出现数据不一致的情况。

由于以上种种问题,在实际操作中,我更推荐使用另一种分布式事务的算法——三阶段提交算法。

三阶段提交

三阶段提交(Three-phase commit)简称 3PC , 其实是在二阶段算法的基础上进行了优化和改进。如下图所示,在整个三阶段提交的过程中,相比二阶段提交,增加了预提交阶段

image-20240602194807863

【简单来说,就是问你事务能不能执行,执行的结果如何,🆗的就提交吧!】

底层实现

预提交阶段

为了保证事务性操作的稳定性,同时避免二阶段提交中因为网络原因造成数据不一致等问题,完成提交准备阶段后,集群中的服务器已经为请求操作做好了准备,协调服务器会向参与的服务器发送预提交请求。集群服务器在接收到预提交请求后,在本地执行事务操作,并将执行结果存储到本地事务日志中,并对该条事务日志进行锁定处理。

提交阶段

在处理完预提交阶段后,集群服务器会返回执行结果到协调服务器,最终,协调服务器会根据返回的结果来判断是否继续执行操作。如果所有参与者服务器返回的都是可以执行事务操作,协调者服务器就会再次发送提交请求到参与者服务器。参与者服务器在接收到来自协调者服务器的提交请求后,在本地正式提交该条事务操作,并在完成事务操作后关闭该条会话处理线程、释放系统资源。当参与者服务器执行完相关的操作时,会再次向协调服务器发送执行结果信息。

协调者服务器在接收到返回的状态信息后会进行处理,如果全部参与者服务器都正确执行,并返回 yes 等状态信息,整个事务性会话请求在服务端的操作就结束了。如果在接收到的信息中,有参与者服务器没有正确执行,则协调者服务器会再次向参与者服务器发送 rollback 回滚事务操作请求,整个集群就退回到之前的状态,这样就避免了数据不一致的问题。

总结

本节课我们主要学习了分布式系统下的分布式事务问题。由于分布式系统架构的特点,组成整个系统的网络服务可能分布在不同的网络节点或服务器上,因此在调用这些网络服务的过程中,会面临网络异常中断等不确定的问题,最终导致集群中出现数据不一致的情况。

为了保证数据的有一致性,我们引入了二阶段提交和三阶段提交算法。这两种算法都会将整个事务处理过程分成准备、执行、确认提交这几个阶段。不同的是,二阶段提交会因为网络原因造成数据不一致的问题,而三阶段提交通过增加预加载阶段将执行的事务数据保存到本地,当整个网络中的参与者服务器都能进行事务操作后,协调服务器会发送最终提交请求给参与者服务器,并最终完成事务操作的数据的修改。

29 ZAB 协议算法:崩溃恢复和消息广播

之前谈到当 Leader 节点发生崩溃的时候,在 ZooKeeper 集群中会重新选举出新的 Leader 节点服务器,以保证 ZooKeeper 集群的可用性。那么从 Leader 节点发生崩溃到重新恢复中间经历了哪些过程,又是采用什么算法恢复集群服务的?

ZAB 协议算法

ZooKeeper 最核心的作用就是保证分布式系统的数据一致性,而无论是处理来自客户端的会话请求时,还是集群 Leader 节点发生重新选举时,都会产生数据不一致的情况。为了解决这个问题,ZooKeeper 采用了 ZAB 协议算法。

ZAB 协议算法(Zookeeper Atomic Broadcast ,Zookeeper 原子广播协议)是 ZooKeeper 专门设计用来解决集群最终一致性问题的算法,它的两个核心功能点是崩溃恢复原子广播协议

在整个 ZAB 协议的底层实现中,ZooKeeper 集群主要采用主从模式的系统架构方式来保证 ZooKeeper 集群系统的一致性。整个实现过程如下图所示,当接收到来自客户端的事务性会话请求后,系统集群采用主服务器来处理该条会话请求,经过主服务器处理的结果会通过网络发送给集群中其他从节点服务器进行数据同步操作。

image-20240602195634863

以 ZooKeeper 集群为例,这个操作过程可以概括为:当 ZooKeeper 集群接收到来自客户端的事务性的会话请求后,集群中的其他 Follow 角色服务器会将该请求转发给 Leader 角色服务器进行处理。当 Leader 节点服务器在处理完该条会话请求后,会将结果通过操作日志的方式同步给集群中的 Follow 角色服务器。然后 Follow 角色服务器根据接收到的操作日志,在本地执行相关的数据处理操作,最终完成整个 ZooKeeper 集群对客户端会话的处理工作。

崩溃恢复

【简单来说,就是主节点泵了选一个出来继续当头】

在介绍完 ZAB 协议在架构层面的实现逻辑后,我们不难看出整个 ZooKeeper 集群处理客户端会话的核心点在一台 Leader 服务器上。所有的业务处理和数据同步操作都要靠 Leader 服务器完成。结合我们在“ 28 | 彻底掌握二阶段提交/三阶段提交算法原理” 中学习到的二阶段提交知识,会发现就目前介绍的 ZooKeeper 架构方式而言,极易产生单点问题,即当集群中的 Leader 发生故障的时候,整个集群就会因为缺少 Leader 服务器而无法处理来自客户端的事务性的会话请求。因此,为了解决这个问题。在 ZAB 协议中也设置了处理该问题的崩溃恢复机制。

崩溃恢复机制是保证 ZooKeeper 集群服务高可用的关键。触发 ZooKeeper 集群执行崩溃恢复的事件是集群中的 Leader 节点服务器发生了异常而无法工作,于是 Follow 服务器会通过投票来决定是否选出新的 Leader 节点服务器。

投票过程如下:当崩溃恢复机制开始的时候,整个 ZooKeeper 集群的每台 Follow 服务器会发起投票,并同步给集群中的其他 Follow 服务器。在接收到来自集群中的其他 Follow 服务器的投票信息后,集群中的每个 Follow 服务器都会与自身的投票信息进行对比,如果判断新的投票信息更合适,则采用新的投票信息作为自己的投票信息。在集群中的投票信息还没有达到超过半数原则的情况下,再进行新一轮的投票,最终当整个 ZooKeeper 集群中的 Follow 服务器超过半数投出的结果相同的时候,就会产生新的 Leader 服务器。

举个生活的例子:

举个例子,假设我们有一个小镇,镇长突然离职了,我们需要选出一个新的镇长来。每个居民(对应于Follow服务器)都可以提名一个候选人,然后将这个提名给其他居民看。每个居民在收到其他居民的提名后,会将自己的提名和新的提名进行比较,如果认为新的提名更合适,那么就会采用新的提名作为自己的提名。

这个过程就像是一轮轮的投票,每一轮投票结束后,如果还没有超过半数的居民提名同一个人,那么就需要进行新一轮的投票。最终,当超过半数的居民提名同一个人时,这个人就被选为新的镇长。

选票结构

介绍完整个选举 Leader 节点的过程后,我们来看一下整个投票阶段中的投票信息具有怎样的结构。以 Fast Leader Election 选举的实现方式来讲,如下图所示,一个选票的整体结果可以分为一下六个部分:

image-20240602195729126

  • ogicClock:用来记录服务器的投票轮次。logicClock 会从 1 开始计数,每当该台服务经过一轮投票后,logicClock 的数值就会加 1 。
  • state:用来标记当前服务器的状态。在 ZooKeeper 集群中一台服务器具有 LOOKING、FOLLOWING、LEADERING、OBSERVING 这四种状态。
  • self_id:用来表示当前服务器的 ID 信息,该字段在 ZooKeeper 集群中主要用来作为服务器的身份标识符。
  • self_zxid: 当前服务器上所保存的数据的最大事务 ID ,从 0 开始计数。
  • vote_id:投票要被推举的服务器的唯一 ID 。
  • vote_zxid:被推举的服务器上所保存的数据的最大事务 ID ,从 0 开始计数。

当 ZooKeeper 集群需要重新选举出新的 Leader 服务器的时候,就会根据上面介绍的投票信息内容进行对比,以找出最适合的服务器。

选票筛选

接下来我们再来看一下,当一台 Follow 服务器接收到网络中的其他 Follow 服务器的投票信息后,是如何进行对比来更新自己的投票信息的。Follow 服务器进行选票对比的过程,如下图所示。

image-20240602195829997

首先,会对比 logicClock 服务器的投票轮次,当 logicClock 相同时,表明两张选票处于相同的投票阶段,并进入下一阶段,否则跳过。接下来再对比 vote_zxid 被选举的服务器 ID 信息,若接收到的外部投票信息中的 vote_zxid 字段较大,则将自己的票中的 vote_zxid 与 vote_myid 更新为收到的票中的 vote_zxid 与 vote_myid ,并广播出去。要是对比的结果相同,则继续对比 vote_myid 被选举服务器上所保存的最大事务 ID ,若外部投票的 vote_myid 比较大,则将自己的票中的 vote_myid 更新为收到的票中的 vote_myid 。 经过这些对比和替换后,最终该台 Follow 服务器会产生新的投票信息,并在下一轮的投票中发送到 ZooKeeper 集群中。

举例说明

ZooKeeper集群的选举过程可以比作一个民主投票过程。

假设我们在一个小镇上,需要选出新的镇长。每个居民(对应于ZooKeeper的服务器)都会在一张选票上写下他心目中理想的候选人,并写下他们的理由(这可以看作是服务器的vote_zxid和vote_myid)。

然后,每个人都会拿着自己的选票和其他人的票进行比较。首先,他们会看选票的轮次(logicClock),如果轮次是一样的,那么就说明这两张票是在同一轮投票中产生的,可以进行下一步比较。如果轮次不同,那么就会跳过这张票。

接下来,他们会看票上的理由(vote_zxid),如果别人的理由更有说服力(即vote_zxid更大),那么他就会改变自己的心意,将自己的票改为别人的候选人和理由,并将这个改变告诉其他人。如果理由相同,那么他们会继续看候选人(vote_myid),如果别人的候选人更优秀(即vote_myid更大),那么他也会改变自己的票。

通过这样的比较和替换,每个人都可能产生新的投票信息。然后,在下一轮投票中,他们会将自己的新票发送给其他人。最终,当超过半数的人选出了同一个人,这个人就会被选为新的镇长。

在ZooKeeper集群中,这个过程确保了即使在Leader服务器崩溃后,整个集群也能通过投票选出新的Leader服务器,保证系统的正常运行。

消息广播

在 Leader 节点服务器处理请求后,需要通知集群中的其他角色服务器进行数据同步。ZooKeeper 集群采用消息广播的方式发送通知。

ZooKeeper 集群使用原子广播协议进行消息发送,该协议的底层实现过程与我们在“ 28 | 彻底掌握二阶段提交/三阶段提交算法原理” 的二阶段提交过程非常相似,如下图所示。

image-20240602200426527

当要在集群中的其他角色服务器进行数据同步的时候,Leader 服务器将该操作过程封装成一个 Proposal 提交事务,并将其发送给集群中其他需要进行数据同步的服务器。当这些服务器接收到 Leader 服务器的数据同步事务后,会将该条事务能否在本地正常执行的结果反馈给 Leader 服务器,Leader 服务器在接收到其他 Follow 服务器的反馈信息后进行统计,判断是否在集群中执行本次事务操作。

这里请大家注意 ,与我们“ 28 | 彻底掌握二阶段提交/三阶段提交算法原理” 中提到的二阶段提交过程不同(即需要集群中所有服务器都反馈可以执行事务操作后,主服务器再次发送 commit 提交请求执行数据变更) ,ZAB 协议算法省去了中断的逻辑,当 ZooKeeper 集群中有超过一般的 Follow 服务器能够正常执行事务操作后,整个 ZooKeeper 集群就可以提交 Proposal 事务了。

总结

本节课我们主要介绍了 ZooKeeper 中的 ZAB 协议算法。 ZAB 协议算法能够保证 ZooKeeper 集群服务在处理事务性请求后的数据一致性 ,当集群中的 Leader 服务器发生崩溃的时候,ZAB 协议算法可以在 ZooKeeper 集群中重新选举 Leader 并进行数据的同步恢复。其中值得注意的是消息广播的底层实现过程虽然与二阶段提交非常相似,但是与二阶段提交相比,并没有事务丢弃的过程。在 ZooKeeper 集群的消息广播中,只要满足整个集群中超过半数的 Follow 服务器可以执行本次事务操作,Leader 就可以向集群中发送提交事务操作,最终完成数据的变更。

30 ZAB 与 Paxos 算法的联系与区别

在掌握 ZAB 协议的情况下,我们再进一步学习另一种算法: Paxos 算法。我们会通过研究 Paxos 算法的实现原理,来分析它与 ZAB 协议有什么不同,及它们各自的优缺点。

Paxos 算法

在分布式一致性问题的解决方案中,Paxos 算法可以说是目前最为优秀的。很多方案,包括我们学习的 ZooKeeper 的 ZAB 协议算法都是在其基础上改进和演变过来的。

Paxos 算法是基于消息传递的分布式一致性算法,很多大型的网络技术公司和开源框架都采用 Paxos 算法作为其各自的底层解决方案,比如 Chubby 、 Megastore 以及 MySQL Group Replication 。 Paxos 算法运行在服务器发生宕机故障的时候,能够保证数据的完整性,不要求可靠的消息传递,可容忍消息丢失、延迟、乱序以及重复,保证服务的高可用性。

底层实现

介绍完 Paxos 算法能够解决哪些问题后,接下来我们继续学习 Paxos 算法的底层实现过程。保证分布式系统下数据的一致性操作,本质是协调运行在不同的网络服务器上的线程服务,使这些服务就某一个特定的数据执行一致性的变更操作。在整个 Paxos 算法的实现过程中,将参与算法的集群中的全部服务器,分成三种角色:提议者(Proposer)、决策者(Acceptor)、决策学习者(Learner)。

三种角色

先来看看三种角色的具体分工。

  • 提议者(Proposer):提出提案(Proposal)。Proposal 信息包括提案编号(Proposal ID)和提议的值(Value)。
  • 决策者(Acceptor):参与决策,回应 Proposers 的提案。收到 Proposal 后可以接受提案,若 Proposal 获得超过半数 Acceptors 的许可,则称该 Proposal 被批准。
  • 决策学习者:不参与决策,从 Proposers/Acceptors 学习最新达成一致的提案(Value)。

经过我们之前对 ZooKeeper 的学习,相信对 Paxos 算法的集群角色划分并不陌生。而与 ZAB 协议算法不同的是在 Paxos 算法中,当处理来自客户端的事务性会话请求的过程时,首先会触发一个或多个服务器进程,就本次会话的处理发起提案。当该提案通过网络发送到集群中的其他角色服务器后,这些服务器会就该会话在本地的执行情况反馈给发起提案的服务器。发起提案的服务器会在接收到这些反馈信息后进行统计,当集群中超过半数的服务器认可该条事务性的客户端会话操作后,认为该客户端会话可以在本地执行操作

上面介绍的 Paxos 算法针对事务性会话的处理投票过程与 ZAB 协议十分相似,但不同的是,对于采用 ZAB 协议的 ZooKeeper 集群中发起投票的机器,所采用的是在集群中运行的一台 Leader 角色服务器。而 Paxos 算法则采用多副本的处理方式,即存在多个副本,每个副本分别包含提案者、决策者以及学习者。下图演示了三种角色的服务器之间的关系。

举例说明

将Paxos算法中的参与者比作一个议会的三个角色可能会更好理解:

  1. 提议者(Proposer):就像是议会的议员,他们有权提出新的法案(Proposal)。每个法案都有一个唯一的编号(Proposal ID)和一个具体的内容(Value)。
  2. 决策者(Acceptor):就像是参议院的成员,他们的职责是对提出的法案进行投票。他们可以选择赞成或反对法案,如果一个法案得到了超过半数的赞成票,那么这个法案就被认为是通过了。
  3. 决策学习者(Learner):就像是公众,他们不参与决策,但是可以从议员和参议员那里了解最新通过的法案(Value)。

当处理来自客户端的请求时,Paxos算法会触发一个或多个服务器进程,这些服务器就像是提出新法案的议员。他们会将法案发送给其他的服务器,其他的服务器就像是参议院的成员,他们会根据自己的情况对法案进行投票,并将投票结果反馈给发起法案的服务器。发起法案的服务器收到反馈后,会进行统计,如果超过半数的服务器赞成这个法案,那么就认为这个请求可以在本地执行。

事务处理过程

介绍完 Paxos 算法中的服务器角色和投票的处理过程后,接下来我们再来看一下 Paxos 针对一次提案是如何处理的。如下图所示,整个提案的处理过程可以分为三个阶段,分别是提案准备阶段、事务处理阶段、数据同步阶段。我们分别介绍一下这三个阶段的底层处理逻辑。

image-20240602201705558

  • 提案准备阶段:该阶段是整个 Paxos 算法的最初阶段,所有接收到的来自客户端的事务性会话在执行之前,整个集群中的 Proposer 角色服务器或者节点,需要将会话发送给 Acceptor 决策者服务器。在 Acceptor 服务器接收到该条询问信息后,需要返回 Promise ,承诺可以执行操作信息给 Proposer 角色服务器。
  • 事务处理阶段:在经过提案准备阶段,确认该条事务性的会话操作可以在集群中正常执行后,Proposer 提案服务器会再次向 Acceptor 决策者服务器发送 propose 提交请求。Acceptor 决策者服务器在接收到该 propose 请求后,在本地执行该条事务性的会话操作。
  • 数据同步阶段:在完成了事务处理阶段的操作后,整个集群中对该条事务性会话的数据变更已经在 Acceptor 决策者服务器上执行完成,当整个集群中有超过半数的 Acceptor 决策者服务器都成功执行后,Paxos 算法将针对本次执行结果形成一个决议,并发送给 Learner 服务器。当 Learner 服务器接收到该条决议信息后,会同步 Acceptor 决策者服务器上的数据信息,最终完成该条事务性会话在整个集群中的处理。

举例说明

Paxos算法的处理流程就像一个公司的决策过程。

首先是提案准备阶段,这就像是公司的某个部门(Proposer角色服务器)有一个新的项目想法,他们会将这个想法提交给公司的领导团队(Acceptor决策者服务器)。领导团队在接收到这个想法后,会评估这个项目是否值得进行,并给出他们的承诺(Promise),也就是是否同意进行这个项目。

接下来是事务处理阶段,这个阶段就像是公司开始执行这个项目。部门会再次向领导团队发送项目的详细计划(propose提交请求),领导团队在接收到这个计划后,会开始在本地执行这个项目,也就是开始分配资源,调配人员等。

最后是数据同步阶段,这就像是项目执行完成后,公司开始进行项目的总结和反馈。当超过半数的领导团队成员都同意这个项目的执行结果后,公司会形成一个最终的决议,并将这个决议发送给其他的部门(Learner服务器)。其他的部门在接收到这个决议后,会同步领导团队的决策,也就是根据这个项目的结果进行相应的调整和改进。

这就是Paxos算法的整个处理流程。通过这个流程,Paxos算法能够在分布式环境中实现数据的一致性,保证系统的正常运行。

Paxos PK ZAB

经过上面的介绍我们对 Paxos 算法所能解决的问题,以及底层的实现原理都有了一个详细的了解。现在结合我们之前学习的 ZooKeeper 相关知识,来看看 Paxos 算法与 ZAB 算法的相同及不同之处。

相同之处是,在执行事务行会话的处理中,两种算法最开始都需要一台服务器或者线程针对该会话,在集群中发起提案或是投票。只有当集群中的过半数服务器对该提案投票通过后,才能执行接下来的处理。

而 Paxos 算法与 ZAB 协议不同的是,Paxos 算法的发起者可以是一个或多个。当集群中的 Acceptor 服务器中的大多数可以执行会话请求后,提议者服务器只负责发送提交指令,事务的执行实际发生在 Acceptor 服务器。这与 ZooKeeper 服务器上事务的执行发生在 Leader 服务器上不同。Paxos 算法在数据同步阶段,是多台 Acceptor 服务器作为数据源同步给集群中的多台 Learner 服务器,而 ZooKeeper 则是单台 Leader 服务器作为数据源同步给集群中的其他角色服务器。

举例说明

Paxos算法和ZAB协议都是用于处理分布式系统中数据一致性问题的算法,它们有一些相同之处,也有一些不同之处。

相同之处在于,两种算法在处理事务性会话时,都需要有一个服务器或线程在集群中发起提案或投票,只有当集群中的过半数服务器对这个提案投票通过,才能执行接下来的操作。这就像是一个公司在做重大决策时,需要有一个部门提出建议,然后全公司进行投票,只有当半数以上的员工同意这个建议时,才会执行这个决策。

不同之处在于,Paxos算法的发起者可以是一个或者多个,这就像是公司中的多个部门都可以提出建议。而在ZAB协议中,只有一个Leader服务器可以发起提案,这就像是公司中只有CEO可以提出建议。另外,Paxos算法中,事务的执行实际发生在Acceptor(决策者)服务器,这就像是公司的各个部门根据CEO的决策进行执行。而在ZooKeeper中,事务的执行实际上发生在Leader服务器,这就像是CEO直接负责执行决策。

此外,在数据同步阶段,Paxos算法是由多台Acceptor服务器同步数据给集群中的Learner服务器,就像是公司的各个部门都会将执行结果报告给所有员工。而在ZooKeeper中,只有Leader服务器会同步数据给其他服务器,这就像是只有CEO会将执行结果公布给全公司。

通过这些例子,我们可以更好地理解Paxos算法和ZAB协议的相同之处和不同之处。

总结

本节课我们主要介绍了 Paxos 算法,该算法在解决分布式一致性问题上被广泛采用。Paxos 算法将集群中的服务器或网络节点分为提议者(Proposer)、决策者(Acceptor)、决策学习者(Learner),在处理事务性会话请求的时候,会针对该会话操作在集群中通过提议者(Proposer)服务器发起询问操作,之后由决策者(Acceptor)服务器决定是否执行。在集群中多数服务器都正确执行会话操作后,决策学习者(Learner)会同步(Acceptor)服务器上的数据,并完成最终的操作。

31 ZooKeeper 中二阶段提交算法的实现分析

我们一直围绕在分布式系统环境下,如何解决一致性问题来进行讨论,并分别介绍了在分布式环境中比较常见的二阶段提交、三阶段提交算法,之后又对比介绍了 ZooKeeper 所采用的 ZAB 协议算法和 Paxos 算法的优缺点。

在学习 ZAB 协议和 Paxos 算法的过程中,我们曾提到在处理来自客户端的事务性请求时,为了保证整个集群的数据一致性,其各自的底层实现与二阶段算法都有相似之处。但我们知道,二阶段提交算法自身有一些缺点,比如容易发生单点故障,比如在并发性能上有一些瓶颈,那么今天就深入 ZooKeeper 的底层,来看一下 ZooKeeper 是如何克服这些问题,并实现自己特有的二阶段提交算法的。希望通过本节课的学习,帮助你进一步提高解决分布式一致性问题的能力。

提交请求

前面我们学到,二阶段提交的本质是协调和处理 ZooKeeper 集群中的服务器,使它们在处理事务性会话请求的过程中能保证数据一致性。如果把执行在 ZooKeeper 集群中各个服务器上的事务会话处理操作分别看作不同的函数,那么整个一致性的处理逻辑就相当于包裹这些函数的事务。而在单机环境中处理事务的逻辑是,包含在事务中的所有函数要么全部成功执行,要么全部都不执行。

其中,事务性会话,简单来说,就是一系列的操作,这些操作被视为一个整体,要么全部成功,要么全部失败。这就像是一个链条,只有当所有的环节都成功,链条才能完整,只要有一个环节失败,整个链条就会断开。

image-20240602203300990

不同的是,在分布式环境中,处理事务请求的各个函数是分布在不同的网络服务器上的线程,无法像在单机环境下一样,做到当事务中的某一个环节发生异常的时候,回滚包裹在整个事务中的操作。因此,分布式环境中处理事务操作的时候,一般的算法不会要求全部集群中的机器都成功执行操作,如果有其中一个函数执行异常,那么整个事务就会把所有函数的执行结果回滚到执行前的状态,也就是无论是正确执行的函数,还是执行异常的函数,各自所做的对数据和程序状态的变更都将被删除。

执行请求

看完提交请求的处理过程后,我们再来看一下在执行请求时 ZooKeeper 的底层实现过程。

ZooKeeper 集群中的 Leader 服务器对该条事务性会话操作是否能够在 Follow 服务器上执行,向集群中的 Follow 服务器发起 Proposal 请求。

这里请你注意,与我们之前介绍的二阶段提交不同的是,在 ZooKeeper 的实现中并没有中断提交的逻辑。集群中的 Follow 服务器在接收到上述 Proposal 请求后,只有两种处理情况:

第一种情况:ZooKeeper 集群中的 Follow 服务器能够正确执行操作,并向 ZooKeeper 集群中的 Leader 反馈执行结果。

第二种情况:无法正确执行该条 Proposal 操作,直接抛弃该条请求。

ZooKeeper 集群的这种执行逻辑,最终导致无须等 待所有服务器都执行完成并反馈,集群中的 Leader 服务器只需要接收到集群中过半数的 Follow 服务器成功执行的反馈信息, ZooKeeper 集群中的 Leader 服务器最终会统计 Follow 服务器反馈的信息,当超过半数以上服务器可以正确执行操作后,整个 ZooKeeper 集群就可以进入执行事务提交操作。

底层实现

介绍完 ZooKeeper 实现二阶段提交算法的原理后,接下来我们深入代码层面看看 ZooKeeper 是如何设计架构的。

从源码层面来讲,ZooKeeper 在实现整个二阶段提交算法的过程中,可以分为 Leader 服务器端的发起 Proposal 操作和 Follow 服务器端的执行反馈操作。

我们先来看看,在 ZooKeeper 集群中的 Leader 是如何向其他 Follow 服务器发送 Proposal 请求的呢?

如下面的代码所示, ZooKeeper 通过 SendAckRequestProcessor 类发送 Proposal 来提交请求。这个类首先继承了 RequestProcessor 类,但是它不是处理来自客户端的请求信息,而是用来处理向 Follow 服务器发送的 Proposal 请求信息。它在内部通过 processRequest 函数来判断,责任链中传递请求操作是否是数据同步操作:如果判断是 OpCode.sync 操作(也就是数据同步操作),就通过 learner.writePacket 方法把 Proposal 请求向集群中的所有 Follow 服务器进行发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class SendAckRequestProcessor implements RequestProcessor, Flushable { 

public void processRequest(Request si) {

if(si.type != OpCode.sync){

QuorumPacket qp = new QuorumPacket(Leader.ACK, si.getHdr().getZxid(), null,

null);

try {

learner.writePacket(qp, false);

} catch (IOException e) {

LOG.warn("Closing connection to leader, exception during packet send", e);

try {

if (!learner.sock.isClosed()) {

learner.sock.close();

}

} catch (IOException e1) {

// Nothing to do, we are shutting things down, so an exception here is irrelevant

LOG.debug("Ignoring error closing the connection", e1);

}

}

}

}
}

在介绍完 ZooKeeper 集群中的 Leader 服务器发送 Proposal 的底层实现过程后,接下来我们再来学习一下 Follow 服务端在接收到 Leader 服务器发送的 Proposal 后的整个处理逻辑。

如下面的代码所示,这在 Follow 服务器端是通过 ProposalRequestProcessor 来完成处理的。ProposalRequestProcessor 构造函数中首先初始化了 Leader 服务器、下一个请求处理器,以及负责反馈执行结果给 Leader 服务器的 AckRequestProcessor 处理器。

1
2
3
4
5
6
7
8
9
10
11
12
public ProposalRequestProcessor(LeaderZooKeeperServer zks, 

RequestProcessor nextProcessor) {

this.zks = zks;

this.nextProcessor = nextProcessor;

AckRequestProcessor ackProcessor = new AckRequestProcessor(zks.getLeader());

syncProcessor = new SyncRequestProcessor(zks, ackProcessor);
}

接下来,我们进入到 AckRequestProcessor 函数的内部,来看一下 Follow 服务器是如何反馈处理结果给 Leader 服务器的。

如下面的代码所示, AckRequestProcessor 类同样也继承了 RequestProcessor,从中可以看出在 ZooKeeper 中处理 Leader 服务器的 Proposal 时,是将该 Proposal 请求当作网络中的一条会话请求来处理的。整个处理的逻辑实现也是按照处理链模式设计实现的,在 AckRequestProcessor 类的内部通过 processRequest 函数,来向集群中的 Leader 服务器发送 ack 反馈信息。

1
2
3
4
5
6
7
8
9
10
11
12
class AckRequestProcessor implements RequestProcessor { 

public void processRequest(Request request) {

QuorumPeer self = leader.self;

if(self != null)
leader.processAck(self.getId(), request.zxid, null);
else
LOG.error("Null QuorumPeer");

}}

总结

本节课我们主要介绍了,二阶段提交算法在 ZooKeeper 中的应用,并深入底层分析了 ZooKeeper 实现二阶段提交的具体过程。虽然二阶段提交自身有一些问题,不过还是一个比较好的解决分布式环境下一致性问题的算法,因此 ZooKeeper 在实现的过程中也借鉴了它,并通过自身的崩溃恢复机制来解决二阶段提交算法中的单点故障等问题。

32 ZooKeeper 数据存储底层实现解析

文件系统布局

无论是 ZooKeeper 服务在运行时候产生的数据日志,还是在集群中进行数据同步的时候所用到的数据快照,都可以被看作一种文件系统。而文件系统的两个功能就是对文件的存储和对不同文件格式的解析。ZooKeeper 中的数据存储,可以分为两种类型:数据日志文件和快照文件,接下来我们就分别介绍这两种文件的结构信息和底层实现。

数据日志

在 ZooKeeper 服务运行的过程中,数据日志是用来记录 ZooKeeper 服务运行状态的数据文件。通过这个文件我们不但能统计 ZooKeeper 服务的运行情况,更可以在 ZooKeeper 服务发生异常的情况下,根据日志文件记录的内容来进行分析,定位问题产生的原因并找到解决异常错误的方法。

如何找到日志文件呢?在 ZooKeeper 的 zoo.cfg 配置文件中的 dataLogDir 属性字段,所指定的文件地址就是当前 ZooKeeper 服务的日志文件的存储地址。

在了解了 ZooKeeper 服务在运行的过程中所产生的日志文件的存放位置,以及日志文件的格式结构后,接下来我们就深入到 ZooKeeper 服务的底层,来看一下它是如何实现日志的搜集以及存储的。

搜集日志

我们先来看一下 ,ZooKeeper 是如何搜集程序的运行信息的。在统计操作情况的日志信息中,ZooKeeper 通过第三方开源日志服务框架 SLF4J 来实现的。

SLF4J 是一个采用门面设计模式(Facade) 的日志框架。如下图所示,门面模式也叫作外观模式,采用这种设计模式的主要作用是,对外隐藏系统内部的复杂性,并向外部调用的客户端或程序提供统一的接口。门面模式通常以接口的方式实现,可以被程序中的方法引用。

在下图中,我们用门面模式创建了一个绘制几何图形的小功能。首先,定义了一个 Shape 接口类,并分别创建了三个类 Circle、Square、Rectangle ,以继承 Shape 接口。其次,我们再来创建一个画笔类 ShapeMaker ,在该类中我定义了 shape 形状字段以及绘画函数 drawCircle等。

image-20240602203726254

之后,当我们在本地项目中需要调用实现的会话功能时,直接调用 ShapeMaker 类,并传入我们要绘制的图形信息,就可以实现图形的绘制功能了。它使用起来非常简单,不必关心其底层是如何实现绘制操作的,只要将我们需要绘制的图形信息传入到接口函数中即可。

而在 ZooKeeper 中使用 SLF4J 日志框架也同样简单,如下面的代码所示,首先在类中通过工厂函数创建日志工具类 LOG,然后在需要搜集的操作流程处引入日志搜集函数 LOG.info 即可。

1
2
3
4
5
6
7
protected static final Logger LOG = LoggerFactory.getLogger(Learner.class);

LOG.info("Revalidating client: 0x" + Long.toHexString(clientId));

LOG.warn("Couldn't find the leader with id = "

+ current.getId());

存储日志

接下来我们看一下搜集完的日志是什么样子的。在开头我们已经说过,系统日志的存放位置,在 zoo.cfg 文件中。假设我们的日志路径为dataDir=/var/lib/zookeeper,打开系统命令行,进入到该文件夹,就会看到如下图所示的样子,所有系统日志文件都放在了该文件夹下。

快照文件

除了上面介绍的记录系统操作日志的文件外,ZooKeeper 中另一种十分重要的文件数据是快照日志文件。快照日志文件主要用来存储 ZooKeeper 服务中的事务性操作日志,并通过数据快照文件实现集群之间服务器的数据同步功能。

快照创建

接下来我们来介绍,在 ZooKeeper 的底层实现中,一个快照文件是如何创建的。

如下面的代码所示,在 ZooKeeper 的源码中定义了一个 SnapShot 接口类,在该接口中描述了 ZooKeeper 服务的相关属性和方法。其中 serialize 函数是用来将内存中的快照文件转存到本地磁盘中时的序列化操作。而 deserialize 的作用正好与其相反,是把快照文件从本地磁盘中加载到内存中时的反序列化操作。无论是序列化还是反序列化,整个快照所操作的数据对象是 ZooKeeper 数据模型,也就是由 Znode 组成的结构树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface SnapShot {

long deserialize(DataTree dt, Map<Long, Integer> sessions)

throws IOException;

void serialize(DataTree dt, Map<Long, Integer> sessions,

File name, boolean fsync)

throws IOException;

File findMostRecentSnapshot() throws IOException;

void close() throws IOException;

}

快照存储

创建完 ZooKeeper 服务的数据快照文件后,接下来就要对数据文件进行持久化的存储操作了。其实在整个 ZooKeeper 中,随着服务的不同阶段变化,数据快照存放文件的位置也随之变化。存储位置的变化,主要是内存和本地磁盘之间的转变。当 ZooKeeper 集群处理来自客户端的事务性的会话请求的时候,会首先在服务器内存中针对本次会话生成数据快照。当整个集群可以执行该条事务会话请求后,提交该请求操作,就会将数据快照持久化到本地磁盘中,如下图所示。

image-20240602203745904

存储到本地磁盘中的数据快照文件,是经过 ZooKeeper 序列化后的二进制格式文件,通常我们无法直接查看,但如果想要查看,也可以通过 ZooKeeper 自带的 SnapshotFormatter 类来实现。如下图所示,在 SnapshotFormatter 类的内部用来查看快照文件的几种函数分别是: printDetails 函数,用来打印日志中的数据节点和 Session 会话信息;printZnodeDetails 函数,用来查看日志文件中节点的详细信息,包括节点 id 编码、state 状态信息、version 节点版本信息等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SnapshotFormatter {

private void printDetails(DataTree dataTree, Map<Long, Integer> sessions)

private void printZnodeDetails(DataTree dataTree)

private void printZnode(DataTree dataTree, String name)

private void printSessionDetails(DataTree dataTree, Map<Long, Integer> sessions)

private void printStat(StatPersisted stat)

private void printHex(String prefix, long value)

}

虽然 ZooKeeper 提供了 SnapshotFormatter 类,但其实现的查询功能比较单一,我们可以通过本节课的学习,按照自己的业务需求,编写自己的快照文件查看器。

到目前位置,我们对 ZooKeeper 服务相关的数据文件都做了讲解。无论是数据日志文件,还是数据快照文件,最终都会存储在本地磁盘中。而从文件的生成方式来看,两种日志文件的不同是:数据日志文件实施性更高,相对的产生的日志文件也不断变化,只要 ZooKeeper 服务一直运行,就会产生新的操作日志数据;而数据快照并非实时产生,它是当集群中数据发生变化后,先在内存中生成数据快照文件,经过序列化后再存储到本地磁盘中。

总结

本节课我们讲解了在 ZooKeeper 服务运行过程中所产生的两种主要数据文件:数据日志文件和数据快照文件的结构信息和底层实现,以便加强你对它们的理解。