ZooKeeper 内部
简介
此文档包含有关 ZooKeeper 内部工作原理的信息。它讨论了以下主题
原子广播
ZooKeeper 的核心是一个原子消息传递系统,它使所有服务器保持同步。
保证、属性和定义
ZooKeeper 使用的消息传递系统提供的具体保证如下
-
可靠交付:如果消息
m
由一台服务器交付,则消息m
最终将由所有服务器交付。 -
全序:如果消息
a
在消息b
之前由一台服务器交付,则消息a
将在所有服务器上在b
之前交付。 -
因果顺序:如果消息
b
在消息a
已由b
的发送者交付后发送,则消息a
必须在b
之前排序。如果发送者在发送b
之后发送c
,则c
必须在b
之后排序。
ZooKeeper 消息传递系统还需要高效、可靠且易于实现和维护。我们大量使用消息传递,因此我们需要该系统能够每秒处理数千个请求。尽管我们可以要求至少 k+1 台正确的服务器发送新消息,但我们必须能够从相关故障(如断电)中恢复。当我们实施该系统时,我们几乎没有时间和工程资源,因此我们需要一个对工程师来说易于理解且易于实施的协议。我们发现我们的协议满足了所有这些目标。
我们的协议假设我们可以在服务器之间构建点对点 FIFO 通道。虽然类似的服务通常假设消息传递可能会丢失或重新排序消息,但鉴于我们使用 TCP 进行通信,因此我们对 FIFO 通道的假设非常实用。具体来说,我们依赖 TCP 的以下属性
-
有序交付:数据按照发送顺序交付,并且仅在
m
之前发送的所有消息都已交付后才交付消息m
。(推论是,如果消息m
丢失,则m
之后的所有消息都将丢失。) -
关闭后无消息:一旦 FIFO 通道关闭,将不会从中收到任何消息。
FLP 证明,如果可能发生故障,则无法在异步分布式系统中达成共识。为了确保我们在发生故障时达成共识,我们使用超时。但是,我们依赖时间来保证活性,而不是正确性。因此,如果超时停止工作(例如,时钟倾斜),则消息传递系统可能会挂起,但它不会违反其保证。
在描述 ZooKeeper 消息传递协议时,我们将讨论数据包、建议和消息
-
数据包:通过 FIFO 通道发送的字节序列。
-
建议:一个协议单元。建议通过与 ZooKeeper 服务器的法定人数交换数据包来达成一致。大多数建议都包含消息,但是 NEW_LEADER 建议是不包含消息的建议的一个示例。
-
消息:要以原子方式广播到所有 ZooKeeper 服务器的字节序列。在交付之前放入建议并达成一致的消息。
如上所述,ZooKeeper 保证消息的总顺序,并且还保证建议的总顺序。ZooKeeper 使用 ZooKeeper 事务 ID (zxid) 公开总顺序。所有建议在提出时都将盖上 zxid,并准确反映总顺序。建议被发送到所有 ZooKeeper 服务器,并在法定人数确认建议时提交。如果建议包含消息,则在提交建议时将交付消息。确认意味着服务器已将建议记录到持久性存储中。我们的法定人数要求任何一对法定人数都必须至少有一个服务器是公共的。我们通过要求所有法定人数的大小为 (n/2+1) 来确保这一点,其中 n 是组成 ZooKeeper 服务的服务器数量。
zxid 有两部分:纪元和计数器。在我们的实现中,zxid 是一个 64 位数字。我们使用高阶 32 位作为纪元,使用低阶 32 位作为计数器。由于 zxid 由两部分组成,因此 zxid 可以表示为数字,也可以表示为一对整数 (epoch, count)。纪元数表示领导层的变化。每次新领导人上台,他都会有自己的纪元数。我们有一个简单的算法为建议分配唯一的 zxid:领导人只需增加 zxid 即可为每个建议获取唯一的 zxid。领导激活将确保只有一个领导人使用给定的纪元,因此我们的简单算法保证每个建议都将具有唯一的 ID。
ZooKeeper 消息传递包括两个阶段
-
领导激活:在此阶段,领导者建立系统的正确状态并准备开始提出建议。
-
活动消息传递:在此阶段,领导者接受要提出的消息并协调消息传递。
ZooKeeper 是一个整体协议。我们不关注个别提议,而是将提议流作为一个整体来考虑。我们的严格排序使我们能够高效地执行此操作,并极大地简化了我们的协议。领导激活体现了这一整体概念。只有当法定人数的追随者(领导者也算作追随者。您始终可以为自己投票)与领导者同步,他们具有相同的状态时,领导者才会变得活跃。此状态包含领导者认为已提交的所有提议以及遵循领导者的提议,即 NEW_LEADER 提议。(希望您在想,领导者认为已提交的提议集是否包括所有真正已提交的提议?答案是是。下面,我们将阐明原因。)
领导者激活
领导者激活包括领导者选举 (FastLeaderElection
)。只要满足以下条件,ZooKeeper 消息传递就不关心选举领导者的确切方法
- 领导者已看到所有追随者的最高 zxid。
- 法定人数的服务器已承诺遵循领导者。
在这两个要求中,只有第一个要求(追随者中的最高 zxid)需要满足才能进行正确的操作。第二个要求(法定人数的追随者)只需要以高概率满足即可。我们将重新检查第二个要求,因此,如果在领导者选举期间或之后发生故障并且法定人数丢失,我们将通过放弃领导者激活并进行另一次选举来恢复。
在领导者选举之后,将指定一台服务器为领导者,并开始等待追随者连接。其余服务器将尝试连接到领导者。领导者将通过发送任何他们错过的提议与追随者同步,或者如果追随者错过了太多提议,它将向追随者发送状态的完整快照。
有一个极端情况,即具有提议的追随者 U
到达,而领导者没有看到。提议按顺序查看,因此 U
的提议将具有高于领导者看到的 zxid 的 zxid。追随者必须在领导者选举之后到达,否则追随者将当选为领导者,因为它看到了更高的 zxid。由于已提交的提议必须由法定人数的服务器看到,而选举领导者的法定人数的服务器没有看到 U
,因此 U
的提议尚未提交,因此可以将其丢弃。当追随者连接到领导者时,领导者将告诉追随者丢弃 U
。
新领导者通过获取它已看到的最高 zxid 的纪元 e,并将要使用的下一个 zxid 设置为 (e+1, 0) 来建立一个用于新提议的 zxid,在领导者与追随者同步之后,它将提出一个 NEW_LEADER 提议。一旦 NEW_LEADER 提议被提交,领导者将激活并开始接收和发布提议。
这一切听起来很复杂,但以下是领导者激活期间的基本操作规则
- 追随者在与领导者同步后将确认 NEW_LEADER 提议。
- 一个跟随者只会对来自单个服务器的给定 zxid 的 NEW_LEADER 提议进行 ACK。
- 当法定数量的跟随者对 NEW_LEADER 提议进行 ACK 时,新领导者将提交该提议。
- 当 NEW_LEADER 提议提交时,跟随者将提交从领导者接收到的任何状态。
- 在 NEW_LEADER 提议提交之前,新领导者不会接受新提议。
如果领导者选举错误终止,我们不会遇到问题,因为 NEW_LEADER 提议不会提交,因为领导者不会有法定数量。发生这种情况时,领导者和任何剩余的跟随者将超时并返回领导者选举。
活动消息传递
领导者激活完成所有繁重的工作。一旦领导者加冕,他就可以开始发布提议。只要他仍然是领导者,就不会出现其他领导者,因为没有其他领导者能够获得法定数量的跟随者。如果出现新领导者,则表示领导者已失去法定数量,新领导者将清理她在领导者激活期间留下的任何混乱。
ZooKeeper 消息传递的操作类似于经典的两阶段提交。
所有通信通道都是 FIFO,因此所有操作都是按顺序进行的。具体来说,遵守以下操作约束
- 领导者使用相同的顺序向所有跟随者发送提议。此外,此顺序遵循请求接收的顺序。由于我们使用 FIFO 通道,这意味着跟随者也会按顺序接收提议。
- 跟随者按接收顺序处理消息。这意味着消息将按顺序进行 ACK,并且由于 FIFO 通道,领导者将按顺序从跟随者接收 ACK。这也意味着如果消息
m
已写入非易失性存储,则在m
之前提出的所有消息都已写入非易失性存储。 - 一旦法定数量的跟随者对消息进行 ACK,领导者将向所有跟随者发出 COMMIT。由于消息按顺序进行 ACK,因此领导者将按跟随者接收的顺序发送 COMMIT。
- COMMIT 按顺序处理。当提议提交时,跟随者会传递提议消息。
摘要
所以就是这样。为什么它能起作用?具体来说,为什么新领导人相信的一组提议总是包含任何实际已提交的提议?首先,所有提议都有一个唯一的 zxid,因此与其他协议不同,我们不必担心为同一个 zxid 提出了两个不同的值;追随者(领导者也是追随者)按顺序查看和记录提议;提议按顺序提交;一次只有一个活动领导者,因为追随者一次只追随一个领导者;新领导者已经看到了前一个时期所有已提交的提议,因为它已经看到了来自服务器法定人数的最高 zxid;新领导者看到的来自前一个时期的任何未提交的提议都将在其变为活动之前由该领导者提交。
比较
这不就是 Multi-Paxos 吗?不,Multi-Paxos 需要某种方式来确保只有一个协调器。我们不依靠这样的保证。相反,我们使用领导者激活从领导者变更或旧领导者相信他们仍然处于活动状态中恢复。
这不就是 Paxos 吗?你的活动消息传递阶段看起来就像 Paxos 的第 2 阶段?实际上,对我们来说,活动消息传递看起来就像 2 阶段提交,而无需处理中止。活动消息传递与两者不同,因为它具有跨提议排序要求。如果我们不维护所有数据包的严格 FIFO 排序,那么一切都将分崩离析。此外,我们的领导者激活阶段与它们两者不同。特别是,我们对 epoch 的使用允许我们跳过未提交提议的块,并且不必担心给定 zxid 的重复提议。
一致性保证
ZooKeeper 的一致性保证介于顺序一致性和线性化之间。在本节中,我们将解释 ZooKeeper 提供的确切一致性保证。
ZooKeeper 中的写操作是可线性化的。换句话说,每个write
看起来都会在客户端发出请求和收到相应响应之间某个时间点以原子方式生效。这意味着 ZooKeeper 中所有客户端执行的写操作都可以完全按顺序排列,这种顺序符合这些写操作的实时顺序。但是,仅仅说写操作是可线性化的并没有什么意义,除非我们也讨论读操作。
ZooKeeper 中的读取操作不可线性化,因为它们可能会返回潜在的陈旧数据。这是因为 ZooKeeper 中的读取
不是仲裁操作,并且服务器将立即响应执行读取
的客户端。ZooKeeper 这样做是因为它优先考虑读取用例的性能而不是一致性。但是,ZooKeeper 中的读取是顺序一致的,因为读取
操作似乎会按某种顺序生效,并且还会遵循每个客户端操作的顺序。解决此问题的常见模式是在发出读取
之前发出同步
。这也不能严格保证最新数据,因为同步
目前不是仲裁操作。举例来说,考虑一种情况,即两台服务器同时认为自己是领导者,如果 TCP 连接超时小于syncLimit * tickTime
,则可能会发生这种情况。请注意,在实践中,这种情况不太可能发生,但讨论严格的理论保证时仍应牢记这一点。在这种情况下,有可能同步
由具有陈旧数据的“领导者”提供服务,从而也允许以下读取
陈旧。如果在读取
之前执行实际的仲裁操作(例如写入
),则会提供更强的线性化保证。
总体而言,ZooKeeper 的一致性保证在形式上由有序顺序一致性或确切地说是OSC(U)
的概念所捕获,它介于顺序一致性和线性化之间。
法定人数
原子广播和领导者选举使用仲裁的概念来保证系统的视图一致。默认情况下,ZooKeeper 使用多数仲裁,这意味着在这些协议之一中发生的每次投票都需要多数投票。一个示例是确认领导者提议:领导者只有在收到来自服务器仲裁的确认后才能提交。
如果我们提取我们在使用多数派时真正需要的属性,我们发现我们只需要保证用于通过投票验证操作的进程组(例如,确认领导者提议)至少在一个服务器中成对相交。使用多数派保证了这样的属性。但是,还有其他方法可以构建不同于多数派的仲裁组。例如,我们可以为服务器的投票分配权重,并说某些服务器的投票更重要。要获得仲裁组,我们获得足够的投票,以便所有投票的权重总和大于所有权重总和的一半。
使用权重且在广域部署(同地点)中很有用的另一种构建是分层构建。使用这种构建,我们将服务器拆分为不相交的组,并为进程分配权重。要形成仲裁组,我们必须从大多数组 G 中获取足够的服务器,以便对于 G 中的每个组 g,来自 g 的投票总和大于 g 中权重总和的一半。有趣的是,这种构建能够实现更小的仲裁组。例如,如果我们有 9 个服务器,我们将它们拆分为 3 个组,并为每个服务器分配 1 的权重,那么我们能够形成大小为 4 的仲裁组。请注意,由每个大多数组的大多数服务器组成的两个进程子集必然具有非空交集。有理由期望大多数同地点在高概率下将具有大多数可用服务器。
使用 ZooKeeper,我们为用户提供了配置服务器以使用多数派仲裁组、权重或组层次结构的能力。
日志记录
Zookeeper 使用 slf4j 作为日志记录的抽象层。自 ZooKeeper 3.8.0 版本起,选择 Logback 作为日志记录后端。为了获得更好的嵌入支持,未来计划将最终日志记录实现的选择权留给最终用户。因此,始终使用 slf4j api 在代码中编写日志记录语句,但在运行时配置 logback 以了解如何记录日志。请注意,slf4j 没有 FATAL 级别,以前的 FATAL 级别消息已移至 ERROR 级别。有关为 ZooKeeper 配置 logback 的信息,请参阅 日志记录 部分的 ZooKeeper 管理员指南。
开发者指南
在代码中创建日志记录语句时,请遵循 slf4j 手册。在创建日志记录语句时,还要阅读 有关性能的常见问题解答。补丁审阅者将寻找以下内容
以正确的级别记录日志
slf4j 中有几个日志记录级别。
选择正确的级别很重要。从高到低严重性排序
- ERROR 级别表示错误事件,这些事件仍可能允许应用程序继续运行。
- WARN 级别表示潜在的有害情况。
- INFO 级别表示信息性消息,突出显示应用程序在粗粒度级别的进度。
- DEBUG 级别表示细粒度的信息性事件,最有助于调试应用程序。
- TRACE 级别表示比 DEBUG 更细粒度的信息性事件。
ZooKeeper 通常在生产中运行,以便将 INFO 级别严重性及更高(更严重)的日志消息输出到日志。
使用标准 slf4j 惯用法
静态消息日志记录
LOG.debug("process completed successfully!");
但是,当需要创建参数化消息时,请使用格式化锚点。
LOG.debug("got {} messages in {} minutes",new Object[]{count,time});
命名
应根据日志记录器在其中使用的类对其进行命名。
public class Foo {
private static final Logger LOG = LoggerFactory.getLogger(Foo.class);
....
public Foo() {
LOG.info("constructing Foo");
异常处理
try {
// code
} catch (XYZException e) {
// do this
LOG.error("Something bad happened", e);
// don't do this (generally)
// LOG.error(e);
// why? because "don't do" case hides the stack trace
// continue process here as you need... recover or (re)throw
}