This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
<audio id="audio" title="16 | TopicDeletionManager Topic是怎么被删除的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/22/9fdf4de216c6431cc1324361c5132222.mp3"></audio>
你好,我是胡夕。今天,我们正式进入到第四大模块“状态机”的学习。
Kafka源码中有很多状态机和管理器比如之前我们学过的Controller通道管理器ControllerChannelManager、处理Controller事件的ControllerEventManager等等。这些管理器和状态机大多与各自的“宿主”组件关系密切可以说是大小不同、功能各异。就比如Controller的这两个管理器必须要与Controller组件紧耦合在一起才能实现各自的功能。
不过Kafka中还是有一些状态机和管理器具有相对独立的功能框架不严重依赖使用方也就是我在这个模块为你精选的TopicDeletionManager主题删除管理器、ReplicaStateMachine副本状态机和PartitionStateMachine分区状态机
- TopicDeletionManager负责对指定Kafka主题执行删除操作清除待删除主题在集群上的各类“痕迹”。
- ReplicaStateMachine负责定义Kafka副本状态、合法的状态转换以及管理状态之间的转换。
- PartitionStateMachine负责定义Kafka分区状态、合法的状态转换以及管理状态之间的转换。
无论是主题、分区还是副本它们在Kafka中的生命周期通常都有多个状态。而这3个状态机就是来管理这些状态的。而如何实现正确、高效的管理就是源码要解决的核心问题。
今天我们先来学习TopicDeletionManager看一下Kafka是如何删除一个主题的。
## 课前导读
刚开始学习Kafka的时候我对Kafka删除主题的认识非常“浅薄”。之前我以为成功执行了kafka-topics.sh --delete命令后主题就会被删除。我相信很多人可能都有过这样的错误理解。
这种不正确的认知产生的一个结果就是我们经常发现主题没有被删除干净。于是网上流传着一套终极“武林秘籍”手动删除磁盘上的日志文件以及手动删除ZooKeeper下关于主题的各个节点。
就我个人而言,我始终不推荐你使用这套“秘籍”,理由有二:
- 它并不完整。事实上除非你重启Broker否则这套“秘籍”无法清理Controller端和各个Broker上元数据缓存中的待删除主题的相关条目。
- 它并没有被官方所认证,换句话说就是后果自负。从某种程度上说,它会带来什么不好的结果,是你没法把控的。
所谓“本事大不如不摊上”我们与其琢磨删除主题失败之后怎么自救不如踏踏实实地研究下Kafka底层是怎么执行这个操作的。搞明白它的原理之后再有针对性地使用“秘籍”才能做到有的放矢。你说是不是
## TopicDeletionManager概览
好了我们就正式开始学习TopicDeletionManager吧。
这个管理器位于kafka.controller包下文件名是TopicDeletionManager.scala。在总共不到400行的源码中它定义了3个类结构以及20多个方法。总体而言它还是比较容易学习的。
为了让你先有个感性认识我画了一张TopicDeletionManager.scala的代码UML图
<img src="https://static001.geekbang.org/resource/image/52/f9/52032b5ccf820b10090b5a4ad78d8ff9.jpg" alt="">
TopicDeletionManager.scala这个源文件包括3个部分。
- DeletionClient接口负责实现删除主题以及后续的动作比如更新元数据等。这个接口里定义了4个方法分别是deleteTopic、deleteTopicDeletions、mutePartitionModifications和sendMetadataUpdate。我们后面再详细学习它们的代码。
- ControllerDeletionClient类实现DeletionClient接口的类分别实现了刚刚说到的那4个方法。
- TopicDeletionManager类主题删除管理器类定义了若干个方法维护主题删除前后集群状态的正确性。比如什么时候才能删除主题、什么时候主题不能被删除、主题删除过程中要规避哪些操作等等。
## DeletionClient接口及其实现
接下来我们逐一讨论下这3部分。首先是DeletionClient接口及其实现类。
就像前面说的DeletionClient接口定义的方法用于删除主题并将删除主题这件事儿同步给其他Broker。
目前DeletionClient这个接口只有一个实现类即ControllerDeletionClient。我们看下这个实现类的代码
```
class ControllerDeletionClient(controller: KafkaController, zkClient: KafkaZkClient) extends DeletionClient {
// 删除给定主题
override def deleteTopic(topic: String, epochZkVersion: Int): Unit = {
// 删除/brokers/topics/&lt;topic&gt;节点
zkClient.deleteTopicZNode(topic, epochZkVersion)
// 删除/config/topics/&lt;topic&gt;节点
zkClient.deleteTopicConfigs(Seq(topic), epochZkVersion)
// 删除/admin/delete_topics/&lt;topic&gt;节点
zkClient.deleteTopicDeletions(Seq(topic), epochZkVersion)
}
// 删除/admin/delete_topics下的给定topic子节点
override def deleteTopicDeletions(topics: Seq[String], epochZkVersion: Int): Unit = {
zkClient.deleteTopicDeletions(topics, epochZkVersion)
}
// 取消/brokers/topics/&lt;topic&gt;节点数据变更的监听
override def mutePartitionModifications(topic: String): Unit = {
controller.unregisterPartitionModificationsHandlers(Seq(topic))
}
// 向集群Broker发送指定分区的元数据更新请求
override def sendMetadataUpdate(partitions: Set[TopicPartition]): Unit = {
controller.sendUpdateMetadataRequest(
controller.controllerContext.liveOrShuttingDownBrokerIds.toSeq, partitions)
}
}
```
这个类的构造函数接收两个字段。同时由于是DeletionClient接口的实现类因而该类实现了DeletionClient接口定义的四个方法。
先来说构造函数的两个字段KafkaController实例和KafkaZkClient实例。KafkaController实例我们已经很熟悉了就是Controller组件对象而KafkaZkClient实例就是Kafka与ZooKeeper交互的客户端对象。
接下来我们再结合代码看下DeletionClient接口实现类ControllerDeletionClient定义的4个方法。我来简单介绍下这4个方法大致是做什么的。
**1.deleteTopic**
它用于删除主题在ZooKeeper上的所有“痕迹”。具体方法是分别调用KafkaZkClient的3个方法去删除ZooKeeper下/brokers/topics/<topic>节点、/config/topics/<topic>节点和/admin/delete_topics/<topic>节点。</topic></topic></topic>
**2.deleteTopicDeletions**
它用于删除ZooKeeper下待删除主题的标记节点。具体方法是调用KafkaZkClient的deleteTopicDeletions方法批量删除一组主题在/admin/delete_topics下的子节点。注意deleteTopicDeletions这个方法名结尾的Deletions表示/admin/delete_topics下的子节点。所以deleteTopic是删除主题deleteTopicDeletions是删除/admin/delete_topics下的对应子节点。
到这里我们还要注意的一点是这两个方法里都有一个epochZkVersion的字段代表期望的Controller Epoch版本号。如果你使用一个旧的Epoch版本号执行这些方法ZooKeeper会拒绝因为和它自己保存的版本号不匹配。如果一个Controller的Epoch值小于ZooKeeper中保存的那么这个Controller很可能是已经过期的Controller。这种Controller就被称为Zombie Controller。epochZkVersion字段的作用就是隔离Zombie Controller发送的操作。
**3.mutePartitionModifications**
它的作用是屏蔽主题分区数据变更监听器,具体实现原理其实就是取消/brokers/topics/<topic>节点数据变更的监听。这样当该主题的分区数据发生变更后由于对应的ZooKeeper监听器已经被取消了因此不会触发Controller相应的处理逻辑。</topic>
那为什么要取消这个监听器呢其实主要是为了避免操作之间的相互干扰。设想下用户A发起了主题删除而同时用户B为这个主题新增了分区。此时这两个操作就会相互冲突如果允许Controller同时处理这两个操作势必会造成逻辑上的混乱以及状态的不一致。为了应对这种情况在移除主题副本和分区对象前代码要先执行这个方法以确保不再响应用户对该主题的其他操作。
mutePartitionModifications方法的实现原理很简单它会调用unregisterPartitionModificationsHandlers并接着调用KafkaZkClient的unregisterZNodeChangeHandler方法取消ZooKeeper上对给定主题的分区节点数据变更的监听。
**4.sendMetadataUpdate**
它会调用KafkaController的sendUpdateMetadataRequest方法给集群所有Broker发送更新请求告诉它们不要再为已删除主题的分区提供服务。代码如下
```
override def sendMetadataUpdate(partitions: Set[TopicPartition]): Unit = {
// 给集群所有Broker发送UpdateMetadataRequest
// 通知它们给定partitions的状态变化
controller.sendUpdateMetadataRequest(
controller.controllerContext.liveOrShuttingDownBrokerIds.toSeq, partitions)
}
```
该方法会给集群中的所有Broker发送更新元数据请求告知它们要同步给定分区的状态。
## TopicDeletionManager定义及初始化
有了这些铺垫我们再来看主题删除管理器的主要入口TopicDeletionManager类。这个类的定义代码如下
```
class TopicDeletionManager(
// KafkaConfig类保存Broker端参数
config: KafkaConfig,
// 集群元数据
controllerContext: ControllerContext,
// 副本状态机,用于设置副本状态
replicaStateMachine: ReplicaStateMachine,
// 分区状态机,用于设置分区状态
partitionStateMachine: PartitionStateMachine,
// DeletionClient接口实现主题删除
client: DeletionClient) extends Logging {
this.logIdent = s&quot;[Topic Deletion Manager ${config.brokerId}] &quot;
// 是否允许删除主题
val isDeleteTopicEnabled: Boolean = config.deleteTopicEnable
......
}
```
该类主要的属性有6个我们分别来看看。
- configKafkaConfig实例可以用作获取Broker端参数delete.topic.enable的值。该参数用于控制是否允许删除主题默认值是true即Kafka默认允许用户删除主题。
- controllerContextController端保存的元数据信息。删除主题必然要变更集群元数据信息因此TopicDeletionManager需要用到controllerContext的方法去更新它保存的数据。
- replicaStateMachine和partitionStateMachine副本状态机和分区状态机。它们各自负责副本和分区的状态转换以保持副本对象和分区对象在集群上的一致性状态。这两个状态机是后面两讲的重要知识点。
- client前面介绍的DeletionClient接口。TopicDeletionManager通过该接口执行ZooKeeper上节点的相应更新。
- isDeleteTopicEnabled表明主题是否允许被删除。它是Broker端参数delete.topic.enable的值默认是true表示Kafka允许删除主题。源码中大量使用这个字段判断主题的可删除性。前面的config参数的主要目的就是设置这个字段的值。被设定之后config就不再被源码使用了。
好了知道这些字段的含义我们再看看TopicDeletionManager类实例是如何被创建的。
实际上这个实例是在KafkaController类初始化时被创建的。在KafkaController类的源码中你可以很容易地找到这行代码
```
val topicDeletionManager = new TopicDeletionManager(config, controllerContext, replicaStateMachine,
partitionStateMachine, new ControllerDeletionClient(this, zkClient))
```
可以看到代码实例化了一个全新的ControllerDeletionClient对象然后利用这个对象实例和replicaStateMachine、partitionStateMachine一起创建TopicDeletionManager实例。
为了方便你理解,我再给你画一张流程图:
<img src="https://static001.geekbang.org/resource/image/89/e6/89733b9e03df6e1450ba81e082187ce6.jpg" alt="">
## TopicDeletionManager重要方法
除了类定义和初始化TopicDeletionManager类还定义了16个方法。在这些方法中最重要的当属resumeDeletions方法。它是重启主题删除操作过程的方法。
主题因为某些事件可能一时无法完成删除比如主题分区正在进行副本重分配等。一旦这些事件完成后主题重新具备可删除的资格。此时代码就需要调用resumeDeletions重启删除操作。
这个方法之所以很重要是因为它还串联了TopicDeletionManager类的很多方法如completeDeleteTopic和onTopicDeletion等。因此你完全可以从resumeDeletions方法开始逐渐深入到其他方法代码的学习。
那我们就先学习resumeDeletions的实现代码吧。
```
private def resumeDeletions(): Unit = {
// 从元数据缓存中获取要删除的主题列表
val topicsQueuedForDeletion = Set.empty[String] ++ controllerContext.topicsToBeDeleted
// 待重试主题列表
val topicsEligibleForRetry = mutable.Set.empty[String]
// 待删除主题列表
val topicsEligibleForDeletion = mutable.Set.empty[String]
if (topicsQueuedForDeletion.nonEmpty)
info(s&quot;Handling deletion for topics ${topicsQueuedForDeletion.mkString(&quot;,&quot;)}&quot;)
// 遍历每个待删除主题
topicsQueuedForDeletion.foreach { topic =&gt;
// 如果该主题所有副本已经是ReplicaDeletionSuccessful状态
// 即该主题已经被删除
if (controllerContext.areAllReplicasInState(topic, ReplicaDeletionSuccessful)) {
// 调用completeDeleteTopic方法完成后续操作即可
completeDeleteTopic(topic)
info(s&quot;Deletion of topic $topic successfully completed&quot;)
// 如果主题删除尚未开始并且主题当前无法执行删除的话
} else if (!controllerContext.isAnyReplicaInState(topic, ReplicaDeletionStarted)) {
if (controllerContext.isAnyReplicaInState(topic, ReplicaDeletionIneligible)) {
// 把该主题加到待重试主题列表中用于后续重试
topicsEligibleForRetry += topic
}
}
// 如果该主题能够被删除
if (isTopicEligibleForDeletion(topic)) {
info(s&quot;Deletion of topic $topic (re)started&quot;)
topicsEligibleForDeletion += topic
}
}
// 重试待重试主题列表中的主题删除操作
if (topicsEligibleForRetry.nonEmpty) {
retryDeletionForIneligibleReplicas(topicsEligibleForRetry)
}
// 调用onTopicDeletion方法对待删除主题列表中的主题执行删除操作
if (topicsEligibleForDeletion.nonEmpty) {
onTopicDeletion(topicsEligibleForDeletion)
}
}
```
通过代码我们发现,这个方法**首先**从元数据缓存中获取要删除的主题列表,之后定义了两个空的主题列表,分别保存待重试删除主题和待删除主题。
**然后**代码遍历每个要删除的主题去看它所有副本的状态。如果副本状态都是ReplicaDeletionSuccessful就表明该主题已经被成功删除此时再调用completeDeleteTopic方法完成后续的操作就可以了。对于那些删除操作尚未开始并且暂时无法执行删除的主题源码会把这类主题加到待重试主题列表中用于后续重试如果主题是能够被删除的就将其加入到待删除列表中。
**最后**该方法调用retryDeletionForIneligibleReplicas方法来重试待重试主题列表中的主题删除操作。对于待删除主题列表中的主题则调用onTopicDeletion删除之。
值得一提的是retryDeletionForIneligibleReplicas方法用于重试主题删除。这是通过将对应主题副本的状态从ReplicaDeletionIneligible变更到OfflineReplica来完成的。这样后续再次调用resumeDeletions时会尝试重新删除主题。
看到这里我们再次发现Kafka的方法命名真的是非常规范。得益于这一点很多时候我们不用深入到方法内部就能知道这个方法大致是做什么用的。比如
- topicsQueuedForDeletion方法应该是保存待删除的主题列表
- controllerContext.isAnyReplicaInState方法应该是判断某个主题下是否有副本处于某种状态
- 而onTopicDeletion方法应该就是执行主题删除操作用的。
这时你再去阅读这3个方法的源码就会发现它们的作用确实如其名字标识的那样。这也再次证明了Kafka源码质量是非常不错的。因此不管你是不是Kafka的使用者都可以把Kafka的源码作为阅读开源框架源码、提升自己竞争力的一个选择。
下面我再用一张图来解释下resumeDeletions方法的执行流程
<img src="https://static001.geekbang.org/resource/image/d1/34/d165193d4f27ffe18e038643ea050534.jpg" alt="">
到这里resumeDeletions方法的逻辑我就讲完了它果然是串联起了TopicDeletionManger中定义的很多方法。其中比较关键的两个操作是**completeDeleteTopic**和**onTopicDeletion**。接下来,我们就分别看看。
先来看completeDeleteTopic方法代码我给每行代码都加标了注解。
```
private def completeDeleteTopic(topic: String): Unit = {
// 第1步注销分区变更监听器防止删除过程中因分区数据变更
// 导致监听器被触发,引起状态不一致
client.mutePartitionModifications(topic)
// 第2步获取该主题下处于ReplicaDeletionSuccessful状态的所有副本对象
// 即所有已经被成功删除的副本对象
val replicasForDeletedTopic = controllerContext.replicasInState(topic, ReplicaDeletionSuccessful)
// 第3步利用副本状态机将这些副本对象转换成NonExistentReplica状态。
// 等同于在状态机中删除这些副本
replicaStateMachine.handleStateChanges(
replicasForDeletedTopic.toSeq, NonExistentReplica)
// 第4步更新元数据缓存中的待删除主题列表和已开始删除的主题列表
// 因为主题已经成功删除了,没有必要出现在这两个列表中了
controllerContext.topicsToBeDeleted -= topic
controllerContext.topicsWithDeletionStarted -= topic
// 第5步移除ZooKeeper上关于该主题的一切“痕迹”
client.deleteTopic(topic, controllerContext.epochZkVersion)
// 第6步移除元数据缓存中关于该主题的一切“痕迹”
controllerContext.removeTopic(topic)
}
```
整个过程如行云流水般一气呵成,非常容易理解,我就不多解释了。
再来看看onTopicDeletion方法的代码
```
private def onTopicDeletion(topics: Set[String]): Unit = {
// 找出给定待删除主题列表中那些尚未开启删除操作的所有主题
val unseenTopicsForDeletion = topics.diff(controllerContext.topicsWithDeletionStarted)
if (unseenTopicsForDeletion.nonEmpty) {
// 获取到这些主题的所有分区对象
val unseenPartitionsForDeletion = unseenTopicsForDeletion.flatMap(controllerContext.partitionsForTopic)
// 将这些分区的状态依次调整成OfflinePartition和NonExistentPartition
// 等同于将这些分区从分区状态机中删除
partitionStateMachine.handleStateChanges(
unseenPartitionsForDeletion.toSeq, OfflinePartition)
partitionStateMachine.handleStateChanges(
unseenPartitionsForDeletion.toSeq, NonExistentPartition)
// 把这些主题加到“已开启删除操作”主题列表中
controllerContext.beginTopicDeletion(unseenTopicsForDeletion)
}
// 给集群所有Broker发送元数据更新请求告诉它们不要再为这些主题处理数据了
client.sendMetadataUpdate(
topics.flatMap(controllerContext.partitionsForTopic))
// 分区删除操作会执行底层的物理磁盘文件删除动作
onPartitionDeletion(topics)
}
```
我在代码中用注释的方式已经把onTopicDeletion方法的逻辑解释清楚了。你可以发现这个方法也基本是串行化的流程没什么难理解的。我再给你梳理下其中的核心点。
onTopicDeletion方法会多次使用分区状态机来调整待删除主题的分区状态。在后两讲的分区状态机和副本状态机的课里面我还会和你详细讲解它们包括它们都定义了哪些状态这些状态彼此之间的转换规则都是什么等等。
onTopicDeletion方法的最后一行调用了onPartitionDeletion方法来执行真正的底层物理磁盘文件删除。实际上这是通过副本状态机状态转换操作完成的。下节课我会和你详细聊聊这个事情。
学到这里我还想提醒你的是在学习TopicDeletionManager方法的时候非常重要一点的是你要理解主题删除的脉络。对于其他部分的源码也是这个道理。一旦你掌握了整体的流程阅读那些细枝末节的方法代码就没有任何难度了。照着这个方法去读源码搞懂Kafka源码也不是什么难事
## 总结
今天我们主要学习了TopicDeletionManager.scala中关于主题删除的代码。这里有几个要点需要你记住。
- 在主题删除过程中Kafka会调整集群中三个地方的数据ZooKeeper、元数据缓存和磁盘日志文件。删除主题时ZooKeeper上与该主题相关的所有ZNode节点必须被清除Controller端元数据缓存中的相关项也必须要被处理并且要被同步到集群的其他Broker上而磁盘日志文件更是要清理的首要目标。这三个地方必须要统一处理就好似我们常说的原子性操作一样。现在回想下开篇提到的那个“秘籍”你就会发现它缺少了非常重要的一环那就是**无法清除Controller端的元数据缓存项**。因此,你要尽力避免使用这个“大招”。
- DeletionClient接口的作用主要是操作ZooKeeper实现ZooKeeper节点的删除等操作。
- TopicDeletionManager是在KafkaController创建过程中被初始化的主要通过与元数据缓存进行交互的方式来更新各类数据。
<img src="https://static001.geekbang.org/resource/image/3a/3c/3a47958f44b81cef7b502da53495ef3c.jpg" alt="">
今天我们看到的代码中出现了大量的replicaStateMachine和partitionStateMachine其实这就是我今天反复提到的副本状态机和分区状态机。接下来两讲我会逐步带你走进它们的代码世界让你领略下Kafka通过状态机机制管理副本和分区的风采。
## 课后讨论
上节课我们在学习processTopicDeletion方法的代码时看到了一个名为markTopicIneligibleForDeletion的方法这也是和主题删除相关的方法。现在你能说说它是做什么用的、它的实现原理又是怎样的吗
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,450 @@
<audio id="audio" title="17 | ReplicaStateMachine揭秘副本状态机实现原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/87/47a4f15366c12714bc8fb0c79f7e6b87.mp3"></audio>
你好,我是胡夕。今天我们讲副本状态机。
前几节课在讲Controller、TopicDeletionManager时我反复提到副本状态机和分区状态机这两个组件。现在你应该知道了它们分别管理着Kafka集群中所有副本和分区的状态转换但是你知道副本和分区到底都有哪些状态吗
带着这个问题我们用两节课的时间重点学习下这两个组件的源码。我们先从副本状态机ReplicaStateMachine开始。
## 课前导读
坦率地说ReplicaStateMachine不如前面的组件有名气Kafka官网文档中甚至没有任何关于它的描述可见它是一个内部组件一般用户感觉不到它的存在。因此很多人都会有这样的错觉既然它是外部不可见的组件那就没有必要学习它的实现代码了。
其实不然。弄明白副本状态机的原理,对于我们从根本上定位很多数据不一致问题是有帮助的。下面,我跟你分享一个我的真实经历。
曾经我们部署过一个3-Broker的Kafka集群版本是2.0.0。假设这3个Broker是A、B和C我们在这3个Broker上创建了一个单分区、双副本的主题。
当时我们发现了一个奇怪的现象如果两个副本分别位于A和B而Controller在C上那么当关闭A、B之后ZooKeeper中会显示该主题的Leader是-1ISR为空但是如果两个副本依然位于A和B上而Controller在B上当我们依次关闭A和B后该主题在ZooKeeper中的Leader和ISR就变成了B。这显然和刚刚的情况不符。
虽然这并不是特别严重的问题可毕竟出现了数据的不一致性所以还是需要谨慎对待。在仔细查看源码之后我们找到了造成不一致的原因原来在第一种情况下Controller会调用ReplicaStateMachine调整该主题副本的状态进而变更了Leader和ISR而在第二种情况下Controller执行了Failover但是并未在新Controller组件初始化时进行状态转换因而出现了不一致。
你看要是不阅读这部分源码我们肯定是无法定位这个问题的原因的。总之副本状态机代码定义了Kafka副本的状态集合同时控制了这些状态之间的流转规则。对于想要深入了解内部原理的你来说短小精悍的ReplicaStateMachine源码是绝对不能错过的。
## 定义与初始化
今天我们要关注的源码文件是controller包下的ReplicaStateMachine.scala文件。它的代码结构非常简单如下图所示
<img src="https://static001.geekbang.org/resource/image/0f/ff/0f29451b5050c7053f2ac26488e9d1ff.jpg" alt="">
在不到500行的源文件中代码定义了3个部分。
- ReplicaStateMachine副本状态机抽象类定义了一些常用方法如startup、shutdown等以及状态机最重要的处理逻辑方法handleStateChanges。
- ZkReplicaStateMachine副本状态机具体实现类重写了handleStateChanges方法实现了副本状态之间的状态转换。目前ZkReplicaStateMachine是唯一的ReplicaStateMachine子类。
- ReplicaState副本状态集合Kafka目前共定义了7种副本状态。
下面我们看下ReplicaStateMachine及其子类ZKReplicaStateMachine在代码中是如何定义的请看这两个代码片段
```
// ReplicaStateMachine抽象类定义
abstract class ReplicaStateMachine(controllerContext: ControllerContext) extends Logging {
......
}
// ZkReplicaStateMachine具体实现类定义
class ZkReplicaStateMachine(config: KafkaConfig,
stateChangeLogger: StateChangeLogger,
controllerContext: ControllerContext,
zkClient: KafkaZkClient,
controllerBrokerRequestBatch: ControllerBrokerRequestBatch)
extends ReplicaStateMachine(controllerContext) with Logging {
......
}
```
ReplicaStateMachine只需要接收一个ControllerContext对象实例。在前几节课我反复说过ControllerContext封装了Controller端保存的所有集群元数据信息。
ZKReplicaStateMachine的属性则多一些。如果要构造一个ZKReplicaStateMachine实例除了ControllerContext实例比较重要的属性还有**KafkaZkClient对象实例**和**ControllerBrokerRequestBatch实例**。前者负责与ZooKeeper进行交互后者用于给集群Broker发送控制类请求也就是咱们在[第12节课](https://time.geekbang.org/column/article/235904)重点讲过的LeaderAndIsrRequest、StopReplicaRequest和UpdateMetadataRequest
ControllerBrokerRequestBatch对象的源码位于ControllerChannelManager.scala中这是一个只有10行代码的类主要的功能是将给定的Request发送给指定的Broker你可以自行探索下它是如何发送请求的。给你个提示结合我们在[第12节课](https://time.geekbang.org/column/article/235904)讲到的ControllerBrokerStateInfo代码进行思考。
在副本状态转换操作的逻辑中一个很重要的步骤就是为Broker上的副本更新信息而这是通过Controller给Broker发送请求实现的因此你最好了解下这里的请求发送逻辑。
好了,学习了副本状态机类的定义,下面我们看下副本状态机是在何时进行初始化的。
一句话总结就是,**KafkaController对象在构建的时候就会初始化一个ZkReplicaStateMachine实例**,如下列代码所示:
```
val replicaStateMachine: ReplicaStateMachine = new
ZkReplicaStateMachine(config, stateChangeLogger,
controllerContext, zkClient,
new ControllerBrokerRequestBatch(config, controllerChannelManager, eventManager, controllerContext, stateChangeLogger))
```
你可能会问“如果一个Broker没有被选举为Controller它也会构建KafkaController对象实例吗”没错所有Broker在启动时都会创建KafkaController实例因而也会创建ZKReplicaStateMachine实例。
每个Broker都会创建这些实例并不代表每个Broker都会启动副本状态机。事实上只有在Controller所在的Broker上副本状态机才会被启动。具体的启动代码位于KafkaController的onControllerFailover方法如下所示
```
private def onControllerFailover(): Unit = {
......
replicaStateMachine.startup() // 启动副本状态机
partitionStateMachine.startup() // 启动分区状态机
......
}
```
当Broker被成功推举为Controller后onControllerFailover方法会被调用进而启动该Broker早已创建好的副本状态机和分区状态机。
## 副本状态及状态管理流程
副本状态机一旦被启动,就意味着它要行使它最重要的职责了:**管理副本状态的转换**。
不过在学习如何管理状态之前我们必须要弄明白当前都有哪些状态以及它们的含义分别是什么。源码中的ReplicaState定义了7种副本状态。
- NewReplica副本被创建之后所处的状态。
- OnlineReplica副本正常提供服务时所处的状态。
- OfflineReplica副本服务下线时所处的状态。
- ReplicaDeletionStarted副本被删除时所处的状态。
- ReplicaDeletionSuccessful副本被成功删除后所处的状态。
- ReplicaDeletionIneligible开启副本删除但副本暂时无法被删除时所处的状态。
- NonExistentReplica副本从副本状态机被移除前所处的状态。
具体到代码而言,**ReplicaState接口及其实现对象定义了每种状态的序号以及合法的前置状态**。我以OnlineReplica代码为例进行说明
```
// ReplicaState接口
sealed trait ReplicaState {
def state: Byte
def validPreviousStates: Set[ReplicaState] // 定义合法的前置状态
}
// OnlineReplica状态
case object OnlineReplica extends ReplicaState {
val state: Byte = 2
val validPreviousStates: Set[ReplicaState] = Set(NewReplica, OnlineReplica, OfflineReplica, ReplicaDeletionIneligible)
}
```
OnlineReplica的validPreviousStates属性是一个集合类型里面包含NewReplica、OnlineReplica、OfflineReplica和ReplicaDeletionIneligible。这说明Kafka只允许副本从刚刚这4种状态变更到OnlineReplica状态。如果从ReplicaDeletionStarted状态跳转到OnlineReplica状态就是非法的状态转换。
这里我只列出了OnlineReplica。实际上其他6种副本状态的代码逻辑也是类似的因为比较简单我就不一一介绍了课下你可以对照着源码自己探索下重点关注这些状态的validPreviousStates字段看看每个状态合法的前置状态都有哪些。
为了方便你记忆,我直接帮你提炼了出来了。这张图绘制出了完整的状态转换规则:
<img src="https://static001.geekbang.org/resource/image/1c/9a/1c681e2616d1f156221489fc9376649a.jpg" alt="">
图中的单向箭头表示只允许单向状态转换双向箭头则表示转换方向可以是双向的。比如OnlineReplica和OfflineReplica之间有一根双向箭头这就说明副本可以在OnlineReplica和OfflineReplica状态之间随意切换。
结合这张图,我再详细解释下各个状态的含义,以及它们的流转过程。
当副本对象首次被创建出来后它会被置于NewReplica状态。经过一番初始化之后当副本对象能够对外提供服务之后状态机会将其调整为OnlineReplica并一直以该状态持续工作。
如果副本所在的Broker关闭或者是因为其他原因不能正常工作了副本需要从OnlineReplica变更为OfflineReplica表明副本已处于离线状态。
一旦开启了如删除主题这样的操作状态机会将副本状态跳转到ReplicaDeletionStarted以表明副本删除已然开启。倘若删除成功则置为ReplicaDeletionSuccessful倘若不满足删除条件如所在Broker处于下线状态那就设置成ReplicaDeletionIneligible以便后面重试。
当副本对象被删除后其状态会变更为NonExistentReplica副本状态机将移除该副本数据。
这就是一个基本的状态管理流程。
## 具体实现类ZkReplicaStateMachine
了解了这些状态之后我们来看下ZkReplicaStateMachine类的原理毕竟它是副本状态机的具体实现类。
该类定义了1个public方法和7个private方法。这个public方法是副本状态机最重要的逻辑处理代码它就是handleStateChanges方法。而那7个方法全部都是用来辅助public方法的。
### 状态转换方法定义
在详细介绍handleStateChanges方法前我稍微花点时间给你简单介绍下其他7个方法都是做什么用的。就像前面说过的这些方法主要是起辅助的作用。只有清楚了这些方法的用途你才能更好地理解handleStateChanges的实现逻辑。
- logFailedStateChange仅仅是记录一条错误日志表明执行了一次无效的状态变更。
- logInvalidTransition同样也是记录错误之用记录一次非法的状态转换。
- logSuccessfulTransition记录一次成功的状态转换操作。
- getTopicPartitionStatesFromZk从ZooKeeper中获取指定分区的状态信息包括每个分区的Leader副本、ISR集合等数据。
- doRemoveReplicasFromIsr把给定的副本对象从给定分区ISR中移除。
- removeReplicasFromIsr调用doRemoveReplicasFromIsr方法实现将给定的副本对象从给定分区ISR中移除的功能。
- doHandleStateChanges执行状态变更和转换操作的主力方法。接下来我们会详细学习它的源码部分。
### handleStateChanges方法
handleStateChange方法的作用是处理状态的变更是对外提供状态转换操作的入口方法。其方法签名如下
```
def handleStateChanges(replicas: Seq[PartitionAndReplica], targetState: ReplicaState): Unit
```
该方法接收两个参数:**replicas**是一组副本对象每个副本对象都封装了它们各自所属的主题、分区以及副本所在的Broker ID数据**targetState**是这组副本对象要转换成的目标状态。
这个方法的完整代码如下:
```
override def handleStateChanges(
replicas: Seq[PartitionAndReplica],
targetState: ReplicaState): Unit = {
if (replicas.nonEmpty) {
try {
// 清空Controller待发送请求集合
controllerBrokerRequestBatch.newBatch()
// 将所有副本对象按照Broker进行分组依次执行状态转换操作
replicas.groupBy(_.replica).foreach {
case (replicaId, replicas) =&gt;
doHandleStateChanges(replicaId, replicas, targetState)
}
// 发送对应的Controller请求给Broker
controllerBrokerRequestBatch.sendRequestsToBrokers(
controllerContext.epoch)
} catch {
// 如果Controller易主则记录错误日志然后抛出异常
case e: ControllerMovedException =&gt;
error(s&quot;Controller moved to another broker when moving some replicas to $targetState state&quot;, e)
throw e
case e: Throwable =&gt; error(s&quot;Error while moving some replicas to $targetState state&quot;, e)
}
}
}
```
代码逻辑总体上分为两步第1步是调用doHandleStateChanges方法执行真正的副本状态转换第2步是给集群中的相应Broker批量发送请求。
在执行第1步的时候它会将replicas按照Broker ID进行分组。
举个例子,如果我们使用&lt;主题名分区号副本Broker ID&gt;表示副本对象假设replicas为集合&lt;test, 0, 0&gt;, &lt;test, 0, 1&gt;, &lt;test, 1, 0&gt;, &lt;test, 1, 1&gt;那么在调用doHandleStateChanges方法前代码会将replicas按照Broker ID进行分组即变成Map(0 -&gt; Set(&lt;test, 0, 0&gt;, &lt;test, 1, 0&gt;)1 -&gt; Set(&lt;test, 0, 1&gt;, &lt;test, 1, 1&gt;))。
待这些都做完之后代码开始调用doHandleStateChanges方法执行状态转换操作。这个方法看着很长其实都是不同的代码分支。
### doHandleStateChanges方法
我先用一张图,帮你梳理下它的流程,然后再具体分析下它的代码:
<img src="https://static001.geekbang.org/resource/image/4f/01/4f341dde3cfa4a883ea9f7a82acae301.jpg" alt="">
从图中我们可以发现代码的第1步会尝试获取给定副本对象在Controller端元数据缓存中的当前状态如果没有保存某个副本对象的状态代码会将其初始化为NonExistentReplica状态。
第2步代码根据不同ReplicaState中定义的合法前置状态集合以及传入的目标状态targetState将给定的副本对象集合划分成两部分能够合法转换的副本对象集合以及执行非法状态转换的副本对象集合。doHandleStateChanges方法会为后者中的每个副本对象记录一条错误日志。
第3步代码携带能够执行合法转换的副本对象集合进入到不同的代码分支。由于当前Kafka为副本定义了7类状态因此这里的代码分支总共有7路。
我挑选几路最常见的状态转换路径详细说明下包括副本被创建时被转换到NewReplica状态副本正常工作时被转换到OnlineReplica状态副本停止服务后被转换到OfflineReplica状态。至于剩下的记录代码你可以在课后自行学习下它们的转换操作原理大致是相同的。
#### 第1路转换到NewReplica状态
首先我们先来看第1路即目标状态是NewReplica的代码。代码如下
```
case NewReplica =&gt;
// 遍历所有能够执行转换的副本对象
validReplicas.foreach { replica =&gt;
// 获取该副本对象的分区对象,即&lt;主题名,分区号&gt;数据
val partition = replica.topicPartition
// 获取副本对象的当前状态
val currentState = controllerContext.replicaState(replica)
// 尝试从元数据缓存中获取该分区当前信息
// 包括Leader是谁、ISR都有哪些副本等数据
controllerContext.partitionLeadershipInfo.get(partition) match {
// 如果成功拿到分区数据信息
case Some(leaderIsrAndControllerEpoch) =&gt;
// 如果该副本是Leader副本
if (leaderIsrAndControllerEpoch.leaderAndIsr.leader == replicaId) {
val exception = new StateChangeFailedException(s&quot;Replica $replicaId for partition $partition cannot be moved to NewReplica state as it is being requested to become leader&quot;)
// 记录错误日志。Leader副本不能被设置成NewReplica状态
logFailedStateChange(replica, currentState, OfflineReplica, exception)
// 否则给该副本所在的Broker发送LeaderAndIsrRequest
// 向它同步该分区的数据, 之后给集群当前所有Broker发送
// UpdateMetadataRequest通知它们该分区数据发生变更
} else {
controllerBrokerRequestBatch
.addLeaderAndIsrRequestForBrokers(
Seq(replicaId),
replica.topicPartition,
leaderIsrAndControllerEpoch,
controllerContext.partitionFullReplicaAssignment(
replica.topicPartition),
isNew = true)
if (traceEnabled)
logSuccessfulTransition(
stateLogger, replicaId,
partition, currentState, NewReplica)
// 更新元数据缓存中该副本对象的当前状态为NewReplica
controllerContext.putReplicaState(replica, NewReplica)
}
// 如果没有相应数据
case None =&gt;
if (traceEnabled)
logSuccessfulTransition(
stateLogger, replicaId,
partition, currentState, NewReplica)
// 仅仅更新元数据缓存中该副本对象的当前状态为NewReplica即可
controllerContext.putReplicaState(replica, NewReplica)
}
}
```
看完了代码,你可以再看下这张流程图:
<img src="https://static001.geekbang.org/resource/image/cf/04/cfb649ec78f789a3889e6920b5413b04.jpg" alt="">
这一路主要做的事情是尝试从元数据缓存中获取这些副本对象的分区信息数据包括分区的Leader副本在哪个Broker上、ISR中都有哪些副本等等。
如果找不到对应的分区数据就直接把副本状态更新为NewReplica。否则代码就需要给该副本所在的Broker发送请求让它知道该分区的信息。同时代码还要给集群所有运行中的Broker发送请求让它们感知到新副本的加入。
#### 第2路转换到OnlineReplica状态
下面我们来看第2路即转换副本对象到OnlineReplica。
刚刚我说过,这是副本对象正常工作时所处的状态。我们来看下要转换到这个状态,源码都做了哪些事情:
```
case OnlineReplica =&gt;
validReplicas.foreach { replica =&gt;
// 获取副本所在分区
val partition = replica.topicPartition
// 获取副本当前状态
val currentState = controllerContext.replicaState(replica)
currentState match {
// 如果当前状态是NewReplica
case NewReplica =&gt;
// 从元数据缓存中拿到分区副本列表
val assignment = controllerContext
.partitionFullReplicaAssignment(partition)
// 如果副本列表不包含当前副本,视为异常情况
if (!assignment.replicas.contains(replicaId)) {
error(s&quot;Adding replica ($replicaId) that is not part of the assignment $assignment&quot;)
// 将该副本加入到副本列表中,并更新元数据缓存中该分区的副本列表
val newAssignment = assignment.copy(
replicas = assignment.replicas :+ replicaId)
controllerContext.updatePartitionFullReplicaAssignment(
partition, newAssignment)
}
// 如果当前状态是其他状态
case _ =&gt;
// 尝试获取该分区当前信息数据
controllerContext.partitionLeadershipInfo
.get(partition) match {
// 如果存在分区信息
// 向该副本对象所在Broker发送请求令其同步该分区数据
case Some(leaderIsrAndControllerEpoch) =&gt;
controllerBrokerRequestBatch
.addLeaderAndIsrRequestForBrokers(Seq(replicaId),
replica.topicPartition,
leaderIsrAndControllerEpoch,
controllerContext
.partitionFullReplicaAssignment(partition),
isNew = false)
case None =&gt;
}
}
if (traceEnabled)
logSuccessfulTransition(
stateLogger, replicaId,
partition, currentState, OnlineReplica)
// 将该副本对象设置成OnlineReplica状态
controllerContext.putReplicaState(replica, OnlineReplica)
}
```
我同样使用一张图来说明:
<img src="https://static001.geekbang.org/resource/image/c7/90/c72643c179f8cf72bd054138c5a1c990.jpg" alt="">
代码依然会对副本对象进行遍历,并依次执行下面的几个步骤。
- 第1步获取元数据中该副本所属的分区对象以及该副本的当前状态。
- 第2步查看当前状态是否是NewReplica。如果是则获取分区的副本列表并判断该副本是否在当前的副本列表中假如不在就记录错误日志并更新元数据中的副本列表如果状态不是NewReplica就说明这是一个已存在的副本对象那么源码会获取对应分区的详细数据然后向该副本对象所在的Broker发送LeaderAndIsrRequest请求令其同步获知并保存该分区数据。
- 第3步将该副本对象状态变更为OnlineReplica。至此该副本处于正常工作状态。
#### 第3路转换到OfflineReplica状态
最后再来看下第3路分支。这路分支要将副本对象的状态转换成OfflineReplica。我依然以代码注释的方式给出主要的代码逻辑
```
case OfflineReplica =&gt;
validReplicas.foreach { replica =&gt;
// 向副本所在Broker发送StopReplicaRequest请求停止对应副本
controllerBrokerRequestBatch
.addStopReplicaRequestForBrokers(Seq(replicaId),
replica.topicPartition, deletePartition = false)
}
// 将副本对象集合划分成有Leader信息的副本集合和无Leader信息的副本集合
val (replicasWithLeadershipInfo, replicasWithoutLeadershipInfo) =
validReplicas.partition { replica =&gt;
controllerContext.partitionLeadershipInfo
.contains(replica.topicPartition)
}
// 对于有Leader信息的副本集合而言从
// 它们对应的所有分区中移除该副本对象并更新ZooKeeper节点
val updatedLeaderIsrAndControllerEpochs =
removeReplicasFromIsr(replicaId,
replicasWithLeadershipInfo.map(_.topicPartition))
// 遍历每个更新过的分区信息
updatedLeaderIsrAndControllerEpochs.foreach {
case (partition, leaderIsrAndControllerEpoch) =&gt;
stateLogger.info(s&quot;Partition $partition state changed to $leaderIsrAndControllerEpoch after removing replica $replicaId from the ISR as part of transition to $OfflineReplica&quot;)
// 如果分区对应主题并未被删除
if (!controllerContext.isTopicQueuedUpForDeletion(
partition.topic)) {
// 获取该分区除给定副本以外的其他副本所在的Broker
val recipients = controllerContext
.partitionReplicaAssignment(partition)
.filterNot(_ == replicaId)
// 向这些Broker发送请求更新该分区更新过的分区LeaderAndIsr数据
controllerBrokerRequestBatch.addLeaderAndIsrRequestForBrokers(
recipients,
partition,
leaderIsrAndControllerEpoch,
controllerContext.partitionFullReplicaAssignment(partition),
isNew = false)
}
val replica = PartitionAndReplica(partition, replicaId)
val currentState = controllerContext.replicaState(replica)
if (traceEnabled)
logSuccessfulTransition(stateLogger, replicaId,
partition, currentState, OfflineReplica)
// 设置该分区给定副本的状态为OfflineReplica
controllerContext.putReplicaState(replica, OfflineReplica)
}
// 遍历无Leader信息的所有副本对象
replicasWithoutLeadershipInfo.foreach { replica =&gt;
val currentState = controllerContext.replicaState(replica)
if (traceEnabled)
logSuccessfulTransition(stateLogger, replicaId,
replica.topicPartition, currentState, OfflineReplica)
// 向集群所有Broker发送请求更新对应分区的元数据
controllerBrokerRequestBatch.addUpdateMetadataRequestForBrokers(
controllerContext.liveOrShuttingDownBrokerIds.toSeq,
Set(replica.topicPartition))
// 设置该分区给定副本的状态为OfflineReplica
controllerContext.putReplicaState(replica, OfflineReplica)
}
```
我依然用一张图来说明它的执行流程:
<img src="https://static001.geekbang.org/resource/image/ac/18/ace407b3bf74852dbc5506af1cae2318.jpg" alt="">
首先代码会给所有符合状态转换的副本所在的Broker发送StopReplicaRequest请求显式地告诉这些Broker停掉其上的对应副本。Kafka的副本管理器组件ReplicaManager负责处理这个逻辑。后面我们会用两节课的时间专门讨论ReplicaManager的实现这里你只需要了解StopReplica请求被发送出去之后这些Broker上对应的副本就停止工作了。
其次代码根据分区是否保存了Leader信息将副本集合划分成两个子集有Leader副本集合和无Leader副本集合。有无Leader信息并不仅仅包含Leader还有ISR和controllerEpoch等数据。不过你大致可以认为副本集合是根据有无Leader进行划分的。
接下来源码会遍历有Leader的子集合向这些副本所在的Broker发送LeaderAndIsrRequest请求去更新停止副本操作之后的分区信息再把这些分区状态设置为OfflineReplica。
最后源码遍历无Leader的子集合执行与上一步非常类似的操作。只不过对于无Leader而言因为我们没有执行任何Leader选举操作所以给这些副本所在的Broker发送的就不是LeaderAndIsrRequest请求了而是UpdateMetadataRequest请求显式去告知它们更新对应分区的元数据即可然后再把副本状态设置为OfflineReplica。
从这段描述中我们可以知道把副本状态变更为OfflineReplica的主要逻辑其实就是停止对应副本+更新远端Broker元数据的操作。
## 总结
今天我们重点学习了Kafka的副本状态机实现原理还仔细研读了这部分的源码。我们简单回顾一下这节课的重点。
- 副本状态机ReplicaStateMachine是Kafka Broker端源码中控制副本状态流转的实现类。每个Broker启动时都会创建ReplicaStateMachine实例但只有Controller组件所在的Broker才会启动它。
- 副本状态当前Kafka定义了7类副本状态。同时它还规定了每类状态合法的前置状态。
- handleStateChanges用于执行状态转换的核心方法。底层调用doHandleStateChanges方法以7路case分支的形式穷举每类状态的转换逻辑。
<img src="https://static001.geekbang.org/resource/image/27/29/272df3f3b024c34fde619e122eeba429.jpg" alt="">
下节课我将带你学习Kafka中另一类著名的状态机分区状态机。掌握了这两个状态机你就能清楚地知道Kafka Broker端管理分区和副本对象的完整流程和手段了。事实上弄明白了这两个组件之后Controller负责主题方面的所有工作内容基本上都不会难倒你了。
## 课后讨论
请尝试分析doHandleStateChanges方法中最后一路分支的代码。
欢迎你在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,589 @@
<audio id="audio" title="18 | PartitionStateMachine分区状态转换如何实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/ac/66bbea807c71235125288f2faf46beac.mp3"></audio>
你好我是胡夕。今天我们进入到分区状态机PartitionStateMachine源码的学习。
PartitionStateMachine负责管理Kafka分区状态的转换和ReplicaStateMachine是一脉相承的。从代码结构、实现功能和设计原理来看二者都极为相似。上节课我们已经学过了ReplicaStateMachine相信你在学习这节课的PartitionStateMachine时会轻松很多。
在面试的时候很多面试官都非常喜欢问Leader选举的策略。学完了今天的课程之后你不但能够说出4种Leader选举的场景还能总结出它们的共性。对于面试来说绝对是个加分项
话不多说,我们就正式开始吧。
## PartitionStateMachine简介
PartitionStateMachine.scala文件位于controller包下代码结构不复杂可以看下这张思维导图
<img src="https://static001.geekbang.org/resource/image/b3/5e/b3f286586b39b9910e756c3539a1125e.jpg" alt="">
代码总共有5大部分。
- **PartitionStateMachine**分区状态机抽象类。它定义了诸如startup、shutdown这样的公共方法同时也给出了处理分区状态转换入口方法handleStateChanges的签名。
- **ZkPartitionStateMachine**PartitionStateMachine唯一的继承子类。它实现了分区状态机的主体逻辑功能。和ZkReplicaStateMachine类似ZkPartitionStateMachine重写了父类的handleStateChanges方法并配以私有的doHandleStateChanges方法共同实现分区状态转换的操作。
- **PartitionState接口及其实现对象**定义4类分区状态分别是NewPartition、OnlinePartition、OfflinePartition和NonExistentPartition。除此之外还定义了它们之间的流转关系。
- **PartitionLeaderElectionStrategy接口及其实现对象**定义4类分区Leader选举策略。你可以认为它们是发生Leader选举的4种场景。
- **PartitionLeaderElectionAlgorithms**分区Leader选举的算法实现。既然定义了4类选举策略就一定有相应的实现代码PartitionLeaderElectionAlgorithms就提供了这4类选举策略的实现代码。
## 类定义与字段
PartitionStateMachine和ReplicaStateMachine非常类似我们先看下面这两段代码
```
// PartitionStateMachine抽象类定义
abstract class PartitionStateMachine(
controllerContext: ControllerContext) extends Logging {
......
}
// ZkPartitionStateMachine继承子类定义
class ZkPartitionStateMachine(config: KafkaConfig,
stateChangeLogger: StateChangeLogger,
controllerContext: ControllerContext,
zkClient: KafkaZkClient,
controllerBrokerRequestBatch: ControllerBrokerRequestBatch) extends PartitionStateMachine(controllerContext) {
// Controller所在Broker的Id
private val controllerId = config.brokerId
......
}
```
从代码中可以发现它们的类定义一模一样尤其是ZkPartitionStateMachine和ZKReplicaStateMachine它们接收的字段列表都是相同的。此刻你应该可以体会到它们要做的处理逻辑其实也是差不多的。
同理ZkPartitionStateMachine实例的创建和启动时机也跟ZkReplicaStateMachine的完全相同每个Broker进程启动时会在创建KafkaController对象的过程中生成ZkPartitionStateMachine实例而只有Controller组件所在的Broker才会启动分区状态机。
下面这段代码展示了ZkPartitionStateMachine实例创建和启动的位置
```
class KafkaController(......) {
......
// 在KafkaController对象创建过程中生成ZkPartitionStateMachine实例
val partitionStateMachine: PartitionStateMachine =
new ZkPartitionStateMachine(config, stateChangeLogger,
controllerContext, zkClient,
new ControllerBrokerRequestBatch(config,
controllerChannelManager, eventManager, controllerContext,
stateChangeLogger))
......
private def onControllerFailover(): Unit = {
......
replicaStateMachine.startup() // 启动副本状态机
partitionStateMachine.startup() // 启动分区状态机
......
}
}
```
有句话我要再强调一遍:**每个Broker启动时都会创建对应的分区状态机和副本状态机实例但只有Controller所在的Broker才会启动它们**。如果Controller变更到其他Broker老Controller所在的Broker要调用这些状态机的shutdown方法关闭它们新Controller所在的Broker调用状态机的startup方法启动它们。
## 分区状态
既然ZkPartitionStateMachine是管理分区状态转换的那么我们至少要知道分区都有哪些状态以及Kafka规定的转换规则是什么。这就是PartitionState接口及其实现对象做的事情。和ReplicaState类似PartitionState定义了分区的状态空间以及流转规则。
我以OnlinePartition状态为例说明下代码是如何实现流转的
```
sealed trait PartitionState {
def state: Byte // 状态序号,无实际用途
def validPreviousStates: Set[PartitionState] // 合法前置状态集合
}
case object OnlinePartition extends PartitionState {
val state: Byte = 1
val validPreviousStates: Set[PartitionState] = Set(NewPartition, OnlinePartition, OfflinePartition)
}
```
如代码所示每个PartitionState都定义了名为validPreviousStates的集合也就是每个状态对应的合法前置状态集。
对于OnlinePartition而言它的合法前置状态集包括NewPartition、OnlinePartition和OfflinePartition。在Kafka中从合法状态集以外的状态向目标状态进行转换将被视为非法操作。
目前Kafka为分区定义了4类状态。
- NewPartition分区被创建后被设置成这个状态表明它是一个全新的分区对象。处于这个状态的分区被Kafka认为是“未初始化”因此不能选举Leader。
- OnlinePartition分区正式提供服务时所处的状态。
- OfflinePartition分区下线后所处的状态。
- NonExistentPartition分区被删除并且从分区状态机移除后所处的状态。
下图展示了完整的分区状态转换规则:
<img src="https://static001.geekbang.org/resource/image/5d/b7/5dc219ace96f3a493f42ca658410f2b7.jpg" alt="">
图中的双向箭头连接的两个状态可以彼此进行转换如OnlinePartition和OfflinePartition。Kafka允许一个分区从OnlinePartition切换到OfflinePartition反之亦然。
另外OnlinePartition和OfflinePartition都有一根箭头指向自己这表明OnlinePartition切换到OnlinePartition的操作是允许的。**当分区Leader选举发生的时候就可能出现这种情况。接下来我们就聊聊分区Leader选举那些事儿**。
## 分区Leader选举的场景及方法
刚刚我们说了两个状态机的相同点接下来我们要学习的分区Leader选举可以说是PartitionStateMachine特有的功能了。
每个分区都必须选举出Leader才能正常提供服务因此对于分区而言Leader副本是非常重要的角色。既然这样我们就必须要了解Leader选举什么流程以及在代码中是如何实现的。我们重点学习下选举策略以及具体的实现方法代码。
### PartitionLeaderElectionStrategy
先明确下分区Leader选举的含义其实很简单就是为Kafka主题的某个分区推选Leader副本。
那么Kafka定义了哪几种推选策略或者说在什么情况下需要执行Leader选举呢
这就是PartitionLeaderElectionStrategy接口要做的事情请看下面的代码
```
// 分区Leader选举策略接口
sealed trait PartitionLeaderElectionStrategy
// 离线分区Leader选举策略
final case class OfflinePartitionLeaderElectionStrategy(
allowUnclean: Boolean) extends PartitionLeaderElectionStrategy
// 分区副本重分配Leader选举策略
final case object ReassignPartitionLeaderElectionStrategy
extends PartitionLeaderElectionStrategy
// 分区Preferred副本Leader选举策略
final case object PreferredReplicaPartitionLeaderElectionStrategy
extends PartitionLeaderElectionStrategy
// Broker Controlled关闭时Leader选举策略
final case object ControlledShutdownPartitionLeaderElectionStrategy
extends PartitionLeaderElectionStrategy
```
当前分区Leader选举有4类场景。
- OfflinePartitionLeaderElectionStrategy因为Leader副本下线而引发的分区Leader选举。
- ReassignPartitionLeaderElectionStrategy因为执行分区副本重分配操作而引发的分区Leader选举。
- PreferredReplicaPartitionLeaderElectionStrategy因为执行Preferred副本Leader选举而引发的分区Leader选举。
- ControlledShutdownPartitionLeaderElectionStrategy因为正常关闭Broker而引发的分区Leader选举。
### PartitionLeaderElectionAlgorithms
针对这4类场景分区状态机的PartitionLeaderElectionAlgorithms对象定义了4个方法分别负责为每种场景选举Leader副本这4种方法是
- offlinePartitionLeaderElection
- reassignPartitionLeaderElection
- preferredReplicaPartitionLeaderElection
- controlledShutdownPartitionLeaderElection。
offlinePartitionLeaderElection方法的逻辑是这4个方法中最复杂的我们就先从它开始学起。
```
def offlinePartitionLeaderElection(assignment: Seq[Int],
isr: Seq[Int], liveReplicas: Set[Int],
uncleanLeaderElectionEnabled: Boolean, controllerContext: ControllerContext): Option[Int] = {
// 从当前分区副本列表中寻找首个处于存活状态的ISR副本
assignment.find(id =&gt; liveReplicas.contains(id) &amp;&amp; isr.contains(id)).orElse {
// 如果找不到满足条件的副本查看是否允许Unclean Leader选举
// 即Broker端参数unclean.leader.election.enable是否等于true
if (uncleanLeaderElectionEnabled) {
// 选择当前副本列表中的第一个存活副本作为Leader
val leaderOpt = assignment.find(liveReplicas.contains)
if (leaderOpt.isDefined)
controllerContext.stats.uncleanLeaderElectionRate.mark()
leaderOpt
} else {
None // 如果不允许Unclean Leader选举则返回None表示无法选举Leader
}
}
}
```
我再画一张流程图,帮助你理解它的代码逻辑:
<img src="https://static001.geekbang.org/resource/image/28/7c/2856c2a7c6dd1818dbdf458c4e409d7c.jpg" alt="">
这个方法总共接收5个参数。除了你已经很熟悉的ControllerContext类其他4个非常值得我们花一些时间去探究下。
**1.assignments**
这是分区的副本列表。该列表有个专属的名称叫Assigned Replicas简称AR。当我们创建主题之后使用kafka-topics脚本查看主题时应该可以看到名为Replicas的一列数据。这列数据显示的就是主题下每个分区的AR。assignments参数类型是Seq[Int]。**这揭示了一个重要的事实AR是有顺序的而且不一定和ISR的顺序相同**
**2.isr**
ISR在Kafka中很有名气它保存了分区所有与Leader副本保持同步的副本列表。注意Leader副本自己也在ISR中。另外作为Seq[Int]类型的变量isr自身也是有顺序的。
**3.liveReplicas**
从名字可以推断出它保存了该分区下所有处于存活状态的副本。怎么判断副本是否存活呢可以根据Controller元数据缓存中的数据来判定。简单来说所有在运行中的Broker上的副本都被认为是存活的。
**4.uncleanLeaderElectionEnabled**
在默认配置下只要不是由AdminClient发起的Leader选举这个参数的值一般是false即Kafka不允许执行Unclean Leader选举。所谓的Unclean Leader选举是指在ISR列表为空的情况下Kafka选择一个非ISR副本作为新的Leader。由于存在丢失数据的风险目前社区已经通过把Broker端参数unclean.leader.election.enable的默认值设置为false的方式禁止Unclean Leader选举了。
值得一提的是社区于2.4.0.0版本正式支持在AdminClient端为给定分区选举Leader。目前的设计是如果Leader选举是由AdminClient端触发的那就默认开启Unclean Leader选举。不过在学习offlinePartitionLeaderElection方法时你可以认为uncleanLeaderElectionEnabled=false这并不会影响你对该方法的理解。
了解了这几个参数的含义,我们就可以研究具体的流程了。
代码首先会顺序搜索AR列表并把第一个同时满足以下两个条件的副本作为新的Leader返回
- 该副本是存活状态即副本所在的Broker依然在运行中
- 该副本在ISR列表中。
倘若无法找到这样的副本代码会检查是否开启了Unclean Leader选举如果开启了则降低标准只要满足上面第一个条件即可如果未开启则本次Leader选举失败没有新Leader被选出。
其他3个方法要简单得多我们直接看代码
```
def reassignPartitionLeaderElection(reassignment: Seq[Int], isr: Seq[Int], liveReplicas: Set[Int]): Option[Int] = {
reassignment.find(id =&gt; liveReplicas.contains(id) &amp;&amp; isr.contains(id))
}
def preferredReplicaPartitionLeaderElection(assignment: Seq[Int], isr: Seq[Int], liveReplicas: Set[Int]): Option[Int] = {
assignment.headOption.filter(id =&gt; liveReplicas.contains(id) &amp;&amp; isr.contains(id))
}
def controlledShutdownPartitionLeaderElection(assignment: Seq[Int], isr: Seq[Int], liveReplicas: Set[Int], shuttingDownBrokers: Set[Int]): Option[Int] = {
assignment.find(id =&gt; liveReplicas.contains(id) &amp;&amp; isr.contains(id) &amp;&amp; !shuttingDownBrokers.contains(id))
}
```
可以看到它们的逻辑几乎是相同的大概的原理都是从AR或给定的副本列表中寻找存活状态的ISR副本。
讲到这里你应该已经知道Kafka为分区选举Leader的大体思路了。**基本上就是找出AR列表或给定副本列表中首个处于存活状态且在ISR列表的副本将其作为新Leader。**
## 处理分区状态转换的方法
掌握了刚刚的这些知识之后现在我们正式来看PartitionStateMachine的工作原理。
### handleStateChanges
前面我提到过handleStateChanges是入口方法所以我们先看它的方法签名
```
def handleStateChanges(
partitions: Seq[TopicPartition],
targetState: PartitionState,
leaderElectionStrategy: Option[PartitionLeaderElectionStrategy]):
Map[TopicPartition, Either[Throwable, LeaderAndIsr]]
```
如果用一句话概括handleStateChanges的作用应该这样说**handleStateChanges把partitions的状态设置为targetState同时还可能需要用leaderElectionStrategy策略为partitions选举新的Leader最终将partitions的Leader信息返回。**
其中partitions是待执行状态变更的目标分区列表targetState是目标状态leaderElectionStrategy是一个可选项如果传入了就表示要执行Leader选举。
下面是handleStateChanges方法的完整代码我以注释的方式给出了主要的功能说明
```
override def handleStateChanges(
partitions: Seq[TopicPartition],
targetState: PartitionState,
partitionLeaderElectionStrategyOpt: Option[PartitionLeaderElectionStrategy]
): Map[TopicPartition, Either[Throwable, LeaderAndIsr]] = {
if (partitions.nonEmpty) {
try {
// 清空Controller待发送请求集合准备本次请求发送
controllerBrokerRequestBatch.newBatch()
// 调用doHandleStateChanges方法执行真正的状态变更逻辑
val result = doHandleStateChanges(
partitions,
targetState,
partitionLeaderElectionStrategyOpt
)
// Controller给相关Broker发送请求通知状态变化
controllerBrokerRequestBatch.sendRequestsToBrokers(
controllerContext.epoch)
// 返回状态变更处理结果
result
} catch {
// 如果Controller易主则记录错误日志然后重新抛出异常
// 上层代码会捕获该异常并执行maybeResign方法执行卸任逻辑
case e: ControllerMovedException =&gt;
error(s&quot;Controller moved to another broker when moving some partitions to $targetState state&quot;, e)
throw e
// 如果是其他异常,记录错误日志,封装错误返回
case e: Throwable =&gt;
error(s&quot;Error while moving some partitions to $targetState state&quot;, e)
partitions.iterator.map(_ -&gt; Left(e)).toMap
}
} else { // 如果partitions为空什么都不用做
Map.empty
}
}
```
整个方法就两步第1步是调用doHandleStateChanges方法执行分区状态转换第2步是Controller给相关Broker发送请求告知它们这些分区的状态变更。至于哪些Broker属于相关Broker以及给Broker发送哪些请求实际上是在第1步中被确认的。
当然这个方法的重点就是第1步中调用的doHandleStateChanges方法。
### doHandleStateChanges
先来看这个方法的实现:
```
private def doHandleStateChanges(
partitions: Seq[TopicPartition],
targetState: PartitionState,
partitionLeaderElectionStrategyOpt: Option[PartitionLeaderElectionStrategy]
): Map[TopicPartition, Either[Throwable, LeaderAndIsr]] = {
val stateChangeLog = stateChangeLogger.withControllerEpoch(controllerContext.epoch)
val traceEnabled = stateChangeLog.isTraceEnabled
// 初始化新分区的状态为NonExistentPartition
partitions.foreach(partition =&gt; controllerContext.putPartitionStateIfNotExists(partition, NonExistentPartition))
// 找出要执行非法状态转换的分区,记录错误日志
val (validPartitions, invalidPartitions) = controllerContext.checkValidPartitionStateChange(partitions, targetState)
invalidPartitions.foreach(partition =&gt; logInvalidTransition(partition, targetState))
// 根据targetState进入到不同的case分支
targetState match {
......
}
}
```
这个方法首先会做状态初始化的工作具体来说就是在方法调用时不在元数据缓存中的所有分区的状态会被初始化为NonExistentPartition。
接着,检查哪些分区执行的状态转换不合法,并为这些分区记录相应的错误日志。
之后代码携合法状态转换的分区列表进入到case分支。由于分区状态只有4个因此它的case分支代码远比ReplicaStateMachine中的简单而且只有OnlinePartition这一路的分支逻辑相对复杂其他3路仅仅是将分区状态设置成目标状态而已
所以我们来深入研究下目标状态是OnlinePartition的分支
```
case OnlinePartition =&gt;
// 获取未初始化分区列表也就是NewPartition状态下的所有分区
val uninitializedPartitions = validPartitions.filter(
partition =&gt; partitionState(partition) == NewPartition)
// 获取具备Leader选举资格的分区列表
// 只能为OnlinePartition和OfflinePartition状态的分区选举Leader
val partitionsToElectLeader = validPartitions.filter(
partition =&gt; partitionState(partition) == OfflinePartition ||
partitionState(partition) == OnlinePartition)
// 初始化NewPartition状态分区在ZooKeeper中写入Leader和ISR数据
if (uninitializedPartitions.nonEmpty) {
val successfulInitializations = initializeLeaderAndIsrForPartitions(uninitializedPartitions)
successfulInitializations.foreach { partition =&gt;
stateChangeLog.info(s&quot;Changed partition $partition from ${partitionState(partition)} to $targetState with state &quot; +
s&quot;${controllerContext.partitionLeadershipInfo(partition).leaderAndIsr}&quot;)
controllerContext.putPartitionState(partition, OnlinePartition)
}
}
// 为具备Leader选举资格的分区推选Leader
if (partitionsToElectLeader.nonEmpty) {
val electionResults = electLeaderForPartitions(
partitionsToElectLeader,
partitionLeaderElectionStrategyOpt.getOrElse(
throw new IllegalArgumentException(&quot;Election strategy is a required field when the target state is OnlinePartition&quot;)
)
)
electionResults.foreach {
case (partition, Right(leaderAndIsr)) =&gt;
stateChangeLog.info(
s&quot;Changed partition $partition from ${partitionState(partition)} to $targetState with state $leaderAndIsr&quot;
)
// 将成功选举Leader后的分区设置成OnlinePartition状态
controllerContext.putPartitionState(
partition, OnlinePartition)
case (_, Left(_)) =&gt; // 如果选举失败,忽略之
}
// 返回Leader选举结果
electionResults
} else {
Map.empty
}
```
虽然代码有点长,但总的步骤就两步。
**第1步**是为NewPartition状态的分区做初始化操作也就是在ZooKeeper中创建并写入分区节点数据。节点的位置是`/brokers/topics/&lt;topic&gt;/partitions/&lt;partition&gt;`每个节点都要包含分区的Leader和ISR等数据。而**Leader和ISR的确定规则是选择存活副本列表的第一个副本作为Leader选择存活副本列表作为ISR**。至于具体的代码可以看下initializeLeaderAndIsrForPartitions方法代码片段的倒数第5行
```
private def initializeLeaderAndIsrForPartitions(partitions: Seq[TopicPartition]): Seq[TopicPartition] = {
......
// 获取每个分区的副本列表
val replicasPerPartition = partitions.map(partition =&gt; partition -&gt; controllerContext.partitionReplicaAssignment(partition))
// 获取每个分区的所有存活副本
val liveReplicasPerPartition = replicasPerPartition.map { case (partition, replicas) =&gt;
val liveReplicasForPartition = replicas.filter(replica =&gt; controllerContext.isReplicaOnline(replica, partition))
partition -&gt; liveReplicasForPartition
}
// 按照有无存活副本对分区进行分组
// 分为两组:有存活副本的分区;无任何存活副本的分区
val (partitionsWithoutLiveReplicas, partitionsWithLiveReplicas) = liveReplicasPerPartition.partition { case (_, liveReplicas) =&gt; liveReplicas.isEmpty }
......
// 为&quot;有存活副本的分区&quot;确定Leader和ISR
// Leader确认依据存活副本列表的首个副本被认定为Leader
// ISR确认依据存活副本列表被认定为ISR
val leaderIsrAndControllerEpochs = partitionsWithLiveReplicas.map { case (partition, liveReplicas) =&gt;
val leaderAndIsr = LeaderAndIsr(liveReplicas.head, liveReplicas.toList)
......
}.toMap
......
}
```
**第2步**是为具备Leader选举资格的分区推选Leader代码调用electLeaderForPartitions方法实现。这个方法会不断尝试为多个分区选举Leader直到所有分区都成功选出Leader。
选举Leader的核心代码位于doElectLeaderForPartitions方法中该方法主要有3步。
代码很长,我先画一张图来展示它的主要步骤,然后再分步骤给你解释每一步的代码,以免你直接陷入冗长的源码行里面去。
<img src="https://static001.geekbang.org/resource/image/ee/ad/ee017b731c54ee6667e3759c1a6b66ad.jpg" alt="">
看着好像图也很长,别着急,我们来一步步拆解下。
就像前面说的这个方法大体分为3步。第1步是从ZooKeeper中获取给定分区的Leader、ISR信息并将结果封装进名为validLeaderAndIsrs的容器中代码如下
```
// doElectLeaderForPartitions方法的第1部分
val getDataResponses = try {
// 批量获取ZooKeeper中给定分区的znode节点数据
zkClient.getTopicPartitionStatesRaw(partitions)
} catch {
case e: Exception =&gt;
return (partitions.iterator.map(_ -&gt; Left(e)).toMap, Seq.empty)
}
// 构建两个容器分别保存可选举Leader分区列表和选举失败分区列表
val failedElections = mutable.Map.empty[TopicPartition, Either[Exception, LeaderAndIsr]]
val validLeaderAndIsrs = mutable.Buffer.empty[(TopicPartition, LeaderAndIsr)]
// 遍历每个分区的znode节点数据
getDataResponses.foreach { getDataResponse =&gt;
val partition = getDataResponse.ctx.get.asInstanceOf[TopicPartition]
val currState = partitionState(partition)
// 如果成功拿到znode节点数据
if (getDataResponse.resultCode == Code.OK) {
TopicPartitionStateZNode.decode(getDataResponse.data, getDataResponse.stat) match {
// 节点数据中含Leader和ISR信息
case Some(leaderIsrAndControllerEpoch) =&gt;
// 如果节点数据的Controller Epoch值大于当前Controller Epoch值
if (leaderIsrAndControllerEpoch.controllerEpoch &gt; controllerContext.epoch) {
val failMsg = s&quot;Aborted leader election for partition $partition since the LeaderAndIsr path was &quot; +
s&quot;already written by another controller. This probably means that the current controller $controllerId went through &quot; +
s&quot;a soft failure and another controller was elected with epoch ${leaderIsrAndControllerEpoch.controllerEpoch}.&quot;
// 将该分区加入到选举失败分区列表
failedElections.put(partition, Left(new StateChangeFailedException(failMsg)))
} else {
// 将该分区加入到可选举Leader分区列表
validLeaderAndIsrs += partition -&gt; leaderIsrAndControllerEpoch.leaderAndIsr
}
// 如果节点数据不含Leader和ISR信息
case None =&gt;
val exception = new StateChangeFailedException(s&quot;LeaderAndIsr information doesn't exist for partition $partition in $currState state&quot;)
// 将该分区加入到选举失败分区列表
failedElections.put(partition, Left(exception))
}
// 如果没有拿到znode节点数据则将该分区加入到选举失败分区列表
} else if (getDataResponse.resultCode == Code.NONODE) {
val exception = new StateChangeFailedException(s&quot;LeaderAndIsr information doesn't exist for partition $partition in $currState state&quot;)
failedElections.put(partition, Left(exception))
} else {
failedElections.put(partition, Left(getDataResponse.resultException.get))
}
}
if (validLeaderAndIsrs.isEmpty) {
return (failedElections.toMap, Seq.empty)
}
```
首先代码会批量读取ZooKeeper中给定分区的所有Znode数据。之后会构建两个容器分别保存可选举Leader分区列表和选举失败分区列表。接着开始遍历每个分区的Znode节点数据如果成功拿到Znode节点数据节点数据包含Leader和ISR信息且节点数据的Controller Epoch值小于当前Controller Epoch值那么就将该分区加入到可选举Leader分区列表。倘若发现Zookeeper中保存的Controller Epoch值大于当前Epoch值说明该分区已经被一个更新的Controller选举过Leader了此时必须终止本次Leader选举并将该分区放置到选举失败分区列表中。
遍历完这些分区之后代码要看下validLeaderAndIsrs容器中是否包含可选举Leader的分区。如果一个满足选举Leader的分区都没有方法直接返回。至此doElectLeaderForPartitions方法的第一大步完成。
下面我们看下该方法的第2部分代码
```
// doElectLeaderForPartitions方法的第2部分
// 开始选举Leader并根据有无Leader将分区进行分区
val (partitionsWithoutLeaders, partitionsWithLeaders) =
partitionLeaderElectionStrategy match {
case OfflinePartitionLeaderElectionStrategy(allowUnclean) =&gt;
val partitionsWithUncleanLeaderElectionState = collectUncleanLeaderElectionState(
validLeaderAndIsrs,
allowUnclean
)
// 为OffinePartition分区选举Leader
leaderForOffline(controllerContext, partitionsWithUncleanLeaderElectionState).partition(_.leaderAndIsr.isEmpty)
case ReassignPartitionLeaderElectionStrategy =&gt;
// 为副本重分配的分区选举Leader
leaderForReassign(controllerContext, validLeaderAndIsrs).partition(_.leaderAndIsr.isEmpty)
case PreferredReplicaPartitionLeaderElectionStrategy =&gt;
// 为分区执行Preferred副本Leader选举
leaderForPreferredReplica(controllerContext, validLeaderAndIsrs).partition(_.leaderAndIsr.isEmpty)
case ControlledShutdownPartitionLeaderElectionStrategy =&gt;
// 为因Broker正常关闭而受影响的分区选举Leader
leaderForControlledShutdown(controllerContext, validLeaderAndIsrs).partition(_.leaderAndIsr.isEmpty)
}
```
这一步是根据给定的PartitionLeaderElectionStrategy调用PartitionLeaderElectionAlgorithms的不同方法执行Leader选举同时区分出成功选举Leader和未选出Leader的分区。
前面说过了这4种不同的策略定义了4个专属的方法来进行Leader选举。其实如果你打开这些方法的源码就会发现它们大同小异。基本上选择Leader的规则就是选择副本集合中首个存活且处于ISR中的副本作为Leader。
现在我们再来看这个方法的最后一部分代码这一步主要是更新ZooKeeper节点数据以及Controller端元数据缓存信息。
```
// doElectLeaderForPartitions方法的第3部分
// 将所有选举失败的分区全部加入到Leader选举失败分区列表
partitionsWithoutLeaders.foreach { electionResult =&gt;
val partition = electionResult.topicPartition
val failMsg = s&quot;Failed to elect leader for partition $partition under strategy $partitionLeaderElectionStrategy&quot;
failedElections.put(partition, Left(new StateChangeFailedException(failMsg)))
}
val recipientsPerPartition = partitionsWithLeaders.map(result =&gt; result.topicPartition -&gt; result.liveReplicas).toMap
val adjustedLeaderAndIsrs = partitionsWithLeaders.map(result =&gt; result.topicPartition -&gt; result.leaderAndIsr.get).toMap
// 使用新选举的Leader和ISR信息更新ZooKeeper上分区的znode节点数据
val UpdateLeaderAndIsrResult(finishedUpdates, updatesToRetry) = zkClient.updateLeaderAndIsr(
adjustedLeaderAndIsrs, controllerContext.epoch, controllerContext.epochZkVersion)
// 对于ZooKeeper znode节点数据更新成功的分区封装对应的Leader和ISR信息
// 构建LeaderAndIsr请求并将该请求加入到Controller待发送请求集合
// 等待后续统一发送
finishedUpdates.foreach { case (partition, result) =&gt;
result.foreach { leaderAndIsr =&gt;
val replicaAssignment = controllerContext.partitionFullReplicaAssignment(partition)
val leaderIsrAndControllerEpoch = LeaderIsrAndControllerEpoch(leaderAndIsr, controllerContext.epoch)
controllerContext.partitionLeadershipInfo.put(partition, leaderIsrAndControllerEpoch)
controllerBrokerRequestBatch.addLeaderAndIsrRequestForBrokers(recipientsPerPartition(partition), partition,
leaderIsrAndControllerEpoch, replicaAssignment, isNew = false)
}
}
// 返回选举结果包括成功选举并更新ZooKeeper节点的分区、选举失败分区以及
// ZooKeeper节点更新失败的分区
(finishedUpdates ++ failedElections, updatesToRetry)
```
首先将上一步中所有选举失败的分区全部加入到Leader选举失败分区列表。
然后使用新选举的Leader和ISR信息更新ZooKeeper上分区的Znode节点数据。对于ZooKeeper Znode节点数据更新成功的那些分区源码会封装对应的Leader和ISR信息构建LeaderAndIsr请求并将该请求加入到Controller待发送请求集合等待后续统一发送。
最后方法返回选举结果包括成功选举并更新ZooKeeper节点的分区列表、选举失败分区列表以及ZooKeeper节点更新失败的分区列表。
这会儿你还记得handleStateChanges方法的第2步是Controller给相关的Broker发送请求吗那么到底要给哪些Broker发送哪些请求呢其实就是在上面这步完成的即这行语句
```
controllerBrokerRequestBatch.addLeaderAndIsrRequestForBrokers(
recipientsPerPartition(partition), partition,
leaderIsrAndControllerEpoch, replicaAssignment, isNew = false)
```
## 总结
今天我们重点学习了PartitionStateMachine.scala文件的源码主要是研究了Kafka分区状态机的构造原理和工作机制。
学到这里我们再来回答开篇面试官的问题应该就不是什么难事了。现在我们知道了Kafka目前提供4种Leader选举策略分别是分区下线后的Leader选举、分区执行副本重分配时的Leader选举、分区执行Preferred副本Leader选举以及Broker下线时的分区Leader选举。
这4类选举策略在选择Leader这件事情上有着类似的逻辑那就是它们几乎都是选择当前副本有序集合中的、首个处于ISR集合中的存活副本作为新的Leader。当然个别选举策略可能会有细小的差别你可以结合我们今天学到的源码课下再深入地研究一下每一类策略的源码。
我们来回顾下这节课的重点。
- PartitionStateMachine是Kafka Controller端定义的分区状态机负责定义、维护和管理合法的分区状态转换。
- 每个Broker启动时都会实例化一个分区状态机对象但只有Controller所在的Broker才会启动它。
- Kafka分区有4类状态分别是NewPartition、OnlinePartition、OfflinePartition和NonExistentPartition。其中OnlinPartition是分区正常工作时的状态。NewPartition是未初始化状态处于该状态下的分区尚不具备选举Leader的资格。
- Leader选举有4类场景分别是Offline、Reassign、Preferrer Leader Election和ControlledShutdown。每类场景都对应于一种特定的Leader选举策略。
- handleStateChanges方法是主要的入口方法下面调用doHandleStateChanges私有方法实现实际的Leader选举功能。
下个模块我们将来到Kafka延迟操作代码的世界。在那里你能了解Kafka是如何实现一个延迟请求的处理的。另外一个O(N)时间复杂度的时间轮算法也等候在那里,到时候我们一起研究下它!
## 课后讨论
源码中有个triggerOnlineStateChangeForPartitions方法请你分析下它是做什么用的以及它何时被调用
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。