ZooKeeper 与一致性模型

一致性模型的用处

数据复制主要的目的有两个:可用性和性能。首先数据复制可以提高系统的可用性。在保持多副本的情况,有一个副本不可用,系统切换到其他副本就会恢复。常用的 MySQL 主备同步方案就是一个典型的例子。另一方面,数据复制能够提供系统的性能。当分布式系统需要在服务器数量和地理区域上进行扩展时,数据复制是一个相当重要的手段。有了多个数据副本,就能将请求分流;在多个区域提供服务时,也能通过就近原则提高客户端访问数据的效率。常用的 CDN 技术就是一个典型的例子。
但是数据复制是要付出代价的。数据复制带来了多副本数据一致性的问题。一个副本的数据更新之后,其他副本必须要保持同步,否则数据不一致就可能导致业务出现问题。因此,每次更新数据对所有副本进行修改的时间以及方式决定了复制代价的大小。全局同步与性能实际上是矛盾的,而为了提高性能,往往会采用放宽一致性要求的方法。因此,我们需要用一致性模型来理解和推理在分布式系统中数据复制需要考虑的问题和基本假设。

一致性模型分类

强一致性模型

能够保证所有进程对数据的读写顺序都保持一致的一致性模型称为强一致性模型

顺序一致性(Sequential Consistency)

注意哈,很多地方认为强一致性单指一个线性一致性,而顺序一致性比强一致性弱,比弱一致性强。

两个约束:

  • 单个节点的事件历史在全局历史上符合程序的先后顺序。
  • 全局事件历史在各个节点上一致。

我们可以根据这两个约束来判断一个系统是否满足顺序一致性:如果在系统中找不到任何一个符合上述两个约束的全局事件历史,则说明该系统一定不满足顺序一致性;如果能找到一个符合上述两个约束的全局事件历史,这说明系统在这段过程内是满足顺序一致性的。

看起来比较抽象,例子如下:

假设分布式系统存在A、B、C三个节点,初始值x=0、y=0,各个节点上发生的事件如图所示。

img

我们发现 B2 读到 y=3,那么在全局历史中 B2 在 A2 之后,而 B3 读出 x=0,那么在全局历史中 B3 在 A1 之前,但是在节点 A 的历史中,A1 在 A2 之前,在节点 B 的历史中,B2 在 B3 之前,这样子就不存在一个全局历史排列,能够将 A1、A2、B2、B3 排列起来,并且满足节点内历史的要求。所以这个情况不满足顺序一致性。

下面再展示一个满足顺序一致性的:

img

在上图中,我们能够找到满足以上约束的全局历史,例如:

  • B1-B2-A1-B3-A2-C1-C2
  • B1-A1-B2-B3-A2-C1-C2
  • B1-A1-C1-B2-A2-C2-B3
  • ……

这样的全局历史还可以找出很多,这意味着目前的这些片段是满足顺序一致性的。而如果一个系统的工作中,其全部事件都能满足顺序一致性,那这个系统就满足顺序一致性。

线性一致性(Linearizable Consistency)

线性一致性也叫严格一致性(Strict Consistency)或者原子一致性(Atomic Consistency),它的条件是:

  • 任何一次读都能读取到某个数据最近的一次写的数据。

  • 所有进程看到的操作顺序都跟全局时钟下的顺序一致。

线性一致性是对一致性要求最高的一致性模型,就现有技术是不可能实现的。因为它要求所有操作都实时同步,在分布式系统中要做到全局完全一致时钟现有技术是做不到的。首先通信是必然有延迟的,一旦有延迟,时钟的同步就没法做到一致。

当你只有一些写操作时,很难判断线性一致,因为你没有任何证据证明系统实际做了哪些操作,或者存储了什么数据,所以(在判断线性一致时)我们必须要有一些读操作。

我们一直在用一些历史记录来判断是不是具有顺序一致性或者线性一致性,因为这是关于系统行为的定义。我们需要从系统的输出来判断它是不是满足这样的准则。

在设计系统的时候,没有一个方法能将系统设计成线性一致。除非在一个非常简单的系统中,你只有一个服务器,一份数据拷贝,并且没有运行多线程,没有使用多核,在这样一个非常简单的系统中,要想违反线性一致还有点难。但是在任何分布式系统中,又是非常容易违反线性一致性。

首先我们先来分析一下线性一致性和顺序一致性的相同点在哪里。他们都能够保证所有进程对数据的读写顺序保持一致。线性一致性的实现很简单,就按照全局时钟(可以简单理解为物理时钟)为参考系,所有进程都按照全局时钟的时间戳来区分事件的先后,那么必然所有进程看到的数据读写操作顺序一定是一样的,因为它们的参考系是一样的。而顺序一致性使用的是逻辑时钟来作为分布式系统中的全局时钟,进而所有进程也有了一个统一的参考系对读写操作进行排序,因此所有进程看到的数据读写操作顺序也是一样的。

那么线性一致性和顺序一致性的区别在哪里呢?通过上面的分析可以发现,顺序一致性虽然通过逻辑时钟保证所有进程保持一致的读写操作顺序,但这些读写操作的顺序跟实际上发生的顺序并不一定一致。而线性一致性是严格保证跟实际发生的顺序一致的。

弱一致性模型

不能保证所有进程对数据的读写顺序都保持一致的一致性模型称为弱一致性模型

因果一致性(Causal Consistency)

因果一致性是一种弱化的顺序一致性模型,因为它将具有潜在因果关系的事件和没有因果关系的事件区分开了。那么什么是因果关系?如果事件 B 是由事件 A 引起的或者受事件 A 的影响,那么这两个事件就具有因果关系。
举个分布式数据库的示例,假设进程 P1 对数据项 x 进行了写操作,然后进程 P2 先读取了 x,然后对 y 进行了写操作,那么对 x 的读操作和对 y 的写操作就具有潜在的因果关系,因为 y 的计算可能依赖于 P2 读取到 x 的值(也就是 P1 写的值)。
另一方面,如果两个进程同时对两个不同的数据项进行写操作,那么这两个事件就不具备因果关系。无因果关系的操作称为并发操作。

两个约束:

  • 所有进程必须以相同的顺序看到具有因果关系的读写操作。
  • 不同进程可以以不同的顺序看到并发的读写操作。

因果一致性是一种弱化的顺序一致性模型。顺序一致性虽然不保证事件发生的顺序跟实际发生的保持一致,但是它能够保证所有进程看到的读写操作顺序是一样的。而因果一致性更进一步弱化了顺序一致性中对读写操作顺序的约束,仅保证有因果关系的读写操作有序,没有因果关系的读写操作(并发事件)则不做保证。也就是说如果是无因果关系的数据操作不同进程看到的值是有可能是不一样,而有因果关系的数据操作不同进程看到的值保证是一样的。

最终一致性(Eventual Consistency)

最终一致性是更加弱化的一致性模型,因果一致性起码还保证了有因果关系的数据不同进程读取到的值保证是一样的,而最终一致性只保证所有副本的数据最终在某个时刻会保持一致。
从某种意义上讲,最终一致性保证的数据在某个时刻会最终保持一致就像是在说:“人总有一天会死”一样。实际上我们更加关心的是:

1. “最终”到底是多久?通常来说,实际运行的系统需要能够保证提供一个有下限的时间范围。

2. 多副本之间对数据更新采用什么样的策略?一段时间内可能数据可能多次更新,到底以哪个数据为准?一个常用的数据更新策略就是以时间戳最新的数据为准。

由于最终一致性对数据一致性的要求比较低,在对性能要求高的场景中是经常使用的一致性模型。

以客户端为中心的一致性(Client-centric Consistency)

前面我们讨论的一致性模型都是针对数据存储的多副本之间如何做到一致性,考虑这么一种场景:在最终一致性的模型中,如果客户端在数据不同步的时间窗口内访问不同的副本的同一个数据,会出现读取同一个数据却得到不同的值的情况。为了解决这个问题,有人提出了以客户端为中心的一致性模型。以客户端为中心的一致性为单一客户端提供一致性保证,保证该客户端对数据存储的访问的一致性,但是它不为不同客户端的并发访问提供任何一致性保证。
举个例子:客户端 A 在副本 M 上读取 x 的最新值为 1,假设副本 M 挂了,客户端 A 连接到副本 N 上,此时副本 N 上面的 x 值为旧版本的 0,那么一致性模型会保证客户端 A 读取到的 x 的值为 1,而不是旧版本的 0。一种可行的方案就是给数据 x 加版本标记,同时客户端 A 会缓存 x 的值,通过比较版本来识别数据的新旧,保证客户端不会读取到旧的值。

以客户端为中心的一致性包含了四种子模型:

1. 单调读一致性(Monotonic-read Consistency):如果一个进程读取数据项 x 的值,那么该进程对于 x 后续的所有读操作要么读取到第一次读取的值要么读取到更新的值。即保证客户端不会读取到旧值。
2. 单调写一致性(Monotonic-write Consistency):一个进程对数据项 x 的写操作必须在该进程对 x 执行任何后续写操作之前完成。即保证客户端的写操作是串行的。
3. 读写一致性(Read-your-writes Consistency):一个进程对数据项 x 执行一次写操作的结果总是会被该进程对 x 执行的后续读操作看见。即保证客户端能读到自己最新写入的值。
4. 写读一致性(Writes-follow-reads Consistency):同一个进程对数据项 x 执行的读操作之后的写操作,保证发生在与 x 读取值相同或比之更新的值上。即保证客户端对一个数据项的写操作是基于该客户端最新读取的值。

What about Raft?

在讨论分布式系统时,共识算法(Consensus algorithm)和一致性(Consistency)通常是讨论热点,两者的联系很微妙,很容易搞混。一些常见的误解:使用了 Raft 或者 paxos 的系统都是线性一致的(Linearizability,即强一致),其实不然,共识算法只能提供基础,要实现线性一致还需要在算法之上做出更多的努力。以 TiKV 为例,它的共识算法是 Raft,在 Raft 的保证下,TiKV 提供了满足线性一致性的服务。

一个分布式系统正确实现了共识算法并不意味着能线性一致。共识算法只能保证多个节点对某个对象的状态是一致的,以 Raft 为例,它只能保证不同节点对 Raft Log(以下简称 Log)能达成一致。那么 Log 后面的状态机(state machine)的一致性呢?并没有做详细规定,用户可以自由实现。

线性一致性和 Raft - 知乎 (zhihu.com) TiKV 和 6.824 的 lab3 还是比较相似的,并且有一些优化,可以研究研究。

ZooKeeper

ZooKeeper 是一个分布式协调服务 ,最初由 Yahoo! 开发,现由 Apache 进行维护。ZooKeeper 主要用来解决分布式集群中应用系统的一致性问题,它能提供基于类似于文件系统的目录节点树方式的数据存储。但是 ZooKeeper 并不是用来专门存储数据的,它的作用主要是用来维护和监控存储数据的状态变化。通过监控这些数据状态的变化,从而可以达到基于数据的集群管理。有一个事实是,ZooKeeper 限制总文件大小在 1MB 以内(而且全都放在内存里),所以也可见它本来就没打算当一个真正的文件系统,而是以存储、监控一些文件这种方式来为分布式系统提供协调功能。

相比Raft来说,Raft实际上就是一个库。你可以在一些更大的多副本系统中使用Raft库。但是Raft不是一个你可以直接交互的独立的服务,你必须要设计你自己的应用程序来与Raft库交互。所以这里有一个有趣的问题:是否有一些有用的,独立的,通用的系统可以帮助人们构建分布式系统?是否有这样的服务可以包装成一个任何人都可以使用的独立服务,并且极大的减轻构建分布式应用的痛苦?所以,第一个问题是,对于一个通用的服务,API应该是怎样?我不太确定类似于Zookeeper这类软件的名字是什么,它们可以被认为是一个通用的协调服务(General-Purpose Coordination Service)。

作为一个多副本系统,Zookeeper是一个容错的,通用的协调服务,它与其他系统一样,通过多副本来完成容错。所以一个Zookeeper可能有3个、5个或者7个服务器,而这些服务器是要花钱的,例如7个服务器的Zookeeper集群比1个服务器的Zookeeper要贵7倍。所以很自然就会问,如果你买了7个服务器来运行你的多副本服务,你是否能通过这7台服务器得到7倍的性能?我们怎么能达到这一点呢?所以,现在问题是,如果我们有了n倍数量的服务器,是否可以为我们带来n倍的性能?

把Zookeeper看成一个类似于Raft的多副本系统。Zookeeper实际上运行在Zab之上,从我们的角度来看,Zab几乎与Raft是一样的。这里我只看多副本系统的性能,我并不关心Zookeeper的具体功能。

所以,现在全局来看,我们有大量的客户端,或许有数百个客户端,并且我们有一个Leader,这个Leader有两层,上面一层是与客户端交互的Zookeeper,下面是与Raft类似的管理多副本的Zab。Zab所做的工作是维护用来存放一系列操作的Log,这些操作是从客户端发送过来的,这与Raft非常相似。然后会有多个副本,每个副本都有自己的Log,并且会将新的请求加到Log中。这是一个很熟悉的配置(与Raft是一样的)。

当一个客户端发送了一个请求,Zab层会将这个请求的拷贝发送给其他的副本,其他副本会将请求追加在它们的内存中的Log或者是持久化存储在磁盘上,这样它们故障重启之后可以取回这些Log。

我们可以发现,加入更多的服务器时,Leader几乎可以确定是一个瓶颈,因为Leader需要处理每一个请求,它需要将每个请求的拷贝发送给每一个其他服务器。当你添加更多的服务器时,你只是为现在的瓶颈(Leader节点)添加了更多的工作负载。所以是的,你并不能通过添加服务器来达到提升性能的目的,因为新增的服务器并没有实际完成任何工作,它们只是愉快的完成Leader交代的工作,它们并没有减少Leader的工作。每一个操作都经过Leader。所以,在这里,随着服务器数量的增加,性能反而会降低,因为Leader需要做的工作更多了。所以,在这个系统中,我们现在有这个问题:更多的服务器使得系统更慢了。

好就好在,现实世界中,就像 web 一样,很多时候只需要取得页面就行,修改是稀少的。对于这种读多写少的情况,我们应该自然地去考虑,是不是能够把读请求发给副本,而不是全都交给 leader。

这里的问题是,没有理由可以相信,除了Leader以外的任何一个副本的数据是最新(up to date)的。所以,如果我们要做一个线性一致性系统,还是不能把读发给副本。但是 ZooKeeper 实现了副本越多,读性能越强,因为它并不要求返回最新的写入数据,不提供线性一致性的读。它有自己有关一致性的定义,而这个定义不是线性一致的,因此允许为读请求返回旧的数据。所以,Zookeeper这里声明,自己最开始就不支持线性一致性,来解决这里的技术问题。如果不提供这个能力,那么(为读请求返回旧数据)就不是一个bug。这实际上是一种经典的解决性能和强一致之间矛盾的方法,也就是不提供强一致。

ZooKeeper 的一致性保证

Zookeeper的确有一些一致性的保证,用来帮助那些使用基于Zookeeper开发应用程序的人,来理解他们的应用程序,以及理解当他们运行程序时,会发生什么。

  • 写请求线性一致。

    这里的意思是,尽管客户端可以并发的发送写请求,然后Zookeeper表现的就像以某种顺序,一次只执行一个写请求,并且也符合写请求的实际时间。所以如果一个写请求在另一个写请求开始前就结束了,那么Zookeeper实际上也会先执行第一个写请求,再执行第二个写请求。所以,这里不包括读请求,单独看写请求是线性一致的。

  • FIFO 客户端序列。

    这里的意思是,如果一个特定的客户端发送了一个写请求之后是一个读请求或者任意请求,那么首先,所有的写请求会以这个客户端发送的相对顺序,加入到所有客户端的写请求中(满足保证1)。所以,如果一个客户端说,先完成这个写操作,再完成另一个写操作,之后是第三个写操作,那么在最终整体的写请求的序列中,可以看到这个客户端的写请求以相同顺序出现(虽然可能不是相邻的)。所以,对于写请求,最终会以客户端确定的顺序执行。

    这里实际上是服务端需要考虑的问题,因为客户端是可以发送异步的写请求,也就是说客户端可以发送多个写请求给Zookeeper Leader节点,而不用等任何一个请求完成。Zookeeper论文并没有明确说明,但是可以假设,为了让Leader可以实际的按照客户端确定的顺序执行写请求,我设想,客户端实际上会对它的写请求打上序号,表明它先执行这个,再执行这个,第三个是这个,而Zookeeper Leader节点会遵从这个顺序。

    对于读请求,这里会更加复杂一些。我之前说过,读请求不需要经过Leader,只有写请求经过Leader,读请求只会到达某个副本。所以,读请求只能看到那个副本的Log对应的状态。对于读请求,我们应该这么考虑FIFO客户端序列,客户端会以某种顺序读某个数据,之后读第二个数据,之后是第三个数据,对于那个副本上的Log来说,每一个读请求必然要在Log的某个特定的点执行,或者说每个读请求都可以在Log一个特定的点观察到对应的状态。

    然后,后续的读请求,必须要在不早于当前读请求对应的Log点执行。也就是一个客户端发起了两个读请求,如果第一个读请求在Log中的一个位置执行,那么第二个读请求只允许在第一个读请求对应的位置或者更后的位置执行。第二个读请求不允许看到之前的状态,第二个读请求至少要看到第一个读请求的状态。这是一个极其重要的事实,我们会用它来实现正确的Zookeeper应用程序。

    这里特别有意思的是,如果一个客户端正在与一个副本交互,客户端发送了一些读请求给这个副本,之后这个副本故障了,客户端需要将读请求发送给另一个副本。这时,尽管客户端切换到了一个新的副本,FIFO客户端序列仍然有效。就是说我们需要保证,即使一个副本中途坏了,这个顺序依然要被保证。

    这里工作的原理是,每个Log条目都会被Leader打上zxid的标签,这些标签就是Log对应的条目号。任何时候一个副本回复一个客户端的读请求,首先这个读请求是在Log的某个特定点执行的,其次回复里面会带上zxid,对应的就是Log中执行点的前一条Log条目号。客户端会记住最高的zxid,当客户端发出一个请求到一个相同或者不同的副本时,它会在它的请求中带上这个最高的zxid。这样,其他的副本就知道,应该至少在Log中这个点或者之后执行这个读请求。这里有个有趣的场景,如果第二个副本并没有最新的Log,当它从客户端收到一个请求,客户端说,上一次我的读请求在其他副本Log的这个位置执行,那么在获取到对应这个位置的Log之前,这个副本不能响应客户端请求。我不是很清楚这里具体怎么工作,但是要么副本阻塞了对于客户端的响应,要么副本拒绝了客户端的读请求并说:我并不了解这些信息,去问问其他的副本,或者过会再来问我。

    最终,如果这个副本连上了Leader,它会更新上最新的Log,到那个时候,这个副本就可以响应读请求了。

    更进一步,FIFO客户端请求序列是对一个客户端的所有读请求,写请求生效。所以,如果我发送一个写请求给Leader,在Leader commit这个请求之前需要消耗一些时间,所以我现在给Leader发了一个写请求,而Leader还没有处理完它,或者commit它。之后,我发送了一个读请求给某个副本。这个读请求需要暂缓一下,以确保FIFO客户端请求序列。读请求需要暂缓,直到这个副本发现之前的写请求已经执行了。这是FIFO客户端请求序列的必然结果,(对于某个特定的客户端)读写请求是线性一致的。

    最明显的理解这种行为的方式是,如果一个客户端写了一份数据,例如向Leader发送了一个写请求,之后立即读同一份数据,并将读请求发送给了某一个副本,那么客户端需要看到自己刚刚写入的值。如果我写了某个变量为17,那么我之后读这个变量,返回的不是17,这会很奇怪,这表明系统并没有执行我的请求。因为如果执行了的话,写请求应该在读请求之前执行。所以,副本必然有一些有意思的行为来暂缓客户端,比如当客户端发送一个读请求说,我上一次发送给Leader的写请求对应了zxid是多少,这个副本必须等到自己看到对应zxid的写请求再执行读请求。

至此,我们可以根据前面的定义来确定,ZooKeeper 提供的是顺序一致性保证。

ZooKeeper 数据模型

ZooKeeper 的数据模型是一个树形结构的文件系统。

树中的节点被称为 znode,其中根节点为 /,每个节点上都会保存自己的数据和节点信息。znode 可以用于存储数据,并且有一个与之相关联的 ACL(Access Control List 权限控制表)。

image-20221126202043354

znode 有两种类型:

  • 临时的( EPHEMERAL ):户端会话结束时,ZooKeeper 就会删除临时的 znode。
  • 持久的( PERSISTENT ):除非客户端主动执行删除操作,否则 ZooKeeper 不会删除持久的 znode。

znode 上有一个顺序标志( SEQUENTIAL )。如果在创建 znode 时,设置了顺序标志( SEQUENTIAL ),那么 ZooKeeper 会使用计数器为 znode 添加一个单调递增的数值,即 zxid。ZooKeeper 正是利用 zxid 实现了严格的顺序访问控制能力。

每个 znode 节点在存储数据的同时,都会维护一个叫做 Stat 的数据结构,里面存储了关于该节点的全部状态信息。如下:

image-20221126202343257

读与写

先说一下身份,很简单:

  • Leader:它负责 发起并维护与各 Follwer 及 Observer 间的心跳。所有的写操作必须要通过 Leader 完成再由 Leader 将写操作广播给其它服务器。一个 Zookeeper 集群同一时间只会有一个实际工作的 Leader。
  • Follower:它会响应 Leader 的心跳。Follower 可直接处理并返回客户端的读请求,同时会将写请求转发给 Leader 处理,并且负责在 Leader 处理写请求时对请求进行投票。一个 Zookeeper 集群可能同时存在多个 Follower。
  • Observer:角色与 Follower 类似,但是无投票权。

Leader/Follower/Observer 都可直接处理读请求,从本地内存中读取数据并返回给客户端即可。

由于处理读请求不需要服务器之间的交互,Follower/Observer 越多,整体系统的读请求吞吐量越大,也即读性能越好。

image-20221126202738182

所有的写请求实际上都要交给 Leader 处理。Leader 将写请求以事务形式发给所有 Follower 并等待 ACK,一旦收到半数以上 Follower 的 ACK,即认为写操作成功。

image-20221126202827194

由上图可见,通过 Leader 进行写操作,主要分为五步:

  1. 客户端向 Leader 发起写请求。
  2. Leader 将写请求以事务 Proposal 的形式发给所有 Follower 并等待 ACK。
  3. Follower 收到 Leader 的事务 Proposal 后返回 ACK。
  4. Leader 得到过半数的 ACK(Leader 对自己默认有一个 ACK)后向所有的 Follower 和 Observer 发送 Commmit。
  5. Leader 将处理结果返回给客户端。

Follower/Observer 均可接受写请求,但不能直接处理,而需要将写请求转发给 Leader 处理。

除了多了一步请求转发,其它流程与直接写 Leader 无任何区别。

事务

对于来自客户端的每个更新请求,ZooKeeper 具备严格的顺序访问控制能力。

为了保证事务的顺序一致性,ZooKeeper 采用了递增的事务 id 号(zxid)来标识事务。

Leader 服务会为每一个 Follower 服务器分配一个单独的队列,然后将事务 Proposal 依次放入队列中,并根据 FIFO(先进先出) 的策略进行消息发送。Follower 服务在接收到 Proposal 后,会将其以事务日志的形式写入本地磁盘中,并在写入成功后反馈给 Leader 一个 Ack 响应。当 Leader 接收到超过半数 Follower 的 Ack 响应后,就会广播一个 Commit 消息给所有的 Follower 以通知其进行事务提交,之后 Leader 自身也会完成对事务的提交。而每一个 Follower 则在接收到 Commit 消息后,完成事务的提交。

所有的提议(proposal)都在被提出的时候加上了 zxid。zxid 是一个 64 位的数字,它的高 32 位是 epoch 用来标识 Leader 关系是否改变,每次一个 Leader 被选出来,它都会有一个新的 epoch,标识当前属于那个 leader 的统治时期。低 32 位用于递增计数。

详细过程如下:

  • Leader 等待 Server 连接;
  • Follower 连接 Leader,将最大的 zxid 发送给 Leader;
  • Leader 根据 Follower 的 zxid 确定同步点;
  • 完成同步后通知 follower 已经成为 uptodate 状态;
  • Follower 收到 uptodate 消息后,又可以重新接受 client 的请求进行服务了。

客户端 API

下面给出了 ZooKeeper API 的一个子集,并讨论每个请求的语义。

  • **create(path, data, flags)**:使用 path 名称创建一个 znode 节点,保存 data,返回新创建的 znode 名称。 flags 用于创建普通或者临时节点,也可以设置顺序标识。
  • **delete(path, version)**: 删除指定 path 和 version 的 znode 节点。
  • **exists(path, watch)**: 如果指定 path 的 znode 存在则返回真,如果不存在则返回假。watch 标识用于在 znode 上设置监视器。
  • **getData(path, watch)**: 返回数据和元数据,如版本信息。watch 标识与 exists() 的 watch 标识一样,但如果 znode 不存在则不会设置监视器。
  • **setData(path, data, version)**: 根据 path 和 version 将数据写入到 znode。
  • **getChildren(path, watch)**: 返回 znode 所有子节点的名称集合。
  • **sync(path)**: 在操作开始时,等待所有挂起的更新操作发送到客户端连接的服务器。path 当前未使用。

Watch

客户端注册监听它关心的 znode,当 znode 状态发生变化(数据变化、子节点增减变化)时,ZooKeeper 服务会通知客户端。

客户端和服务端保持连接一般有两种形式:

  • 客户端向服务端不断轮询
  • 服务端向客户端推送状态

Zookeeper 的选择是服务端主动推送状态,也就是观察机制( Watch )。

ZooKeeper 的观察机制允许用户在指定节点上针对感兴趣的事件注册监听,当事件发生时,监听器会被触发,并将事件信息推送到客户端。

客户端使用 getData 等接口获取 znode 状态时传入了一个用于处理节点变更的回调,那么服务端就会主动向客户端推送节点的变更:

从这个方法中传入的 Watcher 对象实现了相应的 process 方法,每次对应节点出现了状态的改变,WatchManager 都会通过以下的方式调用传入 Watcher 的方法:

1
2
3
4
5
6
7
8
9
10
11
Set<Watcher> triggerWatch(String path, EventType type, Set<Watcher> supress) {
WatchedEvent e = new WatchedEvent(type, KeeperState.SyncConnected, path);
Set<Watcher> watchers;
synchronized (this) {
watchers = watchTable.remove(path);
}
for (Watcher w : watchers) {
w.process(e);
}
return
}

Zookeeper 中的所有数据其实都是由一个名为 DataTree 的数据结构管理的,所有的读写数据的请求最终都会改变这颗树的内容,在发出读请求时可能会传入 Watcher 注册一个回调函数,而写请求就可能会触发相应的回调,由 WatchManager 通知客户端数据的变化。

通知机制的实现其实还是比较简单的,通过读请求设置 Watcher 监听事件,写请求在触发事件时就能将通知发送给指定的客户端。

会话

ZooKeeper 客户端通过 TCP 长连接连接到 ZooKeeper 服务集群。会话 (Session) 从第一次连接开始就已经建立,之后通过心跳检测机制来保持有效的会话状态。通过这个连接,客户端可以发送请求并接收响应,同时也可以接收到 Watch 事件的通知。

每个 ZooKeeper 客户端配置中都配置了 ZooKeeper 服务器集群列表。启动时,客户端会遍历列表去尝试建立连接。如果失败,它会尝试连接下一个服务器,依次类推。

一旦一台客户端与一台服务器建立连接,这台服务器会为这个客户端创建一个新的会话。每个会话都会有一个超时时间,若服务器在超时时间内没有收到任何请求,则相应会话被视为过期。一旦会话过期,就无法再重新打开,且任何与该会话相关的临时 znode 都会被删除。

通常来说,会话应该长期存在,而这需要由客户端来保证。客户端可以通过心跳方式(ping)来保持会话不过期。

实际应用案例

详解分布式协调服务 ZooKeeper,再也不怕面试问这个了 (qq.com)

看这里的 part 5。

CAP 原理

CAP定理又称CAP原则,指的是在一个分布式系统中:Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),最多只能同时三个特性中的两个,三者不可兼得,最多满足其中的两个特性。分布式系统要么满足CA,要么CP,要么AP。无法同时满足CAP。

[分布式系统架构中CAP原理及案例 - 腾讯云开发者社区-腾讯云 (tencent.com)](https://cloud.tencent.com/developer/article/1554867#:~:text=根据百度百科的定义,CAP定理又称CAP原则,指的是在一个分布式系统中:Consistency(一致性)、,Availability(可用性)、Partition tolerance(分区容错性),最多只能同时三个特性中的两个,三者不可兼得,最多满足其中的两个特性。2019年12月17日)

参考

分布式系统:一致性模型 - 知乎 (zhihu.com)

什么是顺序一致性? - 知乎 (zhihu.com)

如何理解ZooKeeper的顺序一致性? - 掘金 (juejin.cn)

详解分布式协调服务 ZooKeeper,再也不怕面试问这个了 (qq.com)

8.5 一致保证(Consistency Guarantees) - MIT6.824 (gitbook.io)

分布式系统协议Paxos、Raft和ZAB - 知乎 (zhihu.com)