本文译自KIP-500:用自我管理元数据替换ZooKeeper。
动机(Motivation)
目前,Kafka使用ZooKeeper来存储分区和broker的元数据,并选择一个broker作为Kafka的控制器。我们希望去除对ZooKeeper的依赖性。这将使我们能够以一种更加可扩展和强大的方式来管理元数据,从而支持更多的分区。这也将进一步的简化Kafka的部署和配置。
元数据作为事件日志
我们经常谈论将状态作为事件流进行管理的好处。一个数字,即offset(偏移量),描述了消费者在流中的位置。多个消费者只需重播比当前偏移量新的事件,即可快速赶上最新状态。日志在事件之间建立了清晰的顺序,并确保消费者始终沿着单一的时间线移动。
然而,尽管我们的用户享有这些好处,但Kafka本身却被排除在外。我们将元数据的变化视为孤立的变化,彼此之间没有关系。当控制器将状态变化通知(如LeaderAndIsrRequest)推送给集群中的其他broker时,broker有可能得到部分变化,但不是全部。虽然控制器重试了几次,但它最终还是放弃了。这可能会使broker处于分歧的状态。
更糟糕的是,虽然 ZooKeeper 是记录的存储,但 ZooKeeper 中的状态通常与控制器内存中的状态不匹配。例如,当分区领导者在 ZK 中更改其 ISR 时,控制器通常不会在几秒钟内知道这些更改。控制器没有通用的方法来跟踪 ZooKeeper 事件日志。 虽然控制器可以设置一次性监听,但出于性能原因,watch的数量是有限的。 当watch触发时,它不会告诉控制器当前的状态 —— 只是状态已经改变。当控制器重新读取 znode 并设置一个新的 watch 时,状态可能已经从 watch 最初触发时的状态改变了。 如果没有设置watch,控制器可能根本不了解变化。 在某些情况下,重新启动控制器是解决差异的唯一方法。
元数据不应该被存储在一个单独的系统中,而应该存储在Kafka本身。 这将避免与控制器状态和Zookeeper状态之间的差异相关的所有问题。不应该向broker推送通知,而应该简单地从事件日志中消费元数据事件。这确保了元数据变化总是以相同的顺序到达。broker将能够在本地将元数据存储在一个文件中。当启动时,只需要从控制器中读取变化的内容,而不是完整的状态。这将使我们能够以较少的CPU消耗支持更多的分区。
更简单的部署和配置
ZooKeeper是一个独立的系统,有自己的配置文件语法、管理工具和部署模式。这意味着系统管理员需要学习如何管理和部署两个独立的分布式系统,才能部署Kafka。这对管理员来说是一项艰巨的任务,尤其是当他们对部署Java服务不是很熟悉的时候。统一系统将大大改善运行Kafka的"第一天"的体验,并有助于kafka扩大其采用范围。
由于Kafka和ZooKeeper的配置是分开的,所以很容易犯错。例如,管理员可能会在Kafka上设置SASL,并错误地认为他们已经保护了所有在网络上传输的数据。事实上,还需要在独立的外部ZooKeeper系统中配置安全,才能做到这一点。统一这两个系统将提供一个统一的安全配置模型。
最后,在未来我们可能希望支持单节点的Kafka模式。这对那些想快速测试Kafka而不需要启动多个守护进程的人来说非常有用。删除ZooKeeper的依赖性就很有必要了。
架构(Architecture)
介绍
这个KIP提出了一个可扩展的后ZooKeeper Kafka的整体愿景。为了展示大局观,我基本上没有提及诸如RPC格式、磁盘格式等细节。我们将希望有后续的KIP来更详细地描述每一步。
概念
目前,一个Kafka集群包含几个broker节点,以及一个由ZooKeeper节点组成的外部的法定人数。 我们在这张图中画了4个broker节点和3个ZooKeeper节点。这是一个小型集群的典型规模。控制器(用橙色描述)在当选后从ZooKeeper中加载其状态。从控制器延伸到broker的其他节点的线代表控制器推送的更新,如LeaderAndIsr和UpdateMetadata消息。
注意,这张图略有误导性。除了控制器之外,其他broker其实也在与ZooKeeper进行通信。所以,实际上的应该是从每个broker到ZK画一条线。但是,画那么多线会使图片难以阅读。这张图遗漏的另一个问题是,外部的命令行工具和工具也可以修改ZooKeeper的状态。没有控制器的参与。如前所述,这些问题使得我们很难知道控制器上内存中的状态是否真正反映了ZooKeeper中的持久状态。
在右边的架构中,三个控制器节点替代了三个ZooKeeper节点。控制器节点和broker节点在不同的JVM中运行。控制器节点为元数据分区选出一个leader,显示为橙色的。broker从这个leader那里获取元数据更新,而不是由控制器向broker推送更新。这就是为什么箭头指向控制器。
请注意,尽管控制器进程在逻辑上与broker进程分开,但它们不需要在物理上分开。在某些情况下,将部分或全部控制器进程部署在与broker进程相同的节点上可能是合理的。这类似于今天在较小的集群中,ZooKeeper进程可以与Kafka broker部署在同一节点上。按照惯例,各种部署选项都是可能的,包括在同一个JVM中运行。
控制器法定人数(The Controller Quorum)
控制器节点包括一个管理元数据日志的Raft quorum。 该日志包含关于集群元数据的每一次变化的信息。当前存储在ZooKeeper中的所有内容,如topic、分区、ISR、配置等,都将存储在该日志中。
使用Raft算法,控制器节点将从它们之间选出一个leader,而不依赖任何外部系统。元数据日志的leader被称为活跃控制器
。活跃控制器处理所有来自broker的RPC。follower控制器复制写入活跃控制器的数据,并在活跃控制器发生故障时充当热备用。因为控制器现在都会跟踪最新的状态,所以控制器的故障切换不需要一个漫长的重载期,我们可以把所有的状态转移到新的控制器上。
就像ZooKeeper一样,Raft要求大多数节点都在运行,以便高可用。因此,一个三节点的控制器集群可以承受一次故障。 五个节点的控制器集群可以承受两次故障,以此类推。
定期地,控制器会把元数据的快照写到磁盘上。虽然这在概念上与压缩相似,但代码路径会有些不同,因为我们可以简单地从内存中读取状态,而不是从磁盘中重新读取日志。
broker元数据管理(Broker Metadata Management)
这些broker将通过新的MetadataFetch API从活跃控制器获取更新,而不是由控制器向其他broker推送更新。
MetadataFetch类似于一个fetch请求。就像获取请求一样,broker将跟踪它获取的最后一次更新的offset,并且只从活跃控制器中请求新的更新。
broker将把它获取的元数据持久化到磁盘上。这将使broker能够非常迅速地启动,即使有几十万甚至上百万个分区。
大多数情况下,broker应该只需要获取增量,而不是完整的状态。然而,如果broker落后于活跃的控制器太多的话,或者broker根本没有缓存的元数据,控制器将发送一个完整的元数据镜像,而不是增量数据。
broker将定期从活跃的控制器中请求元数据更新。这个请求将作为一个心跳,让控制器知道broker是活的。
请注意,虽然本文只讨论了broker的元数据管理,但是客户端的元数据管理对于可扩展性也很重要。 一旦有了发送增量元数据更新的基础设施,我们将希望把它用于客户端和broker。毕竟,客户端的数量通常比broker多得多。随着分区数量的增加,向对许多分区感兴趣的客户增量发送元数据更新会变得越来越重要。我们将在后续的KIP中进一步讨论这个问题。
Broker状态机
目前,broker在启动后立即向ZooKeeper注册。这个注册有两个作用:它让broker知道它是否被选为控制器,并让其他节点知道如何连接它。
在后ZooKeeper时代,broker将在控制器的法定人数中注册自己,而不是在ZooKeeper中注册。
目前,如果一个broker失去了它的ZooKeeper会话,控制器会将其从集群元数据中删除。在后ZooKeeper时代,如果一个broker在足够长的时间内没有发送MetadataFetch心跳,活跃的控制器会将其从集群元数据中删除。
在目前的情况下,一个可以联系ZooKeeper但与控制器隔开的broker将继续为用户请求提供服务,但不会收到任何元数据更新。这可能会导致一些混乱和困难的情况。例如,一个使用acks=1的生产者可能会继续向一个实际上不再是leader的leader生产,但它未能收到控制器LeaderAndIsrRequest的leader变更。
在后ZK时代,集群成员与元数据更新相结合。如果broker不能接收元数据更新,他们就不能继续成为集群的成员。虽然broker仍有可能从一个特定的客户那里被分区,但如果broker从控制器那里被分区,它将被从集群中移除。
Broker状态
离线(Offline)
当broker进程处于离线状态时,它要么根本没有运行,要么正在执行启动所需的单节点任务,如初始化JVM或执行日志恢复。
围栏(Fenced)
当broker处于围栏状态时,它将不响应来自客户端的RPC。在启动和尝试获取最新的元数据时,broker将处于围栏状态。如果它不能连接活跃的控制器,它也将重新进入围栏状态。被围栏的broker应该从发送给客户端的元数据中忽略。
在线(Online)
当一个broker在线时,它就准备好回应客户的请求。
停机(Stopping)
broker在收到SIGINT时进入停止状态。这表明系统管理员要关闭broker了。
当broker停止时,它仍在运行,但正试图将分区leader从它那里迁移出去。
最终,活跃的控制器将通过在MetadataFetchResponse中返回一个特殊的结果代码,要求broker最终下线。另外,如果leader不能在预定的时间内被转移,broker也将关闭。
将一些现有的API过渡到只用控制器
许多以前由直接写入ZooKeeper的操作将变成控制器操作。例如,配置变更,默认授权所存储的ACL,等等。
新版本的客户端应该将这些操作直接发送到活跃控制器。这是一个向后兼容的变化:它将与新旧集群一起工作。为了保持与将这些操作发送到随机brokers的旧客户端的兼容性,brokers将把这些请求转发给活跃控制器。
新的控制器API
在某些情况下,我们需要创建一个新的API来取代以前通过ZooKeeper完成的操作。比如,当一个分区的leader想要修改同步复制集时,它目前是直接修改ZooKeeper。
从工具中删除直接访问ZooKeeper的权限
目前,一些工具和脚本直接连接ZooKeeper。在后ZooKeeper时代,这些工具必须使用Kafka APIs来代替。幸运的是,"KIP-4:命令行和集中管理操作" 几年前就开始了取消直接访问ZooKeeper的任务,现在已经接近完成。
兼容性、废止性和迁移计划
客户端兼容性
我们将保留与现有Kafka客户端的兼容性。在某些情况下,现有的客户端将采取一个效率较低的代码路径。例如,brokers可能需要将他们的请求转发给活跃的控制器。
过度版本
兼容性的总体计划是创建一个Kafka的"过度版本",其中ZooKeeper的依赖性被很好地隔离了。
虽然这个版本不会删除ZooKeeper,但它将消除系统其他部分与它进行通信的大部分接触点。我们将尽可能地在控制器中执行对ZooKeeper的所有访问,而不是在其他broker、客户端或工具中。因此,尽管ZooKeeper在过度版本中仍然需要,但它将是一个很好的隔离的依赖。
我们将能够从任何版本的Kafka升级到这个过度版本,以及从过度版本升级到后ZK版本。 当从早期版本升级到后ZK版本时,必须分两步进行:首先,你必须升级到过度版本,然后你必须升级到后ZK版本。
滚动升级
从过度版本的滚动升级将采取几个步骤。
升级到过度版本
如果集群还没有升级到桥接版本,则必须将其升级到桥接版本。
启动控制器Quorum节点
我们将用ZooKeeper法定人数的地址配置控制器的法定人数节点。一旦建立了控制器法定人数,活跃的控制器将把它的节点信息输入到/brokers/ids
中,并用它的ID覆盖/controller
节点。这将防止任何未升级的broker节点在滚动升级过程中的时候成为控制器。
一旦它接管了/controller
节点,活跃的控制器将着手加载ZooKeeper的全部状态。 它将会把这些信息写出来,放到元数据配额的存储器中。在这之后,元数据配额将成为元数据存储的记录,而不是ZooKeeper中的数据。
我们不需要担心ZooKeeper的状态在这个加载过程中被并发修改。 在过度版本中,无论是工具还是非控制器的brokers都不会修改ZooKeeper。
新的活跃的控制器将监控ZooKeeper对传统broker节点的注册。它将知道如何在过渡期内向这些节点发送传统的"推送"元数据请求。
滚动broker节点
我们将像往常一样滚动broker节点。新的broker节点将不会连接ZooKeeper。如果在配置中还有zookeeper服务器地址的配置,它也将被忽略。
滚动控制器的法定人数
一旦最后一个broker节点被滚动,将不再需要ZooKeeper。我们将从控制器法定人数节点的配置中删除它,然后滚动控制器法定人数以完全删除它。
被拒的替代方案
可插拔共识
与其自己管理元数据,我们可以使元数据存储层具有可插拔性,这样它就可以与ZooKeeper以外的系统一起工作。 例如,我们可以让元数据存储在etcd、Consul或类似系统中。
不幸的是,这种策略不会解决去除ZooKeeper的两个主要目标中的任何一个。因为它们有类似ZooKeeper的API和设计目标,这些外部系统不会让我们把元数据当作事件日志。 因为它们仍然是没有与项目整合的外部系统,部署和配置仍然会比需要的更复杂。
支持多种元数据存储选项将不可避免地减少我们可以给每个配置的测试量。我们的系统测试将不得不在每个可能的配置存储机制下运行,这将大大增加所需的资源,或者选择留下一些用户的测试不足。以这种方式增加测试的规模将真正损害项目。
此外,如果我们支持多种元数据存储选项,我们将不得不使用"最小公分母"的API。换句话说,我们不能使用任何API,除非所有可能的元数据存储选项都支持它。在实践中,这将使我们很难优化系统。
也使得第一次使用kafka的管理员增加了学习成本。