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,287 @@
<audio id="audio" title="11 | Controller元数据Controller都保存有哪些东西有几种状态" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/8b/5f0417c8727827798cb3225eba50f98b.mp3"></audio>
你好我是胡夕。从今天开始我们正式进入到第三大模块的学习控制器Controller模块 。
提起Kafka中的Controller组件我相信你一定不陌生。从某种意义上说它是Kafka最核心的组件。一方面它要为集群中的所有主题分区选举领导者副本另一方面它还承载着集群的全部元数据信息并负责将这些元数据信息同步到其他Broker上。既然我们是Kafka源码解读课那就绝对不能错过这么重量级的组件。
我画了一张图片希望借助它帮你建立起对这个模块的整体认知。今天我们先学习下Controller元数据。
<img src="https://static001.geekbang.org/resource/image/13/5f/13c0d8b3f52c295c70c71a154dae185f.jpg" alt="">
## 案例分享
在正式学习源码之前,我想向你分享一个真实的案例。
在我们公司的Kafka集群环境上曾经出现了一个比较“诡异”的问题某些核心业务的主题分区一直处于“不可用”状态。
通过使用“kafka-topics”命令查询我们发现这些分区的Leader显示是-1。之前这些Leader所在的Broker机器因为负载高宕机了当Broker重启回来后Controller竟然无法成功地为这些分区选举Leader因此它们一直处于“不可用”状态。
由于是生产环境我们的当务之急是马上恢复受损分区然后才能调研问题的原因。有人提出重启这些分区旧Leader所在的所有Broker机器——这很容易想到毕竟“重启大法”一直很好用。但是这一次竟然没有任何作用。
之后有人建议升级重启大法即重启集群的所有Broker——这在当时是不能接受的。且不说有很多业务依然在运行着单是重启Kafka集群本身就是一件非常缺乏计划性的事情。毕竟生产环境怎么能随意重启呢
后来我突然想到了Controller组件中重新选举Controller的代码。一旦Controller被选举出来它就会向所有Broker更新集群元数据也就是说会“重刷”这些分区的状态。
那么问题来了我们如何在避免重启集群的情况下干掉已有Controller并执行新的Controller选举呢答案就在源码中的**ControllerZNode.path**上也就是ZooKeeper的/controller节点。倘若我们手动删除了/controller节点Kafka集群就会触发Controller选举。于是我们马上实施了这个方案效果出奇得好之前的受损分区全部恢复正常业务数据得以正常生产和消费。
当然,给你分享这个案例的目的,并不是让你记住可以随意干掉/controller节点——这个操作其实是有一点危险的。事实上我只是想通过这个真实的例子向你说明很多打开“精通Kafka之门”的钥匙是隐藏在源码中的。那么接下来我们就开始找“钥匙”吧。
## 集群元数据
想要完整地了解Controller的工作原理我们首先就要学习它管理了哪些数据。毕竟Controller的很多代码仅仅是做数据的管理操作而已。今天我们就来重点学习Kafka集群元数据都有哪些。
如果说ZooKeeper是整个Kafka集群元数据的“真理之源Source of Truth那么Controller可以说是集群元数据的“真理之源副本Backup Source of Truth”。好吧后面这个词是我自己发明的。你只需要理解Controller承载了ZooKeeper上的所有元数据即可。
事实上集群Broker是不会与ZooKeeper直接交互去获取元数据的。相反地它们总是与Controller进行通信获取和更新最新的集群数据。而且社区已经打算把ZooKeeper“干掉”了我会在之后的“特别放送”里具体给你解释社区干掉ZooKeeper的操作以后Controller将成为新的“真理之源”。
我们总说元数据那么到底什么是集群的元数据或者说Kafka集群的元数据都定义了哪些内容呢我用一张图给你完整地展示一下当前Kafka定义的所有集群元数据信息。
<img src="https://static001.geekbang.org/resource/image/f1/54/f146aceb78a5da31d887618303b5ff54.jpg" alt="">
可以看到目前Controller定义的元数据有17项之多。不过并非所有的元数据都同等重要你也不用完整地记住它们我们只需要重点关注那些最重要的元数据并结合源代码来了解下这些元数据都是用来做什么的。
在了解具体的元数据之前我要先介绍下ControllerContext类。刚刚我们提到的这些元数据信息全部封装在这个类里。应该这么说**这个类是Controller组件的数据容器类**。
## ControllerContext
Controller组件的源代码位于core包的src/main/scala/kafka/controller路径下这里面有很多Scala源文件**ControllerContext类就位于这个路径下的ControllerContext.scala文件中。**
该文件只有几百行代码其中最重要的数据结构就是ControllerContext类。前面说过**它定义了前面提到的所有元数据信息,以及许多实用的工具方法**。比如获取集群上所有主题分区对象的allPartitions方法、获取某主题分区副本列表的partitionReplicaAssignment方法等等。
首先我们来看下ControllerContext类的定义如下所示
```
class ControllerContext {
val stats = new ControllerStats // Controller统计信息类
var offlinePartitionCount = 0 // 离线分区计数器
val shuttingDownBrokerIds = mutable.Set.empty[Int] // 关闭中Broker的Id列表
private val liveBrokers = mutable.Set.empty[Broker] // 当前运行中Broker对象列表
private val liveBrokerEpochs = mutable.Map.empty[Int, Long] // 运行中Broker Epoch列表
var epoch: Int = KafkaController.InitialControllerEpoch // Controller当前Epoch值
var epochZkVersion: Int = KafkaController.InitialControllerEpochZkVersion // Controller对应ZooKeeper节点的Epoch值
val allTopics = mutable.Set.empty[String] // 集群主题列表
val partitionAssignments = mutable.Map.empty[String, mutable.Map[Int, ReplicaAssignment]] // 主题分区的副本列表
val partitionLeadershipInfo = mutable.Map.empty[TopicPartition, LeaderIsrAndControllerEpoch] // 主题分区的Leader/ISR副本信息
val partitionsBeingReassigned = mutable.Set.empty[TopicPartition] // 正处于副本重分配过程的主题分区列表
val partitionStates = mutable.Map.empty[TopicPartition, PartitionState] // 主题分区状态列表
val replicaStates = mutable.Map.empty[PartitionAndReplica, ReplicaState] // 主题分区的副本状态列表
val replicasOnOfflineDirs = mutable.Map.empty[Int, Set[TopicPartition]] // 不可用磁盘路径上的副本列表
val topicsToBeDeleted = mutable.Set.empty[String] // 待删除主题列表
val topicsWithDeletionStarted = mutable.Set.empty[String] // 已开启删除的主题列表
val topicsIneligibleForDeletion = mutable.Set.empty[String] // 暂时无法执行删除的主题列表
......
}
```
不多不少这段代码中定义的字段正好17个它们一一对应着上图中的那些元数据信息。下面我选取一些重要的元数据来详细解释下它们的含义。
这些元数据理解起来还是比较简单的掌握了它们之后你在理解MetadataCache也就是元数据缓存的时候就容易得多了。比如接下来我要讲到的liveBrokers信息就是Controller通过UpdateMetadataRequest请求同步给其他Broker的MetadataCache的。
### ControllerStats
第一个是ControllerStats类的变量。它的完整代码如下
```
private[controller] class ControllerStats extends KafkaMetricsGroup {
// 统计每秒发生的Unclean Leader选举次数
val uncleanLeaderElectionRate = newMeter(&quot;UncleanLeaderElectionsPerSec&quot;, &quot;elections&quot;, TimeUnit.SECONDS)
// Controller事件通用的统计速率指标的方法
val rateAndTimeMetrics: Map[ControllerState, KafkaTimer] = ControllerState.values.flatMap { state =&gt;
state.rateAndTimeMetricName.map { metricName =&gt;
state -&gt; new KafkaTimer(newTimer(metricName, TimeUnit.MILLISECONDS, TimeUnit.SECONDS))
}
}.toMap
}
```
顾名思义它表征的是Controller的一些统计信息。目前源码中定义了两大类统计指标**UncleanLeaderElectionsPerSec和所有Controller事件状态的执行速率与时间**。
其中,**前者是计算Controller每秒执行的Unclean Leader选举数量通常情况下执行Unclean Leader选举可能造成数据丢失一般不建议开启它**。一旦开启你就需要时刻关注这个监控指标的值确保Unclean Leader选举的速率维持在一个很低的水平否则会出现很多数据丢失的情况。
**后者是统计所有Controller状态的速率和时间信息**单位是毫秒。当前Controller定义了很多事件比如TopicDeletion是执行主题删除的Controller事件、ControllerChange是执行Controller重选举的事件。ControllerStats的这个指标通过在每个事件名后拼接字符串RateAndTimeMs的方式为每类Controller事件都创建了对应的速率监控指标。
由于Controller事件有很多种对应的速率监控指标也有很多有一些Controller事件是需要你额外关注的。
举个例子IsrChangeNotification事件是标志ISR列表变更的事件如果这个事件经常出现说明副本的ISR列表经常发生变化而这通常被认为是非正常情况因此你最好关注下这个事件的速率监控指标。
### offlinePartitionCount
**该字段统计集群中所有离线或处于不可用状态的主题分区数量**。所谓的不可用状态就是我最开始举的例子中“Leader=-1”的情况。
ControllerContext中的updatePartitionStateMetrics方法根据**给定主题分区的当前状态和目标状态**来判断该分区是否是离线状态的分区。如果是则累加offlinePartitionCount字段的值否则递减该值。方法代码如下
```
// 更新offlinePartitionCount元数据
private def updatePartitionStateMetrics(
partition: TopicPartition,
currentState: PartitionState,
targetState: PartitionState): Unit = {
// 如果该主题当前并未处于删除中状态
if (!isTopicDeletionInProgress(partition.topic)) {
// targetState表示该分区要变更到的状态
// 如果当前状态不是OfflinePartition即离线状态并且目标状态是离线状态
// 这个if语句判断是否要将该主题分区状态转换到离线状态
if (currentState != OfflinePartition &amp;&amp; targetState == OfflinePartition) {
offlinePartitionCount = offlinePartitionCount + 1
// 如果当前状态已经是离线状态但targetState不是
// 这个else if语句判断是否要将该主题分区状态转换到非离线状态
} else if (currentState == OfflinePartition &amp;&amp; targetState != OfflinePartition) {
offlinePartitionCount = offlinePartitionCount - 1
}
}
}
```
该方法首先要判断此分区所属的主题当前是否处于删除操作的过程中。如果是的话Kafka就不能修改这个分区的状态那么代码什么都不做直接返回。否则代码会判断该分区是否要转换到离线状态。如果targetState是OfflinePartition那么就将offlinePartitionCount值加1毕竟多了一个离线状态的分区。相反地如果currentState是offlinePartition而targetState反而不是那么就将offlinePartitionCount值减1。
### shuttingDownBrokerIds
顾名思义,**该字段保存所有正在关闭中的Broker ID列表**。当Controller在管理集群Broker时它要依靠这个字段来甄别Broker当前是否已关闭因为处于关闭状态的Broker是不适合执行某些操作的如分区重分配Reassignment以及主题删除等。
另外Kafka必须要为这些关闭中的Broker执行很多清扫工作Controller定义了一个onBrokerFailure方法它就是用来做这个的。代码如下
```
private def onBrokerFailure(deadBrokers: Seq[Int]): Unit = {
info(s&quot;Broker failure callback for ${deadBrokers.mkString(&quot;,&quot;)}&quot;)
// deadBrokers给定的一组已终止运行的Broker Id列表
// 更新Controller元数据信息将给定Broker从元数据的replicasOnOfflineDirs中移除
deadBrokers.foreach(controllerContext.replicasOnOfflineDirs.remove)
// 找出这些Broker上的所有副本对象
val deadBrokersThatWereShuttingDown =
deadBrokers.filter(id =&gt; controllerContext.shuttingDownBrokerIds.remove(id))
if (deadBrokersThatWereShuttingDown.nonEmpty)
info(s&quot;Removed ${deadBrokersThatWereShuttingDown.mkString(&quot;,&quot;)} from list of shutting down brokers.&quot;)
// 执行副本清扫工作
val allReplicasOnDeadBrokers = controllerContext.replicasOnBrokers(deadBrokers.toSet)
onReplicasBecomeOffline(allReplicasOnDeadBrokers)
// 取消这些Broker上注册的ZooKeeper监听器
unregisterBrokerModificationsHandler(deadBrokers)
}
```
该方法接收一组已终止运行的Broker ID列表首先是更新Controller元数据信息将给定Broker从元数据的replicasOnOfflineDirs和shuttingDownBrokerIds中移除然后为这组Broker执行必要的副本清扫工作也就是onReplicasBecomeOffline方法做的事情。
该方法主要依赖于分区状态机和副本状态机来完成对应的工作。在后面的课程中,我们会专门讨论副本状态机和分区状态机,这里你只要简单了解下它要做的事情就行了。后面等我们学完了这两个状态机之后,你可以再看下这个方法的具体实现原理。
这个方法的主要目的是把给定的副本标记成Offline状态即不可用状态。具体分为以下这几个步骤
1. 利用分区状态机将给定副本所在的分区标记为Offline状态
1. 将集群上所有新分区和Offline分区状态变更为Online状态
1. 将相应的副本对象状态变更为Offline。
### liveBrokers
**该字段保存当前所有运行中的Broker对象**。每个Broker对象就是一个&lt;IdEndPoint机架信息&gt;的三元组。ControllerContext中定义了很多方法来管理该字段如addLiveBrokersAndEpochs、removeLiveBrokers和updateBrokerMetadata等。我拿updateBrokerMetadata方法进行说明以下是源码
```
def updateBrokerMetadata(oldMetadata: Broker, newMetadata: Broker): Unit = {
liveBrokers -= oldMetadata
liveBrokers += newMetadata
}
```
每当新增或移除已有Broker时ZooKeeper就会更新其保存的Broker数据从而引发Controller修改元数据也就是会调用updateBrokerMetadata方法来增减Broker列表中的对象。怎么样超简单吧
### liveBrokerEpochs
**该字段保存所有运行中Broker的Epoch信息**。Kafka使用Epoch数据防止Zombie Broker即一个非常老的Broker被选举成为Controller。
另外源码大多使用这个字段来获取所有运行中Broker的ID序号如下面这个方法定义的那样
```
def liveBrokerIds: Set[Int] = liveBrokerEpochs.keySet -- shuttingDownBrokerIds
```
liveBrokerEpochs的keySet方法返回Broker序号列表然后从中移除关闭中的Broker序号剩下的自然就是处于运行中的Broker序号列表了。
### epoch &amp; epochZkVersion
这两个字段一起说因为它们都有“epoch”字眼放在一起说可以帮助你更好地理解两者的区别。epoch实际上就是ZooKeeper中/controller_epoch节点的值你可以认为它就是Controller在整个Kafka集群的版本号而epochZkVersion实际上是/controller_epoch节点的dataVersion值。
Kafka使用epochZkVersion来判断和防止Zombie Controller。这也就是说原先在老Controller任期内的Controller操作在新Controller不能成功执行因为新Controller的epochZkVersion要比老Controller的大。
另外你可能会问“这里的两个Epoch和上面的liveBrokerEpochs有啥区别呢”实际上这里的两个Epoch值都是属于Controller侧的数据而liveBrokerEpochs是每个Broker自己的Epoch值。
### allTopics
**该字段保存集群上所有的主题名称**。每当有主题的增减Controller就要更新该字段的值。
比如Controller有个processTopicChange方法从名字上来看它就是处理主题变更的。我们来看下它的代码实现我把主要逻辑以注释的方式标注了出来
```
private def processTopicChange(): Unit = {
if (!isActive) return // 如果Contorller已经关闭直接返回
val topics = zkClient.getAllTopicsInCluster(true) // 从ZooKeeper中获取当前所有主题列表
val newTopics = topics -- controllerContext.allTopics // 找出当前元数据中不存在、ZooKeeper中存在的主题视为新增主题
val deletedTopics = controllerContext.allTopics -- topics // 找出当前元数据中存在、ZooKeeper中不存在的主题视为已删除主题
controllerContext.allTopics = topics // 更新Controller元数据
// 为新增主题和已删除主题执行后续处理操作
registerPartitionModificationsHandlers(newTopics.toSeq)
val addedPartitionReplicaAssignment = zkClient.getFullReplicaAssignmentForTopics(newTopics)
deletedTopics.foreach(controllerContext.removeTopic)
addedPartitionReplicaAssignment.foreach {
case (topicAndPartition, newReplicaAssignment) =&gt; controllerContext.updatePartitionFullReplicaAssignment(topicAndPartition, newReplicaAssignment)
}
info(s&quot;New topics: [$newTopics], deleted topics: [$deletedTopics], new partition replica assignment &quot; +
s&quot;[$addedPartitionReplicaAssignment]&quot;)
if (addedPartitionReplicaAssignment.nonEmpty)
onNewPartitionCreation(addedPartitionReplicaAssignment.keySet)
}
```
### partitionAssignments
**该字段保存所有主题分区的副本分配情况**。在我看来,**这是Controller最重要的元数据了**。事实上你可以从这个字段衍生、定义很多实用的方法来帮助Kafka从各种维度获取数据。
比如如果Kafka要获取某个Broker上的所有分区那么它可以这样定义
```
partitionAssignments.flatMap {
case (topic, topicReplicaAssignment) =&gt; topicReplicaAssignment.filter {
case (_, partitionAssignment) =&gt; partitionAssignment.replicas.contains(brokerId)
}.map {
case (partition, _) =&gt; new TopicPartition(topic, partition)
}
}.toSet
```
再比如如果Kafka要获取某个主题的所有分区对象代码可以这样写
```
partitionAssignments.getOrElse(topic, mutable.Map.empty).map {
case (partition, _) =&gt; new TopicPartition(topic, partition)
}.toSet
```
实际上这两段代码分别是ControllerContext.scala中partitionsOnBroker方法和partitionsForTopic两个方法的主体实现代码。
讲到这里9个重要的元数据字段我就介绍完了。前面说过ControllerContext中一共定义了17个元数据字段你可以结合这9个字段把其余8个的定义也过一遍做到心中有数。**你对Controller元数据掌握得越好就越能清晰地理解Controller在集群中发挥的作用**。
值得注意的是,在学习每个元数据字段时,除了它的定义之外,我建议你去搜索一下,与之相关的工具方法都是如何实现的。如果后面你想要新增获取或更新元数据的方法,你要对操作它们的代码有很强的把控力才行。
## 总结
今天我们揭开了Kafka重要组件Controller的学习大幕。我给出了Controller模块的学习路线还介绍了Controller的重要元数据。
- Controller元数据Controller当前定义了17种元数据涵盖Kafka集群数据的方方面面。
- ControllerContext定义元数据以及操作它们的类。
- 关键元数据字段最重要的元数据包括offlinePartitionCount、liveBrokers、partitionAssignments等。
- ControllerContext工具方法ControllerContext 类定义了很多实用方法来管理这些元数据信息。
下节课我们将学习Controller是如何给Broker发送请求的。Controller与Broker进行交互与通信是Controller奠定王者地位的重要一环我会向你详细解释它是如何做到这一点的。
## 课后讨论
我今天并未给出所有的元数据说明请你自行结合代码分析一下partitionLeadershipInfo里面保存的是什么数据
欢迎你在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,385 @@
<audio id="audio" title="12 | ControllerChannelManagerController如何管理请求发送" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/03/2788d6cb2b61d63a9dd239be95b61d03.mp3"></audio>
你好我是胡夕。上节课我们深入研究了ControllerContext.scala源码文件掌握了Kafka集群定义的重要元数据。今天我们来学习下Controller是如何给其他Broker发送请求的。
掌握了这部分实现原理你就能更好地了解Controller究竟是如何与集群Broker进行交互从而实现管理集群元数据的功能的。而且阅读这部分源码还能帮你定位和解决线上问题。我先跟你分享一个真实的案例。
当时还是在Kafka 0.10.0.1时代我们突然发现在线上环境中很多元数据变更无法在集群的所有Broker上同步了。具体表现为创建了主题后有些Broker依然无法感知到。
我的第一感觉是Controller出现了问题但又苦于无从排查和验证。后来我想到会不会是Controller端请求队列中积压的请求太多造成的呢因为当时Controller所在的Broker本身承载着非常重的业务这是非常有可能的原因。
在看了相关代码后我们就在相应的源码中新加了一个监控指标用于实时监控Controller的请求队列长度。当更新到生产环境后我们很轻松地定位了问题。果然由于Controller所在的Broker自身负载过大导致Controller端的请求积压从而造成了元数据更新的滞后。精准定位了问题之后解决起来就很容易了。后来社区于0.11版本正式引入了相关的监控指标。
你看,阅读源码,除了可以学习优秀开发人员编写的代码之外,我们还能根据自身的实际情况做定制化方案,实现一些非开箱即用的功能。
## Controller发送请求类型
下面我们就正式进入到Controller请求发送管理部分的学习。你可能会问“Controller也会给Broker发送请求吗”当然**Controller会给集群中的所有Broker包括它自己所在的Broker机器发送网络请求**。发送请求的目的是让Broker执行相应的指令。我用一张图来展示下Controller都会发送哪些请求如下所示
<img src="https://static001.geekbang.org/resource/image/3e/f7/3e8b0a34f003db5d67d5adafe8781ef7.jpg" alt="">
当前Controller只会向Broker发送三类请求分别是LeaderAndIsrRequest、StopReplicaRequest和UpdateMetadataRequest。注意这里我使用的是“当前”我只是说目前仅有这三类不代表以后不会有变化。事实上我几乎可以肯定以后能发送的RPC协议种类一定会变化的。因此你需要掌握请求发送的原理。毕竟所有请求发送都是通过相同的机制完成的。
还记得我在[第8节课](https://time.geekbang.org/column/article/232134)提到的控制类请求吗?没错,这三类请求就是典型的控制类请求。我来解释下它们的作用。
- LeaderAndIsrRequest最主要的功能是告诉Broker相关主题各个分区的Leader副本位于哪台Broker上、ISR中的副本都在哪些Broker上。在我看来**它应该被赋予最高的优先级,毕竟,它有令数据类请求直接失效的本领**。试想一下如果这个请求中的Leader副本变更了之前发往老的Leader的PRODUCE请求是不是全部失效了因此我认为它是非常重要的控制类请求。
- StopReplicaRequest告知指定Broker停止它上面的副本对象该请求甚至还能删除副本底层的日志数据。这个请求主要的使用场景是**分区副本迁移**和**删除主题**。在这两个场景下都要涉及停掉Broker上的副本操作。
- UpdateMetadataRequest顾名思义该请求会更新Broker上的元数据缓存。集群上的所有元数据变更都首先发生在Controller端然后再经由这个请求广播给集群上的所有Broker。在我刚刚分享的案例中正是因为这个请求被处理得不及时才导致集群Broker无法获取到最新的元数据信息。
现在,社区越来越倾向于**将重要的数据结构源代码从服务器端的core工程移动到客户端的clients工程中**。这三类请求Java类的定义就封装在clients中它们的抽象基类是AbstractControlRequest类这个类定义了这三类请求的公共字段。
我用代码展示下这三类请求及其抽象父类的定义以便让你对Controller发送的请求类型有个基本的认识。这些类位于clients工程下的src/main/java/org/apache/kafka/common/requests路径下。
先来看AbstractControlRequest类的主要代码
```
public abstract class AbstractControlRequest extends AbstractRequest {
public static final long UNKNOWN_BROKER_EPOCH = -1L;
public static abstract class Builder&lt;T extends AbstractRequest&gt; extends AbstractRequest.Builder&lt;T&gt; {
protected final int controllerId;
protected final int controllerEpoch;
protected final long brokerEpoch;
......
}
```
区别于其他的数据类请求抽象类请求必然包含3个字段。
- **controllerId**Controller所在的Broker ID。
- **controllerEpoch**Controller的版本信息。
- **brokerEpoch**目标Broker的Epoch。
后面这两个Epoch字段用于隔离Zombie Controller和Zombie Broker以保证集群的一致性。
在同一源码路径下你能找到LeaderAndIsrRequest、StopReplicaRequest和UpdateMetadataRequest的定义如下所示
```
public class LeaderAndIsrRequest extends AbstractControlRequest { ...... }
public class StopReplicaRequest extends AbstractControlRequest { ...... }
public class UpdateMetadataRequest extends AbstractControlRequest { ...... }
```
## RequestSendThread
说完了Controller发送什么请求接下来我们说说怎么发。
Kafka源码非常喜欢生产者-消费者模式。该模式的好处在于,**解耦生产者和消费者逻辑,分离两者的集中性交互**。学完了“请求处理”模块现在你一定很赞同这个说法吧。还记得Broker端的SocketServer组件吗它就在内部定义了一个线程共享的请求队列它下面的Processor线程扮演Producer而KafkaRequestHandler线程扮演Consumer。
对于Controller而言源码同样使用了这个模式它依然是一个线程安全的阻塞队列Controller事件处理线程第13节课会详细说它负责向这个队列写入待发送的请求而一个名为RequestSendThread的线程负责执行真正的请求发送。如下图所示
<img src="https://static001.geekbang.org/resource/image/82/21/825d084eb1517daace5532d1c93b0321.jpg" alt="">
Controller会为集群中的每个Broker都创建一个对应的RequestSendThread线程。Broker上的这个线程持续地从阻塞队列中获取待发送的请求。
那么Controller往阻塞队列上放什么数据呢这其实是由源码中的QueueItem类定义的。代码如下
```
case class QueueItem(apiKey: ApiKeys, request: AbstractControlRequest.Builder[_ &lt;: AbstractControlRequest], callback: AbstractResponse =&gt; Unit, enqueueTimeMs: Long)
```
每个QueueItem的核心字段都是**AbstractControlRequest.Builder对象**。你基本上可以认为它就是阻塞队列上AbstractControlRequest类型。
需要注意的是这里的“&lt;:”符号它在Scala中表示**上边界**的意思即字段request必须是AbstractControlRequest的子类也就是上面说到的那三类请求。
这也就是说每个QueueItem实际保存的都是那三类请求中的其中一类。如果使用一个BlockingQueue对象来保存这些QueueItem那么代码就实现了一个请求阻塞队列。这就是RequestSendThread类做的事情。
接下来我们就来学习下RequestSendThread类的定义。我给一些主要的字段添加了注释。
```
class RequestSendThread(val controllerId: Int, // Controller所在Broker的Id
val controllerContext: ControllerContext, // Controller元数据信息
val queue: BlockingQueue[QueueItem], // 请求阻塞队列
val networkClient: NetworkClient, // 用于执行发送的网络I/O类
val brokerNode: Node, // 目标Broker节点
val config: KafkaConfig, // Kafka配置信息
val time: Time,
val requestRateAndQueueTimeMetrics: Timer,
val stateChangeLogger: StateChangeLogger,
name: String) extends ShutdownableThread(name = name) {
......
}
```
其实RequestSendThread最重要的是它的**doWork方法**,也就是执行线程逻辑的方法:
```
override def doWork(): Unit = {
def backoff(): Unit = pause(100, TimeUnit.MILLISECONDS)
val QueueItem(apiKey, requestBuilder, callback, enqueueTimeMs) = queue.take() // 以阻塞的方式从阻塞队列中取出请求
requestRateAndQueueTimeMetrics.update(time.milliseconds() - enqueueTimeMs, TimeUnit.MILLISECONDS) // 更新统计信息
var clientResponse: ClientResponse = null
try {
var isSendSuccessful = false
while (isRunning &amp;&amp; !isSendSuccessful) {
try {
// 如果没有创建与目标Broker的TCP连接或连接暂时不可用
if (!brokerReady()) {
isSendSuccessful = false
backoff() // 等待重试
}
else {
val clientRequest = networkClient.newClientRequest(brokerNode.idString, requestBuilder,
time.milliseconds(), true)
// 发送请求等待接收Response
clientResponse = NetworkClientUtils.sendAndReceive(networkClient, clientRequest, time)
isSendSuccessful = true
}
} catch {
case e: Throwable =&gt;
warn(s&quot;Controller $controllerId epoch ${controllerContext.epoch} fails to send request $requestBuilder &quot; +
s&quot;to broker $brokerNode. Reconnecting to broker.&quot;, e)
// 如果出现异常关闭与对应Broker的连接
networkClient.close(brokerNode.idString)
isSendSuccessful = false
backoff()
}
}
// 如果接收到了Response
if (clientResponse != null) {
val requestHeader = clientResponse.requestHeader
val api = requestHeader.apiKey
// 此Response的请求类型必须是LeaderAndIsrRequest、StopReplicaRequest或UpdateMetadataRequest中的一种
if (api != ApiKeys.LEADER_AND_ISR &amp;&amp; api != ApiKeys.STOP_REPLICA &amp;&amp; api != ApiKeys.UPDATE_METADATA)
throw new KafkaException(s&quot;Unexpected apiKey received: $apiKey&quot;)
val response = clientResponse.responseBody
stateChangeLogger.withControllerEpoch(controllerContext.epoch)
.trace(s&quot;Received response &quot; +
s&quot;${response.toString(requestHeader.apiVersion)} for request $api with correlation id &quot; +
s&quot;${requestHeader.correlationId} sent to broker $brokerNode&quot;)
if (callback != null) {
callback(response) // 处理回调
}
}
} catch {
case e: Throwable =&gt;
error(s&quot;Controller $controllerId fails to send a request to broker $brokerNode&quot;, e)
networkClient.close(brokerNode.idString)
}
}
```
我用一张图来说明doWork的执行逻辑
<img src="https://static001.geekbang.org/resource/image/86/19/869727e22f882509a149d1065a8a1719.jpg" alt="">
总体上来看doWork的逻辑很直观。它的主要作用是从阻塞队列中取出待发送的请求然后把它发送出去之后等待Response的返回。在等待Response的过程中线程将一直处于阻塞状态。当接收到Response之后调用callback执行请求处理完成后的回调逻辑。
需要注意的是RequestSendThread线程对请求发送的处理方式与Broker处理请求不太一样。它调用的sendAndReceive方法在发送完请求之后会原地进入阻塞状态等待Response返回。只有接收到Response并执行完回调逻辑之后该线程才能从阻塞队列中取出下一个待发送请求进行处理。
## ControllerChannelManager
了解了RequestSendThread线程的源码之后我们进入到ControllerChannelManager类的学习。
这个类和RequestSendThread是合作共赢的关系。在我看来它有两大类任务。
- 管理Controller与集群Broker之间的连接并为每个Broker创建RequestSendThread线程实例
- 将要发送的请求放入到指定Broker的阻塞队列中等待该Broker专属的RequestSendThread线程进行处理。
由此可见,它们是紧密相连的。
ControllerChannelManager类最重要的数据结构是brokerStateInfo它是在下面这行代码中定义的
```
protected val brokerStateInfo = new HashMap[Int, ControllerBrokerStateInfo]
```
这是一个HashMap类型Key是Integer类型其实就是集群中Broker的ID信息而Value是一个ControllerBrokerStateInfo。
你可能不太清楚ControllerBrokerStateInfo类是什么我先解释一下。它本质上是一个POJO类仅仅是承载若干数据结构的容器如下所示
```
case class ControllerBrokerStateInfo(networkClient: NetworkClient,
brokerNode: Node,
messageQueue: BlockingQueue[QueueItem],
requestSendThread: RequestSendThread,
queueSizeGauge: Gauge[Int],
requestRateAndTimeMetrics: Timer,
reconfigurableChannelBuilder: Option[Reconfigurable])
```
它有三个非常关键的字段。
- **brokerNode**目标Broker节点对象里面封装了目标Broker的连接信息比如主机名、端口号等。
- **messageQueue**请求消息阻塞队列。你可以发现Controller为每个目标Broker都创建了一个消息队列。
- **requestSendThread**Controller使用这个线程给目标Broker发送请求。
你一定要记住这三个字段因为它们是实现Controller发送请求的关键因素。
为什么呢我们思考一下如果Controller要给Broker发送请求肯定需要解决三个问题发给谁发什么怎么发“发给谁”就是由brokerNode决定的messageQueue里面保存了要发送的请求因而解决了“发什么”的问题最后的“怎么发”就是依赖requestSendThread变量实现的。
好了我们现在回到ControllerChannelManager。它定义了5个public方法我来一一介绍下。
- **startup方法**Controller组件在启动时会调用ControllerChannelManager的startup方法。该方法会从元数据信息中找到集群的Broker列表然后依次为它们调用addBroker方法把它们加到brokerStateInfo变量中最后再依次启动brokerStateInfo中的RequestSendThread线程。
- **shutdown方法**关闭所有RequestSendThread线程并清空必要的资源。
- **sendRequest方法**:从名字看,就是发送请求,实际上就是把请求对象提交到请求队列。
- **addBroker方法**添加目标Broker到brokerStateInfo数据结构中并创建必要的配套资源如请求队列、RequestSendThread线程对象等。最后RequestSendThread启动线程。
- **removeBroker方法**从brokerStateInfo移除目标Broker的相关数据。
这里面大部分的方法逻辑都很简单,从方法名字就可以看得出来。我重点说一下**addBroker**,以及**底层相关的私有方法addNewBroker和startRequestSendThread方法**。
毕竟addBroker是最重要的逻辑。每当集群中扩容了新的Broker时Controller就会调用这个方法为新Broker增加新的RequestSendThread线程。
我们先来看addBroker
```
def addBroker(broker: Broker): Unit = {
brokerLock synchronized {
// 如果该Broker是新Broker的话
if (!brokerStateInfo.contains(broker.id)) {
// 将新Broker加入到Controller管理并创建对应的RequestSendThread线程
addNewBroker(broker)
// 启动RequestSendThread线程
startRequestSendThread(broker.id)
}
}
}
```
整个代码段被brokerLock保护起来了。还记得brokerStateInfo的定义吗它仅仅是一个HashMap对象因为不是线程安全的所以任何访问该变量的地方都需要锁的保护。
这段代码的逻辑是判断目标Broker的序号是否已经保存在brokerStateInfo中。如果是就说明这个Broker之前已经添加过了就没必要再次添加了否则addBroker方法会对目前的Broker执行两个操作
1. 把该Broker节点添加到brokerStateInfo中
1. 启动与该Broker对应的RequestSendThread线程。
这两步分别是由addNewBroker和startRequestSendThread方法实现的。
addNewBroker方法的逻辑比较复杂我用注释的方式给出主要步骤
```
private def addNewBroker(broker: Broker): Unit = {
// 为该Broker构造请求阻塞队列
val messageQueue = new LinkedBlockingQueue[QueueItem]
debug(s&quot;Controller ${config.brokerId} trying to connect to broker ${broker.id}&quot;)
val controllerToBrokerListenerName = config.controlPlaneListenerName.getOrElse(config.interBrokerListenerName)
val controllerToBrokerSecurityProtocol = config.controlPlaneSecurityProtocol.getOrElse(config.interBrokerSecurityProtocol)
// 获取待连接Broker节点对象信息
val brokerNode = broker.node(controllerToBrokerListenerName)
val logContext = new LogContext(s&quot;[Controller id=${config.brokerId}, targetBrokerId=${brokerNode.idString}] &quot;)
val (networkClient, reconfigurableChannelBuilder) = {
val channelBuilder = ChannelBuilders.clientChannelBuilder(
controllerToBrokerSecurityProtocol,
JaasContext.Type.SERVER,
config,
controllerToBrokerListenerName,
config.saslMechanismInterBrokerProtocol,
time,
config.saslInterBrokerHandshakeRequestEnable,
logContext
)
val reconfigurableChannelBuilder = channelBuilder match {
case reconfigurable: Reconfigurable =&gt;
config.addReconfigurable(reconfigurable)
Some(reconfigurable)
case _ =&gt; None
}
// 创建NIO Selector实例用于网络数据传输
val selector = new Selector(
NetworkReceive.UNLIMITED,
Selector.NO_IDLE_TIMEOUT_MS,
metrics,
time,
&quot;controller-channel&quot;,
Map(&quot;broker-id&quot; -&gt; brokerNode.idString).asJava,
false,
channelBuilder,
logContext
)
// 创建NetworkClient实例
// NetworkClient类是Kafka clients工程封装的顶层网络客户端API
// 提供了丰富的方法实现网络层IO数据传输
val networkClient = new NetworkClient(
selector,
new ManualMetadataUpdater(Seq(brokerNode).asJava),
config.brokerId.toString,
1,
0,
0,
Selectable.USE_DEFAULT_BUFFER_SIZE,
Selectable.USE_DEFAULT_BUFFER_SIZE,
config.requestTimeoutMs,
ClientDnsLookup.DEFAULT,
time,
false,
new ApiVersions,
logContext
)
(networkClient, reconfigurableChannelBuilder)
}
// 为这个RequestSendThread线程设置线程名称
val threadName = threadNamePrefix match {
case None =&gt; s&quot;Controller-${config.brokerId}-to-broker-${broker.id}-send-thread&quot;
case Some(name) =&gt; s&quot;$name:Controller-${config.brokerId}-to-broker-${broker.id}-send-thread&quot;
}
// 构造请求处理速率监控指标
val requestRateAndQueueTimeMetrics = newTimer(
RequestRateAndQueueTimeMetricName, TimeUnit.MILLISECONDS, TimeUnit.SECONDS, brokerMetricTags(broker.id)
)
// 创建RequestSendThread实例
val requestThread = new RequestSendThread(config.brokerId, controllerContext, messageQueue, networkClient,
brokerNode, config, time, requestRateAndQueueTimeMetrics, stateChangeLogger, threadName)
requestThread.setDaemon(false)
val queueSizeGauge = newGauge(QueueSizeMetricName, () =&gt; messageQueue.size, brokerMetricTags(broker.id))
// 创建该Broker专属的ControllerBrokerStateInfo实例
// 并将其加入到brokerStateInfo统一管理
brokerStateInfo.put(broker.id, ControllerBrokerStateInfo(networkClient, brokerNode, messageQueue,
requestThread, queueSizeGauge, requestRateAndQueueTimeMetrics, reconfigurableChannelBuilder))
}
```
为了方便你理解,我还画了一张流程图形象说明它的执行流程:
<img src="https://static001.geekbang.org/resource/image/4f/22/4f34c319f9480c16ac12dee78d5ba322.jpg" alt="">
addNewBroker的关键在于**要为目标Broker创建一系列的配套资源**比如NetworkClient用于网络I/O操作、messageQueue用于阻塞队列、requestThread用于发送请求等等。
至于startRequestSendThread方法就简单得多了只有几行代码而已。
```
protected def startRequestSendThread(brokerId: Int): Unit = {
// 获取指定Broker的专属RequestSendThread实例
val requestThread = brokerStateInfo(brokerId).requestSendThread
if (requestThread.getState == Thread.State.NEW)
// 启动线程
requestThread.start()
}
```
它首先根据给定的Broker序号信息从brokerStateInfo中找出对应的ControllerBrokerStateInfo对象。有了这个对象也就有了为该目标Broker服务的所有配套资源。下一步就是从ControllerBrokerStateInfo中拿出RequestSendThread对象再启动它就好了。
## 总结
今天我结合ControllerChannelManager.scala文件重点分析了Controller向Broker发送请求机制的实现原理。
Controller主要通过ControllerChannelManager类来实现与其他Broker之间的请求发送。其中ControllerChannelManager类中定义的RequestSendThread是主要的线程实现类用于实际发送请求给集群Broker。除了RequestSendThread之外ControllerChannelManager还定义了相应的管理方法如添加Broker、移除Broker等。通过这些管理方法Controller在集群扩缩容时能够快速地响应到这些变化完成对应Broker连接的创建与销毁。
我们来回顾下这节课的重点。
- Controller端请求Controller发送三类请求给Broker分别是LeaderAndIsrRequest、StopReplicaRequest和UpdateMetadataRequest。
- RequestSendThread该线程负责将请求发送给集群中的相关或所有Broker。
- 请求阻塞队列+RequestSendThreadController会为集群上所有Broker创建对应的请求阻塞队列和RequestSendThread线程。
其实今天讲的所有东西都只是这节课的第二张图中“消费者”的部分我们并没有详细了解请求是怎么被放到请求队列中的。接下来我们就会针对这个问题深入地去探讨Controller单线程的事件处理器是如何实现的。
<img src="https://static001.geekbang.org/resource/image/00/b9/00fce28d26a94389f2bb5e957b650bb9.jpg" alt="">
## 课后讨论
你觉得为每个Broker都创建一个RequestSendThread的方案有什么优缺点
欢迎你在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,350 @@
<audio id="audio" title="13 | ControllerEventManager变身单线程后的Controller如何处理事件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/15/3578ea76868ab16b2eab2a4512646b15.mp3"></audio>
你好,我是胡夕。 今天我们来学习下Controller的单线程事件处理器源码。
所谓的单线程事件处理器就是Controller端定义的一个组件。该组件内置了一个专属线程负责处理其他线程发送过来的Controller事件。另外它还定义了一些管理方法用于为专属线程输送待处理事件。
在0.11.0.0版本之前Controller组件的源码非常复杂。集群元数据信息在程序中同时被多个线程访问因此源码里有大量的Monitor锁、Lock锁或其他线程安全机制这就导致这部分代码读起来晦涩难懂改动起来也困难重重因为你根本不知道变动了这个线程访问的数据会不会影响到其他线程。同时开发人员在修复Controller Bug时也非常吃力。
鉴于这个原因自0.11.0.0版本开始社区陆续对Controller代码结构进行了改造。其中非常重要的一环就是将**多线程并发访问的方式改为了单线程的事件队列方式**。
这里的单线程并非是指Controller只有一个线程了而是指**对局部状态的访问限制在一个专属线程上**即让这个特定线程排他性地操作Controller元数据信息。
这样一来整个组件代码就不必担心多线程访问引发的各种线程安全问题了源码也可以抛弃各种不必要的锁机制最终大大简化了Controller端的代码结构。
这部分源码非常重要,**它能够帮助你掌握Controller端处理各类事件的原理这将极大地提升你在实际场景中处理Controller各类问题的能力**。因此我建议你多读几遍彻底了解Controller是怎么处理各种事件的。
## 基本术语和概念
接下来我们先宏观领略一下Controller单线程事件队列处理模型及其基础组件。
<img src="https://static001.geekbang.org/resource/image/67/31/67fbf8a12ebb57bc309188dcbc18e231.jpg" alt="">
从图中可见Controller端有多个线程向事件队列写入不同种类的事件比如ZooKeeper端注册的Watcher线程、KafkaRequestHandler线程、Kafka定时任务线程等等。而在事件队列的另一端只有一个名为ControllerEventThread的线程专门负责“消费”或处理队列中的事件。这就是所谓的单线程事件队列模型。
参与实现这个模型的源码类有4个。
- ControllerEventProcessorController端的事件处理器接口。
- ControllerEventController事件也就是事件队列中被处理的对象。
- ControllerEventManager事件处理器用于创建和管理ControllerEventThread。
- ControllerEventThread专属的事件处理线程唯一的作用是处理不同种类的ControllEvent。这个类是ControllerEventManager类内部定义的线程类。
今天我们的重要目标就是要搞懂这4个类。就像我前面说的它们完整地构建出了单线程事件队列模型。下面我们将一个一个地学习它们的源码你要重点掌握事件队列的实现以及专属线程是如何访问事件队列的。
## ControllerEventProcessor
这个接口位于controller包下的ControllerEventManager.scala文件中。它定义了一个支持普通处理和抢占处理Controller事件的接口代码如下所示
```
trait ControllerEventProcessor {
def process(event: ControllerEvent): Unit
def preempt(event: ControllerEvent): Unit
}
```
该接口定义了两个方法分别是process和preempt。
- process接收一个Controller事件并进行处理。
- preempt接收一个Controller事件并抢占队列之前的事件进行优先处理。
目前在Kafka源码中KafkaController类是Controller组件的功能实现类它也是ControllerEventProcessor接口的唯一实现类。
对于这个接口你要重点掌握process方法的作用因为**它是实现Controller事件处理的主力方法**。你要了解process方法**处理各类Controller事件的代码结构是什么样的**,而且还要能够准确地找到处理每类事件的子方法。
至于preempt方法你仅需要了解Kafka使用它实现某些高优先级事件的抢占处理即可毕竟目前在源码中只有两类事件ShutdownEventThread和Expire需要抢占式处理出镜率不是很高。
## ControllerEvent
这就是前面说到的Controller事件在源码中对应的就是ControllerEvent接口。该接口定义在KafkaController.scala文件中本质上是一个trait类型如下所示
```
sealed trait ControllerEvent {
def state: ControllerState
}
```
**每个ControllerEvent都定义了一个状态**。Controller在处理具体的事件时会对状态进行相应的变更。这个状态是由源码文件ControllerState.scala中的抽象类ControllerState定义的代码如下
```
sealed abstract class ControllerState {
def value: Byte
def rateAndTimeMetricName: Option[String] =
if (hasRateAndTimeMetric) Some(s&quot;${toString}RateAndTimeMs&quot;) else None
protected def hasRateAndTimeMetric: Boolean = true
}
```
每类ControllerState都定义一个value值表示Controller状态的序号从0开始。另外rateAndTimeMetricName方法是用于构造Controller状态速率的监控指标名称的。
比如TopicChange是一类ControllerState用于表示主题总数发生了变化。为了监控这类状态变更速率代码中的rateAndTimeMetricName方法会定义一个名为TopicChangeRateAndTimeMs的指标。当然并非所有的ControllerState都有对应的速率监控指标比如表示空闲状态的Idle就没有对应的指标。
目前Controller总共定义了25类事件和17种状态它们的对应关系如下表所示
<img src="https://static001.geekbang.org/resource/image/a4/63/a4bd821a8fac58bdf9c813379bc28e63.jpg" alt="">
内容看着好像有很多,那我们应该怎样使用这张表格呢?
实际上你并不需要记住每一行的对应关系。这张表格更像是一个工具当你监控到某些Controller状态变更速率异常的时候你可以通过这张表格快速确定可能造成瓶颈的Controller事件并定位处理该事件的函数代码辅助你进一步地调试问题。
另外,你要了解的是,**多个ControllerEvent可能归属于相同的ControllerState。**
比如TopicChange和PartitionModifications事件都属于TopicChange状态毕竟它们都与Topic的变更有关。前者是创建Topic后者是修改Topic的属性比如分区数或副本因子等等。
再比如BrokerChange和BrokerModifications事件都属于 BrokerChange状态表征的都是对Broker属性的修改。
## ControllerEventManager
有了这些铺垫,我们就可以开始学习事件处理器的实现代码了。
在Kafka中Controller事件处理器代码位于controller包下的ControllerEventManager.scala文件下。我用一张图来展示下这个文件的结构
<img src="https://static001.geekbang.org/resource/image/11/4b/1137cfd21025c797369fa3d39cee5d4b.jpg" alt="">
如图所示该文件主要由4个部分组成。
- **ControllerEventManager Object**:保存一些字符串常量,比如线程名字。
- **ControllerEventProcessor**前面讲过的事件处理器接口目前只有KafkaController实现了这个接口。
- **QueuedEvent**:表征事件队列上的事件对象。
- **ControllerEventManager Class**ControllerEventManager的伴生类主要用于创建和管理事件处理线程和事件队列。就像我前面说的这个类中定义了重要的ControllerEventThread线程类还有一些其他值得我们学习的重要方法一会儿我们详细说说。
ControllerEventManager对象仅仅定义了3个公共变量没有任何逻辑你简单看下就行。至于ControllerEventProcessor接口我们刚刚已经学习过了。接下来我们重点学习后面这两个类。
### QueuedEvent
我们先来看QueuedEvent的定义全部代码如下
```
// 每个QueuedEvent定义了两个字段
// event: ControllerEvent类表示Controller事件
// enqueueTimeMs表示Controller事件被放入到事件队列的时间戳
class QueuedEvent(val event: ControllerEvent,
val enqueueTimeMs: Long) {
// 标识事件是否开始被处理
val processingStarted = new CountDownLatch(1)
// 标识事件是否被处理过
val spent = new AtomicBoolean(false)
// 处理事件
def process(processor: ControllerEventProcessor): Unit = {
if (spent.getAndSet(true))
return
processingStarted.countDown()
processor.process(event)
}
// 抢占式处理事件
def preempt(processor: ControllerEventProcessor): Unit = {
if (spent.getAndSet(true))
return
processor.preempt(event)
}
// 阻塞等待事件被处理完成
def awaitProcessing(): Unit = {
processingStarted.await()
}
override def toString: String = {
s&quot;QueuedEvent(event=$event, enqueueTimeMs=$enqueueTimeMs)&quot;
}
}
```
可以看到每个QueuedEvent对象实例都裹挟了一个ControllerEvent。另外每个QueuedEvent还定义了process、preempt和awaitProcessing方法分别表示**处理事件**、**以抢占方式处理事件**,以及**等待事件处理**。
其中process方法和preempt方法的实现原理就是调用给定ControllerEventProcessor接口的process和preempt方法非常简单。
在QueuedEvent对象中我们再一次看到了CountDownLatch的身影我在[第7节课](https://time.geekbang.org/column/article/231139)里提到过它。Kafka源码非常喜欢用CountDownLatch来做各种条件控制比如用于侦测线程是否成功启动、成功关闭等等。
在这里QueuedEvent使用它的唯一目的是确保Expire事件在建立ZooKeeper会话前被处理。
如果不是在这个场景下那么代码就用spent来标识该事件是否已经被处理过了如果已经被处理过了再次调用process方法时就会直接返回什么都不做。
### ControllerEventThread
了解了QueuedEvent我们来看下消费它们的ControllerEventThread类。
首先是这个类的定义代码:
```
class ControllerEventThread(name: String) extends ShutdownableThread(name = name, isInterruptible = false) {
logIdent = s&quot;[ControllerEventThread controllerId=$controllerId] &quot;
......
}
```
这个类就是一个普通的线程类继承了ShutdownableThread基类而后者是Kafka为很多线程类定义的公共父类。该父类是Java Thread类的子类其线程逻辑方法run的主要代码如下
```
def doWork(): Unit
override def run(): Unit = {
......
try {
while (isRunning)
doWork()
} catch {
......
}
......
}
```
可见这个父类会循环地执行doWork方法的逻辑而该方法的具体实现则交由子类来完成。
作为Controller唯一的事件处理线程我们要时刻关注这个线程的运行状态。因此我们必须要知道这个线程在JVM上的名字这样后续我们就能有针对性地对其展开监控。这个线程的名字是由ControllerEventManager Object中ControllerEventThreadName变量定义的如下所示
```
object ControllerEventManager {
val ControllerEventThreadName = &quot;controller-event-thread&quot;
......
}
```
现在我们看看ControllerEventThread类的doWork是如何实现的。代码如下
```
override def doWork(): Unit = {
// 从事件队列中获取待处理的Controller事件否则等待
val dequeued = queue.take()
dequeued.event match {
// 如果是关闭线程事件,什么都不用做。关闭线程由外部来执行
case ShutdownEventThread =&gt;
case controllerEvent =&gt;
_state = controllerEvent.state
// 更新对应事件在队列中保存的时间
eventQueueTimeHist.update(time.milliseconds() - dequeued.enqueueTimeMs)
try {
def process(): Unit = dequeued.process(processor)
// 处理事件,同时计算处理速率
rateAndTimeMetrics.get(state) match {
case Some(timer) =&gt; timer.time { process() }
case None =&gt; process()
}
} catch {
case e: Throwable =&gt; error(s&quot;Uncaught error processing event $controllerEvent&quot;, e)
}
_state = ControllerState.Idle
}
}
```
我用一张图来展示下具体的执行流程:
<img src="https://static001.geekbang.org/resource/image/db/d1/db4905db1a32ac7d356317f29d920dd1.jpg" alt="">
大体上看,执行逻辑很简单。
首先是调用LinkedBlockingQueue的take方法去获取待处理的QueuedEvent对象实例。注意这里用的是**take方法**这说明如果事件队列中没有QueuedEvent那么ControllerEventThread线程将一直处于阻塞状态直到事件队列上插入了新的待处理事件。
一旦拿到QueuedEvent事件后线程会判断是否是ShutdownEventThread事件。当ControllerEventManager关闭时会显式地向事件队列中塞入ShutdownEventThread表明要关闭ControllerEventThread线程。如果是该事件那么ControllerEventThread什么都不用做毕竟要关闭这个线程了。相反地如果是其他的事件就调用QueuedEvent的process方法执行对应的处理逻辑同时计算事件被处理的速率。
该process方法底层调用的是ControllerEventProcessor的process方法如下所示
```
def process(processor: ControllerEventProcessor): Unit = {
// 若已经被处理过,直接返回
if (spent.getAndSet(true))
return
processingStarted.countDown()
// 调用ControllerEventProcessor的process方法处理事件
processor.process(event)
}
```
方法首先会判断该事件是否已经被处理过如果是就直接返回如果不是就调用ControllerEventProcessor的process方法处理事件。
你可能很关心每个ControllerEventProcessor的process方法是在哪里实现的实际上它们都封装在KafkaController.scala文件中。还记得我之前说过KafkaController类是目前源码中ControllerEventProcessor接口的唯一实现类吗
实际上就是KafkaController类实现了ControllerEventProcessor的process方法。由于代码过长而且有很多重复结构的代码因此我只展示部分代码
```
override def process(event: ControllerEvent): Unit = {
try {
// 依次匹配ControllerEvent事件
event match {
case event: MockEvent =&gt;
event.process()
case ShutdownEventThread =&gt;
error(&quot;Received a ShutdownEventThread event. This type of event is supposed to be handle by ControllerEventThread&quot;)
case AutoPreferredReplicaLeaderElection =&gt;
processAutoPreferredReplicaLeaderElection()
......
}
} catch {
// 如果Controller换成了别的Broker
case e: ControllerMovedException =&gt;
info(s&quot;Controller moved to another broker when processing $event.&quot;, e)
// 执行Controller卸任逻辑
maybeResign()
case e: Throwable =&gt;
error(s&quot;Error processing event $event&quot;, e)
} finally {
updateMetrics()
}
}
```
这个process方法接收一个ControllerEvent实例接着会判断它是哪类Controller事件并调用相应的处理方法。比如如果是AutoPreferredReplicaLeaderElection事件则调用processAutoPreferredReplicaLeaderElection方法如果是其他类型的事件则调用process***方法。
### 其他方法
除了QueuedEvent和ControllerEventThread之外**put方法**和**clearAndPut方法也很重要**。如果说ControllerEventThread是读取队列事件的那么这两个方法就是向队列生产元素的。
在这两个方法中put是把指定ControllerEvent插入到事件队列而clearAndPut则是先执行具有高优先级的抢占式事件之后清空队列所有事件最后再插入指定的事件。
下面这两段源码分别对应于这两个方法:
```
// put方法
def put(event: ControllerEvent): QueuedEvent = inLock(putLock) {
// 构建QueuedEvent实例
val queuedEvent = new QueuedEvent(event, time.milliseconds())
// 插入到事件队列
queue.put(queuedEvent)
// 返回新建QueuedEvent实例
queuedEvent
}
// clearAndPut方法
def clearAndPut(event: ControllerEvent): QueuedEvent = inLock(putLock) {
// 优先处理抢占式事件
queue.forEach(_.preempt(processor))
// 清空事件队列
queue.clear()
// 调用上面的put方法将给定事件插入到事件队列
put(event)
}
```
整体上代码很简单,需要解释的地方不多,但我想和你讨论一个问题。
你注意到源码中的put方法使用putLock对代码进行保护了吗
就我个人而言我觉得这个putLock是不需要的因为LinkedBlockingQueue数据结构本身就已经是线程安全的了。put方法只会与全局共享变量queue打交道因此它们的线程安全性完全可以委托LinkedBlockingQueue实现。更何况LinkedBlockingQueue内部已经维护了一个putLock和一个takeLock专门保护读写操作。
当然我同意在clearAndPut中使用锁的做法毕竟我们要保证访问抢占式事件和清空操作构成一个原子操作。
## 总结
今天我们重点学习了Controller端的单线程事件队列实现方式即ControllerEventManager通过构建ControllerEvent、ControllerState和对应的ControllerEventThread线程并且结合专属事件队列共同实现事件处理。我们来回顾下这节课的重点。
- ControllerEvent定义Controller能够处理的各类事件名称目前总共定义了25类事件。
- ControllerState定义Controller状态。你可以认为它是ControllerEvent的上一级分类因此ControllerEvent和ControllerState是多对一的关系。
- ControllerEventManagerController定义的事件管理器专门定义和维护专属线程以及对应的事件队列。
- ControllerEventThread事件管理器创建的事件处理线程。该线程排他性地读取事件队列并处理队列中的所有事件。
<img src="https://static001.geekbang.org/resource/image/4e/26/4ec79e1ff2b83d0a1e850b6acf30b226.jpg" alt="">
下节课我们将正式进入到KafkaController的学习。这是一个有着2100多行的大文件不过大部分的代码都是实现那27类ControllerEvent的处理逻辑因此你不要被它吓到了。我们会先学习Controller是如何选举出来的后面会再详谈Controller的具体作用。
## 课后讨论
你认为ControllerEventManager中put方法代码是否有必要被一个Lock保护起来
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,550 @@
<audio id="audio" title="14 | Controller选举是怎么实现的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/18/aab8712a07a0638527505d3e3574df18.mp3"></audio>
你好,我是胡夕。
上节课我们学习了单线程事件队列模型处理Controller事件的代码。Controller组件通过ControllerEventManager类构造了一个阻塞队列同时配以专属的事件处理线程实现了对各类ControllerEvent的处理。
这种设计思路既保证了多线程访问所需的线程安全还简化了Controller端的代码结构极大地提升了代码的可维护性。
今天我们学习下Controller选举部分的源码。
还记得我在[第11节课](https://time.geekbang.org/column/article/235562)的案例中提到的“恢复大法”——删除ZooKeeper的/controller节点吗当时我们靠着这个“秘籍”涉险过关既恢复了错误的集群状态又避免了重启整个生产环境。
但你有没有想过,为什么删除/controller节点能够令集群元数据重新保持同步呢如果不了解这背后的原理我们是不敢贸然在生产环境做这种操作的。今天我们要学习的就是这背后的一整套实现逻辑重点关注下Controller是怎么被选举出来的。
我始终认为只有掌握了这些知识才算真正入门Kafka服务器端的代码了。作为Broker端最重要的组件之一Controller在Kafka中的地位无可替代。整个Kafka集群就只有一个Controller从某种意义上来说它是目前Kafka这个分布式系统中唯一的“单点”。
因此了解这个“单点”的选举触发场景以及如何被选举出来的对于我们后面深入理解Controller在集群中的作用非常有帮助。毕竟Controller对外提供的一些服务也是采用了类似的实现原理。
## 概览
### ZooKeeper /controller节点
再次强调下,**在一个Kafka集群中某段时间内只能有一台Broker被选举为Controller。随着时间的推移可能会有不同的Broker陆续担任过Controller的角色但是在某一时刻Controller只能由一个Broker担任**。
那选择哪个Broker充当Controller呢当前Controller的选举过程依赖ZooKeeper完成。ZooKeeper除了扮演集群元数据的“真理之源”角色还定义了/controller临时节点Ephemeral Node以协助完成Controller的选举。
下面这段代码展示的是一个双Broker的Kafka集群上的ZooKeeper中/controller节点
```
{&quot;version&quot;:1,&quot;brokerid&quot;:0,&quot;timestamp&quot;:&quot;1585098432431&quot;}
cZxid = 0x1a
ctime = Wed Mar 25 09:07:12 CST 2020
mZxid = 0x1a
mtime = Wed Mar 25 09:07:12 CST 2020
pZxid = 0x1a
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x100002d3a1f0000
dataLength = 54
numChildren = 0
```
有两个地方的内容,你要重点关注一下。
- Controller Broker Id是0表示序号为0的Broker是集群Controller。
- ephemeralOwner字段不是0x0说明这是一个临时节点。
既然是临时节点那么一旦Broker与ZooKeeper的会话终止该节点就会消失。Controller选举就依靠了这个特性。每个Broker都会监听/controller节点随时准备应聘Controller角色。下图展示了Broker与/controller节点的交互关系
<img src="https://static001.geekbang.org/resource/image/2e/83/2e75cdbfb68c86169ec83f58e59e1283.jpg" alt="">
如图所示集群上所有的Broker都在实时监听ZooKeeper上的这个节点。这里的“监听”有两个含义。
- **监听这个节点是否存在**。倘若发现这个节点不存在Broker会立即“抢注”该节点即创建/controller节点。创建成功的那个Broker即当选为新一届的Controller。
- **监听这个节点数据是否发生了变更**。同样一旦发现该节点的内容发生了变化Broker也会立即启动新一轮的Controller选举。
掌握了这些基础之后下面我们来阅读具体的源码文件KafkaController.scala。这是一个2200行的大文件。我先向你介绍一下这个文件的大致结构以免你陷入到一些繁枝末节中。
### 源码结构
KafkaController文件的代码结构如下图所示
<img src="https://static001.geekbang.org/resource/image/7e/88/7e5ddb69df585b5bbbcc91336ab8f588.jpg" alt="">
整体而言,该文件大致由五部分组成。
- **选举触发器**ElectionTrigger这里的选举不是指Controller选举而是指主题分区副本的选举即为哪些分区选择Leader副本。后面在学习副本管理器和分区管理器时我们会讲到它。
- **KafkaController Object**KafkaController伴生对象仅仅定义了一些常量和回调函数类型。
- **ControllerEvent**定义Controller事件类型。上节课我们详细学习过Controller事件以及基于事件的单线程事件队列模型。这部分的代码看着很多但实际上都是千篇一律的。你看懂了一个事件的定义其他的也就不在话下了。
- **各种ZooKeeper监听器**定义ZooKeeper监听器去监听ZooKeeper中各个节点的变更。今天我们重点关注监听/controller节点的那个监听器。
- **KafkaController Class**定义KafkaController类以及实际的处理逻辑。这是我们今天的重点学习对象。
接下来我会给你重点介绍KafkaController类、ZooKeeper监听器和Controller选举这三大部分。在众多的ZooKeeper监听器中我会详细介绍监听Controller变更的监听器它也是我们了解Controller选举流程的核心环节。
## KafkaController类
这个类大约有1900行代码里面定义了非常多的变量和方法。这些方法大多是处理不同Controller事件的。后面讲到选举流程的时候我会挑一些有代表性的来介绍。我希望你能举一反三借此吃透其他方法的代码。毕竟它们做的事情大同小异至少代码风格非常相似。
在学习重要的方法之前我们必须要先掌握KafkaController类的定义。接下来我们从4个维度来进行学习分别是原生字段、辅助字段、各类ZooKeeper监听器字段和统计字段。
弄明白了这些字段的含义之后,再去看操作这些字段的方法,会更加有的放矢,理解起来也会更加容易。
### 原生字段
首先来看原生字段。所谓的原生字段是指在创建一个KafkaController实例时需要指定的字段。
先来看下KafkaController类的定义代码
```
// 字段含义:
// configKafka配置信息通过它你能拿到Broker端所有参数的值
// zkClientZooKeeper客户端Controller与ZooKeeper的所有交互均通过该属性完成
// time提供时间服务(如获取当前时间)的工具类
// metrics实现指标监控服务(如创建监控指标)的工具类
// initialBrokerInfoBroker节点信息包括主机名、端口号所用监听器等
// initialBrokerEpochBroker Epoch值用于隔离老Controller发送的请求
// tokenManager实现Delegation token管理的工具类。Delegation token是一种轻量级的认证机制
// threadNamePrefixController端事件处理线程名字前缀
class KafkaController(val config: KafkaConfig,
zkClient: KafkaZkClient,
time: Time,
metrics: Metrics,
initialBrokerInfo: BrokerInfo,
initialBrokerEpoch: Long,
tokenManager: DelegationTokenManager,
threadNamePrefix: Option[String] = None)
extends ControllerEventProcessor with Logging with KafkaMetricsGroup {
......
}
```
就像我上节课说过的KafkaController实现了ControllerEventProcessor接口因而也就实现了处理Controller事件的process方法。这里面比较重要的字段有3个。
- **config**KafkaConfig类实例里面封装了Broker端所有参数的值。
- **zkClient**ZooKeeper客户端类定义了与ZooKeeper交互的所有方法。
- **initialBrokerEpoch**Controller所在Broker的Epoch值。Kafka使用它来确保Broker不会处理老Controller发来的请求。
其他字段要么是像time、metrics一样是工具类字段要么是像initialBrokerInfo、tokenManager字段一样使用场景很有限我就不展开讲了。
### 辅助字段
除了原生字段之外KafkaController还定义了很多辅助字段帮助实现Controller的各类功能。
我们来看一些重要的辅助字段:
```
......
// 集群元数据类,保存集群所有元数据
val controllerContext = new ControllerContext
// Controller端通道管理器类负责Controller向Broker发送请求
var controllerChannelManager = new ControllerChannelManager(controllerContext, config, time, metrics,
stateChangeLogger, threadNamePrefix)
// 线程调度器当前唯一负责定期执行Leader重选举
private[controller] val kafkaScheduler = new KafkaScheduler(1)
// Controller事件管理器负责管理事件处理线程
private[controller] val eventManager = new ControllerEventManager(config.brokerId, this, time,
controllerContext.stats.rateAndTimeMetrics)
......
// 副本状态机,负责副本状态转换
val replicaStateMachine: ReplicaStateMachine = new ZkReplicaStateMachine(config, stateChangeLogger, controllerContext, zkClient,
new ControllerBrokerRequestBatch(config, controllerChannelManager, eventManager, controllerContext, stateChangeLogger))
// 分区状态机,负责分区状态转换
val partitionStateMachine: PartitionStateMachine = new ZkPartitionStateMachine(config, stateChangeLogger, controllerContext, zkClient,
new ControllerBrokerRequestBatch(config, controllerChannelManager, eventManager, controllerContext, stateChangeLogger))
// 主题删除管理器,负责删除主题及日志
val topicDeletionManager = new TopicDeletionManager(config, controllerContext, replicaStateMachine,
partitionStateMachine, new ControllerDeletionClient(this, zkClient))
......
```
其中有7个字段是重中之重。
- **controllerContext**:集群元数据类,保存集群所有元数据。
- **controllerChannelManager**Controller端通道管理器类负责Controller向Broker发送请求。
- **kafkaScheduler**线程调度器当前唯一负责定期执行分区重平衡Leader选举。
- **eventManager**Controller事件管理器负责管理事件处理线程。
- **replicaStateMachine**:副本状态机,负责副本状态转换。
- **partitionStateMachine**:分区状态机,负责分区状态转换。
- **topicDeletionManager**:主题删除管理器,负责删除主题及日志。
### 各类ZooKeeper监听器
我们今天开头学到的ControllerChangeHandler仅仅是其中的一种。实际上该类定义了很多监听器如下所示
```
// Controller节点ZooKeeper监听器
private val controllerChangeHandler = new ControllerChangeHandler(eventManager)
// Broker数量ZooKeeper监听器
private val brokerChangeHandler = new BrokerChangeHandler(eventManager)
// Broker信息变更ZooKeeper监听器集合
private val brokerModificationsHandlers: mutable.Map[Int, BrokerModificationsHandler] = mutable.Map.empty
// 主题数量ZooKeeper监听器
private val topicChangeHandler = new TopicChangeHandler(eventManager)
// 主题删除ZooKeeper监听器
private val topicDeletionHandler = new TopicDeletionHandler(eventManager)
// 主题分区变更ZooKeeper监听器
private val partitionModificationsHandlers: mutable.Map[String, PartitionModificationsHandler] = mutable.Map.empty
// 主题分区重分配ZooKeeper监听器
private val partitionReassignmentHandler = new PartitionReassignmentHandler(eventManager)
// Preferred Leader选举ZooKeeper监听器
private val preferredReplicaElectionHandler = new PreferredReplicaElectionHandler(eventManager)
// ISR副本集合变更ZooKeeper监听器
private val isrChangeNotificationHandler = new IsrChangeNotificationHandler(eventManager)
// 日志路径变更ZooKeeper监听器
private val logDirEventNotificationHandler = new LogDirEventNotificationHandler(eventManager)
```
我分别解释一下这些ZooKeeper监听器的作用
- **controllerChangeHandler**:前面说过,它是监听/controller节点变更的。这种变更包括节点创建、删除以及数据变更。
- **brokerChangeHandler**监听Broker的数量变化。
- **brokerModificationsHandlers**监听Broker的数据变更比如Broker的配置信息发生的变化。
- **topicChangeHandler**:监控主题数量变更。
- **topicDeletionHandler**:监听主题删除节点/admin/delete_topics的子节点数量变更。
- **partitionModificationsHandlers**监控主题分区数据变更的监听器比如新增加了副本、分区更换了Leader副本。
- **partitionReassignmentHandler**:监听分区副本重分配任务。一旦发现新提交的任务,就为目标分区执行副本重分配。
- **preferredReplicaElectionHandler**监听Preferred Leader选举任务。一旦发现新提交的任务就为目标主题执行Preferred Leader选举。
- **isrChangeNotificationHandler**监听ISR副本集合变更。一旦被触发就需要获取ISR发生变更的分区列表然后更新Controller端对应的Leader和ISR缓存元数据。
- **logDirEventNotificationHandler**监听日志路径变更。一旦被触发需要获取受影响的Broker列表然后处理这些Broker上失效的日志路径。
我画了一张脑图希望可以帮助你更高效地记住这些ZooKeeper监听器
<img src="https://static001.geekbang.org/resource/image/8f/31/8feed623165ab6e50b31614e67498c31.jpg" alt="">
### 统计字段
最后,我们来看统计字段。
这些统计字段大多用于计算统计指标。有的监控指标甚至是非常重要的Controller监控项比如ActiveControllerCount指标。下面我们来了解下KafkaController都定义了哪些统计字段。这些指标的含义一目了然非常清晰我用注释的方式给出每个字段的含义
```
// 当前Controller所在Broker Id
@volatile private var activeControllerId = -1
// 离线分区总数
@volatile private var offlinePartitionCount = 0
// 满足Preferred Leader选举条件的总分区数
@volatile private var preferredReplicaImbalanceCount = 0
// 总主题数
@volatile private var globalTopicCount = 0
// 总主题分区数
@volatile private var globalPartitionCount = 0
// 待删除主题数
@volatile private var topicsToDeleteCount = 0
//待删除副本数
@volatile private var replicasToDeleteCount = 0
// 暂时无法删除的主题数
@volatile private var ineligibleTopicsToDeleteCount = 0
// 暂时无法删除的副本数
@volatile private var ineligibleReplicasToDeleteCount = 0
```
好了KafkaController类的定义我们就全部介绍完了。再次强调下因为KafkaController类的代码很多我强烈建议你熟练掌握这些字段的含义因为后面的所有方法都是围绕着这些字段进行操作的。
接下来我以Controller的选举流程为例引出KafkaController的一些方法的实现原理。不过在此之前我们要学习监听Controller变更的ZooKeeper监听器ControllerChangeHandler的源码。
## ControllerChangeHandler监听器
就像我前面说到的KafkaController定义了十几种ZooKeeper监听器。和Controller相关的监听器是ControllerChangeHandler用于监听Controller的变更定义代码如下
```
class ControllerChangeHandler(eventManager: ControllerEventManager) extends ZNodeChangeHandler {
// ZooKeeper中Controller节点路径即/controller
override val path: String = ControllerZNode.path
// 监听/controller节点创建事件
override def handleCreation(): Unit = eventManager.put(ControllerChange)
// 监听/controller节点被删除事件
override def handleDeletion(): Unit = eventManager.put(Reelect)
// 监听/controller节点数据变更事件
override def handleDataChange(): Unit = eventManager.put(ControllerChange)
}
```
该监听器接收ControllerEventManager实例实现了ZNodeChangeHandler接口的三个方法**handleCreation**、**handleDeletion**和**handleDataChange**。该监听器下的path变量实际上就是/controller字符串表示它监听ZooKeeper的这个节点。
3个handle方法都用于监听/controller节点的变更但实现细节上稍有不同。
handleCreation和handleDataChange的处理方式是向事件队列写入ControllerChange事件handleDeletion的处理方式是向事件队列写入Reelect事件。
Deletion表明ZooKeeper中/controller节点不存在了即Kafka集群中的Controller暂时空缺了。因为它和Creation和DataChange是不同的状态需要区别对待因此Reelect事件做的事情要比ControllerChange的多处理ControllerChange事件只需要当前Broker执行“卸任Controller”的逻辑即可而Reelect事件是重选举除了Broker执行卸任逻辑之外还要求Broker参与到重选举中来。
由于KafkaController的process方法代码非常长因此我节选了刚刚提到的那两个事件的处理代码
```
// process方法(部分)
override def process(event: ControllerEvent): Unit = {
try {
event match {
......
// ControllerChange事件
case ControllerChange =&gt;
processControllerChange()
// Reelect事件
case Reelect =&gt;
processReelect()
......
}
}
......
}
// 如果是ControllerChange事件仅执行卸任逻辑即可
private def processControllerChange(): Unit = {
maybeResign()
}
// 如果是Reelect事件还需要执行elect方法参与新一轮的选举
private def processReelect(): Unit = {
maybeResign()
elect()
}
```
可以看到虽然代码非常长但整体结构却工整清晰全部都是基于模式匹配的事件处理。process方法会根据给定的Controller事件类型调用对应的process***方法处理该事件。这里只列举了ZooKeeper端/controller节点监听器监听的两类事件以及对应的处理方法。
对于ControllerChange事件而言处理方式是调用maybeResign去执行Controller的卸任逻辑。如果是Reelect事件除了执行卸任逻辑之外还要额外执行elect方法进行新一轮的Controller选举。
## Controller选举流程
说完了ControllerChangeHandler源码我们来看下Controller的选举。所谓的Controller选举是指Kafka选择集群中一台Broker行使Controller职责。整个选举过程分为两个步骤触发选举和开始选举。
### 触发选举
我先用一张图展示下可能触发Controller选举的三个场景。
<img src="https://static001.geekbang.org/resource/image/a8/98/a8cbc562518f93f9befc6bd7a87d5b98.jpg" alt="">
这三个场景是:
1. 集群从零启动时;
1. Broker侦测/controller节点消失时
1. Broker侦测到/controller节点数据发生变更时。
这三个场景殊途同归最后都要执行选举Controller的动作。我来一一解释下这三个场景然后再介绍选举Controller的具体操作。
#### 场景一:集群从零启动
集群首次启动时Controller尚未被选举出来。于是Broker启动后首先将Startup这个ControllerEvent写入到事件队列中然后启动对应的事件处理线程和ControllerChangeHandler ZooKeeper监听器最后依赖事件处理线程进行Controller的选举。
在源码中KafkaController类的startup方法就是做这些事情的。当Broker启动时它会调用这个方法启动ControllerEventThread线程。值得注意的是**每个Broker都需要做这些事情不是说只有Controller所在的Broker才需要执行这些逻辑**。
startup方法的主体代码如下
```
def startup() = {
// 第1步注册ZooKeeper状态变更监听器它是用于监听Zookeeper会话过期的
zkClient.registerStateChangeHandler(new StateChangeHandler {
override val name: String = StateChangeHandlers.ControllerHandler
override def afterInitializingSession(): Unit = {
eventManager.put(RegisterBrokerAndReelect)
}
override def beforeInitializingSession(): Unit = {
val queuedEvent = eventManager.clearAndPut(Expire)
queuedEvent.awaitProcessing()
}
})
// 第2步写入Startup事件到事件队列
eventManager.put(Startup)
// 第3步启动ControllerEventThread线程开始处理事件队列中的ControllerEvent
eventManager.start()
}
```
首先startup方法会注册ZooKeeper状态变更监听器用于监听Broker与ZooKeeper之间的会话是否过期。接着写入Startup事件到事件队列然后启动ControllerEventThread线程开始处理事件队列中的Startup事件。
接下来我们来学习下KafkaController的process方法处理Startup事件的方法
```
// KafkaController的process方法
override def process(event: ControllerEvent): Unit = {
try {
event match {
......
case Startup =&gt;
processStartup() // 处理Startup事件
}
}
......
}
private def processStartup(): Unit = {
// 注册ControllerChangeHandler ZooKeeper监听器
zkClient.registerZNodeChangeHandlerAndCheckExistence(
controllerChangeHandler)
// 执行Controller选举
elect()
}
```
从这段代码可知process方法调用processStartup方法去处理Startup事件。而processStartup方法又会调用zkClient的registerZNodeChangeHandlerAndCheckExistence方法注册ControllerChangeHandler监听器。
值得注意的是,虽然前面的三个场景是并列的关系,但实际上,后面的两个场景必须要等场景一的这一步成功执行之后,才能被触发。
这三种场景都要选举Controller因此我们最后统一学习elect方法的代码实现。
总体来说集群启动时Broker通过向事件队列“塞入”Startup事件的方式来触发Controller的竞选。
#### 场景二:/controller节点消失
Broker检测到/controller节点消失时就意味着此时整个集群中没有Controller。因此所有检测到/controller节点消失的Broker都会立即调用elect方法执行竞选逻辑。
你可能会问“Broker是怎么侦测到ZooKeeper上的这一变化的呢”实际上这是ZooKeeper监听器提供的功能换句话说这是Apache ZooKeeper自己实现的功能所以我们才说Kafka依赖ZooKeeper完成Controller的选举。
讲到这里我说点题外话社区最近正在酝酿彻底移除ZooKeeper依赖。具体到Controller端的变化就是在Kafka内部实现一个类似于Raft的共识算法来选举Controller。我会在后面的特别放送里详细讲一下社区移除ZooKeeper的全盘计划。
#### 场景三:/controller节点数据变更
Broker检测到/controller节点数据发生变化通常表明Controller“易主”了这就分为两种情况
- 如果Broker之前是Controller那么该Broker需要首先执行卸任操作然后再尝试竞选
- 如果Broker之前不是Controller那么该Broker直接去竞选新Controller。
具体到代码层面maybeResign方法形象地说明了这两种情况。你要注意方法中的maybe字样这表明Broker可能需要执行卸任操作也可能不需要。Kafka源码非常喜欢用maybe***来命名方法名以表示那些在特定条件下才需要执行的逻辑。以下是maybeResign的实现
```
private def maybeResign(): Unit = {
// 非常关键的一步!这是判断是否需要执行卸任逻辑的重要依据!
// 判断该Broker之前是否是Controller
val wasActiveBeforeChange = isActive
// 注册ControllerChangeHandler监听器
zkClient.registerZNodeChangeHandlerAndCheckExistence(
controllerChangeHandler)
// 获取当前集群Controller所在的Broker Id如果没有Controller则返回-1
activeControllerId = zkClient.getControllerId.getOrElse(-1)
// 如果该Broker之前是Controller但现在不是了
if (wasActiveBeforeChange &amp;&amp; !isActive) {
onControllerResignation() // 执行卸任逻辑
}
}
```
代码的第一行非常关键它是决定是否需要执行卸任的重要依据。毕竟如果Broker之前不是Controller那何来“卸任”一说呢之后代码要注册ControllerChangeHandler监听器获取当前集群Controller所在的Broker ID如果没有Controller则返回-1。有了这些数据之后maybeResign方法需要判断该Broker是否之前是Controller但现在不是了。如果是这种情况的话则调用onControllerResignation方法执行Controller卸任逻辑。
说到“卸任”你可能会问“卸任逻辑是由哪个方法执行的呢”实际上这是由onControllerResignation方法执行的它主要是用于清空各种数据结构的值、取消ZooKeeper监听器、关闭各种状态机以及管理器等等。我用注释的方式给出它的逻辑实现
```
private def onControllerResignation(): Unit = {
debug(&quot;Resigning&quot;)
// 取消ZooKeeper监听器的注册
zkClient.unregisterZNodeChildChangeHandler(
isrChangeNotificationHandler.path)
zkClient.unregisterZNodeChangeHandler(
partitionReassignmentHandler.path)
zkClient.unregisterZNodeChangeHandler(
preferredReplicaElectionHandler.path)
zkClient.unregisterZNodeChildChangeHandler(
logDirEventNotificationHandler.path)
unregisterBrokerModificationsHandler(
brokerModificationsHandlers.keySet)
// 关闭Kafka线程调度器其实就是取消定期的Leader重选举
kafkaScheduler.shutdown()
// 将统计字段全部清0
offlinePartitionCount = 0
preferredReplicaImbalanceCount = 0
globalTopicCount = 0
globalPartitionCount = 0
topicsToDeleteCount = 0
replicasToDeleteCount = 0
ineligibleTopicsToDeleteCount = 0
ineligibleReplicasToDeleteCount = 0
// 关闭Token过期检查调度器
if (tokenCleanScheduler.isStarted)
tokenCleanScheduler.shutdown()
// 取消分区重分配监听器的注册
unregisterPartitionReassignmentIsrChangeHandlers()
// 关闭分区状态机
partitionStateMachine.shutdown()
// 取消主题变更监听器的注册
zkClient.unregisterZNodeChildChangeHandler(topicChangeHandler.path)
// 取消分区变更监听器的注册
unregisterPartitionModificationsHandlers(
partitionModificationsHandlers.keys.toSeq)
// 取消主题删除监听器的注册
zkClient.unregisterZNodeChildChangeHandler(
topicDeletionHandler.path)
// 关闭副本状态机
replicaStateMachine.shutdown()
// 取消Broker变更监听器的注册
zkClient.unregisterZNodeChildChangeHandler(brokerChangeHandler.path)
// 关闭Controller通道管理器
controllerChannelManager.shutdown()
// 清空集群元数据
controllerContext.resetContext()
info(&quot;Resigned&quot;)
}
```
### 选举Controller
讲完了触发场景接下来我们就要学习Controller选举的源码了。前面说过了这三种选举场景最后都会调用elect方法来执行选举逻辑。我们来看下它的实现
```
private def elect(): Unit = {
// 第1步获取当前Controller所在Broker的序号如果Controller不存在显式标记为-1
activeControllerId = zkClient.getControllerId.getOrElse(-1)
// 第2步如果当前Controller已经选出来了直接返回即可
if (activeControllerId != -1) {
debug(s&quot;Broker $activeControllerId has been elected as the controller, so stopping the election process.&quot;)
return
}
try {
// 第3步注册Controller相关信息
// 主要是创建/controller节点
val (epoch, epochZkVersion) = zkClient.registerControllerAndIncrementControllerEpoch(config.brokerId)
controllerContext.epoch = epoch
controllerContext.epochZkVersion = epochZkVersion
activeControllerId = config.brokerId
info(s&quot;${config.brokerId} successfully elected as the controller. Epoch incremented to ${controllerContext.epoch} &quot; +
s&quot;and epoch zk version is now ${controllerContext.epochZkVersion}&quot;)
// 第4步执行当选Controller的后续逻辑
onControllerFailover()
} catch {
case e: ControllerMovedException =&gt;
maybeResign()
if (activeControllerId != -1)
debug(s&quot;Broker $activeControllerId was elected as controller instead of broker ${config.brokerId}&quot;, e)
else
warn(&quot;A controller has been elected but just resigned, this will result in another round of election&quot;, e)
case t: Throwable =&gt;
error(s&quot;Error while electing or becoming controller on broker ${config.brokerId}. &quot; +
s&quot;Trigger controller movement immediately&quot;, t)
triggerControllerMove()
}
}
```
为了帮助你更好地理解这个方法,我再画一张图来进行说明:
<img src="https://static001.geekbang.org/resource/image/23/1b/2331395774956a61f37836c46d65d01b.jpg" alt="">
该方法首先检查Controller是否已经选出来了。要知道集群中的所有Broker都要执行这些逻辑因此非常有可能出现某些Broker在执行elect方法时Controller已经被选出来的情况。如果Controller已经选出来了那么自然也就不用再做什么了。相反地如果Controller尚未被选举出来那么代码会尝试创建/controller节点去抢注Controller。
一旦抢注成功就调用onControllerFailover方法执行选举成功后的动作。这些动作包括注册各类ZooKeeper监听器、删除日志路径变更和ISR副本变更通知事件、启动Controller通道管理器以及启动副本状态机和分区状态机。
如果抢注失败了代码会抛出ControllerMovedException异常。这通常表明Controller已经被其他Broker抢先占据了那么此时代码调用maybeResign方法去执行卸任逻辑。
## 总结
今天我们梳理了Controller选举的全过程包括Controller如何借助ZooKeeper监听器实现监听Controller节点以及Controller的选举触发场景和完整流程。我们来回顾一下这节课的重点。
- Controller依赖ZooKeeper实现Controller选举主要是借助于/controller临时节点和ZooKeeper的监听器机制。
- Controller触发场景有3种集群启动时/controller节点被删除时/controller节点数据变更时。
- 源码最终调用elect方法实现Controller选举。
<img src="https://static001.geekbang.org/resource/image/e2/74/e28c134e4fd11ff8ed87933aee88d374.jpg" alt="">
下节课我将带你学习Controller的其他重要功能包括它如何管理Broker和副本等。你千万不要错过。
## 课后讨论
在这节课刚开始的时候,我提到,删除/controller会触发Controller选举之后会同步集群元数据信息。那么你知道源码是在哪里更新的元数据请求吗
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,409 @@
<audio id="audio" title="15 | 如何理解Controller在Kafka集群中的作用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/41/5afb796241d45b2e75d1f295067f0441.mp3"></audio>
你好,我是胡夕。
上节课我们学习了Controller选举的源码了解了Controller组件的选举触发场景以及它是如何被选举出来的。Controller就绪之后就会行使它作为控制器的重要权利了包括管理集群成员、维护主题、操作元数据等等。
之前在学习Kafka的时候我一直很好奇新启动的Broker是如何加入到集群中的。官方文档里的解释是“Adding servers to a Kafka cluster is easy, just assign them a unique broker id and start up Kafka on your new servers.”显然你只要启动Broker进程就可以实现集群的扩展甚至包括集群元数据信息的同步。
不过你是否思考过这一切是怎么做到的呢其实这就是Controller组件源码提供的一个重要功能管理新集群成员。
当然作为核心组件Controller提供的功能非常多。除了集群成员管理主题管理也是一个极其重要的功能。今天我就带你深入了解下它们的实现代码。可以说这是Controller最核心的两个功能它们几乎涉及到了集群元数据中的所有重要数据。掌握了这些之后你在探索Controller的其他代码时会更加游刃有余。
## 集群成员管理
首先我们来看Controller管理集群成员部分的代码。这里的成员管理包含两个方面
1. 成员数量的管理,主要体现在新增成员和移除现有成员;
1. 单个成员的管理如变更单个Broker的数据等。
### 成员数量管理
每个Broker在启动的时候会在ZooKeeper的/brokers/ids节点下创建一个名为broker.id参数值的临时节点。
举个例子假设Broker的broker.id参数值设置为1001那么当Broker启动后你会在ZooKeeper的/brokers/ids下观测到一个名为1001的子节点。该节点的内容包括了Broker配置的主机名、端口号以及所用监听器的信息注意这里的监听器和上面说的ZooKeeper监听器不是一回事
当该Broker正常关闭或意外退出时ZooKeeper上对应的临时节点会自动消失。
基于这种临时节点的机制Controller定义了BrokerChangeHandler监听器专门负责监听/brokers/ids下的子节点数量变化。
一旦发现新增或删除Broker/brokers/ids下的子节点数目一定会发生变化。这会被Controller侦测到进而触发BrokerChangeHandler的处理方法即handleChildChange方法。
我给出BrokerChangeHandler的代码。可以看到这里面定义了handleChildChange方法
```
class BrokerChangeHandler(eventManager: ControllerEventManager) extends ZNodeChildChangeHandler {
// Broker ZooKeeper ZNode: /brokers/ids
override val path: String = BrokerIdsZNode.path
override def handleChildChange(): Unit = {
eventManager.put(BrokerChange) // 仅仅是向事件队列写入BrokerChange事件
}
}
```
该方法的作用就是向Controller事件队列写入一个BrokerChange事件。**事实上Controller端定义的所有Handler的处理逻辑都是向事件队列写入相应的ControllerEvent真正的事件处理逻辑位于KafkaController类的process方法中。**
那么接下来我们就来看process方法。你会发现处理BrokerChange事件的方法实际上是processBrokerChange代码如下
```
private def processBrokerChange(): Unit = {
// 如果该Broker不是Controller自然无权处理直接返回
if (!isActive) return
// 第1步从ZooKeeper中获取集群Broker列表
val curBrokerAndEpochs = zkClient.getAllBrokerAndEpochsInCluster
val curBrokerIdAndEpochs = curBrokerAndEpochs map { case (broker, epoch) =&gt; (broker.id, epoch) }
val curBrokerIds = curBrokerIdAndEpochs.keySet
// 第2步获取Controller当前保存的Broker列表
val liveOrShuttingDownBrokerIds = controllerContext.liveOrShuttingDownBrokerIds
// 第3步比较两个列表获取新增Broker列表、待移除Broker列表、
// 已重启Broker列表和当前运行中的Broker列表
val newBrokerIds = curBrokerIds.diff(liveOrShuttingDownBrokerIds)
val deadBrokerIds = liveOrShuttingDownBrokerIds.diff(curBrokerIds)
val bouncedBrokerIds = (curBrokerIds &amp; liveOrShuttingDownBrokerIds)
.filter(brokerId =&gt; curBrokerIdAndEpochs(brokerId) &gt; controllerContext.liveBrokerIdAndEpochs(brokerId))
val newBrokerAndEpochs = curBrokerAndEpochs.filter { case (broker, _) =&gt; newBrokerIds.contains(broker.id) }
val bouncedBrokerAndEpochs = curBrokerAndEpochs.filter { case (broker, _) =&gt; bouncedBrokerIds.contains(broker.id) }
val newBrokerIdsSorted = newBrokerIds.toSeq.sorted
val deadBrokerIdsSorted = deadBrokerIds.toSeq.sorted
val liveBrokerIdsSorted = curBrokerIds.toSeq.sorted
val bouncedBrokerIdsSorted = bouncedBrokerIds.toSeq.sorted
info(s&quot;Newly added brokers: ${newBrokerIdsSorted.mkString(&quot;,&quot;)}, &quot; +
s&quot;deleted brokers: ${deadBrokerIdsSorted.mkString(&quot;,&quot;)}, &quot; +
s&quot;bounced brokers: ${bouncedBrokerIdsSorted.mkString(&quot;,&quot;)}, &quot; +
s&quot;all live brokers: ${liveBrokerIdsSorted.mkString(&quot;,&quot;)}&quot;)
// 第4步为每个新增Broker创建与之连接的通道管理器和
// 底层的请求发送线程RequestSendThread
newBrokerAndEpochs.keySet.foreach(
controllerChannelManager.addBroker)
// 第5步为每个已重启的Broker移除它们现有的配套资源
//通道管理器、RequestSendThread等并重新添加它们
bouncedBrokerIds.foreach(controllerChannelManager.removeBroker)
bouncedBrokerAndEpochs.keySet.foreach(
controllerChannelManager.addBroker)
// 第6步为每个待移除Broker移除对应的配套资源
deadBrokerIds.foreach(controllerChannelManager.removeBroker)
// 第7步为新增Broker执行更新Controller元数据和Broker启动逻辑
if (newBrokerIds.nonEmpty) {
controllerContext.addLiveBrokers(newBrokerAndEpochs)
onBrokerStartup(newBrokerIdsSorted)
}
// 第8步为已重启Broker执行重添加逻辑包含
// 更新ControllerContext、执行Broker重启动逻辑
if (bouncedBrokerIds.nonEmpty) {
controllerContext.removeLiveBrokers(bouncedBrokerIds)
onBrokerFailure(bouncedBrokerIdsSorted)
controllerContext.addLiveBrokers(bouncedBrokerAndEpochs)
onBrokerStartup(bouncedBrokerIdsSorted)
}
// 第9步为待移除Broker执行移除ControllerContext和Broker终止逻辑
if (deadBrokerIds.nonEmpty) {
controllerContext.removeLiveBrokers(deadBrokerIds)
onBrokerFailure(deadBrokerIdsSorted)
}
if (newBrokerIds.nonEmpty || deadBrokerIds.nonEmpty ||
bouncedBrokerIds.nonEmpty) {
info(s&quot;Updated broker epochs cache: ${controllerContext.liveBrokerIdAndEpochs}&quot;)
}
}
```
代码有点长,你可以看下我添加的重点注释。同时,我再画一张图,帮你梳理下这个方法做的事情。
<img src="https://static001.geekbang.org/resource/image/ff/d3/fffc8456d8ede9219462e607fa4241d3.jpg" alt="">
整个方法共有9步。
第1~3步
前两步分别是从ZooKeeper和ControllerContext中获取Broker列表第3步是获取4个Broker列表新增Broker列表、待移除Broker列表、已重启的Broker列表和当前运行中的Broker列表。
假设前两步中的Broker列表分别用A和B表示由于Kafka以ZooKeeper上的数据为权威数据因此A就是最新的运行中Broker列表“A-B”就表示新增的Broker“B-A”就表示待移除的Broker。
已重启的Broker的判断逻辑要复杂一些它判断的是A∧B集合中的那些Epoch值变更了的Broker。你大体上可以把Epoch值理解为Broker的版本或重启的次数。显然Epoch值变更了就说明Broker发生了重启行为。
第4~9步
拿到这些集合之后Controller会分别为这4个Broker列表执行相应的操作也就是这个方法中第4~9步要做的事情。总体上这些相应的操作分为3类。
- 执行元数据更新操作调用ControllerContext类的各个方法更新不同的集群元数据信息。比如需要将新增Broker加入到集群元数据将待移除Broker从元数据中移除等。
- 执行Broker终止操作为待移除Broker和已重启Broker调用onBrokerFailure方法。
- 执行Broker启动操作为已重启Broker和新增Broker调用onBrokerStartup方法。
下面我们深入了解下onBrokerFailure和onBrokerStartup方法的逻辑。相比于其他方法这两个方法的代码逻辑有些复杂要做的事情也很多因此我们重点研究下它们。
首先是处理Broker终止逻辑的onBrokerFailure方法代码如下
```
private def onBrokerFailure(deadBrokers: Seq[Int]): Unit = {
info(s&quot;Broker failure callback for ${deadBrokers.mkString(&quot;,&quot;)}&quot;)
// 第1步为每个待移除Broker删除元数据对象中的相关项
deadBrokers.foreach(controllerContext.replicasOnOfflineDirs.remove
// 第2步将待移除Broker从元数据对象中处于已关闭状态的Broker列表中去除
val deadBrokersThatWereShuttingDown =
deadBrokers.filter(id =&gt; controllerContext.shuttingDownBrokerIds.remove(id))
if (deadBrokersThatWereShuttingDown.nonEmpty)
info(s&quot;Removed ${deadBrokersThatWereShuttingDown.mkString(&quot;,&quot;)} from list of shutting down brokers.&quot;)
// 第3步找出待移除Broker上的所有副本对象执行相应操作
// 将其置为“不可用状态”即Offline
val allReplicasOnDeadBrokers = controllerContext.replicasOnBrokers(deadBrokers.toSet)
onReplicasBecomeOffline(allReplicasOnDeadBrokers)
// 第4步注销注册的BrokerModificationsHandler监听器
unregisterBrokerModificationsHandler(deadBrokers)
}
```
Broker终止意味着我们必须要删除Controller元数据缓存中与之相关的所有项还要处理这些Broker上保存的副本。最后我们还要注销之前为该Broker注册的BrokerModificationsHandler监听器。
其实主体逻辑在于如何处理Broker上的副本对象即onReplicasBecomeOffline方法。该方法大量调用了Kafka副本管理器和分区管理器的相关功能后面我们会专门学习这两个管理器因此这里我就不展开讲了。
现在我们看onBrokerStartup方法。它是处理Broker启动的方法也就是Controller端应对集群新增Broker启动的方法。同样我先给出带注释的完整方法代码
```
private def onBrokerStartup(newBrokers: Seq[Int]): Unit = {
info(s&quot;New broker startup callback for ${newBrokers.mkString(&quot;,&quot;)}&quot;)
// 第1步移除元数据中新增Broker对应的副本集合
newBrokers.foreach(controllerContext.replicasOnOfflineDirs.remove)
val newBrokersSet = newBrokers.toSet
val existingBrokers = controllerContext.
liveOrShuttingDownBrokerIds.diff(newBrokersSet)
// 第2步给集群现有Broker发送元数据更新请求令它们感知到新增Broker的到来
sendUpdateMetadataRequest(existingBrokers.toSeq, Set.empty)
// 第3步给新增Broker发送元数据更新请求令它们同步集群当前的所有分区数据
sendUpdateMetadataRequest(newBrokers, controllerContext.partitionLeadershipInfo.keySet)
val allReplicasOnNewBrokers = controllerContext.replicasOnBrokers(newBrokersSet)
// 第4步将新增Broker上的所有副本设置为Online状态即可用状态
replicaStateMachine.handleStateChanges(
allReplicasOnNewBrokers.toSeq, OnlineReplica)
partitionStateMachine.triggerOnlinePartitionStateChange()
// 第5步重启之前暂停的副本迁移操作
maybeResumeReassignments { (_, assignment) =&gt;
assignment.targetReplicas.exists(newBrokersSet.contains)
}
val replicasForTopicsToBeDeleted = allReplicasOnNewBrokers.filter(p =&gt; topicDeletionManager.isTopicQueuedUpForDeletion(p.topic))
// 第6步重启之前暂停的主题删除操作
if (replicasForTopicsToBeDeleted.nonEmpty) {
info(s&quot;Some replicas ${replicasForTopicsToBeDeleted.mkString(&quot;,&quot;)} for topics scheduled for deletion &quot; +
s&quot;${controllerContext.topicsToBeDeleted.mkString(&quot;,&quot;)} are on the newly restarted brokers &quot; +
s&quot;${newBrokers.mkString(&quot;,&quot;)}. Signaling restart of topic deletion for these topics&quot;)
topicDeletionManager.resumeDeletionForTopics(
replicasForTopicsToBeDeleted.map(_.topic))
}
// 第7步为新增Broker注册BrokerModificationsHandler监听器
registerBrokerModificationsHandler(newBrokers)
}
```
如代码所示第1步是移除新增Broker在元数据缓存中的信息。你可能会问“这些Broker不都是新增的吗元数据缓存中有它们的数据吗”实际上这里的newBrokers仅仅表示新启动的Broker它们不一定是全新的Broker。因此这里的删除元数据缓存是非常安全的做法。
第2、3步分别给集群的已有Broker和新增Broker发送更新元数据请求。这样一来整个集群上的Broker就可以互相感知到彼此而且最终所有的Broker都能保存相同的分区数据。
第4步将新增Broker上的副本状态置为Online状态。Online状态表示这些副本正常提供服务即Leader副本对外提供读写服务Follower副本自动向Leader副本同步消息。
第5、6步分别重启可能因为新增Broker启动、而能够重新被执行的副本迁移和主题删除操作。
第7步为所有新增Broker注册BrokerModificationsHandler监听器允许Controller监控它们在ZooKeeper上的节点的数据变更。
### 成员信息管理
了解了Controller管理集群成员数量的机制之后接下来我们要重点学习下Controller如何监听Broker端信息的变更以及具体的操作。
和管理集群成员类似Controller也是通过ZooKeeper监听器的方式来应对Broker的变化。这个监听器就是BrokerModificationsHandler。一旦Broker的信息发生变更该监听器的handleDataChange方法就会被调用向事件队列写入BrokerModifications事件。
KafkaController类的processBrokerModification方法负责处理这类事件代码如下
```
private def processBrokerModification(brokerId: Int): Unit = {
if (!isActive) return
// 第1步获取目标Broker的详细数据
// 包括每套监听器配置的主机名、端口号以及所使用的安全协议等
val newMetadataOpt = zkClient.getBroker(brokerId)
// 第2步从元数据缓存中获得目标Broker的详细数据
val oldMetadataOpt = controllerContext.liveOrShuttingDownBroker(brokerId)
if (newMetadataOpt.nonEmpty &amp;&amp; oldMetadataOpt.nonEmpty) {
val oldMetadata = oldMetadataOpt.get
val newMetadata = newMetadataOpt.get
// 第3步如果两者不相等说明Broker数据发生了变更
// 那么更新元数据缓存以及执行onBrokerUpdate方法处理Broker更新的逻辑
if (newMetadata.endPoints != oldMetadata.endPoints) {
info(s&quot;Updated broker metadata: $oldMetadata -&gt; $newMetadata&quot;)
controllerContext.updateBrokerMetadata(oldMetadata, newMetadata)
onBrokerUpdate(brokerId)
}
}
}
```
该方法首先获取ZooKeeper上最权威的Broker数据将其与元数据缓存上的数据进行比对。如果发现两者不一致就会更新元数据缓存同时调用onBrokerUpdate方法执行更新逻辑。
那么onBrokerUpdate方法又是如何实现的呢我们先看下代码
```
private def onBrokerUpdate(updatedBrokerId: Int): Unit = {
info(s&quot;Broker info update callback for $updatedBrokerId&quot;)
// 给集群所有Broker发送UpdateMetadataRequest让她它们去更新元数据
sendUpdateMetadataRequest(
controllerContext.liveOrShuttingDownBrokerIds.toSeq, Set.empty)
}
```
可以看到onBrokerUpdate就是向集群所有Broker发送更新元数据信息请求把变更信息广播出去。
怎么样应对Broker信息变更的方法还是比较简单的吧
## 主题管理
除了维护集群成员之外Controller还有一个重要的任务那就是对所有主题进行管理主要包括主题的创建、变更与删除。
掌握了前面集群成员管理的方法,在学习下面的内容时会轻松很多。因为它们的实现机制是一脉相承的,几乎没有任何差异。
### 主题创建/变更
我们重点学习下主题是如何被创建的。实际上,主题变更与创建是相同的逻辑,因此,源码使用了一套监听器统一处理这两种情况。
你一定使用过Kafka的kafka-topics脚本或AdminClient创建主题吧实际上这些工具仅仅是向ZooKeeper对应的目录下写入相应的数据而已那么Controller或者说Kafka集群是如何感知到新创建的主题的呢
这当然要归功于监听主题路径的ZooKeeper监听器TopicChangeHandler。代码如下
```
class TopicChangeHandler(eventManager: ControllerEventManager) extends ZNodeChildChangeHandler {
// ZooKeeper节点/brokers/topics
override val path: String = TopicsZNode.path
// 向事件队列写入TopicChange事件
override def handleChildChange(): Unit = eventManager.put(TopicChange)
}
```
代码中的TopicsZNode.path就是ZooKeeper下/brokers/topics节点。一旦该节点下新增了主题信息该监听器的handleChildChange就会被触发Controller通过ControllerEventManager对象向事件队列写入TopicChange事件。
KafkaController的process方法接到该事件后调用processTopicChange方法执行主题创建。代码如下
```
private def processTopicChange(): Unit = {
if (!isActive) return
// 第1步从ZooKeeper中获取所有主题
val topics = zkClient.getAllTopicsInCluster(true)
// 第2步与元数据缓存比对找出新增主题列表与已删除主题列表
val newTopics = topics -- controllerContext.allTopics
val deletedTopics = controllerContext.allTopics.diff(topics)
// 第3步使用ZooKeeper中的主题列表更新元数据缓存
controllerContext.setAllTopics(topics)
// 第4步为新增主题注册分区变更监听器
// 分区变更监听器是监听主题分区变更的
registerPartitionModificationsHandlers(newTopics.toSeq)
// 第5步从ZooKeeper中获取新增主题的副本分配情况
val addedPartitionReplicaAssignment = zkClient.getFullReplicaAssignmentForTopics(newTopics)
// 第6步清除元数据缓存中属于已删除主题的缓存项
deletedTopics.foreach(controllerContext.removeTopic)
// 第7步为新增主题更新元数据缓存中的副本分配条目
addedPartitionReplicaAssignment.foreach {
case (topicAndPartition, newReplicaAssignment) =&gt; controllerContext.updatePartitionFullReplicaAssignment(topicAndPartition, newReplicaAssignment)
}
info(s&quot;New topics: [$newTopics], deleted topics: [$deletedTopics], new partition replica assignment &quot; +
s&quot;[$addedPartitionReplicaAssignment]&quot;)
// 第8步调整新增主题所有分区以及所属所有副本的运行状态为“上线”状态
if (addedPartitionReplicaAssignment.nonEmpty)
onNewPartitionCreation(addedPartitionReplicaAssignment.keySet)
}
```
虽然一共有8步但大部分的逻辑都与更新元数据缓存项有关因此处理逻辑总体上还是比较简单的。需要注意的是第8步涉及到了使用分区管理器和副本管理器来调整分区和副本状态。后面我们会详细介绍。这里你只需要知道分区和副本处于“上线”状态就表明它们能够正常工作就足够了。
### 主题删除
和主题创建或变更类似删除主题也依赖ZooKeeper监听器完成。
Controller定义了TopicDeletionHandler用它来实现对删除主题的监听代码如下
```
class TopicDeletionHandler(eventManager: ControllerEventManager) extends ZNodeChildChangeHandler {
// ZooKeeper节点/admin/delete_topics
override val path: String = DeleteTopicsZNode.path
// 向事件队列写入TopicDeletion事件
override def handleChildChange(): Unit = eventManager.put(TopicDeletion)
}
```
这里的DeleteTopicsZNode.path指的是/admin/delete_topics节点。目前无论是kafka-topics脚本还是AdminClient删除主题都是在/admin/delete_topics节点下创建名为待删除主题名的子节点。
比如如果我要删除test-topic主题那么Kafka的删除命令仅仅是在ZooKeeper上创建/admin/delete_topics/test-topic节点。一旦监听到该节点被创建TopicDeletionHandler的handleChildChange方法就会被触发Controller会向事件队列写入TopicDeletion事件。
处理TopicDeletion事件的方法是processTopicDeletion代码如下
```
private def processTopicDeletion(): Unit = {
if (!isActive) return
// 从ZooKeeper中获取待删除主题列表
var topicsToBeDeleted = zkClient.getTopicDeletions.toSet
debug(s&quot;Delete topics listener fired for topics ${topicsToBeDeleted.mkString(&quot;,&quot;)} to be deleted&quot;)
// 找出不存在的主题列表
val nonExistentTopics = topicsToBeDeleted -- controllerContext.allTopics
if (nonExistentTopics.nonEmpty) {
warn(s&quot;Ignoring request to delete non-existing topics ${nonExistentTopics.mkString(&quot;,&quot;)}&quot;)
zkClient.deleteTopicDeletions(nonExistentTopics.toSeq, controllerContext.epochZkVersion)
}
topicsToBeDeleted --= nonExistentTopics
// 如果delete.topic.enable参数设置成true
if (config.deleteTopicEnable) {
if (topicsToBeDeleted.nonEmpty) {
info(s&quot;Starting topic deletion for topics ${topicsToBeDeleted.mkString(&quot;,&quot;)}&quot;)
topicsToBeDeleted.foreach { topic =&gt;
val partitionReassignmentInProgress = controllerContext.partitionsBeingReassigned.map(_.topic).contains(topic)
if (partitionReassignmentInProgress)
topicDeletionManager.markTopicIneligibleForDeletion(
Set(topic), reason = &quot;topic reassignment in progress&quot;)
}
// 将待删除主题插入到删除等待集合交由TopicDeletionManager处理
topicDeletionManager.enqueueTopicsForDeletion(topicsToBeDeleted)
}
} else { // 不允许删除主题
info(s&quot;Removing $topicsToBeDeleted since delete topic is disabled&quot;)
// 清除ZooKeeper下/admin/delete_topics下的子节点
zkClient.deleteTopicDeletions(topicsToBeDeleted.toSeq, controllerContext.epochZkVersion)
}
}
```
为了帮助你更直观地理解,我再画一张图来说明下:
<img src="https://static001.geekbang.org/resource/image/97/c9/976d35f7771f4cd5ef94eda856fb53c9.jpg" alt="">
首先代码从ZooKeeper的/admin/delete_topics下获取子节点列表即待删除主题列表。
之后,比对元数据缓存中的主题列表,获知压根不存在的主题列表。如果确实有不存在的主题,删除/admin/delete_topics下对应的子节点就行了。同时代码会更新待删除主题列表将这些不存在的主题剔除掉。
接着代码会检查Broker端参数delete.topic.enable的值。如果该参数为false即不允许删除主题代码就会清除ZooKeeper下的对应子节点不会做其他操作。反之代码会遍历待删除主题列表将那些正在执行分区迁移的主题暂时设置成“不可删除”状态。
最后把剩下可以删除的主题交由TopicDeletionManager由它执行真正的删除逻辑。
这里的TopicDeletionManager是Kafka专门负责删除主题的管理器下节课我会详细讲解它的代码实现。
## 总结
今天我们学习了Controller的两个主要功能管理集群Broker成员和主题。这两个功能是Controller端提供的重要服务。我建议你仔细地查看这两部分的源码弄明白Controller是如何管理集群中的重要资源的。
针对这些内容,我总结了几个重点,希望可以帮助你更好地理解和记忆。
- 集群成员管理Controller负责对集群所有成员进行有效管理包括自动发现新增Broker、自动处理下线Broker以及及时响应Broker数据的变更。
- 主题管理Controller负责对集群上的所有主题进行高效管理包括创建主题、变更主题以及删除主题等等。对于删除主题而言实际的删除操作由底层的TopicDeletionManager完成。
<img src="https://static001.geekbang.org/resource/image/00/37/0035a579a02def8f5234831bf0857f37.jpg" alt="">
接下来我们将进入到下一个模块状态机模块。在该模块中我们将系统学习Kafka提供的三大状态机或管理器。Controller非常依赖这些状态机对下辖的所有Kafka对象进行管理。在下一个模块中我将带你深入了解分区或副本在底层的状态流转是怎么样的你一定不要错过。
## 课后讨论
如果我们想要使用脚本命令增加一个主题的分区你知道应该用KafkaController类中的哪个方法吗
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,277 @@
<audio id="audio" title="21 | AbstractFetcherThread拉取消息分几步" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/99/a4d643b73e71c060a5cea4d4b3867099.mp3"></audio>
你好我是胡夕。从今天开始我们正式进入到第5大模块“副本管理模块”源码的学习。
在Kafka中副本是最重要的概念之一。为什么这么说呢在前面的课程中我曾反复提到过副本机制是Kafka实现数据高可靠性的基础。具体的实现方式就是同一个分区下的多个副本分散在不同的Broker机器上它们保存相同的消息数据以实现高可靠性。对于分布式系统而言一个必须要解决的问题就是如何确保所有副本上的数据是一致的。
针对这个问题最常见的方案当属Leader/Follower备份机制Leader/Follower Replication。在Kafka中 分区的某个副本会被指定为Leader负责响应客户端的读写请求。其他副本自动成为Follower被动地同步Leader副本中的数据。
这里所说的被动同步是指Follower副本不断地向Leader副本发送读取请求以获取Leader处写入的最新消息数据。
那么在接下来的两讲我们就一起学习下Follower副本是如何通过拉取线程做到这一点的。另外Follower副本在副本同步的过程中还可能发生名为截断Truncation的操作。我们一并来看下它的实现原理。
## 课前案例
坦率地说,这部分源码非常贴近底层设计架构原理。你可能在想:阅读它对我实际有什么帮助吗?我举一个实际的例子来说明下。
我们曾经在生产环境中发现一旦Broker上的副本数过多Broker节点的内存占用就会非常高。查过HeapDump之后我们发现根源在于ReplicaFetcherThread文件中的buildFetch方法。这个方法里有这样一句
```
val builder = fetchSessionHandler.newBuilder()
```
这条语句底层会实例化一个LinkedHashMap。如果分区数很多的话这个Map会被扩容很多次因此带来了很多不必要的数据拷贝。这样既增加了内存的Footprint也浪费了CPU资源。
你看通过查询源码我们定位到了这个问题的根本原因。后来我们通过将负载转移到其他Broker的方法解决了这个问题。
其实Kafka社区也发现了这个Bug所以当你现在再看这部分源码的时候就会发现这行语句已经被修正了。它现在长这个样子你可以体会下和之前有什么不同
```
val builder = fetchSessionHandler.newBuilder(partitionMap.size, false)
```
你可能也看出来了修改前后最大的不同其实在于修改后的这条语句直接传入了FETCH请求中总的分区数并直接将其传给LinkedHashMap免得再执行扩容操作了。
你看,有的时候改进一行源码就能解决实际问题。而且,你千万不要以为修改源码是一件多么神秘的事情,搞懂了原理之后,就可以有针对性地调整代码了,这其实是一件非常愉悦的事情。
好了我们说回Follower副本从Leader副本拉取数据这件事儿。不知道你有没有注意到我在前面的例子提到了一个名字ReplicaFetcherThread也就是副本获取线程。没错Kafka源码就是通过这个线程实现的消息拉取及处理。
今天这节课我们先从抽象基类AbstractFetcherThread学起看看它的类定义和三个重要方法。下节课我们再继续学习AbstractFetcherThread类的一个重要方法以及子类ReplicaFetcherThread的源码。这样我们就能彻底搞明白Follower端同步Leader端消息的原理。
## 抽象基类AbstractFetcherThread
等等我们不是要学ReplicaFetcherThread吗为什么要先从它的父类AbstractFetcherThread开始学习呢
其实这里的原因也很简单那就是因为AbstractFetcherThread类是ReplicaFetcherThread的抽象基类。它里面定义和实现了很多重要的字段和方法是我们学习ReplicaFetcherThread源码的基础。同时AbstractFetcherThread类的源码给出了很多子类需要实现的方法。
因此,我们需要事先了解这个抽象基类,否则便无法顺畅过渡到其子类源码的学习。
好了我们来正式认识下AbstractFetcherThread吧。它的源码位于server包下的AbstractFetcherThread.scala文件中。从名字来看它是一个抽象类实现的功能是从Broker获取多个分区的消息数据至于获取之后如何对这些数据进行处理则交由子类来实现。
### 类定义及字段
我们看下AbstractFetcherThread类的定义和一些重要的字段
```
abstract class AbstractFetcherThread(
name: String, // 线程名称
clientId: String, // Client Id用于日志输出
val sourceBroker: BrokerEndPoint, // 数据源Broker地址
failedPartitions: FailedPartitions, // 处理过程中出现失败的分区
fetchBackOffMs: Int = 0, // 获取操作重试间隔
isInterruptible: Boolean = true, // 线程是否允许被中断
val brokerTopicStats: BrokerTopicStats) // Broker端主题监控指标
extends ShutdownableThread(name, isInterruptible) {
// 定义FetchData类型表示获取的消息数据
type FetchData = FetchResponse.PartitionData[Records]
// 定义EpochData类型表示Leader Epoch数据
type EpochData = OffsetsForLeaderEpochRequest.PartitionData
private val partitionStates = new PartitionStates[PartitionFetchState]
......
}
```
我们来看一下AbstractFetcherThread的构造函数接收的几个重要参数的含义。
- name线程名字。
- sourceBroker源Broker节点信息。源Broker是指此线程要从哪个Broker上读取数据。
- failedPartitions线程处理过程报错的分区集合。
- fetchBackOffMs当获取分区数据出错后的等待重试间隔默认是Broker端参数replica.fetch.backoff.ms值。
- brokerTopicStatsBroker端主题的各类监控指标常见的有MessagesInPerSec、BytesInPerSec等。
这些字段中比较重要的是**sourceBroker**因为它决定Follower副本从哪个Broker拉取数据也就是Leader副本所在的Broker是哪台。
除了构造函数的这几个字段外AbstractFetcherThread类还定义了两个type类型。用关键字type定义一个类型属于Scala比较高阶的语法特性。从某种程度上你可以把它当成一个快捷方式比如FetchData这句
```
type FetchData = FetchResponse.PartitionData[Records]
```
这行语句类似于一个快捷方式以后凡是源码中需要用到FetchResponse.PartitionData[Records]的地方都可以简单地使用FetchData替换掉非常简洁方便。自定义类型EpochData也是同样的用法。
FetchData定义里的PartitionData类型是客户端clients工程中FetchResponse类定义的嵌套类。FetchResponse类封装的是FETCH请求的Response对象而里面的PartitionData类是一个POJO类保存的是Response中单个分区数据拉取的各项数据包括从该分区的Leader副本拉取回来的消息、该分区的高水位值和日志起始位移值等。
我们看下它的代码:
```
public static final class PartitionData&lt;T extends BaseRecords&gt; {
public final Errors error; // 错误码
public final long highWatermark; // 高水位值
public final long lastStableOffset; // 最新LSO值
public final long logStartOffset; // 最新Log Start Offset值
// 期望的Read Replica
// KAFKA 2.4之后支持部分Follower副本可以对外提供读服务
public final Optional&lt;Integer&gt; preferredReadReplica;
// 该分区对应的已终止事务列表
public final List&lt;AbortedTransaction&gt; abortedTransactions;
// 消息集合,最重要的字段!
public final T records;
// 构造函数......
}
```
PartitionData这个类定义的字段中除了我们已经非常熟悉的highWatermark和logStartOffset等字段外还有一些属于比较高阶的用法
- preferredReadReplica用于指定可对外提供读服务的Follower副本
- abortedTransactions用于保存该分区当前已终止事务列表
- lastStableOffset是最新的LSO值属于Kafka事务的概念。
关于这几个字段你只要了解它们的基本作用就可以了。实际上在PartitionData这个类中最需要你重点关注的是**records字段**。因为它保存实际的消息集合,而这是我们最关心的数据。
说到这里如果你去查看EpochData的定义能发现它也是PartitionData类型。但你一定要注意的是EpochData的PartitionData是OffsetsForLeaderEpochRequest的PartitionData类型。
事实上,**在Kafka源码中有很多名为PartitionData的嵌套类**。很多请求类型中的数据都是按分区层级进行分组的因此源码很自然地在这些请求类中创建了同名的嵌套类。我们在查看源码时一定要注意区分PartitionData嵌套类是定义在哪类请求中的不同类型请求中的PartitionData类字段是完全不同的。
### 分区读取状态类
好了我们把视线拉回到AbstractFetcherThread类。在这个类的构造函数中我们看到它还封装了一个名为**PartitionStates[PartitionFetchState]**类型的字段。
是不是看上去有些复杂不过没关系我们分开来看先看它泛型的参数类型PartitionFetchState类。直观上理解它是表征分区读取状态的保存的是分区的已读取位移值和对应的副本状态。
注意这里的状态有两个一个是分区读取状态一个是副本读取状态。副本读取状态由ReplicaState接口表示如下所示
```
sealed trait ReplicaState
// 截断中
case object Truncating extends ReplicaState
// 获取中
case object Fetching extends ReplicaState
```
可见副本读取状态有截断中和获取中两个当副本执行截断操作时副本状态被设置成Truncating当副本被读取时副本状态被设置成Fetching。
而分区读取状态有3个分别是
- 可获取,表明副本获取线程当前能够读取数据。
- 截断中表明分区副本正在执行截断操作比如该副本刚刚成为Follower副本
- 被推迟,表明副本获取线程获取数据时出现错误,需要等待一段时间后重试。
值得注意的是,分区读取状态中的可获取、截断中与副本读取状态的获取中、截断中两个状态并非严格对应的。换句话说,副本读取状态处于获取中,并不一定表示分区读取状态就是可获取状态。对于分区而言,它是否能够被获取的条件要比副本严格一些。
接下来我们就来看看这3类分区获取状态的源码定义
```
case class PartitionFetchState(fetchOffset: Long,
lag: Option[Long],
currentLeaderEpoch: Int,
delay: Option[DelayedItem],
state: ReplicaState) {
// 分区可获取的条件是副本处于Fetching且未被推迟执行
def isReadyForFetch: Boolean = state == Fetching &amp;&amp; !isDelayed
// 副本处于ISR的条件没有lag
def isReplicaInSync: Boolean = lag.isDefined &amp;&amp; lag.get &lt;= 0
// 分区处于截断中状态的条件副本处于Truncating状态且未被推迟执行
def isTruncating: Boolean = state == Truncating &amp;&amp; !isDelayed
// 分区被推迟获取数据的条件:存在未过期的延迟任务
def isDelayed: Boolean =
delay.exists(_.getDelay(TimeUnit.MILLISECONDS) &gt; 0)
......
}
```
这段源码中有4个方法你只要重点了解isReadyForFetch和isTruncating这两个方法即可。因为副本获取线程做的事情就是这两件日志截断和消息获取。
至于isReplicaInSync它被用于副本限流出镜率不高。而isDelayed是用于判断是否需要推迟获取对应分区的消息。源码会不断地调整那些不需要推迟的分区的读取顺序以保证读取的公平性。
这个公平性其实就是在partitionStates字段的类型PartitionStates类中实现的。这个类是在clients工程中定义的。它本质上会接收一组要读取的主题分区然后以轮询的方式依次读取这些分区以确保公平性。
鉴于咱们这门儿课聚焦于Broker端源码因此这里我只是简单和你说下这个类的实现原理。如果你想要深入理解这部分内容可以翻开clients端工程的源码自行去探索下这部分的源码。
```
public class PartitionStates&lt;S&gt; {
private final LinkedHashMap&lt;TopicPartition, S&gt; map = new LinkedHashMap&lt;&gt;();
......
public void updateAndMoveToEnd(TopicPartition topicPartition, S state) {
map.remove(topicPartition);
map.put(topicPartition, state);
updateSize();
}
......
}
```
前面说过了PartitionStates类用轮询的方式来处理要读取的多个分区。那具体是怎么实现的呢简单来说就是依靠LinkedHashMap数据结构来保存所有主题分区。LinkedHashMap中的元素有明确的迭代顺序通常就是元素被插入的顺序。
假设Kafka要读取5个分区上的消息A、B、C、D和E。如果插入顺序就是ABCDE那么自然首先读取分区A。一旦A被读取之后为了确保各个分区都有同等机会被读取到代码需要将A插入到分区列表的最后一位这就是updateAndMoveToEnd方法要做的事情。
具体来说就是把A从map中移除掉然后再插回去这样A自然就处于列表的最后一位了。大体上PartitionStates类就是做这个用的。
### 重要方法
说完了AbstractFetcherThread类的定义我们再看下它提供的一些重要方法。
这个类总共封装了近40个方法那接下来我就按照这些方法对于你使用Kafka、解决Kafka问题的重要程度精选出4个方法做重点讲解分别是processPartitionData、truncate、buildFetch和doWork。这4个方法涵盖了拉取线程所做的最重要的3件事儿构建FETCH请求、执行截断操作、处理拉取后的结果。而doWork方法其实是串联起了前面的这3个方法。
好了,我们一个一个来看看吧。
**首先是它最重要的方法processPartitionData**用于处理读取回来的消息集合。它是一个抽象方法因此需要子类实现它的逻辑。具体到Follower副本而言 是由ReplicaFetcherThread类实现的。以下是它的方法签名
```
protected def processPartitionData(
topicPartition: TopicPartition, // 读取哪个分区的数据
fetchOffset: Long, // 读取到的最新位移值
partitionData: FetchData // 读取到的分区消息数据
): Option[LogAppendInfo] // 写入已读取消息数据前的元数据
```
我们需要重点关注的字段是该方法的返回值Option[LogAppendInfo]
- 对于Follower副本读消息写入日志而言你可以忽略这里的Option因为它肯定会返回具体的LogAppendInfo实例而不会是None。
- 至于LogAppendInfo类我们在“日志模块”中已经介绍过了。它封装了很多消息数据被写入到日志前的重要元数据信息比如首条消息的位移值、最后一条消息位移值、最大时间戳等。
除了processPartitionData方法**另一个重要的方法是truncate方法**,其签名代码如下:
```
protected def truncate(
topicPartition: TopicPartition, // 要对哪个分区下副本执行截断操作
truncationState: OffsetTruncationState // Offset + 截断状态
): Unit
```
这里的OffsetTruncationState类封装了一个位移值和一个截断完成与否的布尔值状态。它的主要作用是告诉Kafka要把指定分区下副本截断到哪个位移值。
**第3个重要的方法是buildFetch方法**。代码如下:
```
protected def buildFetch(
// 一组要读取的分区列表
// 分区是否可读取取决于PartitionFetchState中的状态
partitionMap: Map[TopicPartition, PartitionFetchState]):
// 封装FetchRequest.Builder对象
ResultWithPartitions[Option[ReplicaFetch]]
```
buildFetch方法的返回值看似很复杂但其实如果你阅读源码的话就会发现buildFetch的本质就是为指定分区构建对应的FetchRequest.Builder对象而该对象是构建FetchRequest的核心组件。Kafka中任何类型的消息读取都是通过给指定Broker发送FetchRequest请求来完成的。
**第4个重要的方法是doWork**。虽然它的代码行数不多,但却**是串联前面3个方法的主要入口方法也是AbstractFetcherThread**类的核心方法。因此,我们要多花点时间,弄明白这些方法是怎么组合在一起共同工作的。我会在下节课和你详细拆解这里面的代码原理。
## 总结
今天我们主要学习了Kafka的副本同步机制和副本管理器组件。目前Kafka副本之间的消息同步是依靠ReplicaFetcherThread线程完成的。我们重点阅读了它的抽象基类AbstractFetcherThread线程类的代码。作为拉取线程的公共基类AbstractFetcherThread类定义了很多重要方法。
我们来回顾一下这节课的重点。
- AbstractFetcherThread类拉取线程的抽象基类。它定义了公共方法来处理所有拉取线程都要实现的逻辑如执行截断操作获取消息等。
- 拉取线程逻辑:循环执行截断操作和获取数据操作。
- 分区读取状态当前源码定义了3类分区读取状态。拉取线程只能拉取处于可读取状态的分区的数据。
<img src="https://static001.geekbang.org/resource/image/75/8d/750998c099f3d3575f6ba4c418bfce8d.jpg" alt="">
下节课我会带你一起对照着doWork方法的代码把拉取线程的完整执行逻辑串联一遍这样的话我们就能彻底掌握Follower副本拉取线程的工作原理了。在这个过程中我们还会陆续接触到ReplicaFetcherThread类源码的3个重要方法的代码。你需要理解它们的实现机制以及doWork是怎么把它们组织在一起的。
## 课后讨论
请简单描述一下handlePartitionsWithErrors方法的实现原理。
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,471 @@
<audio id="audio" title="22 | ReplicaFetcherThreadFollower如何拉取Leader消息" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9e/4a/9e6f81c79b024e1b5eb4a92a0373fd4a.mp3"></audio>
你好我是胡夕。今天我们继续学习Follower是如何拉取Leader消息的。
要弄明白这个问题在学习源码的时候我们需要从父类AbstractFetcherThread开始学起因为这是理解子类ReplicaFetcherThread的基础。上节课我们已经学习了AbstractFetcherThread的定义以及processPartitionData、truncate、buildFetch这三个方法的作用。现在你应该掌握了拉取线程源码的处理逻辑以及支撑这些逻辑实现的代码结构。
不过在上节课的末尾我卖了个关子——我把串联起这三个方法的doWork方法留到了今天这节课。等你今天学完doWork方法以及这三个方法在子类ReplicaFetcherThread中的实现代码之后你就能完整地理解Follower副本应用拉取线程也就是ReplicaFetcherThread线程从Leader副本获取消息并处理的流程了。
那么现在我们就开启doWork以及子类ReplicaFetcherThread代码的阅读。
## AbstractFetcherThread类doWork方法
doWork方法是AbstractFetcherThread类的核心方法是线程的主逻辑运行方法代码如下
```
override def doWork(): Unit = {
maybeTruncate() // 执行副本截断操作
maybeFetch() // 执行消息获取操作
}
```
怎么样简单吧AbstractFetcherThread线程只要一直处于运行状态就是会不断地重复这两个操作。获取消息这个逻辑容易理解但是为什么AbstractFetcherThread线程总要不断尝试去做截断呢
这是因为分区的Leader可能会随时发生变化。每当有新Leader产生时Follower副本就必须主动执行截断操作将自己的本地日志裁剪成与Leader一模一样的消息序列甚至Leader副本本身也需要执行截断操作将LEO调整到分区高水位处。
那么,具体到代码,这两个操作又是如何实现的呢?
首先我们看看maybeTruncate方法。它的代码不长还不到10行
```
private def maybeTruncate(): Unit = {
// 将所有处于截断中状态的分区依据有无Leader Epoch值进行分组
val (partitionsWithEpochs, partitionsWithoutEpochs) = fetchTruncatingPartitions()
// 对于有Leader Epoch值的分区将日志截断到Leader Epoch值对应的位移值处
if (partitionsWithEpochs.nonEmpty) {
truncateToEpochEndOffsets(partitionsWithEpochs)
}
// 对于没有Leader Epoch值的分区将日志截断到高水位值处
if (partitionsWithoutEpochs.nonEmpty) {
truncateToHighWatermark(partitionsWithoutEpochs)
}
}
```
maybeTruncate方法的逻辑特别简单。
首先是对分区状态进行分组。既然是做截断操作的那么该方法操作的就只能是处于截断中状态的分区。代码会判断这些分区是否存在对应的Leader Epoch值并按照有无Epoch值进行分组。这就是fetchTruncatingPartitions方法做的事情。
我在[第3讲](https://time.geekbang.org/column/article/225993)提到过Leader Epoch机制它是用来替换高水位值在日志截断中的作用。这里便是Leader Epoch机制典型的应用场景
- 当分区存在Leader Epoch值时源码会将副本的本地日志截断到Leader Epoch对应的最新位移值处即方法truncateToEpochEndOffsets的逻辑实现
- 相反地如果分区不存在对应的Leader Epoch记录那么依然使用原来的高水位机制调用方法truncateToHighWatermark将日志调整到高水位值处。
由于Leader Epoch机制属于比较高阶的知识内容这里我们的重点是理解高水位值在截断操作中的应用我就不再和你详细讲解Leader Epoch机制了。如果你希望深入理解这个机制你可以研读一下LeaderEpochFileCache类的源码。
因此我们重点看下truncateToHighWatermark方法的实现代码。
```
private[server] def truncateToHighWatermark(
partitions: Set[TopicPartition]): Unit = inLock(partitionMapLock) {
val fetchOffsets = mutable.HashMap.empty[TopicPartition, OffsetTruncationState]
// 遍历每个要执行截断操作的分区对象
for (tp &lt;- partitions) {
// 获取分区的分区读取状态
val partitionState = partitionStates.stateValue(tp)
if (partitionState != null) {
// 取出高水位值。分区的最大可读取位移值就是高水位值
val highWatermark = partitionState.fetchOffset
val truncationState = OffsetTruncationState(highWatermark, truncationCompleted = true)
info(s&quot;Truncating partition $tp to local high watermark $highWatermark&quot;)
// 执行截断到高水位值
if (doTruncate(tp, truncationState))
fetchOffsets.put(tp, truncationState)
}
}
// 更新这组分区的分区读取状态
updateFetchOffsetAndMaybeMarkTruncationComplete(fetchOffsets)
}
```
我来和你解释下truncateToHighWatermark方法的逻辑首先遍历给定的所有分区然后依次为每个分区获取当前的高水位值并将其保存在前面提到的分区读取状态类中之后调用doTruncate方法执行真正的日志截断操作。等到将给定的所有分区都执行了对应的操作之后代码会更新这组分区的分区读取状态。
doTruncate方法底层调用了抽象方法truncate而truncate方法是在ReplicaFetcherThread中实现的。我们一会儿再详细说它。至于updateFetchOffsetAndMaybeMarkTruncationComplete方法是一个只有十几行代码的私有方法。我就把它当作课后思考题留给你由你来思考一下它是做什么用的吧。
说完了maybeTruncate方法我们再看看maybeFetch方法代码如下
```
private def maybeFetch(): Unit = {
val fetchRequestOpt = inLock(partitionMapLock) {
// 为partitionStates中的分区构造FetchRequest
// partitionStates中保存的是要去获取消息的分区以及对应的状态
val ResultWithPartitions(fetchRequestOpt, partitionsWithError) =
buildFetch(partitionStates.partitionStateMap.asScala)
// 处理出错的分区处理方式主要是将这个分区加入到有序Map末尾
// 等待后续重试
handlePartitionsWithErrors(partitionsWithError, &quot;maybeFetch&quot;)
// 如果当前没有可读取的分区则等待fetchBackOffMs时间等候后续重试
if (fetchRequestOpt.isEmpty) {
trace(s&quot;There are no active partitions. Back off for $fetchBackOffMs ms before sending a fetch request&quot;)
partitionMapCond.await(fetchBackOffMs, TimeUnit.MILLISECONDS)
}
fetchRequestOpt
}
// 发送FETCH请求给Leader副本并处理Response
fetchRequestOpt.foreach { case ReplicaFetch(sessionPartitions, fetchRequest) =&gt;
processFetchRequest(sessionPartitions, fetchRequest)
}
}
```
同样地maybeFetch做的事情也基本可以分为3步。
第1步为partitionStates中的分区构造FetchRequest对象严格来说是FetchRequest.Builder对象。构造了Builder对象之后通过调用其build方法就能创建出所需的FetchRequest请求对象。
这里的partitionStates中保存的是要去获取消息的一组分区以及对应的状态信息。这一步的输出结果是两个对象
- 一个对象是ReplicaFetch即要读取的分区核心信息+ FetchRequest.Builder对象。而这里的核心信息就是指要读取哪个分区从哪个位置开始读最多读多少字节等等。
- 另一个对象是一组出错分区。
第2步处理这组出错分区。处理方式是将这组分区加入到有序Map末尾等待后续重试。如果发现当前没有任何可读取的分区代码会阻塞等待一段时间。
第3步发送FETCH请求给对应的Leader副本并处理相应的Response也就是processFetchRequest方法要做的事情。
processFetchRequest是AbstractFetcherThread所有方法中代码量最多的方法逻辑也有些复杂。为了更好地理解它我提取了其中的精华代码展示给你并在每个关键步骤上都加了注释
```
private def processFetchRequest(sessionPartitions:
util.Map[TopicPartition, FetchRequest.PartitionData],
fetchRequest: FetchRequest.Builder): Unit = {
val partitionsWithError = mutable.Set[TopicPartition]()
var responseData: Map[TopicPartition, FetchData] = Map.empty
try {
trace(s&quot;Sending fetch request $fetchRequest&quot;)
// 给Leader发送FETCH请求
responseData = fetchFromLeader(fetchRequest)
} catch {
......
}
// 更新请求发送速率指标
fetcherStats.requestRate.mark()
if (responseData.nonEmpty) {
inLock(partitionMapLock) {
responseData.foreach { case (topicPartition, partitionData) =&gt;
Option(partitionStates.stateValue(topicPartition)).foreach { currentFetchState =&gt;
// 获取分区核心信息
val fetchPartitionData = sessionPartitions.get(topicPartition)
// 处理Response的条件
// 1. 要获取的位移值和之前已保存的下一条待获取位移值相等
// 2. 当前分区处于可获取状态
if (fetchPartitionData != null &amp;&amp; fetchPartitionData.fetchOffset == currentFetchState.fetchOffset &amp;&amp; currentFetchState.isReadyForFetch) {
// 提取Response中的Leader Epoch值
val requestEpoch = if (fetchPartitionData.currentLeaderEpoch.isPresent) Some(fetchPartitionData.currentLeaderEpoch.get().toInt) else None
partitionData.error match {
// 如果没有错误
case Errors.NONE =&gt;
try {
// 交由子类完成Response的处理
val logAppendInfoOpt = processPartitionData(topicPartition, currentFetchState.fetchOffset,
partitionData)
logAppendInfoOpt.foreach { logAppendInfo =&gt;
val validBytes = logAppendInfo.validBytes
val nextOffset = if (validBytes &gt; 0) logAppendInfo.lastOffset + 1 else currentFetchState.fetchOffset
val lag = Math.max(0L, partitionData.highWatermark - nextOffset)
fetcherLagStats.getAndMaybePut(topicPartition).lag = lag
if (validBytes &gt; 0 &amp;&amp; partitionStates.contains(topicPartition)) {
val newFetchState = PartitionFetchState(nextOffset, Some(lag), currentFetchState.currentLeaderEpoch, state = Fetching)
// 将该分区放置在有序Map读取顺序的末尾保证公平性
partitionStates.updateAndMoveToEnd(
topicPartition, newFetchState)
fetcherStats.byteRate.mark(validBytes)
}
}
} catch {
......
}
// 如果读取位移值越界通常是因为Leader发生变更
case Errors.OFFSET_OUT_OF_RANGE =&gt;
// 调整越界,主要办法是做截断
if (handleOutOfRangeError(topicPartition, currentFetchState, requestEpoch))
// 如果依然不能成功,加入到出错分区列表
partitionsWithError += topicPartition
// 如果Leader Epoch值比Leader所在Broker上的Epoch值要新
case Errors.UNKNOWN_LEADER_EPOCH =&gt;
debug(s&quot;Remote broker has a smaller leader epoch for partition $topicPartition than &quot; +
s&quot;this replica's current leader epoch of ${currentFetchState.currentLeaderEpoch}.&quot;)
// 加入到出错分区列表
partitionsWithError += topicPartition
// 如果Leader Epoch值比Leader所在Broker上的Epoch值要旧
case Errors.FENCED_LEADER_EPOCH =&gt;
if (onPartitionFenced(topicPartition, requestEpoch)) partitionsWithError += topicPartition
// 如果Leader发生变更
case Errors.NOT_LEADER_FOR_PARTITION =&gt;
debug(s&quot;Remote broker is not the leader for partition $topicPartition, which could indicate &quot; +
&quot;that the partition is being moved&quot;)
// 加入到出错分区列表
partitionsWithError += topicPartition
case _ =&gt;
error(s&quot;Error for partition $topicPartition at offset ${currentFetchState.fetchOffset}&quot;,
partitionData.error.exception)
// 加入到出错分区列表
partitionsWithError += topicPartition
}
}
}
}
}
}
if (partitionsWithError.nonEmpty) {
// 处理出错分区列表
handlePartitionsWithErrors(partitionsWithError, &quot;processFetchRequest&quot;)
}
}
```
为了方便你记忆我先用一张流程图来说明下processFetchRequest方法的执行逻辑
<img src="https://static001.geekbang.org/resource/image/38/49/387568fa5477ba71fc6bbe2868d76349.png" alt="">
结合着代码注释和流程图我再和你解释下processFetchRequest的核心逻辑吧。这样你肯定就能明白拉取线程是如何执行拉取动作的了。
我们可以把这个逻辑分为以下3大部分。
第1步调用fetchFromLeader方法给Leader发送FETCH请求并阻塞等待Response的返回然后更新FETCH请求发送速率的监控指标。
第2步拿到Response之后代码从中取出分区的核心信息然后比较要读取的位移值和当前AbstractFetcherThread线程缓存的、该分区下一条待读取的位移值是否相等以及当前分区是否处于可获取状态。
如果不满足这两个条件说明这个Request可能是一个之前等待了许久都未处理的请求压根就不用处理了。
相反如果满足这两个条件且Response没有错误代码会提取Response中的Leader Epoch值然后交由子类实现具体的Response处理也就是调用processPartitionData方法。之后将该分区放置在有序Map的末尾以保证公平性。而如果该Response有错误那么就调用对应错误的定制化处理逻辑然后将出错分区加入到出错分区列表中。
第3步调用handlePartitionsWithErrors方法统一处理上一步处理过程中出现错误的分区。
## 子类ReplicaFetcherThread
到此AbstractFetcherThread类的学习我们就完成了。接下来我们再看下Follower副本侧使用的ReplicaFetcherThread子类。
前面说过了ReplicaFetcherThread继承了AbstractFetcherThread类。ReplicaFetcherThread是Follower副本端创建的线程用于向Leader副本拉取消息数据。我们依然从类定义和重要方法两个维度来学习这个子类的源码。
ReplicaFetcherThread类的源码位于server包下的同名scala文件中。这是一个300多行的小文件因为大部分的处理逻辑都在父类AbstractFetcherThread中定义过了。
### 类定义及字段
我们先学习下ReplicaFetcherThread类的定义和字段
```
class ReplicaFetcherThread(name: String,
fetcherId: Int,
sourceBroker: BrokerEndPoint,
brokerConfig: KafkaConfig,
failedPartitions: FailedPartitions,
replicaMgr: ReplicaManager,
metrics: Metrics,
time: Time,
quota: ReplicaQuota,
leaderEndpointBlockingSend: Option[BlockingSend] = None)
extends AbstractFetcherThread(name = name,
clientId = name,
sourceBroker = sourceBroker,
failedPartitions,
fetchBackOffMs = brokerConfig.replicaFetchBackoffMs,
isInterruptible = false,
replicaMgr.brokerTopicStats) {
// 副本Id就是副本所在Broker的Id
private val replicaId = brokerConfig.brokerId
......
// 用于执行请求发送的类
private val leaderEndpoint = leaderEndpointBlockingSend.getOrElse(
new ReplicaFetcherBlockingSend(sourceBroker, brokerConfig, metrics, time, fetcherId,
s&quot;broker-$replicaId-fetcher-$fetcherId&quot;, logContext))
// Follower发送的FETCH请求被处理返回前的最长等待时间
private val maxWait = brokerConfig.replicaFetchWaitMaxMs
// 每个FETCH Response返回前必须要累积的最少字节数
private val minBytes = brokerConfig.replicaFetchMinBytes
// 每个合法FETCH Response的最大字节数
private val maxBytes = brokerConfig.replicaFetchResponseMaxBytes
// 单个分区能够获取到的最大字节数
private val fetchSize = brokerConfig.replicaFetchMaxBytes
// 维持某个Broker连接上获取会话状态的类
val fetchSessionHandler = new FetchSessionHandler(
logContext, sourceBroker.id)
......
}
```
ReplicaFetcherThread类的定义代码虽然有些长但你会发现没那么难懂因为构造函数中的大部分字段我们上节课都学习过了。现在我们只要学习ReplicaFetcherThread类特有的几个字段就可以了。
- fetcherIdFollower拉取的线程Id也就是线程的编号。单台Broker上允许存在多个ReplicaFetcherThread线程。Broker端参数num.replica.fetchers决定了Kafka到底创建多少个Follower拉取线程。
- brokerConfigKafkaConfig类实例。虽然我们没有正式学习过它的源码但之前学过的很多组件代码中都有它的身影。它封装了Broker端所有的参数信息。同样地ReplicaFetcherThread类也是通过它来获取Broker端指定参数的值。
- replicaMgr副本管理器。该线程类通过副本管理器来获取分区对象、副本对象以及它们下面的日志对象。
- quota用做限流。限流属于高阶用法如果你想深入理解这部分内容的话可以自行阅读ReplicationQuotaManager类的源码。现在只要你下次在源码中碰到quota字样的知道它是用作Follower副本拉取速度控制就行了。
- leaderEndpointBlockingSend这是用于实现同步发送请求的类。所谓的同步发送是指该线程使用它给指定Broker发送请求然后线程处于阻塞状态直到接收到Broker返回的Response。
除了构造函数中定义的字段之外ReplicaFetcherThread类还定义了与消息获取息息相关的4个字段。
- maxWaitFollower发送的FETCH请求被处理返回前的最长等待时间。它是Broker端参数replica.fetch.wait.max.ms的值。
- minBytes每个FETCH Response返回前必须要累积的最少字节数。它是Broker端参数replica.fetch.min.bytes的值。
- maxBytes每个合法FETCH Response的最大字节数。它是Broker端参数replica.fetch.response.max.bytes的值。
- fetchSize单个分区能够获取到的最大字节数。它是Broker端参数replica.fetch.max.bytes的值。
这4个参数都是FETCH请求的参数主要控制了Follower副本拉取Leader副本消息的行为比如一次请求到底能够获取多少字节的数据或者当未达到累积阈值时FETCH请求等待多长时间等。
### 重要方法
接下来我们继续学习ReplicaFetcherThread的3个重要方法processPartitionData、buildFetch和truncate。
为什么是这3个方法呢因为它们代表了Follower副本拉取线程要做的最重要的三件事处理拉取的消息、构建拉取消息的请求以及执行截断日志操作。
#### processPartitionData方法
我们先来看processPartitionData方法。AbstractFetcherThread线程从Leader副本拉取回消息后需要调用processPartitionData方法进行后续动作。该方法的代码很长我给其中的关键步骤添加了注释
```
override def processPartitionData(
topicPartition: TopicPartition,
fetchOffset: Long,
partitionData: FetchData): Option[LogAppendInfo] = {
val logTrace = isTraceEnabled
// 从副本管理器获取指定主题分区对象
val partition = replicaMgr.nonOfflinePartition(topicPartition).get
// 获取日志对象
val log = partition.localLogOrException
// 将获取到的数据转换成符合格式要求的消息集合
val records = toMemoryRecords(partitionData.records)
maybeWarnIfOversizedRecords(records, topicPartition)
// 要读取的起始位移值如果不是本地日志LEO值则视为异常情况
if (fetchOffset != log.logEndOffset)
throw new IllegalStateException(&quot;Offset mismatch for partition %s: fetched offset = %d, log end offset = %d.&quot;.format(
topicPartition, fetchOffset, log.logEndOffset))
if (logTrace)
trace(&quot;Follower has replica log end offset %d for partition %s. Received %d messages and leader hw %d&quot;
.format(log.logEndOffset, topicPartition, records.sizeInBytes, partitionData.highWatermark))
// 写入Follower副本本地日志
val logAppendInfo = partition.appendRecordsToFollowerOrFutureReplica(records, isFuture = false)
if (logTrace)
trace(&quot;Follower has replica log end offset %d after appending %d bytes of messages for partition %s&quot;
.format(log.logEndOffset, records.sizeInBytes, topicPartition))
val leaderLogStartOffset = partitionData.logStartOffset
// 更新Follower副本的高水位值
val followerHighWatermark =
log.updateHighWatermark(partitionData.highWatermark)
// 尝试更新Follower副本的Log Start Offset值
log.maybeIncrementLogStartOffset(leaderLogStartOffset, LeaderOffsetIncremented)
if (logTrace)
trace(s&quot;Follower set replica high watermark for partition $topicPartition to $followerHighWatermark&quot;)
// 副本消息拉取限流
if (quota.isThrottled(topicPartition))
quota.record(records.sizeInBytes)
// 更新统计指标值
if (partition.isReassigning &amp;&amp; partition.isAddingLocalReplica)
brokerTopicStats.updateReassignmentBytesIn(records.sizeInBytes)
brokerTopicStats.updateReplicationBytesIn(records.sizeInBytes)
// 返回日志写入结果
logAppendInfo
}
```
在详细解释前,我使用一张流程图帮助你直观地理解这个方法到底做了什么事情。
<img src="https://static001.geekbang.org/resource/image/d0/26/d0342f40ff5470086fb904983dbd3f26.png" alt="">
processPartitionData方法中的process实际上就是写入Follower副本本地日志的意思。因此这个方法的主体逻辑就是调用分区对象Partition的appendRecordsToFollowerOrFutureReplica写入获取到的消息。如果你沿着这个写入方法一路追下去就会发现它调用的是我们在[第2讲](https://time.geekbang.org/column/article/224795)中讲到过的appendAsFollower方法。你看一切都能串联起来源码也没什么大不了的对吧
当然仅仅写入日志还不够。我们还要做一些更新操作。比如需要更新Follower副本的高水位值即将FETCH请求Response中包含的高水位值作为新的高水位值同时代码还要尝试更新Follower副本的Log Start Offset值。
那为什么Log Start Offset值也可能发生变化呢这是因为Leader的Log Start Offset可能发生变化比如用户手动执行了删除消息的操作等。Follower副本的日志需要和Leader保持严格的一致因此如果Leader的该值发生变化Follower自然也要发生变化以保持一致。
除此之外processPartitionData方法还会更新其他一些统计指标值最后将写入结果返回。
#### buildFetch方法
接下来, 我们看下buildFetch方法。此方法的主要目的是构建发送给Leader副本所在Broker的FETCH请求。它的代码如下
```
override def buildFetch(
partitionMap: Map[TopicPartition, PartitionFetchState]): ResultWithPartitions[Option[ReplicaFetch]] = {
val partitionsWithError = mutable.Set[TopicPartition]()
val builder = fetchSessionHandler.newBuilder(partitionMap.size, false)
// 遍历每个分区将处于可获取状态的分区添加到builder后续统一处理
// 对于有错误的分区加入到出错分区列表
partitionMap.foreach { case (topicPartition, fetchState) =&gt;
if (fetchState.isReadyForFetch &amp;&amp; !shouldFollowerThrottle(quota, fetchState, topicPartition)) {
try {
val logStartOffset = this.logStartOffset(topicPartition)
builder.add(topicPartition, new FetchRequest.PartitionData(
fetchState.fetchOffset, logStartOffset, fetchSize, Optional.of(fetchState.currentLeaderEpoch)))
} catch {
case _: KafkaStorageException =&gt;
partitionsWithError += topicPartition
}
}
}
val fetchData = builder.build()
val fetchRequestOpt = if (fetchData.sessionPartitions.isEmpty &amp;&amp; fetchData.toForget.isEmpty) {
None
} else {
// 构造FETCH请求的Builder对象
val requestBuilder = FetchRequest.Builder
.forReplica(fetchRequestVersion, replicaId, maxWait, minBytes, fetchData.toSend)
.setMaxBytes(maxBytes)
.toForget(fetchData.toForget)
.metadata(fetchData.metadata)
Some(ReplicaFetch(fetchData.sessionPartitions(), requestBuilder))
}
// 返回Builder对象以及出错分区列表
ResultWithPartitions(fetchRequestOpt, partitionsWithError)
}
```
同样,我使用一张图来展示其完整流程。
<img src="https://static001.geekbang.org/resource/image/b3/89/b321756cdc623fe790aa94deae40f989.png" alt="">
这个方法的逻辑比processPartitionData简单。前面说到过它就是构造FETCH请求的Builder对象然后返回。有了Builder对象我们就可以分分钟构造出FETCH请求了仅需要调用builder.build()即可。
当然这个方法的一个副产品是汇总出错分区这样的话调用方后续可以统一处理这些出错分区。值得一提的是在构造Builder的过程中源码会用到ReplicaFetcherThread类定义的那些与消息获取相关的字段如maxWait、minBytes和maxBytes。
#### truncate方法
最后我们看下truncate方法的实现。这个方法的主要目的是对给定分区执行日志截断操作。代码如下
```
override def truncate(
tp: TopicPartition,
offsetTruncationState: OffsetTruncationState): Unit = {
// 拿到分区对象
val partition = replicaMgr.nonOfflinePartition(tp).get
//拿到分区本地日志
val log = partition.localLogOrException
// 执行截断操作截断到的位置由offsetTruncationState的offset指定
partition.truncateTo(offsetTruncationState.offset, isFuture = false)
if (offsetTruncationState.offset &lt; log.highWatermark)
warn(s&quot;Truncating $tp to offset ${offsetTruncationState.offset} below high watermark &quot; +
s&quot;${log.highWatermark}&quot;)
if (offsetTruncationState.truncationCompleted)
replicaMgr.replicaAlterLogDirsManager
.markPartitionsForTruncation(brokerConfig.brokerId, tp,
offsetTruncationState.offset)
}
```
总体来说truncate方法利用给定的offsetTruncationState的offset值对给定分区的本地日志进行截断操作。该操作由Partition对象的truncateTo方法完成但实际上底层调用的是Log的truncateTo方法。truncateTo方法的主要作用是将日志截断到小于给定值的最大位移值处。
## 总结
好了我们总结一下。就像我在开头时所说AbstractFetcherThread线程的doWork方法把上一讲提到的3个重要方法全部连接在一起共同完整了拉取线程要执行的逻辑即日志截断truncate+日志获取buildFetch+日志处理processPartitionData而其子类ReplicaFetcherThread类是真正实现该3个方法的地方。如果用一句话归纳起来那就是Follower副本利用ReplicaFetcherThread线程实时地从Leader副本拉取消息并写入到本地日志从而实现了与Leader副本之间的同步。以下是一些要点
- doWork方法拉取线程工作入口方法联结所有重要的子功能方法如执行截断操作获取Leader副本消息以及写入本地日志。
- truncate方法根据Leader副本返回的位移值和Epoch值执行本地日志的截断操作。
- buildFetch方法为一组特定分区构建FetchRequest对象所需的数据结构。
- processPartitionData方法处理从Leader副本获取到的消息主要是写入到本地日志中。
<img src="https://static001.geekbang.org/resource/image/eb/52/ebd9a667369fc304bce3yybdd439a152.jpg" alt="">
实际上今天的内容中多次出现副本管理器的身影。如果你仔细查看代码你会发现Follower副本正是利用它来获取对应分区Partition对象的然后依靠该对象执行消息写入。那么副本管理器还有哪些其他功能呢下一讲我将一一为你揭晓。
## 课后讨论
你可以去查阅下源码说说updateFetchOffsetAndMaybeMarkTruncationComplete方法是做什么用的吗
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,213 @@
<audio id="audio" title="23 | ReplicaManager必须要掌握的副本管理类定义和核心字段" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/fc/7f1aa7545fe28f963fbc09b9cdbc27fc.mp3"></audio>
你好,我是胡夕。
今天我们要学习的是Kafka中的副本管理器ReplicaManager。它负责管理和操作集群中Broker的副本还承担了一部分的分区管理工作比如变更整个分区的副本日志路径等。
你一定还记得前面讲到状态机的时候我说过Kafka同时实现了副本状态机和分区状态机。但对于管理器而言Kafka源码却没有专门针对分区定义一个类似于“分区管理器”这样的类而是只定义了ReplicaManager类。该类不只实现了对副本的管理还包含了很多操作分区对象的方法。
ReplicaManager类的源码非常重要它是构建Kafka副本同步机制的重要组件之一。副本同步过程中出现的大多数问题都是很难定位和解决的因此熟练掌握这部分源码将有助于我们深入探索线上生产环境问题的根本原因防止以后踩坑。下面我给你分享一个真实的案例。
我们团队曾碰到过一件古怪事在生产环境系统中执行删除消息的操作之后该操作引发了Follower端副本与Leader端副本的不一致问题。刚碰到这个问题时我们一头雾水在正常情况下Leader端副本执行了消息删除后日志起始位移值被更新了Follower端副本也应该更新日志起始位移值但是这里的Follower端的更新失败了。我们查遍了所有日志依然找不到原因最后还是通过分析ReplicaManager类源码才找到了答案。
我们先看一下这个错误的详细报错信息:
```
Caused by: org.apache.kafka.common.errors.OffsetOutOfRangeException: Cannot increment the log start offset to 22786435 of partition XXX-12 since it is larger than the high watermark 22786129
```
这是Follower副本抛出来的异常对应的Leader副本日志则一切如常。下面的日志显示出Leader副本的Log Start Offset已经被成功调整了。
```
INFO Incrementing log start offset of partition XXX-12 to 22786435
```
碰到这个问题时我相信你的第一反应和我一样这像是一个Bug但又不确定到底是什么原因导致的。后来我们顺着KafkaApis一路找下去直到找到了ReplicaManager的deleteRecords方法才看出点端倪。
Follower副本从Leader副本拉取到消息后会做两个操作
1. 写入到自己的本地日志;
1. 更新Follower副本的高水位值和Log Start Offset。
如果删除消息的操作deleteRecords发生在这两步之间因为deleteRecords会变更Log Start Offset所以Follower副本在进行第2步操作时它使用的可能是已经过期的值了因而会出现上面的错误。由此可见这的确是一个Bug。在确认了这一点之后后面的解决方案也就呼之欲出了虽然deleteRecords功能实用方便但鉴于这个Bug我们还是应该尽力避免在线上环境直接使用该功能。
说到这儿我想说一句碰到实际的线上问题不可怕可怕的是我们无法定位到问题的根本原因。写过Java项目的你一定有这种体会很多时候单纯依靠栈异常信息是不足以定位问题的。特别是涉及到Kafka副本同步这块如果只看输出日志的话你是很难搞清楚这里面的原理的因此我们必须要借助源码这也是我们今天学习ReplicaManager类的主要目的。
接下来我们就重点学习一下这个类。它位于server包下的同名scala文件中。这是一个有着将近1900行的大文件里面的代码结构很琐碎。
因为副本的读写操作和管理操作都是重磅功能所以在深入细节之前我们必须要理清ReplicaManager类的结构之间的关系并且搞懂类定义及核心字段这就是我们这节课的重要目标。
在接下来的两节课里我会给你详细地解释副本读写操作和副本管理操作。学完这些之后你就能清晰而深入地掌握ReplicaManager类的主要源码了最重要的是你会搞懂副本成为Leader或者是Follower时需要执行的逻辑这就足以帮助你应对实际遇到的副本操作问题了。
## 代码结构
我们首先看下这个scala文件的代码结构。我用一张思维导图向你展示下
<img src="https://static001.geekbang.org/resource/image/65/00/65d5d226116e75290ca9c98d3154d300.jpg" alt="">
虽然从代码结构上看该文件下有8个部分 不过HostedPartition接口以及实现对象放在一起更好理解所以我把ReplicaManager.scala分为7大部分。
- ReplicaManager类它是副本管理器的具体实现代码里面定义了读写副本、删除副本消息的方法以及其他管理方法。
- ReplicaManager对象ReplicaManager类的伴生对象仅仅定义了3个常量。
- HostedPartition及其实现对象表示Broker本地保存的分区对象的状态。可能的状态包括不存在状态None、在线状态Online和离线状态Offline
- FetchPartitionData定义获取到的分区数据以及重要元数据信息如高水位值、Log Start Offset值等。
- LogReadResult表示副本管理器从副本本地日志中**读取**到的消息数据以及相关元数据信息如高水位值、Log Start Offset值等。
- LogDeleteRecordsResult表示副本管理器执行副本日志**删除**操作后返回的结果信息。
- LogAppendResult表示副本管理器执行副本日志**写入**操作后返回的结果信息。
从含义来看FetchPartitionData和LogReadResult很类似它们的区别在哪里呢
其实它们之间的差别非常小。如果翻开源码的话你会发现FetchPartitionData类总共有8个字段而构建FetchPartitionData实例的前7个字段都是用LogReadResult的字段来赋值的。你大致可以认为两者的作用是类似的。只是FetchPartitionData还有个字段标识该分区是不是处于重分配中。如果是的话需要更新特定的JXM监控指标。这是这两个类的主要区别。
在这7个部分中ReplicaManager类是我们学习的重点。其他类要么仅定义常量要么就是保存数据的POJO类作用一目了然我就不展开讲了。
## ReplicaManager类定义
接下来我们就从Replica类的定义和重要字段这两个维度入手进行学习。首先看ReplicaManager类的定义。
```
class ReplicaManager(
val config: KafkaConfig, // 配置管理类
metrics: Metrics, // 监控指标类
time: Time, // 定时器类
val zkClient: KafkaZkClient, // ZooKeeper客户端
scheduler: Scheduler, // Kafka调度器
val isShuttingDown: AtomicBoolean, // 是否已经关闭
quotaManagers: QuotaManagers, // 配额管理器
val brokerTopicStats: BrokerTopicStats, // Broker主题监控指标类
val metadataCache: MetadataCache, // Broker元数据缓存
logDirFailureChannel: LogDirFailureChannel,
// 处理延时PRODUCE请求的Purgatory
val delayedProducePurgatory: DelayedOperationPurgatory[DelayedProduce],
// 处理延时FETCH请求的Purgatory
val delayedFetchPurgatory: DelayedOperationPurgatory[DelayedFetch],
// 处理延时DELETE_RECORDS请求的Purgatory
val delayedDeleteRecordsPurgatory: DelayedOperationPurgatory[DelayedDeleteRecords],
// 处理延时ELECT_LEADERS请求的Purgatory
val delayedElectLeaderPurgatory: DelayedOperationPurgatory[DelayedElectLeader],
threadNamePrefix: Option[String]) extends Logging with KafkaMetricsGroup {
......
}
```
ReplicaManager类构造函数的字段非常多。有的字段含义很简单像time和metrics这类字段你一看就明白了我就不多说了我详细解释几个比较关键的字段。这些字段是我们理解副本管理器的重要基础。
**1.logManager**
这是日志管理器。它负责创建和管理分区的日志对象里面定义了很多操作日志对象的方法如getOrCreateLog等。
**2.metadataCache**
这是Broker端的元数据缓存保存集群上分区的Leader、ISR等信息。注意它和我们之前说的Controller端元数据缓存是有联系的。每台Broker上的元数据缓存是从Controller端的元数据缓存异步同步过来的。
**3.logDirFailureChannel**
这是失效日志路径的处理器类。Kafka 1.1版本新增了对于JBOD的支持。这也就是说Broker如果配置了多个日志路径当某个日志路径不可用之后比如该路径所在的磁盘已满Broker能够继续工作。那么这就需要一整套机制来保证在出现磁盘I/O故障时Broker的正常磁盘下的副本能够正常提供服务。
其中logDirFailureChannel是暂存失效日志路径的管理器类。我们不用具体学习这个特性的源码但你最起码要知道该功能算是Kafka提升服务器端高可用性的一个改进。有了它之后即使Broker上的单块磁盘坏掉了整个Broker的服务也不会中断。
**4.四个Purgatory相关的字段**
这4个字段是delayedProducePurgatory、delayedFetchPurgatory、delayedDeleteRecordsPurgatory和delayedElectLeaderPurgatory它们分别管理4类延时请求的。其中前两类我们应该不陌生就是处理延时生产者请求和延时消费者请求后面两类是处理延时消息删除请求和延时Leader选举请求属于比较高阶的用法可以暂时不用理会
在副本管理过程中状态的变更大多都会引发对延时请求的处理这时候这些Purgatory字段就派上用场了。
只要掌握了刚刚的这些字段就可以应对接下来的副本管理操作了。其中最重要的就是logManager。它是协助副本管理器操作集群副本对象的关键组件。
## 重要的自定义字段
学完了类定义我们看下ReplicaManager类中那些重要的自定义字段。这样的字段大约有20个我们不用花时间逐一学习它们。像isrExpandRate、isrShrinkRate这样的字段我们只看名字就能知道它们是衡量ISR变化的监控指标。下面我详细介绍几个对理解副本管理器至关重要的字段。我会结合代码具体讲解它们的含义同时还会说明它们的重要用途。
### controllerEpoch
我们首先来看controllerEpoch字段。
这个字段的作用是**隔离过期Controller发送的请求**。这就是说老的Controller发送的请求不能再被继续处理了。至于如何区分是老Controller发送的请求还是新Controller发送的请求就是**看请求携带的controllerEpoch值是否等于这个字段的值**。以下是它的定义代码:
```
@volatile var controllerEpoch: Int =
KafkaController.InitialControllerEpoch
```
该字段表示最新一次变更分区Leader的Controller的Epoch值其默认值为0。Controller每发生一次变更该字段值都会+1。
在ReplicaManager的代码中很多地方都会用到它来判断Controller发送过来的控制类请求是否合法。如果请求中携带的controllerEpoch值小于该字段值就说明这个请求是由一个老的Controller发出的因此ReplicaManager直接拒绝该请求的处理。
值得注意的是它是一个var类型这就说明它的值是能够动态修改的。当ReplicaManager在处理控制类请求时会更新该字段。可以看下下面的代码
```
// becomeLeaderOrFollower方法
// 处理LeaderAndIsrRequest请求时
controllerEpoch = leaderAndIsrRequest.controllerEpoch
// stopReplicas方法
// 处理StopReplicaRequest请求时
this.controllerEpoch = controllerEpoch
// maybeUpdateMetadataCache方法
// 处理UpdateMetadataRequest请求时
controllerEpoch = updateMetadataRequest.controllerEpoch
```
Broker上接收的所有请求都是由Kafka I/O线程处理的而I/O线程可能有多个因此这里的controllerEpoch字段被声明为volatile型以保证其内存可见性。
### allPartitions
下一个重要的字段是allPartitions。这节课刚开始时我说过Kafka没有所谓的分区管理器ReplicaManager类承担了一部分分区管理的工作。这里的allPartitions就承载了Broker上保存的所有分区对象数据。其定义代码如下
```
private val allPartitions = new Pool[TopicPartition, HostedPartition](
valueFactory = Some(tp =&gt; HostedPartition.Online(Partition(tp, time, this)))
)
```
从代码可见allPartitions是分区Partition对象实例的容器。这里的HostedPartition是代表分区状态的类。allPartitions会将所有分区对象初始化成Online状态。
值得注意的是这里的分区状态和我们之前讲到的分区状态机里面的状态完全隶属于两套“领导班子”。也许未来它们会有合并的可能。毕竟它们二者的功能是有重叠的地方的表示的含义也有相似之处。比如它们都定义了Online状态其实都是表示正常工作状态下的分区状态。当然这只是我根据源码功能做的一个大胆推测至于是否会合并我们拭目以待吧。
再多说一句Partition类是表征分区的对象。一个Partition实例定义和管理单个分区它主要是利用logManager帮助它完成对分区底层日志的操作。ReplicaManager类对于分区的管理都是通过Partition对象完成的。
### replicaFetcherManager
第三个比较关键的字段是replicaFetcherManager。它的主要任务是**创建ReplicaFetcherThread类实例**。上节课我们学习了ReplicaFetcherThread类的源码它的主要职责是**帮助Follower副本向Leader副本拉取消息并写入到本地日志中**。
下面展示了ReplicaFetcherManager类的主要方法createFetcherThread源码
```
override def createFetcherThread(fetcherId: Int, sourceBroker: BrokerEndPoint): ReplicaFetcherThread = {
val prefix = threadNamePrefix.map(tp =&gt; s&quot;$tp:&quot;).getOrElse(&quot;&quot;)
val threadName = s&quot;${prefix}ReplicaFetcherThread-$fetcherId-${sourceBroker.id}&quot;
// 创建ReplicaFetcherThread线程实例并返回
new ReplicaFetcherThread(threadName, fetcherId, sourceBroker, brokerConfig, failedPartitions, replicaManager,
metrics, time, quotaManager)
}
```
该方法的主要目的是创建ReplicaFetcherThread实例供Follower副本使用。线程的名字是根据fetcherId和Broker ID来确定的。ReplicaManager类利用replicaFetcherManager字段对所有Fetcher线程进行管理包括线程的创建、启动、添加、停止和移除。
## 总结
这节课我主要介绍了ReplicaManager类的定义以及重要字段。它们是理解后面ReplicaManager类管理功能的基础。
总的来说ReplicaManager类是Kafka Broker端管理分区和副本对象的重要组件。每个Broker在启动的时候都会创建ReplicaManager实例。该实例一旦被创建就会开始行使副本管理器的职责对其下辖的Leader副本或Follower副本进行管理。
我们再简单回顾一下这节课的重点。
- ReplicaManager类副本管理器的具体实现代码里面定义了读写副本、删除副本消息的方法以及其他的一些管理方法。
- allPartitions字段承载了Broker上保存的所有分区对象数据。ReplicaManager类通过它实现对分区下副本的管理。
- replicaFetcherManager字段创建ReplicaFetcherThread类实例该线程类实现Follower副本向Leader副本实时拉取消息的逻辑。
<img src="https://static001.geekbang.org/resource/image/b8/27/b84b7e14a664f0907994ec78c1d19827.jpg" alt="">
今天我多次提到ReplicaManager是副本管理器这件事。实际上副本管理中的两个重要功能就是读取副本对象和写入副本对象。对于Leader副本而言Follower副本需要读取它的消息数据对于Follower副本而言它拿到Leader副本的消息后需要将消息写入到自己的底层日志上。那么读写副本的机制是怎么样的呢下节课我们深入地探究一下ReplicaManager类重要的副本读写方法。
## 课后讨论
在ReplicaManager类中有一个offlinePartitionCount方法它的作用是统计Offline状态的分区数你能写一个方法统计Online状态的分区数吗
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,337 @@
<audio id="audio" title="24 | ReplicaManager副本管理器是如何读写副本的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/1d/8c518f523d1ab848c19dd198b6b17f1d.mp3"></audio>
你好我是胡夕。上节课我们学习了ReplicaManager类的定义和重要字段今天我们接着学习这个类中的读写副本对象部分的源码。无论是读取副本还是写入副本都是通过底层的Partition对象完成的而这些分区对象全部保存在上节课所学的allPartitions字段中。可以说理解这些字段的用途是后续我们探索副本管理器类功能的重要前提。
现在我们就来学习下副本读写功能。整个Kafka的同步机制本质上就是副本读取+副本写入搞懂了这两个功能你就知道了Follower副本是如何同步Leader副本数据的。
## 副本写入appendRecords
所谓的副本写入是指向副本底层日志写入消息。在ReplicaManager类中实现副本写入的方法叫appendRecords。
放眼整个Kafka源码世界需要副本写入的场景有4个。
- 场景一生产者向Leader副本写入消息
- 场景二Follower副本拉取消息后写入副本
- 场景三:消费者组写入组信息;
- 场景四:事务管理器写入事务信息(包括事务标记、事务元数据等)。
除了第二个场景是直接调用Partition对象的方法实现之外其他3个都是调用appendRecords来完成的。
该方法将给定一组分区的消息写入到对应的Leader副本中并且根据PRODUCE请求中acks设置的不同有选择地等待其他副本写入完成。然后调用指定的回调逻辑。
我们先来看下它的方法签名:
```
def appendRecords(
timeout: Long, // 请求处理超时时间
requiredAcks: Short, // 请求acks设置
internalTopicsAllowed: Boolean, // 是否允许写入内部主题
origin: AppendOrigin, // 写入方来源
entriesPerPartition: Map[TopicPartition, MemoryRecords], // 待写入消息
// 回调逻辑
responseCallback: Map[TopicPartition, PartitionResponse] =&gt; Unit,
delayedProduceLock: Option[Lock] = None,
recordConversionStatsCallback:
Map[TopicPartition, RecordConversionStats] =&gt; Unit = _ =&gt; ())
: Unit = {
......
}
```
输入参数有很多,而且都很重要,我一个一个地说。
- **timeout**请求处理超时时间。对于生产者来说它就是request.timeout.ms参数值。
- **requiredAcks**是否需要等待其他副本写入。对于生产者而言它就是acks参数的值。而在其他场景中Kafka默认使用-1表示等待其他副本全部写入成功再返回。
- **internalTopicsAllowed**是否允许向内部主题写入消息。对于普通的生产者而言该字段是False即不允许写入内部主题。对于Coordinator组件特别是消费者组GroupCoordinator组件来说它的职责之一就是向内部位移主题写入消息因此此时该字段值是True。
- **origin**AppendOrigin是一个接口表示写入方来源。当前它定义了3类写入方分别是Replication、Coordinator和Client。Replication表示写入请求是由Follower副本发出的它要将从Leader副本获取到的消息写入到底层的消息日志中。Coordinator表示这些写入由Coordinator发起它既可以是管理消费者组的GroupCooridnator也可以是管理事务的TransactionCoordinator。Client表示本次写入由客户端发起。前面我们说过了Follower副本同步过程不调用appendRecords方法因此这里的origin值只可能是Replication或Coordinator。
- **entriesPerPartitio**n按分区分组的、实际要写入的消息集合。
- **responseCallback**:写入成功之后,要调用的回调逻辑函数。
- **delayedProduceLock**:专门用来保护消费者组操作线程安全的锁对象,在其他场景中用不到。
- **recordConversionStatsCallback**:消息格式转换操作的回调统计逻辑,主要用于统计消息格式转换操作过程中的一些数据指标,比如总共转换了多少条消息,花费了多长时间。
接下来我们就看看appendRecords如何利用这些输入参数向副本日志写入消息。我把它的完整代码贴出来。对于重要的步骤我标注了注释
```
// requiredAcks合法取值是-101否则视为非法
if (isValidRequiredAcks(requiredAcks)) {
val sTime = time.milliseconds
// 调用appendToLocalLog方法写入消息集合到本地日志
val localProduceResults = appendToLocalLog(
internalTopicsAllowed = internalTopicsAllowed,
origin, entriesPerPartition, requiredAcks)
debug(&quot;Produce to local log in %d ms&quot;.format(time.milliseconds - sTime))
val produceStatus = localProduceResults.map { case (topicPartition, result) =&gt;
topicPartition -&gt;
ProducePartitionStatus(
result.info.lastOffset + 1, // 设置下一条待写入消息的位移值
// 构建PartitionResponse封装写入结果
new PartitionResponse(result.error, result.info.firstOffset.getOrElse(-1), result.info.logAppendTime,
result.info.logStartOffset, result.info.recordErrors.asJava, result.info.errorMessage))
}
// 尝试更新消息格式转换的指标数据
recordConversionStatsCallback(localProduceResults.map { case (k, v) =&gt; k -&gt; v.info.recordConversionStats })
// 需要等待其他副本完成写入
if (delayedProduceRequestRequired(
requiredAcks, entriesPerPartition, localProduceResults)) {
val produceMetadata = ProduceMetadata(requiredAcks, produceStatus)
// 创建DelayedProduce延时请求对象
val delayedProduce = new DelayedProduce(timeout, produceMetadata, this, responseCallback, delayedProduceLock)
val producerRequestKeys = entriesPerPartition.keys.map(TopicPartitionOperationKey(_)).toSeq
// 再一次尝试完成该延时请求
// 如果暂时无法完成则将对象放入到相应的Purgatory中等待后续处理
delayedProducePurgatory.tryCompleteElseWatch(delayedProduce, producerRequestKeys)
} else { // 无需等待其他副本写入完成可以立即发送Response
val produceResponseStatus = produceStatus.map { case (k, status) =&gt; k -&gt; status.responseStatus }
// 调用回调逻辑然后返回即可
responseCallback(produceResponseStatus)
}
} else { // 如果requiredAcks值不合法
val responseStatus = entriesPerPartition.map { case (topicPartition, _) =&gt;
topicPartition -&gt; new PartitionResponse(Errors.INVALID_REQUIRED_ACKS,
LogAppendInfo.UnknownLogAppendInfo.firstOffset.getOrElse(-1), RecordBatch.NO_TIMESTAMP, LogAppendInfo.UnknownLogAppendInfo.logStartOffset)
}
// 构造INVALID_REQUIRED_ACKS异常并封装进回调函数调用中
responseCallback(responseStatus)
}
```
为了帮助你更好地理解我再用一张图说明一下appendRecords方法的完整流程。
<img src="https://static001.geekbang.org/resource/image/52/d4/52f1dc751ecfc95f509d1f001ff551d4.jpg" alt="">
我再给你解释一下它的执行流程。
首先它会判断requiredAcks的取值是否在合理范围内也就是“是否是-1、0、1这3个数值中的一个”。如果不是合理取值代码就进入到外层的else分支构造名为INVALID_REQUIRED_ACKS的异常并将其封装进回调函数中执行然后返回结果。否则的话代码进入到外层的if分支下。
进入到if分支后代码调用**appendToLocalLog**方法将要写入的消息集合保存到副本的本地日志上。然后构造PartitionResponse对象实例来封装写入结果以及一些重要的元数据信息比如本次写入有没有错误errorMessage、下一条待写入消息的位移值、本次写入消息集合首条消息的位移值等等。待这些做完了之后代码会尝试更新消息格式转换的指标数据。此时源码需要调用delayedProduceRequestRequired方法来判断本次写入是否算是成功了。
如果还需要等待其他副本同步完成消息写入那么就不能立即返回代码要创建DelayedProduce延时请求对象并把该对象交由Purgatory来管理。DelayedProduce是生产者端的延时发送请求对应的Purgatory就是ReplicaManager类构造函数中的delayedProducePurgatory。所谓的Purgatory管理主要是调用tryCompleteElseWatch方法尝试完成延时发送请求。如果暂时无法完成就将对象放入到相应的Purgatory中等待后续处理。
如果无需等待其他副本同步完成消息写入那么appendRecords方法会构造响应的Response并调用回调逻辑函数至此方法结束。
从刚刚的分析中我们可以知道appendRecords实现消息写入的方法是**appendToLocalLog**,用于判断是否需要等待其他副本写入的方法是**delayedProduceRequestRequired**。下面我们就深入地学习下这两个方法的代码。
首先来看appendToLocalLog。从它的名字来看就是写入副本本地日志。我们来看一下该方法的主要代码片段。
```
private def appendToLocalLog(
internalTopicsAllowed: Boolean,
origin: AppendOrigin,
entriesPerPartition: Map[TopicPartition, MemoryRecords],
requiredAcks: Short): Map[TopicPartition, LogAppendResult] = {
......
entriesPerPartition.map { case (topicPartition, records) =&gt;
brokerTopicStats.topicStats(topicPartition.topic)
.totalProduceRequestRate.mark()
brokerTopicStats.allTopicsStats.totalProduceRequestRate.mark()
// 如果要写入的主题是内部主题而internalTopicsAllowed=false则返回错误
if (Topic.isInternal(topicPartition.topic)
&amp;&amp; !internalTopicsAllowed) {
(topicPartition, LogAppendResult(
LogAppendInfo.UnknownLogAppendInfo,
Some(new InvalidTopicException(s&quot;Cannot append to internal topic ${topicPartition.topic}&quot;))))
} else {
try {
// 获取分区对象
val partition = getPartitionOrException(topicPartition, expectLeader = true)
// 向该分区对象写入消息集合
val info = partition.appendRecordsToLeader(records, origin, requiredAcks)
......
// 返回写入结果
(topicPartition, LogAppendResult(info))
} catch {
......
}
}
}
}
```
我忽略了很多打日志以及错误处理的代码。你可以看到该方法主要就是利用Partition的appendRecordsToLeader方法写入消息集合而后者就是利用我们在[第3节课](https://time.geekbang.org/column/article/225993)学到的appendAsLeader方法写入本地日志的。总体来说appendToLocalLog的逻辑不复杂你应该很容易理解。
下面我们看下delayedProduceRequestRequired方法的源码。它用于判断消息集合被写入到日志之后是否需要等待其他副本也写入成功。我们看下它的代码
```
private def delayedProduceRequestRequired(
requiredAcks: Short,
entriesPerPartition: Map[TopicPartition, MemoryRecords],
localProduceResults: Map[TopicPartition, LogAppendResult]): Boolean = {
requiredAcks == -1 &amp;&amp; entriesPerPartition.nonEmpty &amp;&amp;
localProduceResults.values.count(_.exception.isDefined) &lt; entriesPerPartition.size
}
```
该方法返回一个布尔值True表示需要等待其他副本完成False表示无需等待。上面的代码表明如果需要等待其他副本的写入就必须同时满足3个条件
1. requiredAcks必须等于-1
1. 依然有数据尚未写完;
1. 至少有一个分区的消息已经成功地被写入到本地日志。
其实你可以把条件2和3联合在一起来看。如果所有分区的数据写入都不成功就表明可能出现了很严重的错误此时比较明智的做法是不再等待而是直接返回错误给发送方。相反地如果有部分分区成功写入而部分分区写入失败了就表明可能是由偶发的瞬时错误导致的。此时不妨将本次写入请求放入Purgatory再给它一个重试的机会。
## 副本读取fetchMessages
好了,说完了副本的写入,下面我们进入到副本读取的源码学习。
在ReplicaManager类中负责读取副本数据的方法是fetchMessages。不论是Java消费者API还是Follower副本它们拉取消息的主要途径都是向Broker发送FETCH请求Broker端接收到该请求后调用fetchMessages方法从底层的Leader副本取出消息。
和appendRecords方法类似fetchMessages方法也可能会延时处理FETCH请求因为Broker端必须要累积足够多的数据之后才会返回Response给请求发送方。
可以看一下下面的这张流程图它展示了fetchMessages方法的主要逻辑。
<img src="https://static001.geekbang.org/resource/image/0f/2c/0f4b45008bdf0b83d0865c7db6d5452c.jpg" alt="">
我们来看下该方法的签名:
```
def fetchMessages(timeout: Long,
replicaId: Int,
fetchMinBytes: Int,
fetchMaxBytes: Int,
hardMaxBytesLimit: Boolean,
fetchInfos: Seq[(TopicPartition, PartitionData)],
quota: ReplicaQuota,
responseCallback: Seq[(TopicPartition, FetchPartitionData)] =&gt; Unit,
isolationLevel: IsolationLevel,
clientMetadata: Option[ClientMetadata]): Unit = {
......
}
```
这些输入参数都是我们理解下面的重要方法的基础,所以,我们来逐个分析一下。
- **timeout**请求处理超时时间。对于消费者而言该值就是request.timeout.ms参数值对于Follower副本而言该值是Broker端参数replica.fetch.wait.max.ms的值。
- **replicaId**副本ID。对于消费者而言该参数值是-1对于Follower副本而言该值就是Follower副本所在的Broker ID。
- **fetchMinBytes &amp; fetchMaxBytes**能够获取的最小字节数和最大字节数。对于消费者而言它们分别对应于Consumer端参数fetch.min.bytes和fetch.max.bytes值对于Follower副本而言它们分别对应于Broker端参数replica.fetch.min.bytes和replica.fetch.max.bytes值。
- **hardMaxBytesLimit**对能否超过最大字节数做硬限制。如果hardMaxBytesLimit=True就表示读取请求返回的数据字节数绝不允许超过最大字节数。
- **fetchInfos**:规定了读取分区的信息,比如要读取哪些分区、从这些分区的哪个位移值开始读、最多可以读多少字节,等等。
- **quota**:这是一个配额控制类,主要是为了判断是否需要在读取的过程中做限速控制。
- **responseCallback**Response回调逻辑函数。当请求被处理完成后调用该方法执行收尾逻辑。
有了这些铺垫之后我们进入到方法代码的学习。为了便于学习我将整个方法的代码分成两部分第一部分是读取本地日志第二部分是根据读取结果确定Response。
我们先看第一部分的源码:
```
// 判断该读取请求是否来自于Follower副本或Consumer
val isFromFollower = Request.isValidBrokerId(replicaId)
val isFromConsumer = !(isFromFollower || replicaId == Request.FutureLocalReplicaId)
// 根据请求发送方判断可读取范围
// 如果请求来自于普通消费者,那么可以读到高水位值
// 如果请求来自于配置了READ_COMMITTED的消费者那么可以读到Log Stable Offset值
// 如果请求来自于Follower副本那么可以读到LEO值
val fetchIsolation = if (!isFromConsumer)
FetchLogEnd
else if (isolationLevel == IsolationLevel.READ_COMMITTED)
FetchTxnCommitted
else
FetchHighWatermark
val fetchOnlyFromLeader = isFromFollower || (isFromConsumer &amp;&amp; clientMetadata.isEmpty)
// 定义readFromLog方法读取底层日志中的消息
def readFromLog(): Seq[(TopicPartition, LogReadResult)] = {
val result = readFromLocalLog(
replicaId = replicaId,
fetchOnlyFromLeader = fetchOnlyFromLeader,
fetchIsolation = fetchIsolation,
fetchMaxBytes = fetchMaxBytes,
hardMaxBytesLimit = hardMaxBytesLimit,
readPartitionInfo = fetchInfos,
quota = quota,
clientMetadata = clientMetadata)
if (isFromFollower) updateFollowerFetchState(replicaId, result)
else result
}
// 读取消息并返回日志读取结果
val logReadResults = readFromLog()
```
这部分代码首先会判断读取消息的请求方到底是Follower副本还是普通的Consumer。判断的依据就是看**replicaId字段是否大于0**。Consumer的replicaId是-1而Follower副本的则是大于0的数。一旦确定了请求方代码就能确定可读取范围。
这里的fetchIsolation是读取隔离级别的意思。对于Follower副本而言它能读取到Leader副本LEO值以下的所有消息对于普通Consumer而言它只能“看到”Leader副本高水位值以下的消息。
待确定了可读取范围后fetchMessages方法会调用它的内部方法**readFromLog**读取本地日志上的消息数据并将结果赋值给logReadResults变量。readFromLog方法的主要实现是调用readFromLocalLog方法而后者就是在待读取分区上依次调用其日志对象的read方法执行实际的消息读取。
fetchMessages方法的第二部分是根据上一步的读取结果创建对应的Response。我们看下具体实现
```
var bytesReadable: Long = 0
var errorReadingData = false
val logReadResultMap = new mutable.HashMap[TopicPartition, LogReadResult]
// 统计总共可读取的字节数
logReadResults.foreach { case (topicPartition, logReadResult) =&gt;
brokerTopicStats.topicStats(topicPartition.topic).totalFetchRequestRate.mark()
brokerTopicStats.allTopicsStats.totalFetchRequestRate.mark()
if (logReadResult.error != Errors.NONE)
errorReadingData = true
bytesReadable = bytesReadable + logReadResult.info.records.sizeInBytes
logReadResultMap.put(topicPartition, logReadResult)
}
// 判断是否能够立即返回Reponse满足以下4个条件中的任意一个即可
// 1. 请求没有设置超时时间,说明请求方想让请求被处理后立即返回
// 2. 未获取到任何数据
// 3. 已累积到足够多的数据
// 4. 读取过程中出错
if (timeout &lt;= 0 || fetchInfos.isEmpty || bytesReadable &gt;= fetchMinBytes || errorReadingData) {
// 构建返回结果
val fetchPartitionData = logReadResults.map { case (tp, result) =&gt;
tp -&gt; FetchPartitionData(result.error, result.highWatermark, result.leaderLogStartOffset, result.info.records,
result.lastStableOffset, result.info.abortedTransactions, result.preferredReadReplica, isFromFollower &amp;&amp; isAddingReplica(tp, replicaId))
}
// 调用回调函数
responseCallback(fetchPartitionData)
} else { // 如果无法立即完成请求
val fetchPartitionStatus = new mutable.ArrayBuffer[(TopicPartition, FetchPartitionStatus)]
fetchInfos.foreach { case (topicPartition, partitionData) =&gt;
logReadResultMap.get(topicPartition).foreach(logReadResult =&gt; {
val logOffsetMetadata = logReadResult.info.fetchOffsetMetadata
fetchPartitionStatus += (topicPartition -&gt; FetchPartitionStatus(logOffsetMetadata, partitionData))
})
}
val fetchMetadata: SFetchMetadata = SFetchMetadata(fetchMinBytes, fetchMaxBytes, hardMaxBytesLimit,
fetchOnlyFromLeader, fetchIsolation, isFromFollower, replicaId, fetchPartitionStatus)
// 构建DelayedFetch延时请求对象
val delayedFetch = new DelayedFetch(timeout, fetchMetadata, this, quota, clientMetadata,
responseCallback)
val delayedFetchKeys = fetchPartitionStatus.map { case (tp, _) =&gt; TopicPartitionOperationKey(tp) }
// 再一次尝试完成请求如果依然不能完成则交由Purgatory等待后续处理
delayedFetchPurgatory.tryCompleteElseWatch(delayedFetch, delayedFetchKeys)
}
```
这部分代码首先会根据上一步得到的读取结果统计可读取的总字节数之后判断此时是否能够立即返回Reponse。那么怎么判断是否能够立即返回Response呢实际上只要满足以下4个条件中的任意一个即可
1. 请求没有设置超时时间,说明请求方想让请求被处理后立即返回;
1. 未获取到任何数据;
1. 已累积到足够多数据;
1. 读取过程中出错。
如果这4个条件一个都不满足就需要进行延时处理了。具体来说就是构建DelayedFetch对象然后把该延时对象交由delayedFetchPurgatory后续自动处理。
至此关于副本管理器读写副本的两个方法appendRecords和fetchMessages我们就学完了。本质上它们在底层分别调用Log的append和read方法以实现本地日志的读写操作。当完成读写操作之后这两个方法还定义了延时处理的条件。一旦发现满足了延时处理的条件就交给对应的Purgatory进行处理。
从这两个方法中我们已经看到了之前课程中单个组件融合在一起的趋势。就像我在开篇词里面说的虽然我们学习单个源码文件的顺序是自上而下但串联Kafka主要组件功能的路径却是自下而上。
就拿这节课的副本写入操作来说日志对象的append方法被上一层Partition对象中的方法调用而后者又进一步被副本管理器中的方法调用。我们是按照自上而下的方式阅读副本管理器、日志对象等单个组件的代码了解它们各自的独立功能的现在我们开始慢慢地把它们融合在一起勾勒出了Kafka操作分区副本日志对象的完整调用路径。咱们同时采用这两种方式来阅读源码就可以更快、更深入地搞懂Kafka源码的原理了。
## 总结
今天我们学习了Kafka副本状态机类ReplicaManager是如何读写副本的重点学习了它的两个重要方法appendRecords和fetchMessages。我们再简单回顾一下。
- appendRecords向副本写入消息的方法主要利用Log的append方法和Purgatory机制共同实现Follower副本向Leader副本获取消息后的数据同步工作。
- fetchMessages从副本读取消息的方法为普通Consumer和Follower副本所使用。当它们向Broker发送FETCH请求时Broker上的副本管理器调用该方法从本地日志中获取指定消息。
<img src="https://static001.geekbang.org/resource/image/29/b3/295faae205df4255d2861d658df10db3.jpg" alt=""><br>
下节课中,我们要把重心转移到副本管理器对副本和分区对象的管理上。这是除了读写副本之外,副本管理器另一大核心功能,你一定不要错过!
## 课后讨论
appendRecords参数列表中有个origin。我想请你思考一下在写入本地日志的过程中这个参数的作用是什么你能找出最终使用origin参数的具体源码位置吗
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,609 @@
<audio id="audio" title="25 | ReplicaManager副本管理器是如何管理副本的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/ff/63ddc8e509cb9d10e74e9875984145ff.mp3"></audio>
你好,我是胡夕。
上节课我们学习了ReplicaManager类源码中副本管理器是如何执行副本读写操作的。现在我们知道了这个副本读写操作主要是通过appendRecords和fetchMessages这两个方法实现的而这两个方法其实在底层分别调用了Log的append和read方法也就是我们在[第3节课](https://time.geekbang.org/column/article/225993)中学到的日志消息写入和日志消息读取方法。
今天我们继续学习ReplicaManager类源码看看副本管理器是如何管理副本的。这里的副本涵盖了广义副本对象的方方面面包括副本和分区对象、副本位移值和ISR管理等。因此本节课我们结合着源码具体学习下这几个方面。
## 分区及副本管理
除了对副本进行读写之外副本管理器还有一个重要的功能就是管理副本和对应的分区。ReplicaManager管理它们的方式是通过字段allPartitions来实现的。
所以,我想先带你复习下[第23节课](https://time.geekbang.org/column/article/249682)中的allPartitions的代码。不过这次为了强调它作为容器的属性我们要把注意力放在它是对象池这个特点上即allPartitions把所有分区对象汇集在一起统一放入到一个对象池进行管理。
```
private val allPartitions = new Pool[TopicPartition, HostedPartition](
valueFactory = Some(tp =&gt; HostedPartition.Online(Partition(tp, time, this)))
)
```
从代码可以看到每个ReplicaManager实例都维护了所在Broker上保存的所有分区对象而每个分区对象Partition下面又定义了一组副本对象Replica。通过这样的层级关系副本管理器实现了对于分区的直接管理和对副本对象的间接管理。应该这样说**ReplicaManager通过直接操作分区对象来间接管理下属的副本对象**。
对于一个Broker而言它管理下辖的分区和副本对象的主要方式就是要确定在它保存的这些副本中哪些是Leader副本、哪些是Follower副本。
这些划分可不是一成不变的而是随着时间的推移不断变化的。比如说这个时刻Broker是分区A的Leader副本、分区B的Follower副本但在接下来的某个时刻Broker很可能变成分区A的Follower副本、分区B的Leader副本。
而这些变更是通过Controller给Broker发送LeaderAndIsrRequest请求来实现的。当Broker端收到这类请求后会调用副本管理器的becomeLeaderOrFollower方法来处理并依次执行“成为Leader副本”和“成为Follower副本”的逻辑令当前Broker互换分区A、B副本的角色。
### becomeLeaderOrFollower方法
这里我们又提到了LeaderAndIsrRequest请求。其实我们在学习Controller和控制类请求的时候就多次提到过它在[第12讲](https://time.geekbang.org/column/article/235904)中也详细学习过它的作用了。因为隔的时间比较长了,我怕你忘记了,所以这里我们再回顾下。
简单来说它就是告诉接收该请求的Broker在我传给你的这些分区中哪些分区的Leader副本在你这里哪些分区的Follower副本在你这里。
becomeLeaderOrFollower方法就是具体处理LeaderAndIsrRequest请求的地方同时也是副本管理器添加分区的地方。下面我们就完整地学习下这个方法的源码。由于这部分代码很长我将会分为3个部分向你介绍分别是处理Controller Epoch事宜、执行成为Leader和Follower的逻辑以及构造Response。
我们先看becomeLeaderOrFollower方法的第1大部分**处理Controller Epoch及其他相关准备工作**的流程图:
<img src="https://static001.geekbang.org/resource/image/20/96/20298371601540a21da0ec5b1a6b1896.jpg" alt="">
因为becomeLeaderOrFollower方法的开头是一段仅用于调试的日志输出不是很重要因此我直接从if语句开始讲起。第一部分的主体代码如下
```
// 如果LeaderAndIsrRequest携带的Controller Epoch
// 小于当前Controller的Epoch值
if (leaderAndIsrRequest.controllerEpoch &lt; controllerEpoch) {
stateChangeLogger.warn(s&quot;Ignoring LeaderAndIsr request from controller $controllerId with &quot; +
s&quot;correlation id $correlationId since its controller epoch ${leaderAndIsrRequest.controllerEpoch} is old. &quot; +
s&quot;Latest known controller epoch is $controllerEpoch&quot;)
// 说明Controller已经易主抛出相应异常
leaderAndIsrRequest.getErrorResponse(0, Errors.STALE_CONTROLLER_EPOCH.exception)
} else {
val responseMap = new mutable.HashMap[TopicPartition, Errors]
// 更新当前Controller Epoch值
controllerEpoch = leaderAndIsrRequest.controllerEpoch
val partitionStates = new mutable.HashMap[Partition, LeaderAndIsrPartitionState]()
// 遍历LeaderAndIsrRequest请求中的所有分区
requestPartitionStates.foreach { partitionState =&gt;
val topicPartition = new TopicPartition(partitionState.topicName, partitionState.partitionIndex)
// 从allPartitions中获取对应分区对象
val partitionOpt = getPartition(topicPartition) match {
// 如果是Offline状态
case HostedPartition.Offline =&gt;
stateChangeLogger.warn(s&quot;Ignoring LeaderAndIsr request from &quot; +
s&quot;controller $controllerId with correlation id $correlationId &quot; +
s&quot;epoch $controllerEpoch for partition $topicPartition as the local replica for the &quot; +
&quot;partition is in an offline log directory&quot;)
// 添加对象异常到Response并设置分区对象变量partitionOpt=None
responseMap.put(topicPartition, Errors.KAFKA_STORAGE_ERROR)
None
// 如果是Online状态直接赋值partitionOpt即可
case HostedPartition.Online(partition) =&gt;
Some(partition)
// 如果是None状态则表示没有找到分区对象
// 那么创建新的分区对象将新创建的分区对象加入到allPartitions统一管理
// 然后赋值partitionOpt字段
case HostedPartition.None =&gt;
val partition = Partition(topicPartition, time, this)
allPartitions.putIfNotExists(topicPartition, HostedPartition.Online(partition))
Some(partition)
}
// 检查分区的Leader Epoch值
......
}
```
现在,我们一起来学习下这部分内容的核心逻辑。
首先比较LeaderAndIsrRequest携带的Controller Epoch值和当前Controller Epoch值。如果发现前者小于后者说明Controller已经变更到别的Broker上了需要构造一个STALE_CONTROLLER_EPOCH异常并封装进Response返回。否则代码进入else分支。
然后becomeLeaderOrFollower方法会更新当前缓存的Controller Epoch值再提取出LeaderAndIsrRequest请求中涉及到的分区之后依次遍历这些分区并执行下面的两步逻辑。
第1步从allPartitions中取出对应的分区对象。在第23节课我们学习了分区有3种状态即在线Online、离线Offline和不存在None这里代码就需要分别应对这3种情况
- 如果是Online状态的分区直接将其赋值给partitionOpt字段即可
- 如果是Offline状态的分区说明该分区副本所在的Kafka日志路径出现I/O故障时比如磁盘满了需要构造对应的KAFKA_STORAGE_ERROR异常并封装进Response同时令partitionOpt字段为None
- 如果是None状态的分区则创建新分区对象然后将其加入到allPartitions中进行统一管理并赋值给partitionOpt字段。
第2步检查partitionOpt字段表示的分区的Leader Epoch。检查的原则是要确保请求中携带的Leader Epoch值要大于当前缓存的Leader Epoch否则就说明是过期Controller发送的请求就直接忽略它不做处理。
总之呢becomeLeaderOrFollower方法的第一部分代码主要做的事情就是创建新分区、更新Controller Epoch和校验分区Leader Epoch。我们在[第3讲](https://time.geekbang.org/column/article/225993)说到过Leader Epoch机制因为是比较高阶的用法你可以不用重点掌握这不会影响到我们学习副本管理。不过如果你想深入了解的话推荐你课下自行阅读下LeaderEpochFileCache.scala的源码。
当为所有分区都执行完这两个步骤之后,**becomeLeaderOrFollower方法进入到第2部分开始执行Broker成为Leader副本和Follower副本的逻辑**
```
// 确定Broker上副本是哪些分区的Leader副本
val partitionsToBeLeader = partitionStates.filter { case (_, partitionState) =&gt;
partitionState.leader == localBrokerId
}
// 确定Broker上副本是哪些分区的Follower副本
val partitionsToBeFollower = partitionStates.filter { case (k, _) =&gt; !partitionsToBeLeader.contains(k) }
val highWatermarkCheckpoints = new LazyOffsetCheckpoints(this.highWatermarkCheckpoints)
val partitionsBecomeLeader = if (partitionsToBeLeader.nonEmpty)
// 调用makeLeaders方法为partitionsToBeLeader所有分区
// 执行&quot;成为Leader副本&quot;的逻辑
makeLeaders(controllerId, controllerEpoch, partitionsToBeLeader, correlationId, responseMap,
highWatermarkCheckpoints)
else
Set.empty[Partition]
val partitionsBecomeFollower = if (partitionsToBeFollower.nonEmpty)
// 调用makeFollowers方法为令partitionsToBeFollower所有分区
// 执行&quot;成为Follower副本&quot;的逻辑
makeFollowers(controllerId, controllerEpoch, partitionsToBeFollower, correlationId, responseMap,
highWatermarkCheckpoints)
else
Set.empty[Partition]
val leaderTopicSet = leaderPartitionsIterator.map(_.topic).toSet
val followerTopicSet = partitionsBecomeFollower.map(_.topic).toSet
// 对于当前Broker成为Follower副本的主题
// 移除它们之前的Leader副本监控指标
followerTopicSet.diff(leaderTopicSet).foreach(brokerTopicStats.removeOldLeaderMetrics)
// 对于当前Broker成为Leader副本的主题
// 移除它们之前的Follower副本监控指
leaderTopicSet.diff(followerTopicSet).foreach(brokerTopicStats.removeOldFollowerMetrics)
// 如果有分区的本地日志为空,说明底层的日志路径不可用
// 标记该分区为Offline状态
leaderAndIsrRequest.partitionStates.forEach { partitionState =&gt;
val topicPartition = new TopicPartition(partitionState.topicName, partitionState.partitionIndex)
if (localLog(topicPartition).isEmpty)
markPartitionOffline(topicPartition)
}
```
**首先**这部分代码需要先确定两个分区集合一个是把该Broker当成Leader的所有分区一个是把该Broker当成Follower的所有分区。判断的依据主要是看LeaderAndIsrRequest请求中分区的Leader信息是不是和本Broker的ID相同。如果相同则表明该Broker是这个分区的Leader否则表示当前Broker是这个分区的Follower。
一旦确定了这两个分区集合,**接着**代码就会分别为它们调用makeLeaders和makeFollowers方法正式让Leader和Follower角色生效。之后对于那些当前Broker成为Follower副本的主题代码需要移除它们之前的Leader副本监控指标以防出现系统资源泄露的问题。同样地对于那些当前Broker成为Leader副本的主题代码要移除它们之前的Follower副本监控指标。
**最后**如果有分区的本地日志为空说明底层的日志路径不可用那么标记该分区为Offline状态。所谓的标记为Offline状态主要是两步第1步是更新allPartitions中分区的状态第2步是移除对应分区的监控指标。
小结一下becomeLeaderOrFollower方法第2大部分的主要功能是调用makeLeaders和makeFollowers方法令Broker在不同分区上的Leader或Follower角色生效。关于这两个方法的实现细节一会儿我再详细说。
现在,让我们看看**第3大部分的代码构造Response对象**。这部分代码是becomeLeaderOrFollower方法的收尾操作。
```
// 启动高水位检查点专属线程
// 定期将Broker上所有非Offline分区的高水位值写入到检查点文件
startHighWatermarkCheckPointThread()
// 添加日志路径数据迁移线程
maybeAddLogDirFetchers(partitionStates.keySet, highWatermarkCheckpoints)
// 关闭空闲副本拉取线程
replicaFetcherManager.shutdownIdleFetcherThreads()
// 关闭空闲日志路径数据迁移线程
replicaAlterLogDirsManager.shutdownIdleFetcherThreads()
// 执行Leader变更之后的回调逻辑
onLeadershipChange(partitionsBecomeLeader, partitionsBecomeFollower)
// 构造LeaderAndIsrRequest请求的Response并返回
val responsePartitions = responseMap.iterator.map { case (tp, error) =&gt;
new LeaderAndIsrPartitionError()
.setTopicName(tp.topic)
.setPartitionIndex(tp.partition)
.setErrorCode(error.code)
}.toBuffer
new LeaderAndIsrResponse(new LeaderAndIsrResponseData()
.setErrorCode(Errors.NONE.code)
.setPartitionErrors(responsePartitions.asJava))
```
我们来分析下这部分代码的执行逻辑吧。
首先这部分开始时会启动一个专属线程来执行高水位值持久化定期地将Broker上所有非Offline分区的高水位值写入检查点文件。这个线程是个后台线程默认每5秒执行一次。
同时代码还会添加日志路径数据迁移线程。这个线程的主要作用是将路径A上面的数据搬移到路径B上。这个功能是Kafka支持JBODJust a Bunch of Disks的重要前提。
之后becomeLeaderOrFollower方法会关闭空闲副本拉取线程和空闲日志路径数据迁移线程。判断空闲与否的主要条件是分区Leader/Follower角色调整之后是否存在不再使用的拉取线程了。代码要确保及时关闭那些不再被使用的线程对象。
再之后是执行LeaderAndIsrRequest请求的回调处理逻辑。这里的回调逻辑实际上只是对Kafka两个内部主题__consumer_offsets和__transaction_state有用其他主题一概不适用。所以通常情况下你可以无视这里的回调逻辑。
等这些都做完之后代码开始执行这部分最后也是最重要的任务构造LeaderAndIsrRequest请求的Response然后将新创建的Response返回。至此这部分方法的逻辑结束。
纵观becomeLeaderOrFollower方法的这3大部分becomeLeaderOrFollower方法最重要的职责在我看来就是调用makeLeaders和makeFollowers方法为各自的分区列表执行相应的角色确认工作。
接下来,我们就分别看看这两个方法是如何实现这种角色确认的。
### makeLeaders方法
makeLeaders方法的作用是让当前Broker成为给定一组分区的Leader也就是让当前Broker下该分区的副本成为Leader副本。这个方法主要有3步
1. 停掉这些分区对应的获取线程;
1. 更新Broker缓存中的分区元数据信息
1. 将指定分区添加到Leader分区集合。
我们结合代码分析下这些都是如何实现的。首先我们看下makeLeaders的方法签名
```
// controllerIdController所在Broker的ID
// controllEpochController Epoch值可以认为是Controller版本号
// partitionStatesLeaderAndIsrRequest请求中携带的分区信息
// correlationId请求的Correlation字段只用于日志调试
// responseMap按照主题分区分组的异常错误集合
// highWatermarkCheckpoints操作磁盘上高水位检查点文件的工具类
private def makeLeaders(controllerId: Int,
controllerEpoch: Int,
partitionStates: Map[Partition, LeaderAndIsrPartitionState],
correlationId: Int,
responseMap: mutable.Map[TopicPartition, Errors],
highWatermarkCheckpoints: OffsetCheckpoints): Set[Partition] = {
......
}
```
可以看出makeLeaders方法接收6个参数并返回一个分区对象集合。这个集合就是当前Broker是Leader的所有分区。在这6个参数中以下3个参数比较关键我们看下它们的含义。
- controllerIdController所在Broker的ID。该字段只是用于日志输出无其他实际用途。
- controllerEpochController Epoch值可以认为是Controller版本号。该字段用于日志输出使用无其他实际用途。
- partitionStatesLeaderAndIsrRequest请求中携带的分区信息包括每个分区的Leader是谁、ISR都有哪些等数据。
好了现在我们继续学习makeLeaders的代码。我把这个方法的关键步骤放在了注释里并省去了一些日志输出相关的代码。
```
......
// 使用Errors.NONE初始化ResponseMap
partitionStates.keys.foreach { partition =&gt;
......
responseMap.put(partition.topicPartition, Errors.NONE)
}
val partitionsToMakeLeaders = mutable.Set[Partition]()
try {
// 停止消息拉取
replicaFetcherManager.removeFetcherForPartitions(
partitionStates.keySet.map(_.topicPartition))
stateChangeLogger.info(s&quot;Stopped fetchers as part of LeaderAndIsr request correlationId $correlationId from &quot; +
s&quot;controller $controllerId epoch $controllerEpoch as part of the become-leader transition for &quot; +
s&quot;${partitionStates.size} partitions&quot;)
// 更新指定分区的Leader分区信息
partitionStates.foreach { case (partition, partitionState) =&gt;
try {
if (partition.makeLeader(partitionState, highWatermarkCheckpoints))
partitionsToMakeLeaders += partition
else
......
} catch {
case e: KafkaStorageException =&gt;
......
// 把KAFKA_SOTRAGE_ERRROR异常封装到Response中
responseMap.put(partition.topicPartition, Errors.KAFKA_STORAGE_ERROR)
}
}
} catch {
case e: Throwable =&gt;
......
}
......
partitionsToMakeLeaders
```
我把主要的执行流程,梳理为了一张流程图:
<img src="https://static001.geekbang.org/resource/image/05/25/053b8eb9c4bb0342398ce9650b37aa25.png" alt="">
结合着图,我再带着你学习下这个方法的执行逻辑。
首先将给定的一组分区的状态全部初始化成Errors.None。
然后停止为这些分区服务的所有拉取线程。毕竟该Broker现在是这些分区的Leader副本了不再是Follower副本了所以没有必要再使用拉取线程了。
最后makeLeaders方法调用Partition的makeLeader方法去更新给定一组分区的Leader分区信息而这些是由Partition类中的makeLeader方法完成的。该方法保存分区的Leader和ISR信息同时创建必要的日志对象、重设远端Follower副本的LEO值。
那远端Follower副本是什么意思呢远端Follower副本是指保存在Leader副本本地内存中的一组Follower副本集合在代码中用字段remoteReplicas来表征。
ReplicaManager在处理FETCH请求时会更新remoteReplicas中副本对象的LEO值。同时Leader副本会将自己更新后的LEO值与remoteReplicas中副本的LEO值进行比较来决定是否“抬高”高水位值。
而Partition类中的makeLeader方法的一个重要步骤就是要重设这组远端Follower副本对象的LEO值。
makeLeaders方法执行完Partition.makeLeader后如果当前Broker成功地成为了该分区的Leader副本就返回True表示新Leader配置成功否则就表示处理失败。倘若成功设置了Leader那么就把该分区加入到已成功设置Leader的分区列表中并返回该列表。
至此方法结束。我再来小结下makeLeaders的作用是令当前Broker成为给定分区的Leader副本。接下来我们再看看与makeLeaders方法功能相反的makeFollowers方法。
### makeFollowers方法
makeFollowers方法的作用是将当前Broker配置成指定分区的Follower副本。我们还是先看下方法签名
```
// controllerIdController所在Broker的Id
// controllerEpochController Epoch值
// partitionStates当前Broker是Follower副本的所有分区的详细信息
// correlationId连接请求与响应的关联字段
// responseMap封装LeaderAndIsrRequest请求处理结果的字段
// highWatermarkCheckpoints操作高水位检查点文件的工具类
private def makeFollowers(
controllerId: Int,
controllerEpoch: Int,
partitionStates: Map[Partition, LeaderAndIsrPartitionState],
correlationId: Int,
responseMap: mutable.Map[TopicPartition, Errors],
highWatermarkCheckpoints: OffsetCheckpoints) : Set[Partition] = {
......
}
```
你看makeFollowers方法的参数列表与makeLeaders方法是一模一样的。这里我也就不再展开了。
其中比较重要的字段就是partitionStates和responseMap。基本上你可以认为partitionStates是makeFollowers方法的输入responseMap是输出。
因为整个makeFollowers方法的代码很长所以我接下来会先用一张图解释下它的核心逻辑让你先有个全局观然后我再按照功能划分带你学习每一部分的代码。
<img src="https://static001.geekbang.org/resource/image/b2/88/b2dee2575c773afedcf6ee7ce00c7b88.jpg" alt="">
总体来看makeFollowers方法分为两大步
- 第1步遍历partitionStates中的所有分区然后执行“成为Follower”的操作
- 第2步执行其他动作主要包括重建Fetcher线程、完成延时请求等。
首先,**我们学习第1步遍历partitionStates所有分区的代码**
```
// 第一部分遍历partitionStates所有分区
......
partitionStates.foreach { case (partition, partitionState) =&gt;
......
// 将所有分区的处理结果的状态初始化为Errors.NONE
responseMap.put(partition.topicPartition, Errors.NONE)
}
val partitionsToMakeFollower: mutable.Set[Partition] = mutable.Set()
try {
// 遍历partitionStates所有分区
partitionStates.foreach { case (partition, partitionState) =&gt;
// 拿到分区的Leader Broker ID
val newLeaderBrokerId = partitionState.leader
try {
// 在元数据缓存中找到Leader Broke对象
metadataCache.getAliveBrokers.find(_.id == newLeaderBrokerId) match {
// 如果Leader确实存在
case Some(_) =&gt;
// 执行makeFollower方法将当前Broker配置成该分区的Follower副本
if (partition.makeFollower(partitionState, highWatermarkCheckpoints))
// 如果配置成功,将该分区加入到结果返回集中
partitionsToMakeFollower += partition
else // 如果失败,打印错误日志
......
// 如果Leader不存在
case None =&gt;
......
// 依然创建出分区Follower副本的日志对象
partition.createLogIfNotExists(isNew = partitionState.isNew, isFutureReplica = false,
highWatermarkCheckpoints)
}
} catch {
case e: KafkaStorageException =&gt;
......
}
}
```
在这部分代码中,我们可以把它的执行逻辑划分为两大步骤。
第1步将结果返回集合中所有分区的处理结果状态初始化为Errors.NONE第2步遍历partitionStates中的所有分区依次为每个分区执行以下逻辑
- 从分区的详细信息中获取分区的Leader Broker ID
- 拿着上一步获取的Broker ID去Broker元数据缓存中找到Leader Broker对象
- 如果Leader对象存在则执行Partition类的makeFollower方法将当前Broker配置成该分区的Follower副本。如果makeFollower方法执行成功就说明当前Broker被成功配置为指定分区的Follower副本那么将该分区加入到结果返回集中。
- 如果Leader对象不存在依然创建出分区Follower副本的日志对象。
说到Partition的makeFollower方法的执行逻辑主要是包括以下4步
1. 更新Controller Epoch值
1. 保存副本列表Assigned ReplicasAR和清空ISR
1. 创建日志对象;
1. 重设Leader副本的Broker ID。
接下来,**我们看下makeFollowers方法的第2步执行其他动作的代码**
```
// 第二部分:执行其他动作
// 移除现有Fetcher线程
replicaFetcherManager.removeFetcherForPartitions(
partitionsToMakeFollower.map(_.topicPartition))
......
// 尝试完成延迟请求
partitionsToMakeFollower.foreach { partition =&gt;
completeDelayedFetchOrProduceRequests(partition.topicPartition)
}
if (isShuttingDown.get()) {
.....
} else {
// 为需要将当前Broker设置为Follower副本的分区
// 确定Leader Broker和起始读取位移值fetchOffset
val partitionsToMakeFollowerWithLeaderAndOffset = partitionsToMakeFollower.map { partition =&gt;
val leader = metadataCache.getAliveBrokers
.find(_.id == partition.leaderReplicaIdOpt.get).get
.brokerEndPoint(config.interBrokerListenerName)
val fetchOffset = partition.localLogOrException.highWatermark
partition.topicPartition -&gt; InitialFetchState(leader,
partition.getLeaderEpoch, fetchOffset)
}.toMap
// 使用上一步确定的Leader Broker和fetchOffset添加新的Fetcher线程
replicaFetcherManager.addFetcherForPartitions(
partitionsToMakeFollowerWithLeaderAndOffset)
}
} catch {
case e: Throwable =&gt;
......
throw e
}
......
// 返回需要将当前Broker设置为Follower副本的分区列表
partitionsToMakeFollower
```
你看,这部分代码的任务比较简单,逻辑也都是线性递进的,很好理解。我带你简单地梳理一下。
首先移除现有Fetcher线程。因为Leader可能已经更换了所以要读取的Broker以及要读取的位移值都可能随之发生变化。
然后为需要将当前Broker设置为Follower副本的分区确定Leader Broker和起始读取位移值fetchOffset。这些信息都已经在LeaderAndIsrRequest中了。
接下来使用上一步确定的Leader Broker和fetchOffset添加新的Fetcher线程。
最后返回需要将当前Broker设置为Follower副本的分区列表。
至此副本管理器管理分区和副本的主要方法实现我们就都学完啦。可以看出这些代码实现的大部分都是围绕着如何处理LeaderAndIsrRequest请求数据展开的。比如makeLeaders拿到请求数据后会为分区设置Leader和ISRmakeFollowers拿到数据后会为分区更换Fetcher线程以及清空ISR。
LeaderAndIsrRequest请求是Kafka定义的最重要的控制类请求。搞懂它是如何被处理的对于你弄明白Kafka的副本机制是大有裨益的。
## ISR管理
除了读写副本、管理分区和副本的功能之外副本管理器还有一个重要的功能那就是管理ISR。这里的管理主要体现在两个方法
- 一个是maybeShrinkIsr方法作用是阶段性地查看ISR中的副本集合是否需要收缩
- 另一个是maybePropagateIsrChanges方法作用是定期向集群Broker传播ISR的变更。
首先我们看下ISR的收缩操作。
### maybeShrinkIsr方法
收缩是指把ISR副本集合中那些与Leader差距过大的副本移除的过程。所谓的差距过大就是ISR中Follower副本滞后Leader副本的时间超过了Broker端参数replica.lag.time.max.ms值的1.5倍。
稍等为什么是1.5倍呢?你可以看下面的代码:
```
def startup(): Unit = {
scheduler.schedule(&quot;isr-expiration&quot;, maybeShrinkIsr _, period = config.replicaLagTimeMaxMs / 2, unit = TimeUnit.MILLISECONDS)
......
}
```
我来解释下。ReplicaManager类的startup方法会在被调用时创建一个异步线程定时查看是否有ISR需要进行收缩。这里的定时频率是replicaLagTimeMaxMs值的一半而判断Follower副本是否需要被移除ISR的条件是滞后程度是否超过了replicaLagTimeMaxMs值。
因此理论上滞后程度小于1.5倍replicaLagTimeMaxMs值的Follower副本依然有可能在ISR中不会被移除。这就是数字“1.5”的由来了。
接下来我们看下maybeShrinkIsr方法的源码。
```
private def maybeShrinkIsr(): Unit = {
trace(&quot;Evaluating ISR list of partitions to see which replicas can be removed from the ISR&quot;)
allPartitions.keys.foreach { topicPartition =&gt;
nonOfflinePartition(topicPartition).foreach(_.maybeShrinkIsr())
}
}
```
可以看出maybeShrinkIsr方法会遍历该副本管理器上所有分区对象依次为这些分区中状态为Online的分区执行Partition类的maybeShrinkIsr方法。这个方法的源码如下
```
def maybeShrinkIsr(): Unit = {
// 判断是否需要执行ISR收缩
val needsIsrUpdate = inReadLock(leaderIsrUpdateLock) {
needsShrinkIsr()
}
val leaderHWIncremented = needsIsrUpdate &amp;&amp; inWriteLock(leaderIsrUpdateLock) {
leaderLogIfLocal match {
// 如果是Leader副本
case Some(leaderLog) =&gt;
// 获取不同步的副本Id列表
val outOfSyncReplicaIds = getOutOfSyncReplicas(replicaLagTimeMaxMs)
// 如果存在不同步的副本Id列表
if (outOfSyncReplicaIds.nonEmpty) {
// 计算收缩之后的ISR列表
val newInSyncReplicaIds = inSyncReplicaIds -- outOfSyncReplicaIds
assert(newInSyncReplicaIds.nonEmpty)
info(&quot;Shrinking ISR from %s to %s. Leader: (highWatermark: %d, endOffset: %d). Out of sync replicas: %s.&quot;
.format(inSyncReplicaIds.mkString(&quot;,&quot;),
newInSyncReplicaIds.mkString(&quot;,&quot;),
leaderLog.highWatermark,
leaderLog.logEndOffset,
outOfSyncReplicaIds.map { replicaId =&gt;
s&quot;(brokerId: $replicaId, endOffset: ${getReplicaOrException(replicaId).logEndOffset})&quot;
}.mkString(&quot; &quot;)
)
)
// 更新ZooKeeper中分区的ISR数据以及Broker的元数据缓存中的数据
shrinkIsr(newInSyncReplicaIds)
// 尝试更新Leader副本的高水位值
maybeIncrementLeaderHW(leaderLog)
} else {
false
}
// 如果不是Leader副本什么都不做
case None =&gt; false
}
}
// 如果Leader副本的高水位值抬升了
if (leaderHWIncremented)
// 尝试解锁一下延迟请求
tryCompleteDelayedRequests()
}
```
可以看出maybeShrinkIsr方法的整个执行流程是
- **第1步**判断是否需要执行ISR收缩。主要的方法是调用needShrinkIsr方法来获取与Leader不同步的副本。如果存在这样的副本说明需要执行ISR收缩。
- **第2步**再次获取与Leader不同步的副本列表并把它们从当前ISR中剔除出去然后计算得出最新的ISR列表。
- **第3步**调用shrinkIsr方法去更新ZooKeeper上分区的ISR数据以及Broker上元数据缓存。
- **第4步**尝试更新Leader分区的高水位值。这里有必要检查一下是否可以抬升高水位值的原因在于如果ISR收缩后只剩下Leader副本一个了那么高水位值的更新就不再受那么多限制了。
- **第5步**,根据上一步的结果,来尝试解锁之前不满足条件的延迟操作。
我把这个执行过程,梳理到了一张流程图中:
<img src="https://static001.geekbang.org/resource/image/0c/3e/0ce6b2e29byyfd4db331e65df6b8bb3e.jpg" alt="">
### maybePropagateIsrChanges方法
ISR收缩之后ReplicaManager还需要将这个操作的结果传递给集群的其他Broker以同步这个操作的结果。这是由ISR通知事件来完成的。
在ReplicaManager类中方法maybePropagateIsrChanges专门负责创建ISR通知事件。这也是由一个异步线程定期完成的代码如下
```
scheduler.schedule(&quot;isr-change-propagation&quot;, maybePropagateIsrChanges _, period = 2500L, unit = TimeUnit.MILLISECONDS)
```
接下来我们看下maybePropagateIsrChanges方法的代码
```
def maybePropagateIsrChanges(): Unit = {
val now = System.currentTimeMillis()
isrChangeSet synchronized {
// ISR变更传播的条件需要同时满足
// 1. 存在尚未被传播的ISR变更
// 2. 最近5秒没有任何ISR变更或者自上次ISR变更已经有超过1分钟的时间
if (isrChangeSet.nonEmpty &amp;&amp;
(lastIsrChangeMs.get() + ReplicaManager.IsrChangePropagationBlackOut &lt; now ||
lastIsrPropagationMs.get() + ReplicaManager.IsrChangePropagationInterval &lt; now)) {
// 创建ZooKeeper相应的Znode节点
zkClient.propagateIsrChanges(isrChangeSet)
// 清空isrChangeSet集合
isrChangeSet.clear()
// 更新最近ISR变更时间戳
lastIsrPropagationMs.set(now)
}
}
}
```
可以看到maybePropagateIsrChanges方法的逻辑也比较简单。我来概括下其执行逻辑。
首先确定ISR变更传播的条件。这里需要同时满足两点
- 一是, 存在尚未被传播的ISR变更
- 二是, 最近5秒没有任何ISR变更或者自上次ISR变更已经有超过1分钟的时间。
一旦满足了这两个条件代码会创建ZooKeeper相应的Znode节点然后清空isrChangeSet集合最后更新最近ISR变更时间戳。
## 总结
今天我们重点学习了ReplicaManager类的分区和副本管理功能以及ISR管理。我们再完整地梳理下ReplicaManager类的核心功能和方法。
- 分区/副本管理。ReplicaManager类的核心功能是应对Broker端接收的LeaderAndIsrRequest请求并将请求中的分区信息抽取出来让所在Broker执行相应的动作。
- becomeLeaderOrFollower方法。它是应对LeaderAndIsrRequest请求的入口方法。它会将请求中的分区划分成两组分别调用makeLeaders和makeFollowers方法。
- makeLeaders方法。它的作用是让Broker成为指定分区Leader副本。
- makeFollowers方法。它的作用是让Broker成为指定分区Follower副本的方法。
- ISR管理。ReplicaManager类提供了ISR收缩和定期传播ISR变更通知的功能。
<img src="https://static001.geekbang.org/resource/image/b6/f2/b63ecd5619213340df68f0771607f6f2.jpg" alt="">
掌握了这些核心知识点你差不多也就掌握了绝大部分的副本管理器功能比如说Broker如何成为分区的Leader副本和Follower副本以及ISR是如何被管理的。
你可能也发现了有些非核心小功能我们今天并没有展开比如Broker上的元数据缓存是怎么回事。下一节课我将带你深入到这个缓存当中去看看它到底是什么。
## 课后讨论
maybePropagateIsrChanges源码中有个isrChangeSet字段。你知道它是在哪里被更新的吗
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,377 @@
<audio id="audio" title="26 | MetadataCacheBroker是怎么异步更新元数据缓存的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/35/38de7daf20406dcfe3f6aeeaa5a28735.mp3"></audio>
你好我是胡夕。今天我们学习Broker上的元数据缓存MetadataCache
你肯定很好奇前面我们不是学过Controller端的元数据缓存了吗这里的元数据缓存又是啥呢其实这里的MetadataCache是指Broker上的元数据缓存这些数据是Controller通过UpdateMetadataRequest请求发送给Broker的。换句话说Controller实现了一个异步更新机制能够将最新的集群信息广播给所有Broker。
那么为什么每台Broker上都要保存这份相同的数据呢这里有两个原因。
第一个也是最重要的原因就是保存了这部分数据Broker就能够及时**响应客户端发送的元数据请求也就是处理Metadata请求**。Metadata请求是为数不多的能够被集群任意Broker处理的请求类型之一也就是说客户端程序能够随意地向任何一个Broker发送Metadata请求去获取集群的元数据信息这完全得益于MetadataCache的存在。
第二个原因是Kafka的一些重要组件会用到这部分数据。比如副本管理器会使用它来获取Broker的节点信息事务管理器会使用它来获取分区Leader副本的信息等等。
总之MetadataCache是每台Broker上都会保存的数据。Kafka通过异步更新机制来保证所有Broker上的元数据缓存实现最终一致性。
在实际使用的过程中你可能会碰到这样一种场景集群明明新创建了主题但是消费者端却报错说“找不到主题信息”这种情况通常只持续很短的时间。不知道你是否思考过这里面的原因其实说白了很简单这就是因为元数据是异步同步的因此在某一时刻某些Broker尚未更新元数据它们保存的数据就是过期的元数据无法识别最新的主题。
等你今天学完了MetadataCache类特别是元数据的更新之后就会彻底明白这个问题了。下面我们就来学习下MetadataCache的类代码。
## MetadataCache类
MetadataCache类位于server包下的同名scala文件中。这是一个不到400行的小文件里面的代码结构非常简单该文件只定义了一个类那就是MetadataCache。
MetadataCache的实例化是在Kafka Broker启动时完成的具体的调用发生在KafkaServer类的startup方法中。
```
// KafkaServer.scala
def startup(): Unit = {
try {
......
metadataCache = new MetadataCache(config.brokerId)
......
}
catch {
case e: Throwable =&gt;
......
}
}
```
一旦实例被成功创建就会被Kafka的4个组件使用。我来给你解释一下这4个组件的名称以及它们各自使用该实例的主要目的。
- KafkaApis这是源码入口类。它是执行Kafka各类请求逻辑的地方。该类大量使用MetadataCache中的主题分区和Broker数据执行主题相关的判断与比较以及获取Broker信息。
- AdminManager这是Kafka定义的专门用于管理主题的管理器里面定义了很多与主题相关的方法。同KafkaApis类似它会用到MetadataCache中的主题信息和Broker数据以获取主题和Broker列表。
- ReplicaManager这是我们刚刚学过的副本管理器。它需要获取主题分区和Broker数据同时还会更新MetadataCache。
- TransactionCoordinator这是管理Kafka事务的协调者组件它需要用到MetadataCache中的主题分区的Leader副本所在的Broker数据向指定Broker发送事务标记。
## 类定义及字段
搞清楚了MetadataCache类被创建的时机以及它的调用方我们就了解了它的典型使用场景即作为集群元数据集散地它保存了集群中关于主题和Broker的所有重要数据。那么接下来我们来看下这些数据到底都是什么。
```
class MetadataCache(brokerId: Int) extends Logging {
private val partitionMetadataLock = new ReentrantReadWriteLock()
@volatile private var metadataSnapshot: MetadataSnapshot = MetadataSnapshot(partitionStates = mutable.AnyRefMap.empty,
controllerId = None, aliveBrokers = mutable.LongMap.empty, aliveNodes = mutable.LongMap.empty)
this.logIdent = s&quot;[MetadataCache brokerId=$brokerId] &quot;
private val stateChangeLogger = new StateChangeLogger(brokerId, inControllerContext = false, None)
......
}
```
MetadataCache类构造函数只需要一个参数**brokerId**即Broker的ID序号。除了这个参数该类还定义了4个字段。
partitionMetadataLock字段是保护它写入的锁对象logIndent和stateChangeLogger字段仅仅用于日志输出而metadataSnapshot字段保存了实际的元数据信息它是MetadataCache类中最重要的字段我们要重点关注一下它。
该字段的类型是MetadataSnapshot类该类是MetadataCache中定义的一个嵌套类。以下是该嵌套类的源码
```
case class MetadataSnapshot(partitionStates: mutable.AnyRefMap
[String, mutable.LongMap[UpdateMetadataPartitionState]],
controllerId: Option[Int],
aliveBrokers: mutable.LongMap[Broker],
aliveNodes: mutable.LongMap[collection.Map[ListenerName, Node]])
```
从源码可知它是一个case类相当于Java中配齐了Getter方法的POJO类。同时它也是一个不可变类Immutable Class。正因为它的不可变性其字段值是不允许修改的我们只能重新创建一个新的实例来保存更新后的字段值。
我们看下它的各个字段的含义。
- **partitionStates**这是一个Map类型。Key是主题名称Value又是一个Map类型其Key是分区号Value是一个UpdateMetadataPartitionState类型的字段。UpdateMetadataPartitionState类型是UpdateMetadataRequest请求内部所需的数据结构。一会儿我们再说这个类型都有哪些数据。
- **controllerId**Controller所在Broker的ID。
- **aliveBrokers**当前集群中所有存活着的Broker对象列表。
- **aliveNodes**这也是一个Map的Map类型。其Key是Broker ID序号Value是Map类型其Key是ListenerName即Broker监听器类型而Value是Broker节点对象。
现在我们说说UpdateMetadataPartitionState类型。这个类型的源码是由Kafka工程自动生成的。UpdateMetadataRequest请求所需的字段用JSON格式表示由Kafka的generator工程负责为JSON格式自动生成对应的Java文件生成的类是一个POJO类其定义如下
```
static public class UpdateMetadataPartitionState implements Message {
private String topicName; // 主题名称
private int partitionIndex; // 分区号
private int controllerEpoch; // Controller Epoch值
private int leader; // Leader副本所在Broker ID
private int leaderEpoch; // Leader Epoch值
private List&lt;Integer&gt; isr; // ISR列表
private int zkVersion; // ZooKeeper节点Stat统计信息中的版本号
private List&lt;Integer&gt; replicas; // 副本列表
private List&lt;Integer&gt; offlineReplicas; // 离线副本列表
private List&lt;RawTaggedField&gt; _unknownTaggedFields; // 未知字段列表
......
}
```
可以看到UpdateMetadataPartitionState类的字段信息非常丰富它包含了一个主题分区非常详尽的数据从主题名称、分区号、Leader副本、ISR列表到Controller Epoch、ZooKeeper版本号等信息一应俱全。从宏观角度来看Kafka集群元数据由主题数据和Broker数据两部分构成。所以可以这么说MetadataCache中的这个字段撑起了元数据缓存的“一半天空”。
## 重要方法
接下来我们学习下MetadataCache类的重要方法。你需要记住的是这个类最重要的方法就是**操作metadataSnapshot字段的方法**毕竟所谓的元数据缓存就是指MetadataSnapshot类中承载的东西。
我把MetadataCache类的方法大致分为三大类
1. 判断类;
1. 获取类;
1. 更新类。
这三大类方法是由浅入深的关系,我们先从简单的判断类方法开始。
### 判断类方法
所谓的判断类方法就是判断给定主题或主题分区是否包含在元数据缓存中的方法。MetadataCache类提供了两个判断类的方法方法名都是**contains**,只是输入参数不同。
```
// 判断给定主题是否包含在元数据缓存中
def contains(topic: String): Boolean = {
metadataSnapshot.partitionStates.contains(topic)
}
// 判断给定主题分区是否包含在元数据缓存中
def contains(tp: TopicPartition): Boolean = getPartitionInfo(tp.topic, tp.partition).isDefined
// 获取给定主题分区的详细数据信息。如果没有找到对应记录返回None
def getPartitionInfo(topic: String,
partitionId: Int): Option[UpdateMetadataPartitionState] = {
metadataSnapshot.partitionStates.get(topic)
.flatMap(_.get(partitionId))
}
```
第一个contains方法用于判断给定主题是否包含在元数据缓存中比较简单只需要判断metadataSnapshot中partitionStates的所有Key是否包含指定主题就行了。
第二个contains方法相对复杂一点。它首先要从metadataSnapshot中获取指定主题分区的分区数据信息然后根据分区数据是否存在来判断给定主题分区是否包含在元数据缓存中。
判断类的方法实现都很简单,代码也不多,很好理解,我就不多说了。接下来,我们来看获取类方法。
### 获取类方法
MetadataCache类的getXXX方法非常多其中比较有代表性的是getAllTopics方法、getAllPartitions方法和getPartitionReplicaEndpoints它们分别是获取主题、分区和副本对象的方法。在我看来这是最基础的元数据获取方法了非常值得我们学习。
首先我们来看入门级的get方法即getAllTopics方法。该方法返回当前集群元数据缓存中的所有主题。代码如下
```
private def getAllTopics(snapshot: MetadataSnapshot): Set[String] = {
snapshot.partitionStates.keySet
}
```
它仅仅是返回MetadataSnapshot数据类型中partitionStates字段的所有Key字段。前面说过partitionStates是一个Map类型Key就是主题。怎么样简单吧
如果我们要获取元数据缓存中的分区对象,该怎么写呢?来看看**getAllPartitions方法**的实现。
```
def getAllPartitions(): Set[TopicPartition] = {
metadataSnapshot.partitionStates.flatMap { case (topicName, partitionsAndStates) =&gt;
partitionsAndStates.keys.map(partitionId =&gt; new TopicPartition(topicName, partitionId.toInt))
}.toSet
}
```
和getAllTopics方法类似它的主要思想也是遍历partitionStates取出分区号后构建TopicPartition实例并加入到返回集合中返回。
最后我们看一个相对复杂一点的get方法getPartitionReplicaEndpoints。
```
def getPartitionReplicaEndpoints(tp: TopicPartition, listenerName: ListenerName): Map[Int, Node] = {
// 使用局部变量获取当前元数据缓存
val snapshot = metadataSnapshot
// 获取给定主题分区的数据
snapshot.partitionStates.get(tp.topic).flatMap(_.get(tp.partition))
.map { partitionInfo =&gt;
// 拿到副本Id列表
val replicaIds = partitionInfo.replicas
replicaIds.asScala
.map(replicaId =&gt; replicaId.intValue() -&gt; {
// 获取副本所在的Broker Id
snapshot.aliveBrokers.get(replicaId.longValue()) match {
case Some(broker) =&gt;
// 根据Broker Id去获取对应的Broker节点对象
broker.getNode(listenerName).getOrElse(Node.noNode())
case None =&gt; // 如果找不到节点
Node.noNode()
}}).toMap
.filter(pair =&gt; pair match {
case (_, node) =&gt; !node.isEmpty
})
}.getOrElse(Map.empty[Int, Node])
}
```
这个getPartitionReplicaEndpoints方法接收主题分区和ListenerName以获取指定监听器类型下该主题分区所有副本的Broker节点对象并按照Broker ID进行分组。
首先代码使用局部变量获取当前的元数据缓存。这样做的好处在于不需要使用锁技术但是就像我开头说过的这里有一个可能的问题是读到的数据可能是过期的数据。不过好在Kafka能够自行处理过期元数据的问题。当客户端因为拿到过期元数据而向Broker发出错误的指令时Broker会显式地通知客户端错误原因。客户端接收到错误后会尝试再次拉取最新的元数据。这个过程能够保证客户端最终可以取得最新的元数据信息。总体而言过期元数据的不良影响是存在的但在实际场景中并不是太严重。
拿到主题分区数据之后代码会获取副本ID列表接着遍历该列表依次获取每个副本所在的Broker ID再根据这个Broker ID去获取对应的Broker节点对象。最后将这些节点对象封装到返回结果中并返回。
### 更新类方法
下面我们进入到今天的“重头戏”Broker端元数据缓存的更新方法。说它是重头戏有两个原因
1. 跟前两类方法相比,它的代码实现要复杂得多,因此,我们需要花更多的时间去学习;
1. 元数据缓存只有被更新了才能被读取。从某种程度上说它是后续所有getXXX方法的前提条件。
源码中实现更新的方法只有一个:**updateMetadata方法**。该方法的代码比较长,我先画一张流程图,帮助你理解它做了什么事情。
<img src="https://static001.geekbang.org/resource/image/2a/03/2abcce0bb1e7e4d1ac3d8bbc41c3f803.jpg" alt="">
updateMetadata方法的主要逻辑就是**读取UpdateMetadataRequest请求中的分区数据然后更新本地元数据缓存**。接下来,我们详细地学习一下它的实现逻辑。
为了方便你掌握,我将该方法分成几个部分来讲,首先来看第一部分代码:
```
def updateMetadata(correlationId: Int, updateMetadataRequest: UpdateMetadataRequest): Seq[TopicPartition] = {
inWriteLock(partitionMetadataLock) {
// 保存存活Broker对象。Key是Broker IDValue是Broker对象
val aliveBrokers = new mutable.LongMap[Broker](metadataSnapshot.aliveBrokers.size)
// 保存存活节点对象。Key是Broker IDValue是监听器-&gt;节点对象
val aliveNodes = new mutable.LongMap[collection.Map[ListenerName, Node]](metadataSnapshot.aliveNodes.size)
// 从UpdateMetadataRequest中获取Controller所在的Broker ID
// 如果当前没有Controller赋值为None
val controllerIdOpt = updateMetadataRequest.controllerId match {
case id if id &lt; 0 =&gt; None
case id =&gt; Some(id)
}
// 遍历UpdateMetadataRequest请求中的所有存活Broker对象
updateMetadataRequest.liveBrokers.forEach { broker =&gt;
val nodes = new java.util.HashMap[ListenerName, Node]
val endPoints = new mutable.ArrayBuffer[EndPoint]
// 遍历它的所有EndPoint类型也就是为Broker配置的监听器
broker.endpoints.forEach { ep =&gt;
val listenerName = new ListenerName(ep.listener)
endPoints += new EndPoint(ep.host, ep.port, listenerName, SecurityProtocol.forId(ep.securityProtocol))
// 将&lt;监听器Broker节点对象&gt;对保存起来
nodes.put(listenerName, new Node(broker.id, ep.host, ep.port))
}
// 将Broker加入到存活Broker对象集合
aliveBrokers(broker.id) = Broker(broker.id, endPoints, Option(broker.rack))
// 将Broker节点加入到存活节点对象集合
aliveNodes(broker.id) = nodes.asScala
}
......
}
}
```
这部分代码的主要作用是给后面的操作准备数据即aliveBrokers和aliveNodes两个字段中保存的数据。
因此首先代码会创建这两个字段分别保存存活Broker对象和存活节点对象。aliveBrokers的Key类型是Broker ID而Value类型是Broker对象aliveNodes的Key类型也是Broker IDValue类型是&lt;监听器,节点对象&gt;对。
然后该方法从UpdateMetadataRequest中获取Controller所在的Broker ID并赋值给controllerIdOpt字段。如果集群没有Controller则赋值该字段为None。
接着代码会遍历UpdateMetadataRequest请求中的所有存活Broker对象。取出它配置的所有EndPoint类型也就是Broker配置的所有监听器。
最后,代码会遍历它配置的监听器,并将&lt;监听器Broker节点对象&gt;对保存起来再将Broker加入到存活Broker对象集合和存活节点对象集合。至此第一部分代码逻辑完成。
再来看第二部分的代码。这一部分的主要工作是**确保集群Broker配置了相同的监听器同时初始化已删除分区数组对象等待下一部分代码逻辑对它进行操作**。代码如下:
```
// 使用上一部分中的存活Broker节点对象
// 获取当前Broker所有的&lt;监听器,节点&gt;对
aliveNodes.get(brokerId).foreach { listenerMap =&gt;
val listeners = listenerMap.keySet
// 如果发现当前Broker配置的监听器与其他Broker有不同之处记录错误日志
if (!aliveNodes.values.forall(_.keySet == listeners))
error(s&quot;Listeners are not identical across brokers: $aliveNodes&quot;)
}
// 构造已删除分区数组,将其作为方法返回结果
val deletedPartitions = new mutable.ArrayBuffer[TopicPartition]
// UpdateMetadataRequest请求没有携带任何分区信息
if (!updateMetadataRequest.partitionStates.iterator.hasNext) {
// 构造新的MetadataSnapshot对象使用之前的分区信息和新的Broker列表信息
metadataSnapshot = MetadataSnapshot(metadataSnapshot.partitionStates, controllerIdOpt, aliveBrokers, aliveNodes)
// 否则,进入到方法最后一部分
} else {
......
}
```
这部分代码首先使用上一部分中的存活Broker节点对象获取当前Broker所有的&lt;监听器,节点&gt;对。
之后拿到为当前Broker配置的所有监听器。如果发现配置的监听器与其他Broker有不同之处则记录一条错误日志。
接下来代码会构造一个已删除分区数组将其作为方法返回结果。然后判断UpdateMetadataRequest请求是否携带了任何分区信息如果没有则构造一个新的MetadataSnapshot对象使用之前的分区信息和新的Broker列表信息如果有代码进入到该方法的最后一个部分。
最后一部分全部位于上面代码中的else分支上。这部分的主要工作是**提取UpdateMetadataRequest请求中的数据然后填充元数据缓存**。代码如下:
```
val partitionStates = new mutable.AnyRefMap[String, mutable.LongMap[UpdateMetadataPartitionState]](metadataSnapshot.partitionStates.size)
// 备份现有元数据缓存中的分区数据
metadataSnapshot.partitionStates.foreach { case (topic, oldPartitionStates) =&gt;
val copy = new mutable.LongMap[UpdateMetadataPartitionState](oldPartitionStates.size)
copy ++= oldPartitionStates
partitionStates(topic) = copy
}
val traceEnabled = stateChangeLogger.isTraceEnabled
val controllerId = updateMetadataRequest.controllerId
val controllerEpoch = updateMetadataRequest.controllerEpoch
// 获取UpdateMetadataRequest请求中携带的所有分区数据
val newStates = updateMetadataRequest.partitionStates.asScala
// 遍历分区数据
newStates.foreach { state =&gt;
val tp = new TopicPartition(state.topicName, state.partitionIndex)
// 如果分区处于被删除过程中
if (state.leader == LeaderAndIsr.LeaderDuringDelete) {
// 将分区从元数据缓存中移除
removePartitionInfo(partitionStates, tp.topic, tp.partition)
if (traceEnabled)
stateChangeLogger.trace(s&quot;Deleted partition $tp from metadata cache in response to UpdateMetadata &quot; +
s&quot;request sent by controller $controllerId epoch $controllerEpoch with correlation id $correlationId&quot;)
// 将分区加入到返回结果数据
deletedPartitions += tp
} else {
// 将分区加入到元数据缓存
addOrUpdatePartitionInfo(partitionStates, tp.topic, tp.partition, state)
if (traceEnabled)
stateChangeLogger.trace(s&quot;Cached leader info $state for partition $tp in response to &quot; +
s&quot;UpdateMetadata request sent by controller $controllerId epoch $controllerEpoch with correlation id $correlationId&quot;)
}
}
val cachedPartitionsCount = newStates.size - deletedPartitions.size
stateChangeLogger.info(s&quot;Add $cachedPartitionsCount partitions and deleted ${deletedPartitions.size} partitions from metadata cache &quot; +
s&quot;in response to UpdateMetadata request sent by controller $controllerId epoch $controllerEpoch with correlation id $correlationId&quot;)
// 使用更新过的分区元数据和第一部分计算的存活Broker列表及节点列表构建最新的元数据缓存
metadataSnapshot =
MetadataSnapshot(partitionStates, controllerIdOpt, aliveBrokers, aliveNodes)
// 返回已删除分区列表数组
deletedPartitions
```
首先该方法会备份现有元数据缓存中的分区数据到partitionStates的局部变量中。
之后获取UpdateMetadataRequest请求中携带的所有分区数据并遍历每个分区数据。如果发现分区处于被删除的过程中就将分区从元数据缓存中移除并把分区加入到已删除分区数组中。否则的话代码就将分区加入到元数据缓存中。
最后方法使用更新过的分区元数据和第一部分计算的存活Broker列表及节点列表构建最新的元数据缓存然后返回已删除分区列表数组。至此updateMetadata方法结束。
## 总结
今天我们学习了Broker端的MetadataCache类即所谓的元数据缓存类。该类保存了当前集群上的主题分区详细数据和Broker数据。每台Broker都维护了一个MetadataCache实例。Controller通过给Broker发送UpdateMetadataRequest请求的方式来异步更新这部分缓存数据。
我们来回顾下这节课的重点。
- MetadataCache类Broker元数据缓存类保存了分区详细数据和Broker节点数据。
- 四大调用方分别是ReplicaManager、KafkaApis、TransactionCoordinator和AdminManager。
- updateMetadata方法Controller给Broker发送UpdateMetadataRequest请求时触发更新。
<img src="https://static001.geekbang.org/resource/image/e9/81/e95db24997c6cb615150ccc269aeb781.jpg" alt="">
最后,我想和你讨论一个话题。
有人认为Kafka Broker是无状态的。学完了今天的内容现在你应该知道了Broker并非是无状态的节点它需要从Controller端异步更新保存集群的元数据信息。由于Kafka采用的是Leader/Follower模式跟多Leader架构和无Leader架构相比这种分布式架构的一致性是最容易保证的因此Broker间元数据的最终一致性是有保证的。不过就像我前面说过的你需要处理Follower滞后或数据过期的问题。需要注意的是这里的Leader其实是指Controller而Follower是指普通的Broker节点。
总之这一路学到现在不知道你有没有这样的感受很多分布式架构设计的问题与方案是相通的。比如在应对数据备份这个问题上元数据缓存和Kafka副本其实都是相同的设计思路即使用单Leader的架构令Leader对外提供服务Follower只是被动地同步Leader上的数据。
每次学到新的内容之后,希望你不要把它们当作单一的知识看待,要善于进行思考和总结,做到融会贯通。源码学习固然重要,但能让学习源码引领我们升级架构思想,其实是更难得的收获!
## 课后讨论
前面说到Controller发送UpdateMetadataRequest请求给Broker时会更新MetadataCache你能在源码中找到更新元数据缓存的完整调用路径吗
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,406 @@
<audio id="audio" title="19 | TimingWheel探究Kafka定时器背后的高效时间轮算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2b/03/2b009cd5a52215c17768554f58c8af03.mp3"></audio>
你好我是胡夕。今天我们开始学习Kafka延时请求的代码实现。
延时请求Delayed Operation也称延迟请求是指因未满足条件而暂时无法被处理的Kafka请求。举个例子配置了acks=all的生产者发送的请求可能一时无法完成因为Kafka必须确保ISR中的所有副本都要成功响应这次写入。因此通常情况下这些请求没法被立即处理。只有满足了条件或发生了超时Kafka才会把该请求标记为完成状态。这就是所谓的延时请求。
今天,我们的重点是弄明白请求被延时处理的机制——分层时间轮算法。
时间轮的应用范围非常广。很多操作系统的定时任务调度如Crontab以及通信框架如Netty等都利用了时间轮的思想。几乎所有的时间任务调度系统都是基于时间轮算法的。Kafka应用基于时间轮算法管理延迟请求的代码简洁精炼而且和业务逻辑代码完全解耦你可以从0到1地照搬到你自己的项目工程中。
## 时间轮简介
在开始介绍时间轮之前我想先请你思考这样一个问题“如果是你你会怎么实现Kafka中的延时请求呢
针对这个问题我的第一反应是使用Java的DelayQueue。毕竟这个类是Java天然提供的延时队列非常适合建模延时对象处理。实际上Kafka的第一版延时请求就是使用DelayQueue做的。
但是DelayQueue有一个弊端它插入和删除队列元素的时间复杂度是O(logN)。对于Kafka这种非常容易积攒几十万个延时请求的场景来说该数据结构的性能是瓶颈。当然这一版的设计还有其他弊端比如它在清除已过期的延迟请求方面不够高效可能会出现内存溢出的情形。后来社区改造了延时请求的实现机制采用了基于时间轮的方案。
时间轮有简单时间轮Simple Timing Wheel和分层时间轮Hierarchical Timing Wheel两类。两者各有利弊也都有各自的使用场景。Kafka采用的是分层时间轮这是我们重点学习的内容。
关于分层时间轮有很多严谨的科学论文。不过大多数的论文读起来晦涩难懂而且偏理论研究。然而我们并非是要完整系统地学习这套机制我们关心的是如何将其应用于实践当中。要做到这一点结合着源码来学习就是一个不错的途径。你需要关注在代码层面Kafka是如何实现多层时间轮的。
“时间轮”的概念稍微有点抽象,我用一个生活中的例子,来帮助你建立一些初始印象。
想想我们生活中的手表。手表由时针、分针和秒针组成,它们各自有独立的刻度,但又彼此相关:秒针转动一圈,分针会向前推进一格;分针转动一圈,时针会向前推进一格。这就是典型的分层时间轮。
和手表不太一样的是Kafka自己有专门的术语。在Kafka中手表中的“一格”叫“一个桶Bucket而“推进”对应于Kafka中的“滴答”也就是tick。后面你在阅读源码的时候会频繁地看到Bucket、tick字眼你可以把它们理解成手表刻度盘面上的“一格”和“向前推进”的意思。
除此之外每个Bucket下也不是白板一块它实际上是一个双向循环链表Doubly Linked Cyclic List里面保存了一组延时请求。
我先用一张图帮你理解下双向循环链表。
<img src="https://static001.geekbang.org/resource/image/fd/ed/fdcdc45c1c6adc87192e6101be7793ed.png" alt="">
图中的每个节点都有一个next和prev指针分别指向下一个元素和上一个元素。Root是链表的头部节点不包含任何实际数据。它的next指针指向链表的第一个元素而prev指针指向最后一个元素。
由于是双向链表结构因此代码能够利用next和prev两个指针快速地定位元素因此在Bucket下插入和删除一个元素的时间复杂度是O(1)。当然,双向链表要求同时保存两个指针数据,在节省时间的同时消耗了更多的空间。在算法领域,这是典型的用空间去换时间的优化思想。
## 源码层级关系
在Kafka中具体是怎么应用分层时间轮实现请求队列的呢
<img src="https://static001.geekbang.org/resource/image/0d/f6/0d6eddb652a975f10563b594c77fd1f6.png" alt="">
图中的时间轮共有两个层级分别是Level 0和Level 1。每个时间轮有8个Bucket每个Bucket下是一个双向循环链表用来保存延迟请求。
在Kafka源码中时间轮对应utils.timer包下的TimingWheel类每个Bucket下的链表对应TimerTaskList类链表元素对应TimerTaskEntry类而每个链表元素里面保存的延时任务对应TimerTask。
在这些类中TimerTaskEntry与TimerTask是1对1的关系TimerTaskList下包含多个TimerTaskEntryTimingWheel包含多个TimerTaskList。
我画了一张UML图帮助你理解这些类之间的对应关系
<img src="https://static001.geekbang.org/resource/image/2b/17/2b127feffa2475ca14b0c3ae5ca47817.png" alt="">
## 时间轮各个类源码定义
掌握了这些基础知识,下面我就结合这些源码,来解释下延迟请求是如何被这套分层时间轮管理的。根据调用关系,我采用自底向上的方法给出它们的定义。
### TimerTask类
首先是TimerTask类。该类位于utils.timer包下的TimerTask.scala文件中。它的代码只有几十行非常容易理解。
```
trait TimerTask extends Runnable {
val delayMs: Long // 通常是request.timeout.ms参数值
// 每个TimerTask实例关联一个TimerTaskEntry
// 就是说每个定时任务需要知道它在哪个Bucket链表下的哪个链表元素上
private[this] var timerTaskEntry: TimerTaskEntry = null
// 取消定时任务原理就是将关联的timerTaskEntry置空
def cancel(): Unit = {
synchronized {
if (timerTaskEntry != null) timerTaskEntry.remove()
timerTaskEntry = null
}
}
// 关联timerTaskEntry原理是给timerTaskEntry字段赋值
private[timer] def setTimerTaskEntry(entry: TimerTaskEntry)
: Unit = {
synchronized {
if (timerTaskEntry != null &amp;&amp; timerTaskEntry != entry)
timerTaskEntry.remove()
timerTaskEntry = entry
}
}
// 获取关联的timerTaskEntry实例
private[timer] def getTimerTaskEntry(): TimerTaskEntry = {
timerTaskEntry
}
}
```
从代码可知TimerTask是一个Scala接口Trait。每个TimerTask都有一个delayMs字段表示这个定时任务的超时时间。通常来说这就是客户端参数request.timeout.ms的值。这个类还绑定了一个timerTaskEntry字段因为每个定时任务都要知道它存放在哪个Bucket链表下的哪个链表元素上。
既然绑定了这个字段就要提供相应的Setter和Getter方法。Getter方法仅仅是返回这个字段而已Setter方法要稍微复杂一些。在给timerTaskEntry赋值之前它必须要先考虑这个定时任务是否已经绑定了其他的timerTaskEntry如果是的话就必须先取消绑定。另外Setter的整个方法体必须由monitor锁保护起来以保证线程安全性。
这个类还有个cancel方法用于取消定时任务。原理也很简单就是将关联的timerTaskEntry置空。也就是说把定时任务从链表上摘除。
总之TimerTask建模的是Kafka中的定时任务。接下来我们来看TimerTaskEntry是如何承载这个定时任务的以及如何在链表中实现双向关联。
### TimerTaskEntry类
如前所述TimerTaskEntry表征的是Bucket链表下的一个元素。它的主要代码如下
```
private[timer] class TimerTaskEntry(val timerTask: TimerTask, val expirationMs: Long) extends Ordered[TimerTaskEntry] {
@volatile
var list: TimerTaskList = null // 绑定的Bucket链表实例
var next: TimerTaskEntry = null // next指针
var prev: TimerTaskEntry = null // prev指针
// 关联给定的定时任务
if (timerTask != null) timerTask.setTimerTaskEntry(this)
// 关联定时任务是否已经被取消了
def cancelled: Boolean = {
timerTask.getTimerTaskEntry != this
}
// 从Bucket链表中移除自己
def remove(): Unit = {
var currentList = list
while (currentList != null) {
currentList.remove(this)
currentList = list
}
}
......
}
```
该类定义了TimerTask类字段用来指定定时任务同时还封装了一个过期时间戳字段这个字段值定义了定时任务的过期时间。
举个例子假设有个PRODUCE请求在当前时间1点钟被发送到Broker超时时间是30秒那么该请求必须在1点30秒之前完成否则将被视为超时。这里的1点30秒就是expirationMs值。
除了TimerTask类字段该类还定义了3个字段list、next和prev。它们分别对应于Bucket链表实例以及自身的next、prev指针。注意list字段是volatile型的这是因为Kafka的延时请求可能会被其他线程从一个链表搬移到另一个链表中因此**为了保证必要的内存可见性**代码声明list为volatile。
该类的方法代码都很直观你可以看下我写的代码注释。这里我重点解释一下remove方法的实现原理。
remove的逻辑是将TimerTask自身从双向链表中移除掉因此代码调用了TimerTaskList的remove方法来做这件事。那这里就有一个问题“怎么算真正移除掉呢”其实这是根据“TimerTaskEntry的list是否为空”来判断的。一旦置空了该字段那么这个TimerTaskEntry实例就变成了“孤儿”不再属于任何一个链表了。从这个角度来看置空就相当于移除的效果。
需要注意的是置空这个动作是在TimerTaskList的remove中完成的而这个方法可能会被其他线程同时调用因此上段代码使用了while循环的方式来确保TimerTaskEntry的list字段确实被置空了。这样Kafka才能安全地认为此链表元素被成功移除。
### TimerTaskList类
说完了TimerTask和TimerTaskEntry就轮到链表类TimerTaskList上场了。我们先看它的定义
```
private[timer] class TimerTaskList(taskCounter: AtomicInteger) extends Delayed {
private[this] val root = new TimerTaskEntry(null, -1)
root.next = root
root.prev = root
private[this] val expiration = new AtomicLong(-1L)
......
}
```
TimerTaskList实现了刚刚那张图所展示的双向循环链表。它定义了一个Root节点同时还定义了两个字段
- taskCounter用于标识当前这个链表中的总定时任务数
- expiration表示这个链表所在Bucket的过期时间戳。
就像我前面说的每个Bucket对应于手表表盘上的一格。它有起始时间和结束时间因而也就有时间间隔的概念即“结束时间-起始时间=时间间隔”。同一层的Bucket的时间间隔都是一样的。只有当前时间越过了Bucket的起始时间这个Bucket才算是过期。而这里的起始时间就是代码中expiration字段的值。
除了定义的字段之外TimerTaskList类还定义一些重要的方法比如expiration的Getter和Setter方法、add、remove和flush方法。
我们先看expiration的Getter和Setter方法。
```
// Setter方法
def setExpiration(expirationMs: Long): Boolean = {
expiration.getAndSet(expirationMs) != expirationMs
}
// Getter方法
def getExpiration(): Long = {
expiration.get()
}
```
我重点解释下Setter方法。代码使用了AtomicLong的CAS方法getAndSet原子性地设置了过期时间戳之后将新过期时间戳和旧值进行比较看看是否不同然后返回结果。
这里为什么要比较新旧值是否不同呢这是因为目前Kafka使用一个DelayQueue统一管理所有的Bucket也就是TimerTaskList对象。随着时钟不断向前推进原有Bucket会不断地过期然后失效。当这些Bucket失效后源码会重用这些Bucket。重用的方式就是重新设置Bucket的过期时间并把它们加回到DelayQueue中。这里进行比较的目的就是用来判断这个Bucket是否要被插入到DelayQueue。
此外TimerTaskList类还提供了add和remove方法分别实现将给定定时任务插入到链表、从链表中移除定时任务的逻辑。这两个方法的主体代码基本上就是我们在数据结构课上学过的链表元素插入和删除操作所以这里我就不具体展开讲了。你可以将这些代码和数据结构书中的代码比对下看看它们是不是长得很像。
```
// add方法
def add(timerTaskEntry: TimerTaskEntry): Unit = {
var done = false
while (!done) {
// 在添加之前尝试移除该定时任务,保证该任务没有在其他链表中
timerTaskEntry.remove()
synchronized {
timerTaskEntry.synchronized {
if (timerTaskEntry.list == null) {
val tail = root.prev
timerTaskEntry.next = root
timerTaskEntry.prev = tail
timerTaskEntry.list = this
// 把timerTaskEntry添加到链表末尾
tail.next = timerTaskEntry
root.prev = timerTaskEntry
taskCounter.incrementAndGet()
done = true
}
}
}
}
}
// remove方法
def remove(timerTaskEntry: TimerTaskEntry): Unit = {
synchronized {
timerTaskEntry.synchronized {
if (timerTaskEntry.list eq this) {
timerTaskEntry.next.prev = timerTaskEntry.prev
timerTaskEntry.prev.next = timerTaskEntry.next
timerTaskEntry.next = null
timerTaskEntry.prev = null
timerTaskEntry.list = null
taskCounter.decrementAndGet()
}
}
}
}
```
最后我们看看flush方法。它的代码如下
```
def flush(f: (TimerTaskEntry)=&gt;Unit): Unit = {
synchronized {
// 找到链表第一个元素
var head = root.next
// 开始遍历链表
while (head ne root) {
// 移除遍历到的链表元素
remove(head)
// 执行传入参数f的逻辑
f(head)
head = root.next
}
// 清空过期时间设置
expiration.set(-1L)
}
}
```
基本上flush方法是清空链表中的所有元素并对每个元素执行指定的逻辑。该方法用于将高层次时间轮Bucket上的定时任务重新插入回低层次的Bucket中。具体为什么要这么做下节课我会给出答案现在你只需要知道它的大致作用就可以了。
### TimingWheel类
最后我们再来看下TimingWheel类的代码。先看定义
```
private[timer] class TimingWheel(
tickMs: Long, wheelSize: Int,
startMs: Long, taskCounter: AtomicInteger,
queue: DelayQueue[TimerTaskList]) {
private[this] val interval = tickMs * wheelSize
private[this] val buckets = Array.tabulate[TimerTaskList](wheelSize) { _ =&gt; new TimerTaskList(taskCounter) }
private[this] var currentTime = startMs - (startMs % tickMs)
@volatile private[this] var overflowWheel: TimingWheel = null
......
}
```
每个TimingWheel对象都定义了9个字段。这9个字段都非常重要每个字段都是分层时间轮的重要属性。因此我来逐一介绍下。
- tickMs滴答一次的时长类似于手表的例子中向前推进一格的时间。对于秒针而言tickMs就是1秒。同理分针是1分时针是1小时。在Kafka中第1层时间轮的tickMs被固定为1毫秒也就是说向前推进一格Bucket的时长是1毫秒。
- wheelSize每一层时间轮上的Bucket数量。第1层的Bucket数量是20。
- startMs时间轮对象被创建时的起始时间戳。
- taskCounter这一层时间轮上的总定时任务数。
- queue将所有Bucket按照过期时间排序的延迟队列。随着时间不断向前推进Kafka需要依靠这个队列获取那些已过期的Bucket并清除它们。
- interval这层时间轮总时长等于滴答时长乘以wheelSize。以第1层为例interval就是20毫秒。由于下一层时间轮的滴答时长就是上一层的总时长因此第2层的滴答时长就是20毫秒总时长是400毫秒以此类推。
- buckets时间轮下的所有Bucket对象也就是所有TimerTaskList对象。
- currentTime当前时间戳只是源码对它进行了一些微调整将它设置成小于当前时间的最大滴答时长的整数倍。举个例子假设滴答时长是20毫秒当前时间戳是123毫秒那么currentTime会被调整为120毫秒。
- overflowWheelKafka是按需创建上层时间轮的。这也就是说当有新的定时任务到达时会尝试将其放入第1层时间轮。如果第1层的interval无法容纳定时任务的超时时间就现场创建并配置好第2层时间轮并再次尝试放入如果依然无法容纳那么就再创建和配置第3层时间轮以此类推直到找到适合容纳该定时任务的第N层时间轮。
由于每层时间轮的长度都是倍增的,因此,代码并不需要创建太多层的时间轮,就足以容纳绝大部分的延时请求了。
举个例子目前Clients端默认的请求超时时间是30秒按照现在代码中的wheelSize=20进行倍增只需要4层时间轮就能容纳160秒以内的所有延时请求了。
说完了类声明我们再来学习下TimingWheel中定义的3个方法addOverflowWheel、add和advanceClock。就像我前面说的TimingWheel类字段overflowWheel的创建是按需的。每当需要一个新的上层时间轮时代码就会调用addOverflowWheel方法。我们看下它的代码
```
private[this] def addOverflowWheel(): Unit = {
synchronized {
// 只有之前没有创建上层时间轮方法才会继续
if (overflowWheel == null) {
// 创建新的TimingWheel实例
// 滴答时长tickMs等于下层时间轮总时长
// 每层的轮子数都是相同的
overflowWheel = new TimingWheel(
tickMs = interval,
wheelSize = wheelSize,
startMs = currentTime,
taskCounter = taskCounter,
queue
)
}
}
}
```
这个方法就是创建一个新的TimingWheel实例也就是创建上层时间轮。所用的滴答时长等于下层时间轮总时长而每层的轮子数都是相同的。创建完成之后代码将新创建的实例赋值给overflowWheel字段。至此方法结束。
下面我们再来学习下add和advanceClock方法。首先是add方法代码及其注释如下
```
def add(timerTaskEntry: TimerTaskEntry): Boolean = {
// 获取定时任务的过期时间戳
val expiration = timerTaskEntry.expirationMs
// 如果该任务已然被取消了,则无需添加,直接返回
if (timerTaskEntry.cancelled) {
false
// 如果该任务超时时间已过期
} else if (expiration &lt; currentTime + tickMs) {
false
// 如果该任务超时时间在本层时间轮覆盖时间范围内
} else if (expiration &lt; currentTime + interval) {
val virtualId = expiration / tickMs
// 计算要被放入到哪个Bucket中
val bucket = buckets((virtualId % wheelSize.toLong).toInt)
// 添加到Bucket中
bucket.add(timerTaskEntry)
// 设置Bucket过期时间
// 如果该时间变更过说明Bucket是新建或被重用将其加回到DelayQueue
if (bucket.setExpiration(virtualId * tickMs)) {
queue.offer(bucket)
}
true
// 本层时间轮无法容纳该任务,交由上层时间轮处理
} else {
// 按需创建上层时间轮
if (overflowWheel == null) addOverflowWheel()
// 加入到上层时间轮中
overflowWheel.add(timerTaskEntry)
}
}
```
我结合一张图来解释下这个add方法要做的事情
<img src="https://static001.geekbang.org/resource/image/a3/3e/a3f8774eeeb06d0d0394b69f4b106b3e.jpg" alt="">
方法的**第1步**是获取定时任务的过期时间戳。所谓过期时间戳,就是这个定时任务过期时的时点。
**第2步**是看定时任务是否已被取消。如果已经被取消,则无需加入到时间轮中。如果没有被取消,就接着看这个定时任务是否已经过期。如果过期了,自然也不用加入到时间轮中。如果没有过期,就看这个定时任务的过期时间是否能够被涵盖在本层时间轮的时间范围内。如果可以,则进入到下一步。
**第3步**首先计算目标Bucket序号也就是这个定时任务需要被保存在哪个TimerTaskList中。我举个实际的例子来说明一下如何计算目标Bucket。
前面说过了第1层的时间轮有20个Bucket每个滴答时长是1毫秒。那么第2层时间轮的滴答时长应该就是20毫秒总时长是400毫秒。第2层第1个Bucket的时间范围应该是[2040)第2个Bucket的时间范围是[4060依次类推。假设现在有个延时请求的超时时间戳是237那么它就应该被插入到第11个Bucket中。
在确定了目标Bucket序号之后代码会将该定时任务添加到这个Bucket下同时更新这个Bucket的过期时间戳。在刚刚的那个例子中第11号Bucket的起始时间就应该是小于237的最大的20的倍数即220。
**第4步**如果这个Bucket是首次插入定时任务那么还同时要将这个Bucket加入到DelayQueue中方便Kafka轻松地获取那些已过期Bucket并删除它们。如果定时任务的过期时间无法被涵盖在本层时间轮中那么就按需创建上一层时间戳然后在上一层时间轮上完整地执行刚刚所说的所有逻辑。
说完了add方法我们看下advanceClock方法。顾名思义它就是向前驱动时钟的方法。代码如下
```
def advanceClock(timeMs: Long): Unit = {
// 向前驱动到的时点要超过Bucket的时间范围才是有意义的推进否则什么都不做
// 更新当前时间currentTime到下一个Bucket的起始时点
if (timeMs &gt;= currentTime + tickMs) {
currentTime = timeMs - (timeMs % tickMs)
// 同时尝试为上一层时间轮做向前推进动作
if (overflowWheel != null) overflowWheel.advanceClock(currentTime)
}
}
```
参数timeMs表示要把时钟向前推动到这个时点。向前驱动到的时点必须要超过Bucket的时间范围才是有意义的推进否则什么都不做毕竟它还在Bucket时间范围内。
相反一旦超过了Bucket覆盖的时间范围代码就会更新当前时间currentTime到下一个Bucket的起始时点同时递归地为上一层时间轮做向前推进动作。推进时钟的动作是由Kafka后台专属的Reaper线程发起的。
今天我反复提到了删除过期Bucket这个操作是由这个Reaper线程执行的。下节课我们会提到这个Reaper线程。
## 总结
今天我简要介绍了时间轮机制并结合代码重点讲解了分层时间轮在Kafka中的代码实现。Kafka正是利用这套分层时间轮机制实现了对于延迟请求的处理。在源码层级上Kafka定义了4个类来构建整套分层时间轮体系。
- TimerTask类建模Kafka延时请求。它是一个Runnable类Kafka使用一个单独线程异步添加延时请求到时间轮。
- TimerTaskEntry类建模时间轮Bucket下延时请求链表的元素类型封装了TimerTask对象和定时任务的过期时间戳信息。
- TimerTaskList类建模时间轮Bucket下的延时请求双向循环链表提供O(1)时间复杂度的请求插入和删除。
- TimingWheel类建模时间轮类型统一管理下辖的所有Bucket以及定时任务。
<img src="https://static001.geekbang.org/resource/image/ae/8d/ae956dfc9f494be6c50440c347f5fc8d.jpg" alt="">
在下一讲中我们将继续学习Kafka延时请求以及管理它们的DelayedOperation家族的源码。只有了解了DelayedOperation及其具体实现子类的代码我们才能完整地了解当请求不能被及时处理时Kafka是如何应对的。
在分布式系统中如何优雅而高效地延迟处理任务是摆在设计者面前的难题之一。我建议你好好学习下这套实现机制在Kafka中的应用代码活学活用将其彻底私有化加入到你的工具箱中。
## 课后讨论
TimingWheel类中的overflowWheel变量为什么是volatile型的
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,464 @@
<audio id="audio" title="20 | DelayedOperationBroker是怎么延时处理请求的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/88/56df95a03706fa0f29adbc928256a888.mp3"></audio>
你好,我是胡夕。
上节课我们学习了分层时间轮在Kafka中的实现。既然是分层时间轮那就说明源码中构造的时间轮是有多个层次的。每一层所表示的总时长等于该层Bucket数乘以每个Bucket涵盖的时间范围。另外该总时长自动成为下一层单个Bucket所覆盖的时间范围。
举个例子目前Kafka第1层的时间轮固定时长是20毫秒interval即有20个BucketwheelSize每个Bucket涵盖1毫秒tickMs的时间范围。第2层的总时长是400毫秒同样有20个Bucket每个Bucket 20毫秒。依次类推那么第3层的时间轮时长就是8秒因为这一层单个Bucket的时长是400毫秒共有20个Bucket。
基于这种设计每个延迟请求需要根据自己的超时时间来决定它要被保存于哪一层时间轮上。我们假设在t=0时创建了第1层的时间轮那么该层第1个Bucket保存的延迟请求就是介于[01之间第2个Bucket保存的是介于[12)之间的请求。现在如果有两个延迟请求超时时刻分别在18.5毫秒和123毫秒那么第1个请求就应该被保存在第1层的第19个Bucket序号从1开始而第2个请求则应该被保存在第2层时间轮的第6个Bucket中。
这基本上就是Kafka中分层时间轮的实现原理。Kafka不断向前推动各个层级的时间轮的时钟按照时间轮的滴答时长陆续接触到Bucket下的各个延迟任务从而实现了对请求的延迟处理。
但是如果你仔细查看的话就会发现到目前为止这套分层时间轮代码和Kafka概念并无直接的关联比如分层时间轮里并不涉及主题、分区、副本这样的概念也没有和Controller、副本管理器等Kafka组件进行直接交互。但实际上延迟处理请求是Kafka的重要功能之一。你可能会问到底是Kafka的哪部分源码负责创建和维护这套分层时间轮并将它集成到整体框架中去的呢答案就是接下来要介绍的两个类Timer和SystemTimer。
## Timer接口及SystemTimer
这两个类的源码位于utils.timer包下的Timer.scala文件。其中**Timer接口定义了管理延迟操作的方法而SystemTimer是实现延迟操作的关键代码**。后续在学习延迟请求类DelayedOperation时我们就会发现调用分层时间轮上的各类操作都是通过SystemTimer类完成的。
### Timer接口
接下来我们就看下它们的源码。首先是Time接口类代码如下
```
trait Timer {
// 将给定的定时任务插入到时间轮上,等待后续延迟执行
def add(timerTask: TimerTask): Unit
// 向前推进时钟,执行已达过期时间的延迟任务
def advanceClock(timeoutMs: Long): Boolean
// 获取时间轮上总的定时任务数
def size: Int
// 关闭定时器
def shutdown(): Unit
}
```
该Timer接口定义了4个方法。
- add方法将给定的定时任务插入到时间轮上等待后续延迟执行。
- advanceClock方法向前推进时钟执行已达过期时间的延迟任务。
- size方法获取当前总定时任务数。
- shutdown方法关闭该定时器。
其中,最重要的两个方法是**add**和**advanceClock**,它们是**完成延迟请求处理的关键步骤**。接下来我们结合Timer实现类SystemTimer的源码重点分析这两个方法。
### SystemTimer类
SystemTimer类是Timer接口的实现类。它是一个定时器类封装了分层时间轮对象为Purgatory提供延迟请求管理功能。所谓的Purgatory就是保存延迟请求的缓冲区。也就是说它保存的是因为不满足条件而无法完成但是又没有超时的请求。
下面我们从定义和方法两个维度来学习SystemTimer类。
#### 定义
首先是该类的定义,代码如下:
```
class SystemTimer(executorName: String,
tickMs: Long = 1,
wheelSize: Int = 20,
startMs: Long = Time.SYSTEM.hiResClockMs) extends Timer {
// 单线程的线程池用于异步执行定时任务
private[this] val taskExecutor = Executors.newFixedThreadPool(1,
(runnable: Runnable) =&gt; KafkaThread.nonDaemon(&quot;executor-&quot; + executorName, runnable))
// 延迟队列保存所有Bucket即所有TimerTaskList对象
private[this] val delayQueue = new DelayQueue[TimerTaskList]()
// 总定时任务数
private[this] val taskCounter = new AtomicInteger(0)
// 时间轮对象
private[this] val timingWheel = new TimingWheel(
tickMs = tickMs,
wheelSize = wheelSize,
startMs = startMs,
taskCounter = taskCounter,
delayQueue
)
// 维护线程安全的读写锁
private[this] val readWriteLock = new ReentrantReadWriteLock()
private[this] val readLock = readWriteLock.readLock()
private[this] val writeLock = readWriteLock.writeLock()
......
}
```
每个SystemTimer类定义了4个原生字段分别是executorName、tickMs、wheelSize和startMs。
tickMs和wheelSize是构建分层时间轮的基础你一定要重点掌握。不过上节课我已经讲过了而且我在开篇还用具体数字带你回顾了它们的用途这里就不重复了。另外两个参数不太重要你只需要知道它们的含义就行了。
- executorNamePurgatory的名字。Kafka中存在不同的Purgatory比如专门处理生产者延迟请求的Produce缓冲区、处理消费者延迟请求的Fetch缓冲区等。这里的Produce和Fetch就是executorName。
- startMs该SystemTimer定时器启动时间单位是毫秒。
除了原生字段SystemTimer类还定义了其他一些字段属性。我介绍3个比较重要的。这3个字段与时间轮都是强相关的。
1. **delayQueue字段**。它保存了该定时器下管理的所有Bucket对象。因为是DelayQueue所以只有在Bucket过期后才能从该队列中获取到。SystemTimer类的advanceClock方法正是依靠了这个特性向前驱动时钟。关于这一点一会儿我们详细说。
1. **timingWheel**。TimingWheel是实现分层时间轮的类。SystemTimer类依靠它来操作分层时间轮。
1. **taskExecutor**。它是单线程的线程池,用于异步执行提交的定时任务逻辑。
#### 方法
说完了类定义与字段我们看下SystemTimer类的方法。
该类总共定义了6个方法add、addTimerTaskEntry、reinsert、advanceClock、size和shutdown。
其中size方法计算的是给定Purgatory下的总延迟请求数shutdown方法则是关闭前面说到的线程池而addTimerTaskEntry方法则是将给定的TimerTaskEntry插入到时间轮中。如果该TimerTaskEntry表征的定时任务没有过期或被取消方法还会将已经过期的定时任务提交给线程池等待异步执行该定时任务。至于reinsert方法它会调用addTimerTaskEntry重新将定时任务插入回时间轮。
其实SystemTimer类最重要的方法是add和advanceClock方法因为**它们是真正对外提供服务的**。我们先说add方法。add方法的作用是将给定的定时任务插入到时间轮中进行管理。代码如下
```
def add(timerTask: TimerTask): Unit = {
// 获取读锁。在没有线程持有写锁的前提下,
// 多个线程能够同时向时间轮添加定时任务
readLock.lock()
try {
// 调用addTimerTaskEntry执行插入逻辑
addTimerTaskEntry(new TimerTaskEntry(timerTask, timerTask.delayMs + Time.SYSTEM.hiResClockMs))
} finally {
// 释放读锁
readLock.unlock()
}
}
```
add方法就是调用addTimerTaskEntry方法执行插入动作。以下是addTimerTaskEntry的方法代码
```
private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
// 视timerTaskEntry状态决定执行什么逻辑
// 1. 未过期未取消:添加到时间轮
// 2. 已取消:什么都不做
// 3. 已过期:提交到线程池,等待执行
if (!timingWheel.add(timerTaskEntry)) {
// 定时任务未取消,说明定时任务已过期
// 否则timingWheel.add方法应该返回True
if (!timerTaskEntry.cancelled)
taskExecutor.submit(timerTaskEntry.timerTask)
}
}
```
TimingWheel的add方法会在定时任务已取消或已过期时返回False否则该方法会将定时任务添加到时间轮然后返回True。因此addTimerTaskEntry方法到底执行什么逻辑取决于给定定时任务的状态
1. 如果该任务既未取消也未过期那么addTimerTaskEntry方法将其添加到时间轮
1. 如果该任务已取消,则该方法什么都不做,直接返回;
1. 如果该任务已经过期,则提交到相应的线程池,等待后续执行。
另一个关键方法是advanceClock方法。顾名思义它的作用是**驱动时钟向前推进**。我们看下代码:
```
def advanceClock(timeoutMs: Long): Boolean = {
// 获取delayQueue中下一个已过期的Bucket
var bucket = delayQueue.poll(
timeoutMs, TimeUnit.MILLISECONDS)
if (bucket != null) {
// 获取写锁
// 一旦有线程持有写锁其他任何线程执行add或advanceClock方法时会阻塞
writeLock.lock()
try {
while (bucket != null) {
// 推动时间轮向前&quot;滚动&quot;到Bucket的过期时间点
timingWheel.advanceClock(bucket.getExpiration())
// 将该Bucket下的所有定时任务重写回到时间轮
bucket.flush(reinsert)
// 读取下一个Bucket对象
bucket = delayQueue.poll()
}
} finally {
// 释放写锁
writeLock.unlock()
}
true
} else {
false
}
}
```
由于代码逻辑比较复杂,我再画一张图来展示一下:
<img src="https://static001.geekbang.org/resource/image/31/89/310c9160f701082ceb90984a7dcfe089.jpg" alt="">
advanceClock方法要做的事情就是遍历delayQueue中的所有Bucket并将时间轮的时钟依次推进到它们的过期时间点令它们过期。然后再将这些Bucket下的所有定时任务全部重新插入回时间轮。
我用一张图来说明这个重新插入过程。
<img src="https://static001.geekbang.org/resource/image/53/ef/535e18ad9516c90ff58baae8cfc9b9ef.png" alt="">
从这张图中我们可以看到在T0时刻任务①存放在Level 0的时间轮上而任务②和③存放在Level 1的时间轮上。此时时钟推进到Level 0的第0个Bucket上以及Level 1的第0个Bucket上。
当时间来到T19时刻时钟也被推进到Level 0的第19个Bucket任务①会被执行。但是由于一层时间轮是20个Bucket因此T19时刻Level 0的时间轮尚未完整走完一圈此时Level 1的时间轮状态没有发生任何变化。
当T20时刻到达时Level 0的时间轮已经执行完成Level 1的时间轮执行了一次滴答向前推进一格。此时Kafka需要将任务②和③插入到Level 0的时间轮上位置是第20个和第21个Bucket。这个将高层时间轮上的任务插入到低层时间轮的过程是由advanceClock中的reinsert方法完成。
至于为什么要重新插入回低层次的时间轮,其实是因为,随着时钟的推进,当前时间逐渐逼近任务②和③的超时时间点。它们之间差值的缩小,足以让它们被放入到下一层的时间轮中。
总的来说SystemTimer类实现了Timer接口的方法**它封装了底层的分层时间轮,为上层调用方提供了便捷的方法来操作时间轮**。那么它的上层调用方是谁呢答案就是DelayedOperationPurgatory类。这就是我们建模Purgatory的地方。
不过在了解DelayedOperationPurgatory之前我们要先学习另一个重要的类DelayedOperation。前者是一个泛型类它的类型参数恰恰就是DelayedOperation。因此我们不可能在不了解DelayedOperation的情况下很好地掌握DelayedOperationPurgatory。
## DelayedOperation类
这个类位于server包下的DelayedOperation.scala文件中。它是所有Kafka延迟请求类的抽象父类。我们依然从定义和方法这两个维度去剖析它。
### 定义
首先来看定义。代码如下:
```
abstract class DelayedOperation(override val delayMs: Long,
lockOpt: Option[Lock] = None)
extends TimerTask with Logging {
// 标识该延迟操作是否已经完成
private val completed = new AtomicBoolean(false)
// 防止多个线程同时检查操作是否可完成时发生锁竞争导致操作最终超时
private val tryCompletePending = new AtomicBoolean(false)
private[server] val lock: Lock = lockOpt.getOrElse(new ReentrantLock)
......
}
```
DelayedOperation类是一个抽象类它的构造函数中只需要传入一个超时时间即可。这个超时时间通常是**客户端发出请求的超时时间**,也就是客户端参数**request.timeout.ms**的值。这个类实现了上节课学到的TimerTask接口因此作为一个建模延迟操作的类它自动继承了TimerTask接口的cancel方法支持延迟操作的取消以及TimerTaskEntry的Getter和Setter方法支持将延迟操作绑定到时间轮相应Bucket下的某个链表元素上。
除此之外DelayedOperation类额外定义了两个字段**completed**和**tryCompletePending**。
前者理解起来比较容易,它就是**表征这个延迟操作是否完成的布尔变量**。我重点解释一下tryCompletePending的作用。
这个参数是在1.1版本引入的。在此之前只有completed参数。但是这样就可能存在这样一个问题当多个线程同时检查某个延迟操作是否满足完成条件时如果其中一个线程持有了锁也就是上面的lock字段然后执行条件检查会发现不满足完成条件。而与此同时另一个线程执行检查时却发现条件满足了但是这个线程又没有拿到锁此时该延迟操作将永远不会有再次被检查的机会会导致最终超时。
加入tryCompletePending字段目的就是**确保拿到锁的线程有机会再次检查条件是否已经满足**。具体是怎么实现的呢下面讲到maybeTryComplete方法时我会再带你进行深入的分析。
关于DelayedOperation类的定义你掌握到这个程度就可以了重点是学习这些字段是如何在方法中发挥作用的。
### 方法
DelayedOperation类有7个方法。我先介绍下它们的作用这样你在读源码时就可以心中有数。
- forceComplete强制完成延迟操作不管它是否满足完成条件。每当操作满足完成条件或已经过期了就需要调用该方法完成该操作。
- isCompleted检查延迟操作是否已经完成。源码使用这个方法来决定后续如何处理该操作。比如如果操作已经完成了那么通常需要取消该操作。
- onExpiration强制完成之后执行的过期逻辑回调方法。只有真正完成操作的那个线程才有资格调用这个方法。
- onComplete完成延迟操作所需的处理逻辑。这个方法只会在forceComplete方法中被调用。
- tryComplete尝试完成延迟操作的顶层方法内部会调用forceComplete方法。
- maybeTryComplete线程安全版本的tryComplete方法。这个方法其实是社区后来才加入的不过已经慢慢地取代了tryComplete现在外部代码调用的都是这个方法了。
- run调用延迟操作超时后的过期逻辑也就是组合调用forceComplete + onExpiration。
我们说过DelayedOperation是抽象类对于不同类型的延时请求onExpiration、onComplete和tryComplete的处理逻辑也各不相同因此需要子类来实现它们。
其他方法的代码大多短小精悍你一看就能明白我就不做过多解释了。我重点说下maybeTryComplete方法。毕竟这是社区为了规避因多线程访问产生锁争用导致线程阻塞从而引发请求超时问题而做的努力。先看方法代码
```
private[server] def maybeTryComplete(): Boolean = {
var retry = false // 是否需要重试
var done = false // 延迟操作是否已完成
do {
if (lock.tryLock()) { // 尝试获取锁对象
try {
tryCompletePending.set(false)
done = tryComplete()
} finally {
lock.unlock()
}
// 运行到这里的线程持有锁其他线程只能运行else分支的代码
// 如果其他线程将maybeTryComplete设置为true那么retry=true
// 这就相当于其他线程给了本线程重试的机会
retry = tryCompletePending.get()
} else {
// 运行到这里的线程没有拿到锁
// 设置tryCompletePending=true给持有锁的线程一个重试的机会
retry = !tryCompletePending.getAndSet(true)
}
} while (!isCompleted &amp;&amp; retry)
done
}
```
为了方便你理解,我画了一张流程图说明它的逻辑:
<img src="https://static001.geekbang.org/resource/image/35/a3/35bd69c5aa46d52a358976152508daa3.jpg" alt="">
从图中可以看出,这个方法可能会被多个线程同时访问,只是不同线程会走不同的代码分支,分叉点就在**尝试获取锁的if语句**。
如果拿到锁对象就依次执行清空tryCompletePending状态、完成延迟请求、释放锁以及读取最新retry状态的动作。未拿到锁的线程就只能设置tryCompletePending状态来间接影响retry值从而给获取到锁的线程一个重试的机会。这里的重试是通过do…while循环的方式实现的。
好了DelayedOperation类我们就说到这里。除了这些公共方法你最好结合一两个具体子类的方法实现体会下具体延迟请求类是如何实现tryComplete方法的。我推荐你从DelayedProduce类的**tryComplete方法**开始。
我们之前总说acks=all的PRODUCE请求很容易成为延迟请求因为它必须等待所有的ISR副本全部同步消息之后才能完成你可以顺着这个思路研究下DelayedProduce的tryComplete方法是如何实现的。
## DelayedOperationPurgatory类
接下来我们补上延迟请求模块的最后一块“拼图”DelayedOperationPurgatory类的源码分析。
该类是实现Purgatory的地方。从代码结构上看它是一个Scala伴生对象。也就是说源码文件同时定义了DelayedOperationPurgatory Object和Class。Object中仅仅定义了apply工厂方法和一个名为Shards的字段这个字段是DelayedOperationPurgatory监控列表的数组长度信息。因此我们还是重点学习DelayedOperationPurgatory Class的源码。
前面说过DelayedOperationPurgatory类是一个泛型类它的参数类型是DelayedOperation的具体子类。因此通常情况下每一类延迟请求都对应于一个DelayedOperationPurgatory实例。这些实例一般都保存在上层的管理器中。比如与消费者组相关的心跳请求、加入组请求的Purgatory实例就保存在GroupCoordinator组件中而与生产者相关的PRODUCE请求的Purgatory实例被保存在分区对象或副本状态机中。
### 定义
至于怎么学,还是老规矩,我们先从定义开始。代码如下:
```
final class DelayedOperationPurgatory[T &lt;: DelayedOperation](
purgatoryName: String,
timeoutTimer: Timer,
brokerId: Int = 0,
purgeInterval: Int = 1000,
reaperEnabled: Boolean = true,
timerEnabled: Boolean = true) extends Logging with KafkaMetricsGroup {
......
}
```
定义中有6个字段。其中很多字段都有默认参数比如最后两个参数分别表示是否启动删除线程以及是否启用分层时间轮。现在源码中所有类型的Purgatory实例都是默认启动的因此无需特别留意它们。
purgeInterval这个参数用于控制删除线程移除Bucket中的过期延迟请求的频率在绝大部分情况下都是1秒一次。当然对于生产者、消费者以及删除消息的AdminClient而言Kafka分别定义了专属的参数允许你调整这个频率。比如生产者参数producer.purgatory.purge.interval.requests就是做这个用的。
事实上,需要传入的参数一般只有两个:**purgatoryName**和**brokerId**它们分别表示这个Purgatory的名字和Broker的序号。
而timeoutTimer就是我们前面讲过的SystemTimer实例我就不重复解释了。
### Wathcers和WatcherList
DelayedOperationPurgatory还定义了两个内置类分别是Watchers和WatcherList。
**Watchers是基于Key的一个延迟请求的监控链表**。它的主体代码如下:
```
private class Watchers(val key: Any) {
private[this] val operations =
new ConcurrentLinkedQueue[T]()
// 其他方法......
}
```
每个Watchers实例都定义了一个延迟请求链表而这里的Key可以是任何类型比如表示消费者组的字符串类型、表示主题分区的TopicPartitionOperationKey类型。你不用穷尽这里所有的Key类型你只需要了解Watchers是一个通用的延迟请求链表就行了。Kafka利用它来**监控保存其中的延迟请求的可完成状态**。
既然Watchers主要的数据结构是链表那么它的所有方法本质上就是一个链表操作。比如tryCompleteWatched方法会遍历整个链表并尝试完成其中的延迟请求。再比如cancel方法也是遍历链表再取消掉里面的延迟请求。至于watch方法则是将延迟请求加入到链表中。
说完了Watchers我们看下WatcherList类。它非常短小精悍完整代码如下
```
private class WatcherList {
// 定义一组按照Key分组的Watchers对象
val watchersByKey = new Pool[Any, Watchers](Some((key: Any) =&gt; new Watchers(key)))
val watchersLock = new ReentrantLock()
// 返回所有Watchers对象
def allWatchers = {
watchersByKey.values
}
}
```
WatcherList最重要的字段是**watchersByKey**。它是一个PoolPool就是Kafka定义的池对象它本质上就是一个ConcurrentHashMap。watchersByKey的Key可以是任何类型而Value就是Key对应类型的一组Watchers对象。
说完了DelayedOperationPurgatory类的两个内部类Watchers和WatcherList我们可以开始学习该类的两个重要方法tryCompleteElseWatch和checkAndComplete方法。
前者的作用是**检查操作是否能够完成**如果不能的话就把它加入到对应Key所在的WatcherList中。以下是方法代码
```
def tryCompleteElseWatch(operation: T, watchKeys: Seq[Any]): Boolean = {
assert(watchKeys.nonEmpty, &quot;The watch key list can't be empty&quot;)
var isCompletedByMe = operation.tryComplete()
// 如果该延迟请求是由本线程完成的直接返回true即可
if (isCompletedByMe)
return true
var watchCreated = false
// 遍历所有要监控的Key
for(key &lt;- watchKeys) {
// 再次查看请求的完成状态如果已经完成就说明是被其他线程完成的返回false
if (operation.isCompleted)
return false
// 否则将该operation加入到Key所在的WatcherList
watchForOperation(key, operation)
// 设置watchCreated标记表明该任务已经被加入到WatcherList
if (!watchCreated) {
watchCreated = true
// 更新Purgatory中总请求数
estimatedTotalOperations.incrementAndGet()
}
}
// 再次尝试完成该延迟请求
isCompletedByMe = operation.maybeTryComplete()
if (isCompletedByMe)
return true
// 如果依然不能完成此请求,将其加入到过期队列
if (!operation.isCompleted) {
if (timerEnabled)
timeoutTimer.add(operation)
if (operation.isCompleted) {
operation.cancel()
}
}
false
}
```
该方法的名字折射出了它要做的事情先尝试完成请求如果无法完成则把它加入到WatcherList中进行监控。具体来说tryCompleteElseWatch调用tryComplete方法尝试完成延迟请求如果返回结果是true就说明执行tryCompleteElseWatch方法的线程正常地完成了该延迟请求也就不需要再添加到WatcherList了直接返回true就行了。
否则的话代码会遍历所有要监控的Key再次查看请求的完成状态。如果已经完成就说明是被其他线程完成的返回false如果依然无法完成则将该请求加入到Key所在的WatcherList中等待后续完成。同时设置watchCreated标记表明该任务已经被加入到WatcherList以及更新Purgatory中总请求数。
待遍历完所有Key之后源码会再次尝试完成该延迟请求如果完成了就返回true否则就取消该请求然后将其加入到过期队列最后返回false。
总的来看,你要掌握这个方法要做的两个事情:
1. 先尝试完成延迟请求;
1. 如果不行就加入到WatcherList等待后面再试。
那么代码是在哪里进行重试的呢这就需要用到第2个方法checkAndComplete了。
该方法会**检查给定Key所在的WatcherList中的延迟请求是否满足完成条件**,如果是的话,则结束掉它们。我们一起看下源码:
```
def checkAndComplete(key: Any): Int = {
// 获取给定Key的WatcherList
val wl = watcherList(key)
// 获取WatcherList中Key对应的Watchers对象实例
val watchers = inLock(wl.watchersLock) { wl.watchersByKey.get(key) }
// 尝试完成满足完成条件的延迟请求并返回成功完成的请求数
val numCompleted = if (watchers == null)
0
else
watchers.tryCompleteWatched()
debug(s&quot;Request key $key unblocked $numCompleted $purgatoryName operations&quot;)
numCompleted
}
```
代码很简单就是根据给定Key获取对应的WatcherList对象以及它下面保存的Watchers对象实例然后尝试完成满足完成条件的延迟请求并返回成功完成的请求数。
可见,非常重要的步骤就是**调用Watchers的tryCompleteWatched方法去尝试完成那些已满足完成条件的延迟请求**。
## 总结
今天我们重点学习了分层时间轮的上层组件包括Timer接口及其实现类SystemTimer、DelayedOperation类以及DelayedOperationPurgatory类。你基本上可以认为它们是逐级被调用的关系即**DelayedOperation调用SystemTimer类DelayedOperationPurgatory管理DelayedOperation**。它们共同实现了Broker端对于延迟请求的处理基本思想就是**能立即完成的请求马上完成否则就放入到名为Purgatory的缓冲区中**。后续DelayedOperationPurgatory类的方法会自动地处理这些延迟请求。
我们来回顾一下重点。
- SystemTimer类Kafka定义的定时器类封装了底层分层时间轮实现了时间轮Bucket的管理以及时钟向前推进功能。它是实现延迟请求后续被自动处理的基础。
- DelayedOperation类延迟请求的高阶抽象类提供了完成请求以及请求完成和过期后的回调逻辑实现。
- DelayedOperationPurgatory类Purgatory实现类该类定义了WatcherList对象以及对WatcherList的操作方法而WatcherList是实现延迟请求后续自动处理的关键数据结构。
总的来说延迟请求模块属于Kafka的冷门组件。毕竟大部分的请求还是能够被立即处理的。了解这部分模块的最大意义在于你可以学习Kafka这个分布式系统是如何异步循环操作和管理定时任务的。这个功能是所有分布式系统都要面临的课题因此弄明白了这部分的原理和代码实现后续我们在自行设计类似的功能模块时就非常容易了。
## 课后讨论
DelayedOperationPurgatory类中定义了一个Reaper线程用于将已过期的延迟请求从数据结构中移除掉。这实际上是由DelayedOperationPurgatory的advanceClock方法完成的。它里面有这样一句
```
val purged = watcherLists.foldLeft(0) {
case (sum, watcherList) =&gt; sum + watcherList.allWatchers.map(_.purgeCompleted()).sum
}
```
你觉得这个语句是做什么用的?
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,297 @@
<audio id="audio" title="01 | 日志段:保存消息文件的对象是怎么实现的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/e0/94846052e0ed70ff96b1b021e449bfe0.mp3"></audio>
你好,我是胡夕。
今天我们开始学习Kafka源代码分析的第一模块日志Log、日志段LogSegment以及索引Index源码。
日志段及其相关代码是Kafka服务器源码中最为重要的组件代码之一。你可能会非常关心在Kafka中消息是如何被保存和组织在一起的。毕竟**不管是学习任何消息引擎,弄明白消息建模方式都是首要的问题**。因此,你非常有必要学习日志段这个重要的子模块的源码实现。
除此之外了解日志段也有很多实际意义比如说你一定对Kafka底层日志文件00000000000000012345.log的命名感到很好奇。学过日志段之后我相信这个问题一定会迎刃而解的。
今天我会带你详细看下日志段部分的源码。不过在此之前你需要先了解一下Kafka的日志结构。
## Kafka日志结构概览
Kafka日志在磁盘上的组织架构如下图所示
<img src="https://static001.geekbang.org/resource/image/72/4b/72fb27cb49e41a61524322ab6bd1cb4b.jpg" alt="">
日志是Kafka服务器端代码的重要组件之一很多其他的核心组件都是以日志为基础的比如后面要讲到的状态管理机和副本管理器等。
总的来说Kafka日志对象由多个日志段对象组成而每个日志段对象会在磁盘上创建一组文件包括消息日志文件.log、位移索引文件.index、时间戳索引文件.timeindex以及已中止Aborted事务的索引文件.txnindex。当然如果你没有使用Kafka事务已中止事务的索引文件是不会被创建出来的。图中的一串数字0是该日志段的起始位移值Base Offset也就是该日志段中所存的第一条消息的位移值。
一般情况下一个Kafka主题有很多分区每个分区就对应一个Log对象在物理磁盘上则对应于一个子目录。比如你创建了一个双分区的主题test-topic那么Kafka在磁盘上会创建两个子目录test-topic-0和test-topic-1。而在服务器端这就是两个Log对象。每个子目录下存在多组日志段也就是多组.log、.index、.timeindex文件组合只不过文件名不同因为每个日志段的起始位移不同。
## 日志段代码解析
阅读日志段源码是很有必要的因为日志段是Kafka保存消息的最小载体。也就是说消息是保存在日志段中的。然而官网对于日志段的描述少得可怜以至于很多人对于这么重要的概念都知之甚少。
但是,不熟悉日志段的话,如果在生产环境出现相应的问题,我们是没有办法快速找到解决方案的。我跟你分享一个真实案例。
我们公司之前碰到过一个问题当时大面积日志段同时间切分导致瞬时打满磁盘I/O带宽。对此所有人都束手无策最终只能求助于日志段源码。
最后我们在LogSegment的shouldRoll方法中找到了解决方案设置Broker端参数log.roll.jitter.ms值大于0即通过给日志段切分执行时间加一个扰动值的方式来避免大量日志段在同一时刻执行切分动作从而显著降低磁盘I/O。
后来在复盘的时候我们一致认为阅读LogSegment源码是非常正确的决定。否则单纯查看官网对该参数的说明我们不一定能够了解它的真实作用。那log.roll.jitter.ms参数的具体作用是啥呢下面咱们说日志段的时候我会给你详细解释下。
那话不多说现在我们就来看一下日志段源码。我会重点给你讲一下日志段类声明、append方法、read方法和recover方法。
你首先要知道的是,日志段源码位于 Kafka 的 core 工程下,具体文件位置是 core/src/main/scala/kafka/log/LogSegment.scala。实际上所有日志结构部分的源码都在 core 的 kafka.log 包下。
该文件下定义了三个 Scala 对象:
- LogSegment class
- LogSegment object
- LogFlushStats object。LogFlushStats 结尾有个 Stats它是做统计用的主要负责为日志落盘进行计时。
我们主要关心的是 **LogSegment class 和 object**。在 Scala 语言里,在一个源代码文件中同时定义相同名字的 class 和 object 的用法被称为伴生Companion。Class 对象被称为伴生类,它和 Java 中的类是一样的;而 Object 对象是一个单例对象,用于保存一些静态变量或静态方法。如果用 Java 来做类比的话我们必须要编写两个类才能实现这两个类也就是LogSegment 和 LogSegmentUtils。在 Scala 中,你直接使用伴生就可以了。
对了值得一提的是Kafka 中的源码注释写得非常详细。我不打算把注释也贴出来,但我特别推荐你要读一读源码中的注释。比如,今天我们要学习的日志段文件开头的一大段注释写得就非常精彩。我截一个片段让你感受下:
A segment of the log. Each segment has two components: a log and an index. The log is a FileRecords containing the actual messages. The index is an OffsetIndex that maps from logical offsets to physical file positions. Each segment has a base offset which is an offset &lt;= the least offset of any message in this segment and &gt; any offset in any previous segment.
这段文字清楚地说明了每个日志段由两个核心组件构成日志和索引。当然这里的索引泛指广义的索引文件。另外这段注释还给出了一个重要的事实每个日志段都有一个起始位移值Base Offset而该位移值是此日志段所有消息中最小的位移值同时该值却又比前面任何日志段中消息的位移值都大。看完这个注释我们就能够快速地了解起始位移值在日志段中的作用了。
### 日志段类声明
下面,我分批次给出比较关键的代码片段,并对其进行解释。首先,我们看下 LogSegment 的定义:
```
class LogSegment private[log] (val log: FileRecords,
val lazyOffsetIndex: LazyIndex[OffsetIndex],
val lazyTimeIndex: LazyIndex[TimeIndex],
val txnIndex: TransactionIndex,
val baseOffset: Long,
val indexIntervalBytes: Int,
val rollJitterMs: Long,
val time: Time) extends Logging { … }
```
就像我前面说的,一个日志段包含**消息日志文件**、**位移索引文件**、**时间戳索引文件**、**已中止事务索引文件**等。这里的 FileRecords 就是实际保存 Kafka 消息的对象。专栏后面我将专门讨论 Kafka 是如何保存具体消息的,也就是 FileRecords 及其家族的实现方式。同时,我还会给你介绍一下社区在持久化消息这块是怎么演进的,你一定不要错过那部分的内容。
下面的 lazyOffsetIndex、lazyTimeIndex 和 txnIndex 分别对应于刚才所说的 3 个索引文件。不过,在实现方式上,前两种使用了延迟初始化的原理,降低了初始化时间成本。后面我们在谈到索引的时候再详细说。
每个日志段对象保存自己的起始位移 **baseOffset**——这是非常重要的属性事实上你在磁盘上看到的文件名就是baseOffset的值。每个LogSegment对象实例一旦被创建它的起始位移就是固定的了不能再被更改。
indexIntervalBytes 值其实就是 Broker 端参数 log.index.interval.bytes 值,它控制了**日志段对象新增索引项的频率**。默认情况下,日志段至少新写入 4KB 的消息数据才会新增一条索引项。而 rollJitterMs 是日志段对象新增倒计时的“扰动值”。因为目前 Broker 端日志段新增倒计时是全局设置,这就是说,在未来的某个时刻可能同时创建多个日志段对象,这将极大地增加物理磁盘 I/O 压力。有了 rollJitterMs 值的干扰,每个新增日志段在创建时会彼此岔开一小段时间,这样可以缓解物理磁盘的 I/O 负载瓶颈。
至于最后的 time 参数,它就是用于统计计时的一个实现类,在 Kafka 源码中普遍出现,我就不详细展开讲了。
下面我来说一些重要的方法。
对于一个日志段而言,最重要的方法就是写入消息和读取消息了,它们分别对应着源码中的 append 方法和 read 方法。另外recover方法同样很关键它是Broker重启后恢复日志段的操作逻辑。
### append方法
我们先来看append 方法,了解下写入消息的具体操作。
```
def append(largestOffset: Long,
largestTimestamp: Long,
shallowOffsetOfMaxTimestamp: Long,
records: MemoryRecords): Unit = {
if (records.sizeInBytes &gt; 0) {
trace(s&quot;Inserting ${records.sizeInBytes} bytes at end offset $largestOffset at position ${log.sizeInBytes} &quot; +
s&quot;with largest timestamp $largestTimestamp at shallow offset $shallowOffsetOfMaxTimestamp&quot;)
val physicalPosition = log.sizeInBytes()
if (physicalPosition == 0)
rollingBasedTimestamp = Some(largestTimestamp)
ensureOffsetInRange(largestOffset)
// append the messages
val appendedBytes = log.append(records)
trace(s&quot;Appended $appendedBytes to ${log.file} at end offset $largestOffset&quot;)
// Update the in memory max timestamp and corresponding offset.
if (largestTimestamp &gt; maxTimestampSoFar) {
maxTimestampSoFar = largestTimestamp
offsetOfMaxTimestampSoFar = shallowOffsetOfMaxTimestamp
}
// append an entry to the index (if needed)
if (bytesSinceLastIndexEntry &gt; indexIntervalBytes) {
offsetIndex.append(largestOffset, physicalPosition)
timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
bytesSinceLastIndexEntry = 0
}
bytesSinceLastIndexEntry += records.sizeInBytes
}
}
```
append 方法接收 4 个参数,分别表示待写入消息批次中消息的**最大位移值**、**最大时间戳**、**最大时间戳对应消息的位移**以及**真正要写入的消息集合**。下面这张图展示了 append 方法的完整执行流程:
<img src="https://static001.geekbang.org/resource/image/67/5c/6700570d3052fcadda54767ed8dc385c.jpg" alt="">
**第一步:**
在源码中,首先调用 log.sizeInBytes 方法判断该日志段是否为空,如果是空的话, Kafka 需要记录要写入消息集合的最大时间戳,并将其作为后面新增日志段倒计时的依据。
**第二步:**
代码调用 ensureOffsetInRange 方法确保输入参数最大位移值是合法的。那怎么判断是不是合法呢?标准就是看它与日志段起始位移的差值是否在整数范围内,即 largestOffset - baseOffset的值是不是介于 [0Int.MAXVALUE] 之间。在极个别的情况下这个差值可能会越界这时append 方法就会抛出异常,阻止后续的消息写入。一旦你碰到这个问题,你需要做的是升级你的 Kafka 版本,因为这是由已知的 Bug 导致的。
**第三步:**
待这些做完之后append 方法调用 FileRecords 的 append 方法执行真正的写入。前面说过了,专栏后面我们会详细介绍 FileRecords 类。这里你只需要知道它的工作是将内存中的消息对象写入到操作系统的页缓存就可以了。
**第四步:**
再下一步,就是更新日志段的最大时间戳以及最大时间戳所属消息的位移值属性。每个日志段都要保存当前最大时间戳信息和所属消息的位移信息。
还记得 Broker 端提供定期删除日志的功能吗?比如我只想保留最近 7 天的日志,没错,当前最大时间戳这个值就是判断的依据;而最大时间戳对应的消息的位移值则用于时间戳索引项。虽然后面我会详细介绍,这里我还是稍微提一下:**时间戳索引项保存时间戳与消息位移的对应关系**。在这步操作中Kafka会更新并保存这组对应关系。
**第五步:**
append 方法的最后一步就是更新索引项和写入的字节数了。我在前面说过,日志段每写入 4KB 数据就要写入一个索引项。当已写入字节数超过了 4KB 之后append 方法会调用索引对象的 append 方法新增索引项,同时清空已写入字节数,以备下次重新累积计算。
### read 方法
好了append 方法我就解释完了。下面我们来看read方法了解下读取日志段的具体操作。
```
def read(startOffset: Long,
maxSize: Int,
maxPosition: Long = size,
minOneMessage: Boolean = false): FetchDataInfo = {
if (maxSize &lt; 0)
throw new IllegalArgumentException(s&quot;Invalid max size $maxSize for log read from segment $log&quot;)
val startOffsetAndSize = translateOffset(startOffset)
// if the start position is already off the end of the log, return null
if (startOffsetAndSize == null)
return null
val startPosition = startOffsetAndSize.position
val offsetMetadata = LogOffsetMetadata(startOffset, this.baseOffset, startPosition)
val adjustedMaxSize =
if (minOneMessage) math.max(maxSize, startOffsetAndSize.size)
else maxSize
// return a log segment but with zero size in the case below
if (adjustedMaxSize == 0)
return FetchDataInfo(offsetMetadata, MemoryRecords.EMPTY)
// calculate the length of the message set to read based on whether or not they gave us a maxOffset
val fetchSize: Int = min((maxPosition - startPosition).toInt, adjustedMaxSize)
FetchDataInfo(offsetMetadata, log.slice(startPosition, fetchSize),
firstEntryIncomplete = adjustedMaxSize &lt; startOffsetAndSize.size)
}
```
read 方法接收 4 个输入参数。
- startOffset要读取的第一条消息的位移
- maxSize能读取的最大字节数
- maxPosition :能读到的最大文件位置;
- minOneMessage是否允许在消息体过大时至少返回第一条消息。
前3个参数的含义很好理解我重点说下第 4 个。当这个参数为 true 时,即使出现消息体字节数超过了 maxSize 的情形read 方法依然能返回至少一条消息。引入这个参数主要是为了确保不出现消费饿死的情况。
下图展示了 read 方法的完整执行逻辑:
<img src="https://static001.geekbang.org/resource/image/61/45/61c97ee41b52e63e771cf5503e0ee345.jpg" alt="">
逻辑很简单,我们一步步来看下。
第一步是调用 translateOffset 方法定位要读取的起始文件位置 startPosition。输入参数 startOffset 仅仅是位移值Kafka 需要根据索引信息找到对应的物理文件位置才能开始读取消息。
待确定了读取起始位置,日志段代码需要根据这部分信息以及 maxSize 和 maxPosition 参数共同计算要读取的总字节数。举个例子,假设 maxSize=100maxPosition=300startPosition=250那么 read 方法只能读取 50 字节,因为 maxPosition - startPosition = 50。我们把它和maxSize参数相比较其中的最小值就是最终能够读取的总字节数。
最后一步是调用 FileRecords 的 slice 方法,从指定位置读取指定大小的消息集合。
### recover 方法
除了append 和read 方法LogSegment 还有一个重要的方法需要我们关注,它就是 recover方法用于**恢复日志段**。
下面的代码是 recover 方法源码。什么是恢复日志段呢?其实就是说, Broker 在启动时会从磁盘上加载所有日志段信息到内存中,并创建相应的 LogSegment 对象实例。在这个过程中,它需要执行一系列的操作。
```
def recover(producerStateManager: ProducerStateManager, leaderEpochCache: Option[LeaderEpochFileCache] = None): Int = {
offsetIndex.reset()
timeIndex.reset()
txnIndex.reset()
var validBytes = 0
var lastIndexEntry = 0
maxTimestampSoFar = RecordBatch.NO_TIMESTAMP
try {
for (batch &lt;- log.batches.asScala) {
batch.ensureValid()
ensureOffsetInRange(batch.lastOffset)
// The max timestamp is exposed at the batch level, so no need to iterate the records
if (batch.maxTimestamp &gt; maxTimestampSoFar) {
maxTimestampSoFar = batch.maxTimestamp
offsetOfMaxTimestampSoFar = batch.lastOffset
}
// Build offset index
if (validBytes - lastIndexEntry &gt; indexIntervalBytes) {
offsetIndex.append(batch.lastOffset, validBytes)
timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar)
lastIndexEntry = validBytes
}
validBytes += batch.sizeInBytes()
if (batch.magic &gt;= RecordBatch.MAGIC_VALUE_V2) {
leaderEpochCache.foreach { cache =&gt;
if (batch.partitionLeaderEpoch &gt; 0 &amp;&amp; cache.latestEpoch.forall(batch.partitionLeaderEpoch &gt; _))
cache.assign(batch.partitionLeaderEpoch, batch.baseOffset)
}
updateProducerState(producerStateManager, batch)
}
}
} catch {
case e@ (_: CorruptRecordException | _: InvalidRecordException) =&gt;
warn(&quot;Found invalid messages in log segment %s at byte offset %d: %s. %s&quot;
.format(log.file.getAbsolutePath, validBytes, e.getMessage, e.getCause))
}
val truncated = log.sizeInBytes - validBytes
if (truncated &gt; 0)
debug(s&quot;Truncated $truncated invalid bytes at the end of segment ${log.file.getAbsoluteFile} during recovery&quot;)
log.truncateTo(validBytes)
offsetIndex.trimToValidSize()
// A normally closed segment always appends the biggest timestamp ever seen into log segment, we do this as well.
timeIndex.maybeAppend(maxTimestampSoFar, offsetOfMaxTimestampSoFar, skipFullCheck = true)
timeIndex.trimToValidSize()
truncated
}
```
我依然使用一张图来说明 recover 的处理逻辑:
<img src="https://static001.geekbang.org/resource/image/eb/6c/eb5bd324685ee393e8a3072fc4b4276c.jpg" alt="">
recover 开始时,代码依次调用索引对象的 reset 方法清空所有的索引文件之后会开始遍历日志段中的所有消息集合或消息批次RecordBatch。对于读取到的每个消息集合日志段必须要确保它们是合法的这主要体现在两个方面
1. 该集合中的消息必须要符合 Kafka 定义的二进制格式;
1. 该集合中最后一条消息的位移值不能越界,即它与日志段起始位移的差值必须是一个正整数值。
校验完消息集合之后代码会更新遍历过程中观测到的最大时间戳以及所属消息的位移值。同样这两个数据用于后续构建索引项。再之后就是不断累加当前已读取的消息字节数并根据该值有条件地写入索引项。最后是更新事务型Producer的状态以及Leader Epoch缓存。不过这两个并不是理解Kafka日志结构所必需的组件因此我们可以忽略它们。
遍历执行完成后Kafka 会将日志段当前总字节数和刚刚累加的已读取字节数进行比较,如果发现前者比后者大,说明日志段写入了一些非法消息,需要执行截断操作,将日志段大小调整回合法的数值。同时, Kafka 还必须相应地调整索引文件的大小。把这些都做完之后,日志段恢复的操作也就宣告结束了。
## 总结
今天我们对Kafka日志段源码进行了重点的分析包括日志段的append方法、read方法和recover方法。
1. append方法我重点分析了源码是如何写入消息到日志段的。你要重点关注一下写操作过程中更新索引的时机是如何设定的。
1. read方法我重点分析了源码底层读取消息的完整流程。你要关注下Kafka计算待读取消息字节数的逻辑也就是maxSize、maxPosition和startOffset是如何共同影响read方法的。
1. recover方法这个操作会读取日志段文件然后重建索引文件。再强调一下**这个操作在执行过程中要读取日志段文件**。因此如果你的环境上有很多日志段文件你又发现Broker重启很慢那你现在就知道了这是因为Kafka在执行recover的过程中需要读取大量的磁盘文件导致的。你看这就是我们读取源码的收获。
<img src="https://static001.geekbang.org/resource/image/15/80/158bed3c92e7205fc450bb8b2d136480.jpg" alt="">
这三个方法是日志段对象最重要的功能。你一定要仔细阅读它们尽量做到对源码中每行代码的作用都了然于心。没有什么代码是读一遍不能理解的如果有那就再多读几遍。另外我希望你特别关注下append和read方法它们将是后面我们讨论日志对象时重点会用到的两个方法。毕竟读写日志是Kafka最常用的操作而日志读取底层调用的就是日志段的这两个方法。
## 课后讨论
如果你查看日志段源码的话你会发现还有一个比较重要的方法我没有提到那就是truncateTo方法这个方法会将日志段中的数据强制截断到指定的位移处。该方法只有20几行代码我希望你可以自己去阅读下然后思考这样一个问题如果指定的位移值特别特别大以至于超过了日志段本身保存的最大位移值该方法对执行效果是怎么样的
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,586 @@
<audio id="audio" title="02 | 日志(上):日志究竟是如何加载日志段的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/d4/59545895a0902d128c0f04221eced3d4.mp3"></audio>
你好我是胡夕。今天我来讲讲Kafka源码的日志Log对象。
上节课,我们学习了日志段部分的源码,你可以认为,**日志是日志段的容器,里面定义了很多管理日志段的操作**。坦率地说如果看Kafka源码却不看Log就跟你买了这门课却不知道作者是谁一样。在我看来Log对象是Kafka源码特别是Broker端最核心的部分没有之一。
它到底有多重要呢我和你分享一个例子你先感受下。我最近正在修复一个Kafka的Bug[KAFKA-9157](https://issues.apache.org/jira/browse/KAFKA-9157)在某些情况下Kafka的Compaction操作会产生很多空的日志段文件。如果要避免这些空日志段文件被创建出来就必须搞懂创建日志段文件的原理而这些代码恰恰就在Log源码中。
既然Log源码要管理日志段对象那么它就必须先把所有日志段对象加载到内存里面。这个过程是怎么实现的呢今天我就带你学习下日志加载日志段的过程。
首先我们来看下Log对象的源码结构。
## Log源码结构
Log源码位于Kafka core工程的log源码包下文件名是Log.scala。总体上该文件定义了10个类和对象如下图所示
<img src="https://static001.geekbang.org/resource/image/81/ce/8126a191f63d9abea860d71992b0aece.jpg" alt="">
那么这10个类和对象都是做什么的呢我先给你简单介绍一下你可以对它们有个大致的了解。
不过在介绍之前我先提一句图中括号里的C表示ClassO表示Object。还记得我在上节课提到过的伴生对象吗没错同时定义同名的Class和Object就属于Scala中的伴生对象用法。
我们先来看伴生对象也就是LogAppendInfo、Log和RollParams。
**1.LogAppendInfo**
- LogAppendInfoC保存了一组待写入消息的各种元数据信息。比如这组消息中第一条消息的位移值是多少、最后一条消息的位移值是多少再比如这组消息中最大的消息时间戳又是多少。总之这里面的数据非常丰富下节课我再具体说说
- LogAppendInfoO: 可以理解为其对应伴生类的工厂方法类里面定义了一些工厂方法用于创建特定的LogAppendInfo实例。
**2.Log**
- LogC: Log源码中最核心的代码。这里我先卖个关子一会儿细聊。
- LogO同理Log伴生类的工厂方法定义了很多常量以及一些辅助方法。
**3.RollParams**
- RollParamsC定义用于控制日志段是否切分Roll的数据结构。
- RollParamsO同理RollParams伴生类的工厂方法。
除了这3组伴生对象之外还有4类源码。
- LogMetricNames定义了Log对象的监控指标。
- LogOffsetSnapshot封装分区所有位移元数据的容器类。
- LogReadInfo封装读取日志返回的数据及其元数据。
- CompletedTxn记录已完成事务的元数据主要用于构建事务索引。
## Log Class &amp; Object
下面我会按照这些类和对象的重要程度对它们一一进行拆解。首先咱们先说说Log类及其伴生对象。
考虑到伴生对象多用于保存静态变量和静态方法比如静态工厂方法等因此我们先看伴生对象即Log Object的实现。毕竟柿子先找软的捏
```
object Log {
val LogFileSuffix = &quot;.log&quot;
val IndexFileSuffix = &quot;.index&quot;
val TimeIndexFileSuffix = &quot;.timeindex&quot;
val ProducerSnapshotFileSuffix = &quot;.snapshot&quot;
val TxnIndexFileSuffix = &quot;.txnindex&quot;
val DeletedFileSuffix = &quot;.deleted&quot;
val CleanedFileSuffix = &quot;.cleaned&quot;
val SwapFileSuffix = &quot;.swap&quot;
val CleanShutdownFile = &quot;.kafka_cleanshutdown&quot;
val DeleteDirSuffix = &quot;-delete&quot;
val FutureDirSuffix = &quot;-future&quot;
……
}
```
这是Log Object定义的所有常量。如果有面试官问你Kafka中定义了多少种文件类型你可以自豪地把这些说出来。耳熟能详的.log、.index、.timeindex和.txnindex我就不解释了我们来了解下其他几种文件类型。
- .snapshot是Kafka为幂等型或事务型Producer所做的快照文件。鉴于我们现在还处于阅读源码的初级阶段事务或幂等部分的源码我就不详细展开讲了。
- .deleted是删除日志段操作创建的文件。目前删除日志段文件是异步操作Broker端把日志段文件从.log后缀修改为.deleted后缀。如果你看到一大堆.deleted后缀的文件名别慌这是Kafka在执行日志段文件删除。
- .cleaned和.swap都是Compaction操作的产物等我们讲到Cleaner的时候再说。
- -delete则是应用于文件夹的。当你删除一个主题的时候主题的分区文件夹会被加上这个后缀。
- -future是用于变更主题分区文件夹地址的属于比较高阶的用法。
总之记住这些常量吧。记住它们的主要作用是以后不要被面试官唬住开玩笑其实这些常量最重要的地方就在于它们能够让你了解Kafka定义的各种文件类型。
Log Object还定义了超多的工具类方法。由于它们都很简单这里我只给出一个方法的源码我们一起读一下。
```
def filenamePrefixFromOffset(offset: Long): String = {
val nf = NumberFormat.getInstance()
nf.setMinimumIntegerDigits(20)
nf.setMaximumFractionDigits(0)
nf.setGroupingUsed(false)
nf.format(offset)
}
```
这个方法的作用是**通过给定的位移值计算出对应的日志段文件名**。Kafka日志文件固定是20位的长度filenamePrefixFromOffset方法就是用前面补0的方式把给定位移值扩充成一个固定20位长度的字符串。
举个例子我们给定一个位移值是12345那么Broker端磁盘上对应的日志段文件名就应该是00000000000000012345.log。怎么样很简单吧其他的工具类方法也很简单我就不一一展开说了。
下面我们来看Log源码部分的重头戏**Log类**。这是一个2000多行的大类。放眼整个Kafka源码像Log这么大的类也不多见足见它的重要程度。我们先来看这个类的定义
```
class Log(@volatile var dir: File,
@volatile var config: LogConfig,
@volatile var logStartOffset: Long,
@volatile var recoveryPoint: Long,
scheduler: Scheduler,
brokerTopicStats: BrokerTopicStats,
val time: Time,
val maxProducerIdExpirationMs: Int,
val producerIdExpirationCheckIntervalMs: Int,
val topicPartition: TopicPartition,
val producerStateManager: ProducerStateManager,
logDirFailureChannel: LogDirFailureChannel) extends Logging with KafkaMetricsGroup {
……
}
```
看着好像有很多属性,但其实,你只需要记住两个属性的作用就够了:**dir和logStartOffset**。dir就是这个日志所在的文件夹路径也就是**主题分区的路径**。而logStartOffset表示**日志的当前最早位移**。dir和logStartOffset都是volatile var类型表示它们的值是变动的而且可能被多个线程更新。
你可能听过日志的当前末端位移也就是Log End OffsetLEO它是表示日志下一条待插入消息的位移值而这个Log Start Offset是跟它相反的它表示日志当前对外可见的最早一条消息的位移值。我用一张图来标识它们的区别
<img src="https://static001.geekbang.org/resource/image/38/b4/388672f6dab8571f272ed47c9679c2b4.jpg" alt="">
图中绿色的位移值3是日志的Log Start Offset而位移值15表示LEO。另外位移值8是高水位值它是区分已提交消息和未提交消息的分水岭。
有意思的是Log End Offset可以简称为LEO但Log Start Offset却不能简称为LSO。因为在Kafka中LSO特指Log Stable Offset属于Kafka事务的概念。这个课程中不会涉及LSO你只需要知道Log Start Offset不等于LSO即可。
Log类的其他属性你暂时不用理会因为它们要么是很明显的工具类属性比如timer和scheduler要么是高阶用法才会用到的高级属性比如producerStateManager和logDirFailureChannel。工具类的代码大多是做辅助用的跳过它们也不妨碍我们理解Kafka的核心功能而高阶功能代码设计复杂学习成本高性价比不高。
其实除了Log类签名定义的这些属性之外Log类还定义了一些很重要的属性比如下面这段代码
```
@volatile private var nextOffsetMetadata: LogOffsetMetadata = _
@volatile private var highWatermarkMetadata: LogOffsetMetadata = LogOffsetMetadata(logStartOffset)
private val segments: ConcurrentNavigableMap[java.lang.Long, LogSegment] = new ConcurrentSkipListMap[java.lang.Long, LogSegment]
@volatile var leaderEpochCache: Option[LeaderEpochFileCache] = None
```
第一个属性nextOffsetMetadata它封装了下一条待插入消息的位移值你基本上可以把这个属性和LEO等同起来。
第二个属性highWatermarkMetadata是分区日志高水位值。关于高水位的概念我们在[《Kafka核心技术与实战》](https://time.geekbang.org/column/intro/100029201)这个课程中做过详细解释,你可以看一下[这篇文章](https://time.geekbang.org/column/article/112118)(下节课我还会再具体给你介绍下)。
第三个属性segments我认为这是Log类中最重要的属性。它保存了分区日志下所有的日志段信息只不过是用Map的数据结构来保存的。Map的Key值是日志段的起始位移值Value则是日志段对象本身。Kafka源码使用ConcurrentNavigableMap数据结构来保存日志段对象就可以很轻松地利用该类提供的线程安全和各种支持排序的方法来管理所有日志段对象。
第四个属性是Leader Epoch Cache对象。Leader Epoch是社区于0.11.0.0版本引入源码中的主要是用来判断出现Failure时是否执行日志截断操作Truncation。之前靠高水位来判断的机制可能会造成副本间数据不一致的情形。这里的Leader Epoch Cache是一个缓存类数据里面保存了分区Leader的Epoch值与对应位移值的映射关系我建议你查看下LeaderEpochFileCache类深入地了解下它的实现原理。
掌握了这些基本属性之后我们看下Log类的初始化逻辑
```
locally {
val startMs = time.milliseconds
// create the log directory if it doesn't exist
Files.createDirectories(dir.toPath)
initializeLeaderEpochCache()
val nextOffset = loadSegments()
/* Calculate the offset of the next message */
nextOffsetMetadata = LogOffsetMetadata(nextOffset, activeSegment.baseOffset, activeSegment.size)
leaderEpochCache.foreach(_.truncateFromEnd(nextOffsetMetadata.messageOffset))
logStartOffset = math.max(logStartOffset, segments.firstEntry.getValue.baseOffset)
// The earliest leader epoch may not be flushed during a hard failure. Recover it here.
leaderEpochCache.foreach(_.truncateFromStart(logStartOffset))
// Any segment loading or recovery code must not use producerStateManager, so that we can build the full state here
// from scratch.
if (!producerStateManager.isEmpty)
throw new IllegalStateException(&quot;Producer state must be empty during log initialization&quot;)
loadProducerState(logEndOffset, reloadFromCleanShutdown = hasCleanShutdownFile)
info(s&quot;Completed load of log with ${segments.size} segments, log start offset $logStartOffset and &quot; +
s&quot;log end offset $logEndOffset in ${time.milliseconds() - startMs}
```
在详细解释这段初始化代码之前,我使用一张图来说明它到底做了什么:
<img src="https://static001.geekbang.org/resource/image/a1/a8/a10b81680a449e5b1d8882939061f7a8.jpg" alt="">
这里我们重点说说第三步即加载日志段的实现逻辑以下是loadSegments的实现代码
```
private def loadSegments(): Long = {
// first do a pass through the files in the log directory and remove any temporary files
// and find any interrupted swap operations
val swapFiles = removeTempFilesAndCollectSwapFiles()
// Now do a second pass and load all the log and index files.
// We might encounter legacy log segments with offset overflow (KAFKA-6264). We need to split such segments. When
// this happens, restart loading segment files from scratch.
retryOnOffsetOverflow {
// In case we encounter a segment with offset overflow, the retry logic will split it after which we need to retry
// loading of segments. In that case, we also need to close all segments that could have been left open in previous
// call to loadSegmentFiles().
logSegments.foreach(_.close())
segments.clear()
loadSegmentFiles()
}
// Finally, complete any interrupted swap operations. To be crash-safe,
// log files that are replaced by the swap segment should be renamed to .deleted
// before the swap file is restored as the new segment file.
completeSwapOperations(swapFiles)
if (!dir.getAbsolutePath.endsWith(Log.DeleteDirSuffix)) {
val nextOffset = retryOnOffsetOverflow {
recoverLog()
}
// reset the index size of the currently active log segment to allow more entries
activeSegment.resizeIndexes(config.maxIndexSize)
nextOffset
} else {
if (logSegments.isEmpty) {
addSegment(LogSegment.open(dir = dir,
baseOffset = 0,
config,
time = time,
fileAlreadyExists = false,
initFileSize = this.initFileSize,
preallocate = false))
}
0
}
```
这段代码会对分区日志路径遍历两次。
首先它会移除上次Failure遗留下来的各种临时文件包括.cleaned、.swap、.deleted文件等removeTempFilesAndCollectSwapFiles方法实现了这个逻辑。
之后它会清空所有日志段对象并且再次遍历分区路径重建日志段segments Map并删除无对应日志段文件的孤立索引文件。
待执行完这两次遍历之后它会完成未完成的swap操作即调用completeSwapOperations方法。等这些都做完之后再调用recoverLog方法恢复日志段对象然后返回恢复之后的分区日志LEO值。
如果你现在觉得有点蒙,也没关系,我把这段代码再进一步拆解下,以更小的粒度跟你讲下它们做了什么。理解了这段代码之后,你大致就能搞清楚大部分的分区日志操作了。所以,这部分代码绝对值得我们多花一点时间去学习。
我们首先来看第一步removeTempFilesAndCollectSwapFiles方法的实现。我用注释的方式详细解释了每行代码的作用
```
private def removeTempFilesAndCollectSwapFiles(): Set[File] = {
// 在方法内部定义一个名为deleteIndicesIfExist的方法用于删除日志文件对应的索引文件
def deleteIndicesIfExist(baseFile: File, suffix: String = &quot;&quot;): Unit = {
info(s&quot;Deleting index files with suffix $suffix for baseFile $baseFile&quot;)
val offset = offsetFromFile(baseFile)
Files.deleteIfExists(Log.offsetIndexFile(dir, offset, suffix).toPath)
Files.deleteIfExists(Log.timeIndexFile(dir, offset, suffix).toPath)
Files.deleteIfExists(Log.transactionIndexFile(dir, offset, suffix).toPath)
}
var swapFiles = Set[File]()
var cleanFiles = Set[File]()
var minCleanedFileOffset = Long.MaxValue
// 遍历分区日志路径下的所有文件
for (file &lt;- dir.listFiles if file.isFile) {
if (!file.canRead) // 如果不可读直接抛出IOException
throw new IOException(s&quot;Could not read file $file&quot;)
val filename = file.getName
if (filename.endsWith(DeletedFileSuffix)) { // 如果以.deleted结尾
debug(s&quot;Deleting stray temporary file ${file.getAbsolutePath}&quot;)
Files.deleteIfExists(file.toPath) // 说明是上次Failure遗留下来的文件直接删除
} else if (filename.endsWith(CleanedFileSuffix)) { // 如果以.cleaned结尾
minCleanedFileOffset = Math.min(offsetFromFileName(filename), minCleanedFileOffset) // 选取文件名中位移值最小的.cleaned文件获取其位移值并将该文件加入待删除文件集合中
cleanFiles += file
} else if (filename.endsWith(SwapFileSuffix)) { // 如果以.swap结尾
val baseFile = new File(CoreUtils.replaceSuffix(file.getPath, SwapFileSuffix, &quot;&quot;))
info(s&quot;Found file ${file.getAbsolutePath} from interrupted swap operation.&quot;)
if (isIndexFile(baseFile)) { // 如果该.swap文件原来是索引文件
deleteIndicesIfExist(baseFile) // 删除原来的索引文件
} else if (isLogFile(baseFile)) { // 如果该.swap文件原来是日志文件
deleteIndicesIfExist(baseFile) // 删除掉原来的索引文件
swapFiles += file // 加入待恢复的.swap文件集合中
}
}
}
// 从待恢复swap集合中找出那些起始位移值大于minCleanedFileOffset值的文件直接删掉这些无效的.swap文件
val (invalidSwapFiles, validSwapFiles) = swapFiles.partition(file =&gt; offsetFromFile(file) &gt;= minCleanedFileOffset)
invalidSwapFiles.foreach { file =&gt;
debug(s&quot;Deleting invalid swap file ${file.getAbsoluteFile} minCleanedFileOffset: $minCleanedFileOffset&quot;)
val baseFile = new File(CoreUtils.replaceSuffix(file.getPath, SwapFileSuffix, &quot;&quot;))
deleteIndicesIfExist(baseFile, SwapFileSuffix)
Files.deleteIfExists(file.toPath)
}
// Now that we have deleted all .swap files that constitute an incomplete split operation, let's delete all .clean files
// 清除所有待删除文件集合中的文件
cleanFiles.foreach { file =&gt;
debug(s&quot;Deleting stray .clean file ${file.getAbsolutePath}&quot;)
Files.deleteIfExists(file.toPath)
}
// 最后返回当前有效的.swap文件集合
validSwapFiles
}
```
执行完了removeTempFilesAndCollectSwapFiles逻辑之后源码开始清空已有日志段集合并重新加载日志段文件。这就是第二步。这里调用的主要方法是loadSegmentFiles。
```
private def loadSegmentFiles(): Unit = {
// 按照日志段文件名中的位移值正序排列,然后遍历每个文件
for (file &lt;- dir.listFiles.sortBy(_.getName) if file.isFile) {
if (isIndexFile(file)) { // 如果是索引文件
val offset = offsetFromFile(file)
val logFile = Log.logFile(dir, offset)
if (!logFile.exists) { // 确保存在对应的日志文件,否则记录一个警告,并删除该索引文件
warn(s&quot;Found an orphaned index file ${file.getAbsolutePath}, with no corresponding log file.&quot;)
Files.deleteIfExists(file.toPath)
}
} else if (isLogFile(file)) { // 如果是日志文件
val baseOffset = offsetFromFile(file)
val timeIndexFileNewlyCreated = !Log.timeIndexFile(dir, baseOffset).exists()
// 创建对应的LogSegment对象实例并加入segments中
val segment = LogSegment.open(dir = dir,
baseOffset = baseOffset,
config,
time = time,
fileAlreadyExists = true)
try segment.sanityCheck(timeIndexFileNewlyCreated)
catch {
case _: NoSuchFileException =&gt;
error(s&quot;Could not find offset index file corresponding to log file ${segment.log.file.getAbsolutePath}, &quot; +
&quot;recovering segment and rebuilding index files...&quot;)
recoverSegment(segment)
case e: CorruptIndexException =&gt;
warn(s&quot;Found a corrupted index file corresponding to log file ${segment.log.file.getAbsolutePath} due &quot; +
s&quot;to ${e.getMessage}}, recovering segment and rebuilding index files...&quot;)
recoverSegment(segment)
}
addSegment(segment)
}
}
}
```
第三步是处理第一步返回的有效.swap文件集合。completeSwapOperations方法就是做这件事的
```
private def completeSwapOperations(swapFiles: Set[File]): Unit = {
// 遍历所有有效.swap文件
for (swapFile &lt;- swapFiles) {
val logFile = new File(CoreUtils.replaceSuffix(swapFile.getPath, SwapFileSuffix, &quot;&quot;)) // 获取对应的日志文件
val baseOffset = offsetFromFile(logFile) // 拿到日志文件的起始位移值
// 创建对应的LogSegment实例
val swapSegment = LogSegment.open(swapFile.getParentFile,
baseOffset = baseOffset,
config,
time = time,
fileSuffix = SwapFileSuffix)
info(s&quot;Found log file ${swapFile.getPath} from interrupted swap operation, repairing.&quot;)
// 执行日志段恢复操作
recoverSegment(swapSegment)
// We create swap files for two cases:
// (1) Log cleaning where multiple segments are merged into one, and
// (2) Log splitting where one segment is split into multiple.
//
// Both of these mean that the resultant swap segments be composed of the original set, i.e. the swap segment
// must fall within the range of existing segment(s). If we cannot find such a segment, it means the deletion
// of that segment was successful. In such an event, we should simply rename the .swap to .log without having to
// do a replace with an existing segment.
// 确认之前删除日志段是否成功,是否还存在老的日志段文件
val oldSegments = logSegments(swapSegment.baseOffset, swapSegment.readNextOffset).filter { segment =&gt;
segment.readNextOffset &gt; swapSegment.baseOffset
}
// 将生成的.swap文件加入到日志中删除掉swap之前的日志段
replaceSegments(Seq(swapSegment), oldSegments.toSeq, isRecoveredSwapFile = true)
}
}
```
最后一步是recoverLog操作
```
private def recoverLog(): Long = {
// if we have the clean shutdown marker, skip recovery
// 如果不存在以.kafka_cleanshutdown结尾的文件。通常都不存在
if (!hasCleanShutdownFile) {
// 获取到上次恢复点以外的所有unflushed日志段对象
val unflushed = logSegments(this.recoveryPoint, Long.MaxValue).toIterator
var truncated = false
// 遍历这些unflushed日志段
while (unflushed.hasNext &amp;&amp; !truncated) {
val segment = unflushed.next
info(s&quot;Recovering unflushed segment ${segment.baseOffset}&quot;)
val truncatedBytes =
try {
// 执行恢复日志段操作
recoverSegment(segment, leaderEpochCache)
} catch {
case _: InvalidOffsetException =&gt;
val startOffset = segment.baseOffset
warn(&quot;Found invalid offset during recovery. Deleting the corrupt segment and &quot; +
s&quot;creating an empty one with starting offset $startOffset&quot;)
segment.truncateTo(startOffset)
}
if (truncatedBytes &gt; 0) { // 如果有无效的消息导致被截断的字节数不为0直接删除剩余的日志段对象
warn(s&quot;Corruption found in segment ${segment.baseOffset}, truncating to offset ${segment.readNextOffset}&quot;)
removeAndDeleteSegments(unflushed.toList, asyncDelete = true)
truncated = true
}
}
}
// 这些都做完之后,如果日志段集合不为空
if (logSegments.nonEmpty) {
val logEndOffset = activeSegment.readNextOffset
if (logEndOffset &lt; logStartOffset) { // 验证分区日志的LEO值不能小于Log Start Offset值否则删除这些日志段对象
warn(s&quot;Deleting all segments because logEndOffset ($logEndOffset) is smaller than logStartOffset ($logStartOffset). &quot; +
&quot;This could happen if segment files were deleted from the file system.&quot;)
removeAndDeleteSegments(logSegments, asyncDelete = true)
}
}
// 这些都做完之后,如果日志段集合为空了
if (logSegments.isEmpty) {
// 至少创建一个新的日志段以logStartOffset为日志段的起始位移并加入日志段集合中
addSegment(LogSegment.open(dir = dir,
baseOffset = logStartOffset,
config,
time = time,
fileAlreadyExists = false,
initFileSize = this.initFileSize,
preallocate = config.preallocate))
}
// 更新上次恢复点属性,并返回
recoveryPoint = activeSegment.readNextOffset
recoveryPoint
```
## 总结
今天我重点向你介绍了Kafka的Log源码主要包括
1. **Log文件的源码结构**你可以看下下面的导图它展示了Log类文件的架构组成你要重点掌握Log类及其相关方法。
1. **加载日志段机制**:我结合源码重点分析了日志在初始化时是如何加载日志段的。前面说过了,日志是日志段的容器,弄明白如何加载日志段是后续学习日志段管理的前提条件。
<img src="https://static001.geekbang.org/resource/image/dd/fc/dd2bf4882021d969accb14c0017d9dfc.jpg" alt="">
总的来说虽然洋洋洒洒几千字但我也只讲了最重要的部分。我建议你多看几遍Log.scala中加载日志段的代码这对后面我们理解Kafka Broker端日志段管理原理大有裨益。在下节课我会继续讨论日志部分的源码带你学习常见的Kafka日志操作。
## 课后讨论
Log源码中有个maybeIncrementHighWatermark方法你能说说它的实现原理吗
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,803 @@
<audio id="audio" title="03 | 日志彻底搞懂Log对象的常见操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/d1/4ce9089c910559893b291c9d35ca5ed1.mp3"></audio>
你好我是胡夕。上节课我们一起了解了日志加载日志段的过程。今天我会继续带你学习Log源码给你介绍Log对象的常见操作。
我一般习惯把Log的常见操作分为4大部分。
1. **高水位管理操作**高水位的概念在Kafka中举足轻重对它的管理是Log最重要的功能之一。
1. **日志段管理**Log是日志段的容器。高效组织与管理其下辖的所有日志段对象是源码要解决的核心问题。
1. **关键位移值管理**日志定义了很多重要的位移值比如Log Start Offset和LEO等。确保这些位移值的正确性是构建消息引擎一致性的基础。
1. **读写操作**:所谓的操作日志,大体上就是指读写日志。读写操作的作用之大,不言而喻。
接下来我会按照这个顺序和你介绍Log对象的常见操作并希望你特别关注下高水位管理部分。
事实上社区关于日志代码的很多改进都是基于高水位机制的有的甚至是为了替代高水位机制而做的更新。比如Kafka的KIP-101提案正式引入的Leader Epoch机制就是用来替代日志截断操作中的高水位的。显然要深入学习Leader Epoch你至少要先了解高水位并清楚它的弊病在哪儿才行。
既然高水位管理这么重要,那我们就从它开始说起吧。
## 高水位管理操作
在介绍高水位管理操作之前,我们先来了解一下高水位的定义。
### 定义
源码中日志对象定义高水位的语句只有一行:
```
@volatile private var highWatermarkMetadata: LogOffsetMetadata = LogOffsetMetadata(logStartOffset)
```
这行语句传达了两个重要的事实:
1. 高水位值是volatile易变型的。因为多个线程可能同时读取它因此需要设置成volatile保证内存可见性。另外由于高水位值可能被多个线程同时修改因此源码使用Java Monitor锁来确保并发修改的线程安全。
1. 高水位值的初始值是Log Start Offset值。上节课我们提到每个Log对象都会维护一个Log Start Offset值。当首次构建高水位时它会被赋值成Log Start Offset值。
你可能会关心LogOffsetMetadata是什么对象。因为它比较重要我们一起来看下这个类的定义
```
case class LogOffsetMetadata(messageOffset: Long,
segmentBaseOffset: Long = Log.UnknownOffset, relativePositionInSegment: Int = LogOffsetMetadata.UnknownFilePosition)
```
显然它就是一个POJO类里面保存了三个重要的变量。
1. messageOffset**消息位移值**,这是最重要的信息。我们总说高水位值,其实指的就是这个变量的值。
1. segmentBaseOffset**保存该位移值所在日志段的起始位移**。日志段起始位移值辅助计算两条消息在物理磁盘文件中位置的差值,即两条消息彼此隔了多少字节。这个计算有个前提条件,即两条消息必须处在同一个日志段对象上,不能跨日志段对象。否则它们就位于不同的物理文件上,计算这个值就没有意义了。**这里的segmentBaseOffset就是用来判断两条消息是否处于同一个日志段的**。
1. relativePositionSegment**保存该位移值所在日志段的物理磁盘位置**。这个字段在计算两个位移值之间的物理磁盘位置差值时非常有用。你可以想一想Kafka什么时候需要计算位置之间的字节数呢答案就是在读取日志的时候。假设每次读取时只能读1MB的数据那么源码肯定需要关心两个位移之间所有消息的总字节数是否超过了1MB。
LogOffsetMetadata类的所有方法都是围绕这3个变量展开的工具辅助类方法非常容易理解。我会给出一个方法的详细解释剩下的你可以举一反三。
```
def onSameSegment(that: LogOffsetMetadata): Boolean = {
if (messageOffsetOnly)
throw new KafkaException(s&quot;$this cannot compare its segment info with $that since it only has message offset info&quot;)
this.segmentBaseOffset == that.segmentBaseOffset
}
```
看名字我们就知道了这个方法就是用来判断给定的两个LogOffsetMetadata对象是否处于同一个日志段的。判断方法很简单就是比较两个LogOffsetMetadata对象的segmentBaseOffset值是否相等。
好了,我们接着说回高水位,你要重点关注下获取和设置高水位值、更新高水位值,以及读取高水位值的方法。
### 获取和设置高水位值
关于获取高水位值的方法其实很好理解我就不多说了。设置高水位值的方法也就是Setter方法更复杂一些为了方便你理解我用注释的方式来解析它的作用。
```
// getter method读取高水位的位移值
def highWatermark: Long = highWatermarkMetadata.messageOffset
// setter method设置高水位值
private def updateHighWatermarkMetadata(newHighWatermark: LogOffsetMetadata): Unit = {
if (newHighWatermark.messageOffset &lt; 0) // 高水位值不能是负数
throw new IllegalArgumentException(&quot;High watermark offset should be non-negative&quot;)
lock synchronized { // 保护Log对象修改的Monitor锁
highWatermarkMetadata = newHighWatermark // 赋值新的高水位值
producerStateManager.onHighWatermarkUpdated(newHighWatermark.messageOffset) // 处理事务状态管理器的高水位值更新逻辑,忽略它……
maybeIncrementFirstUnstableOffset() // First Unstable Offset是Kafka事务机制的一部分忽略它……
}
trace(s&quot;Setting high watermark $newHighWatermark&quot;)
}
```
### 更新高水位值
除此之外,源码还定义了两个更新高水位值的方法:**updateHighWatermark**和**maybeIncrementHighWatermark**。从名字上来看,前者是一定要更新高水位值的,而后者是可能会更新也可能不会。
我们分别看下它们的实现原理。
```
// updateHighWatermark method
def updateHighWatermark(hw: Long): Long = {
// 新高水位值一定介于[Log Start OffsetLog End Offset]之间
val newHighWatermark = if (hw &lt; logStartOffset)
logStartOffset
else if (hw &gt; logEndOffset)
logEndOffset
else
hw
// 调用Setter方法来更新高水位值
updateHighWatermarkMetadata(LogOffsetMetadata(newHighWatermark))
newHighWatermark // 最后返回新高水位值
}
// maybeIncrementHighWatermark method
def maybeIncrementHighWatermark(newHighWatermark: LogOffsetMetadata): Option[LogOffsetMetadata] = {
// 新高水位值不能越过Log End Offset
if (newHighWatermark.messageOffset &gt; logEndOffset)
throw new IllegalArgumentException(s&quot;High watermark $newHighWatermark update exceeds current &quot; +
s&quot;log end offset $logEndOffsetMetadata&quot;)
lock.synchronized {
val oldHighWatermark = fetchHighWatermarkMetadata // 获取老的高水位值
// 新高水位值要比老高水位值大以维持单调增加特性,否则就不做更新!
// 另外,如果新高水位值在新日志段上,也可执行更新高水位操作
if (oldHighWatermark.messageOffset &lt; newHighWatermark.messageOffset ||
(oldHighWatermark.messageOffset == newHighWatermark.messageOffset &amp;&amp; oldHighWatermark.onOlderSegment(newHighWatermark))) {
updateHighWatermarkMetadata(newHighWatermark)
Some(oldHighWatermark) // 返回老的高水位值
} else {
None
}
}
}
```
你可能觉得奇怪,为什么要定义两个更新高水位的方法呢?
其实这两个方法有着不同的用途。updateHighWatermark方法主要用在Follower副本从Leader副本获取到消息后更新高水位值。一旦拿到新的消息就必须要更新高水位值而maybeIncrementHighWatermark方法主要是用来更新Leader副本的高水位值。需要注意的是Leader副本高水位值的更新是有条件的——某些情况下会更新高水位值某些情况下可能不会。
就像我刚才说的Follower副本成功拉取Leader副本的消息后必须更新高水位值但Producer端向Leader副本写入消息时分区的高水位值就可能不需要更新——因为它可能需要等待其他Follower副本同步的进度。因此源码中定义了两个更新的方法它们分别应用于不同的场景。
### 读取高水位值
关于高水位值管理的最后一个操作是**fetchHighWatermarkMetadata方法**。它不仅仅是获取高水位值,还要获取高水位的其他元数据信息,即日志段起始位移和物理位置信息。下面是它的实现逻辑:
```
private def fetchHighWatermarkMetadata: LogOffsetMetadata = {
checkIfMemoryMappedBufferClosed() // 读取时确保日志不能被关闭
val offsetMetadata = highWatermarkMetadata // 保存当前高水位值到本地变量,避免多线程访问干扰
if (offsetMetadata.messageOffsetOnly) { //没有获得到完整的高水位元数据
lock.synchronized {
val fullOffset = convertToOffsetMetadataOrThrow(highWatermark) // 通过读日志文件的方式把完整的高水位元数据信息拉出来
updateHighWatermarkMetadata(fullOffset) // 然后再更新一下高水位对象
fullOffset
}
} else { // 否则,直接返回即可
offsetMetadata
}
}
```
## 日志段管理
前面我反复说过,日志是日志段的容器,那它究竟是如何承担起容器一职的呢?
```
private val segments: ConcurrentNavigableMap[java.lang.Long, LogSegment] = new ConcurrentSkipListMap[java.lang.Long, LogSegment]
```
可以看到源码使用Java的ConcurrentSkipListMap类来保存所有日志段对象。ConcurrentSkipListMap有2个明显的优势。
- **它是线程安全的**这样Kafka源码不需要自行确保日志段操作过程中的线程安全
- **它是键值Key可排序的Map**。Kafka将每个日志段的起始位移值作为Key这样一来我们就能够很方便地根据所有日志段的起始位移值对它们进行排序和比较同时还能快速地找到与给定位移值相近的前后两个日志段。
所谓的日志段管理无非是增删改查。接下来我们就从这4个方面一一来看下。
**1.增加**
Log对象中定义了添加日志段对象的方法**addSegment**。
```
def addSegment(segment: LogSegment): LogSegment = this.segments.put(segment.baseOffset, segment)
```
很简单吧就是调用Map的put方法将给定的日志段对象添加到segments中。
**2.删除**
删除操作相对来说复杂一点。我们知道Kafka有很多留存策略包括基于时间维度的、基于空间维度的和基于Log Start Offset维度的。那啥是留存策略呢其实它本质上就是**根据一定的规则决定哪些日志段可以删除**。
从源码角度来看Log中控制删除操作的总入口是**deleteOldSegments无参方法**
```
def deleteOldSegments(): Int = {
if (config.delete) {
deleteRetentionMsBreachedSegments() + deleteRetentionSizeBreachedSegments() + deleteLogStartOffsetBreachedSegments()
} else {
deleteLogStartOffsetBreachedSegments()
}
}
```
代码中的deleteRetentionMsBreachedSegments、deleteRetentionSizeBreachedSegments和deleteLogStartOffsetBreachedSegments分别对应于上面的那3个策略。
下面这张图展示了Kafka当前的三种日志留存策略以及底层涉及到日志段删除的所有方法
<img src="https://static001.geekbang.org/resource/image/f3/ad/f321f8f8572356248465f00bd5b702ad.jpg" alt="">
从图中我们可以知道上面3个留存策略方法底层都会调用带参数版本的deleteOldSegments方法而这个方法又相继调用了deletableSegments和deleteSegments方法。下面我们来深入学习下这3个方法的代码。
首先是带参数版的deleteOldSegments方法
```
private def deleteOldSegments(predicate: (LogSegment, Option[LogSegment]) =&gt; Boolean, reason: String): Int = {
lock synchronized {
val deletable = deletableSegments(predicate)
if (deletable.nonEmpty)
info(s&quot;Found deletable segments with base offsets [${deletable.map(_.baseOffset).mkString(&quot;,&quot;)}] due to $reason&quot;)
deleteSegments(deletable)
}
}
```
该方法只有两个步骤:
1. 使用传入的函数计算哪些日志段对象能够被删除;
1. 调用deleteSegments方法删除这些日志段。
接下来是deletableSegments方法我用注释的方式来解释下主体代码含义
```
private def deletableSegments(predicate: (LogSegment, Option[LogSegment]) =&gt; Boolean): Iterable[LogSegment] = {
if (segments.isEmpty) { // 如果当前压根就没有任何日志段对象,直接返回
Seq.empty
} else {
val deletable = ArrayBuffer.empty[LogSegment]
var segmentEntry = segments.firstEntry
// 从具有最小起始位移值的日志段对象开始遍历,直到满足以下条件之一便停止遍历:
// 1. 测定条件函数predicate = false
// 2. 扫描到包含Log对象高水位值所在的日志段对象
// 3. 最新的日志段对象不包含任何消息
// 最新日志段对象是segments中Key值最大对应的那个日志段也就是我们常说的Active Segment。完全为空的Active Segment如果被允许删除后面还要重建它故代码这里不允许删除大小为空的Active Segment。
// 在遍历过程中同时不满足以上3个条件的所有日志段都是可以被删除的
while (segmentEntry != null) {
val segment = segmentEntry.getValue
val nextSegmentEntry = segments.higherEntry(segmentEntry.getKey)
val (nextSegment, upperBoundOffset, isLastSegmentAndEmpty) =
if (nextSegmentEntry != null)
(nextSegmentEntry.getValue, nextSegmentEntry.getValue.baseOffset, false)
else
(null, logEndOffset, segment.size == 0)
if (highWatermark &gt;= upperBoundOffset &amp;&amp; predicate(segment, Option(nextSegment)) &amp;&amp; !isLastSegmentAndEmpty) {
deletable += segment
segmentEntry = nextSegmentEntry
} else {
segmentEntry = null
}
}
deletable
}
}
```
最后是deleteSegments方法这个方法执行真正的日志段删除操作。
```
private def deleteSegments(deletable: Iterable[LogSegment]): Int = {
maybeHandleIOException(s&quot;Error while deleting segments for $topicPartition in dir ${dir.getParent}&quot;) {
val numToDelete = deletable.size
if (numToDelete &gt; 0) {
// 不允许删除所有日志段对象。如果一定要做先创建出一个新的来然后再把前面N个删掉
if (segments.size == numToDelete)
roll()
lock synchronized {
checkIfMemoryMappedBufferClosed() // 确保Log对象没有被关闭
// 删除给定的日志段对象以及底层的物理文件
removeAndDeleteSegments(deletable, asyncDelete = true)
// 尝试更新日志的Log Start Offset值
maybeIncrementLogStartOffset(
segments.firstEntry.getValue.baseOffset)
}
}
numToDelete
}
}
```
这里我稍微解释一下为什么要在删除日志段对象之后尝试更新Log Start Offset值。Log Start Offset值是整个Log对象对外可见消息的最小位移值。如果我们删除了日志段对象很有可能对外可见消息的范围发生了变化自然要看一下是否需要更新Log Start Offset值。这就是deleteSegments方法最后要更新Log Start Offset值的原因。
**3.修改**
说完了日志段删除,接下来我们来看如何修改日志段对象。
其实源码里面不涉及修改日志段对象所谓的修改或更新也就是替换而已用新的日志段对象替换老的日志段对象。举个简单的例子。segments.put(1L, newSegment)语句在没有Key=1时是添加日志段否则就是替换已有日志段。
**4.查询**
最后再说下查询日志段对象。源码中需要查询日志段对象的地方太多了但主要都是利用了ConcurrentSkipListMap的现成方法。
- segments.firstEntry获取第一个日志段对象
- segments.lastEntry获取最后一个日志段对象即Active Segment
- segments.higherEntry获取第一个起始位移值≥给定Key值的日志段对象
- segments.floorEntry获取最后一个起始位移值≤给定Key值的日志段对象。
## 关键位移值管理
Log对象维护了一些关键位移值数据比如Log Start Offset、LEO等。其实高水位值也算是关键位移值只不过它太重要了所以我单独把它拎出来作为独立的一部分来讲了。
还记得我上节课给你说的那张标识LEO和Log Start Offset的图吗我再来借助这张图说明一下这些关键位移值的区别
<img src="https://static001.geekbang.org/resource/image/38/b4/388672f6dab8571f272ed47c9679c2b4.jpg" alt="">
请注意这张图中位移值15的虚线方框。这揭示了一个重要的事实**Log对象中的LEO永远指向下一条待插入消息********也就是说LEO值上面是没有消息的**源码中定义LEO的语句很简单
```
@volatile private var nextOffsetMetadata: LogOffsetMetadata = _
```
这里的nextOffsetMetadata就是我们所说的LEO它也是LogOffsetMetadata类型的对象。Log对象初始化的时候源码会加载所有日志段对象并由此计算出当前Log的下一条消息位移值。之后Log对象将此位移值赋值给LEO代码片段如下
```
locally {
val startMs = time.milliseconds
// 创建日志路径保存Log对象磁盘文件
Files.createDirectories(dir.toPath)
// 初始化Leader Epoch缓存
initializeLeaderEpochCache()
// 加载所有日志段对象并返回该Log对象下一条消息的位移值
val nextOffset = loadSegments()
// 初始化LEO元数据对象LEO值为上一步获取的位移值起始位移值是Active Segment的起始位移值日志段大小是Active Segment的大小
nextOffsetMetadata = LogOffsetMetadata(nextOffset, activeSegment.baseOffset, activeSegment.size)
// 更新Leader Epoch缓存去除LEO值之上的所有无效缓存项
leaderEpochCache.foreach(
_.truncateFromEnd(nextOffsetMetadata.messageOffset))
......
}
```
当然代码中单独定义了更新LEO的updateLogEndOffset方法
```
private def updateLogEndOffset(offset: Long): Unit = {
nextOffsetMetadata = LogOffsetMetadata(offset, activeSegment.baseOffset, activeSegment.size)
if (highWatermark &gt;= offset) {
updateHighWatermarkMetadata(nextOffsetMetadata)
}
if (this.recoveryPoint &gt; offset) {
this.recoveryPoint = offset
}
}
```
根据上面的源码你应该能看到更新过程很简单我就不再展开说了。不过你需要注意的是如果在更新过程中发现新LEO值小于高水位值那么Kafka还要更新高水位值因为对于同一个Log对象而言高水位值是不能越过LEO值的。这一点你一定要切记再切记
讲到这儿我就要提问了Log对象什么时候需要更新LEO呢
实际上LEO对象被更新的时机有4个。
1. **Log对象初始化时**当Log对象初始化时我们必须要创建一个LEO对象并对其进行初始化。
1. **写入新消息时**这个最容易理解。以上面的图为例当不断向Log对象插入新消息时LEO值就像一个指针一样需要不停地向右移动也就是不断地增加。
1. **Log对象发生日志切分Log Roll时**日志切分是啥呢其实就是创建一个全新的日志段对象并且关闭当前写入的日志段对象。这通常发生在当前日志段对象已满的时候。一旦发生日志切分说明Log对象切换了Active Segment那么LEO中的起始位移值和段大小数据都要被更新因此在进行这一步操作时我们必须要更新LEO对象。
1. **日志截断Log Truncation时**这个也是显而易见的。日志中的部分消息被删除了自然可能导致LEO值发生变化从而要更新LEO对象。
你可以在代码中查看一下updateLogEndOffset方法的调用时机验证下是不是和我所说的一致。这里我也想给你一个小小的提示**阅读源码的时候,最好加入一些思考,而不是简单地全盘接受源码的内容,也许你会有不一样的收获**。
说完了LEO我再跟你说说Log Start Offset。其实就操作的流程和原理而言源码管理Log Start Offset的方式要比LEO简单因为Log Start Offset不是一个对象它就是一个长整型的值而已。代码定义了专门的updateLogStartOffset方法来更新它。该方法很简单我就不详细说了你可以自己去学习下它的实现。
现在我们再来思考一下Kafka什么时候需要更新Log Start Offset呢我们一一来看下。
1. **Log对象初始化时**和LEO类似Log对象初始化时要给Log Start Offset赋值一般是将第一个日志段的起始位移值赋值给它。
1. **日志截断时**同理一旦日志中的部分消息被删除可能会导致Log Start Offset发生变化因此有必要更新该值。
1. **Follower副本同步时**一旦Leader副本的Log对象的Log Start Offset值发生变化。为了维持和Leader副本的一致性Follower副本也需要尝试去更新该值。
1. **删除日志段时**这个和日志截断是类似的。凡是涉及消息删除的操作都有可能导致Log Start Offset值的变化。
1. **删除消息时**严格来说这个更新时机有点本末倒置了。在Kafka中删除消息就是通过抬高Log Start Offset值来实现的因此删除消息时必须要更新该值。
## 读写操作
最后我重点说说针对Log对象的读写操作。
**1.写操作**
在Log中涉及写操作的方法有3个appendAsLeader、appendAsFollower和append。它们的调用关系如下图所示
<img src="https://static001.geekbang.org/resource/image/ef/24/efd914ef24911704fa5d23d38447a024.jpg" alt="">
appendAsLeader是用于写Leader副本的appendAsFollower是用于Follower副本同步的。它们的底层都调用了append方法。
我们重点学习下append方法。下图是append方法的执行流程
<img src="https://static001.geekbang.org/resource/image/e4/f1/e4b47776198b7def72332f93930f65f1.jpg" alt="">
看到这张图你可能会感叹“天呐执行步骤居然有12步这么多”别急现在我用代码注释的方式给你分别解释下每步的实现原理。
```
private def append(records: MemoryRecords,
origin: AppendOrigin,
interBrokerProtocolVersion: ApiVersion,
assignOffsets: Boolean,
leaderEpoch: Int): LogAppendInfo = {
maybeHandleIOException(s&quot;Error while appending records to $topicPartition in dir ${dir.getParent}&quot;) {
// 第1步分析和验证待写入消息集合并返回校验结果
val appendInfo = analyzeAndValidateRecords(records, origin)
// 如果压根就不需要写入任何消息,直接返回即可
if (appendInfo.shallowCount == 0)
return appendInfo
// 第2步消息格式规整即删除无效格式消息或无效字节
var validRecords = trimInvalidBytes(records, appendInfo)
lock synchronized {
checkIfMemoryMappedBufferClosed() // 确保Log对象未关闭
if (assignOffsets) { // 需要分配位移
// 第3步使用当前LEO值作为待写入消息集合中第一条消息的位移值
val offset = new LongRef(nextOffsetMetadata.messageOffset)
appendInfo.firstOffset = Some(offset.value)
val now = time.milliseconds
val validateAndOffsetAssignResult = try {
LogValidator.validateMessagesAndAssignOffsets(validRecords,
topicPartition,
offset,
time,
now,
appendInfo.sourceCodec,
appendInfo.targetCodec,
config.compact,
config.messageFormatVersion.recordVersion.value,
config.messageTimestampType,
config.messageTimestampDifferenceMaxMs,
leaderEpoch,
origin,
interBrokerProtocolVersion,
brokerTopicStats)
} catch {
case e: IOException =&gt;
throw new KafkaException(s&quot;Error validating messages while appending to log $name&quot;, e)
}
// 更新校验结果对象类LogAppendInfo
validRecords = validateAndOffsetAssignResult.validatedRecords
appendInfo.maxTimestamp = validateAndOffsetAssignResult.maxTimestamp
appendInfo.offsetOfMaxTimestamp = validateAndOffsetAssignResult.shallowOffsetOfMaxTimestamp
appendInfo.lastOffset = offset.value - 1
appendInfo.recordConversionStats = validateAndOffsetAssignResult.recordConversionStats
if (config.messageTimestampType == TimestampType.LOG_APPEND_TIME)
appendInfo.logAppendTime = now
// 第4步验证消息确保消息大小不超限
if (validateAndOffsetAssignResult.messageSizeMaybeChanged) {
for (batch &lt;- validRecords.batches.asScala) {
if (batch.sizeInBytes &gt; config.maxMessageSize) {
// we record the original message set size instead of the trimmed size
// to be consistent with pre-compression bytesRejectedRate recording
brokerTopicStats.topicStats(topicPartition.topic).bytesRejectedRate.mark(records.sizeInBytes)
brokerTopicStats.allTopicsStats.bytesRejectedRate.mark(records.sizeInBytes)
throw new RecordTooLargeException(s&quot;Message batch size is ${batch.sizeInBytes} bytes in append to&quot; +
s&quot;partition $topicPartition which exceeds the maximum configured size of ${config.maxMessageSize}.&quot;)
}
}
}
} else { // 直接使用给定的位移值,无需自己分配位移值
if (!appendInfo.offsetsMonotonic) // 确保消息位移值的单调递增性
throw new OffsetsOutOfOrderException(s&quot;Out of order offsets found in append to $topicPartition: &quot; +
records.records.asScala.map(_.offset))
if (appendInfo.firstOrLastOffsetOfFirstBatch &lt; nextOffsetMetadata.messageOffset) {
val firstOffset = appendInfo.firstOffset match {
case Some(offset) =&gt; offset
case None =&gt; records.batches.asScala.head.baseOffset()
}
val firstOrLast = if (appendInfo.firstOffset.isDefined) &quot;First offset&quot; else &quot;Last offset of the first batch&quot;
throw new UnexpectedAppendOffsetException(
s&quot;Unexpected offset in append to $topicPartition. $firstOrLast &quot; +
s&quot;${appendInfo.firstOrLastOffsetOfFirstBatch} is less than the next offset ${nextOffsetMetadata.messageOffset}. &quot; +
s&quot;First 10 offsets in append: ${records.records.asScala.take(10).map(_.offset)}, last offset in&quot; +
s&quot; append: ${appendInfo.lastOffset}. Log start offset = $logStartOffset&quot;,
firstOffset, appendInfo.lastOffset)
}
}
// 第5步更新Leader Epoch缓存
validRecords.batches.asScala.foreach { batch =&gt;
if (batch.magic &gt;= RecordBatch.MAGIC_VALUE_V2) {
maybeAssignEpochStartOffset(batch.partitionLeaderEpoch, batch.baseOffset)
} else {
leaderEpochCache.filter(_.nonEmpty).foreach { cache =&gt;
warn(s&quot;Clearing leader epoch cache after unexpected append with message format v${batch.magic}&quot;)
cache.clearAndFlush()
}
}
}
// 第6步确保消息大小不超限
if (validRecords.sizeInBytes &gt; config.segmentSize) {
throw new RecordBatchTooLargeException(s&quot;Message batch size is ${validRecords.sizeInBytes} bytes in append &quot; +
s&quot;to partition $topicPartition, which exceeds the maximum configured segment size of ${config.segmentSize}.&quot;)
}
// 第7步执行日志切分。当前日志段剩余容量可能无法容纳新消息集合因此有必要创建一个新的日志段来保存待写入的所有消息
val segment = maybeRoll(validRecords.sizeInBytes, appendInfo)
val logOffsetMetadata = LogOffsetMetadata(
messageOffset = appendInfo.firstOrLastOffsetOfFirstBatch,
segmentBaseOffset = segment.baseOffset,
relativePositionInSegment = segment.size)
// 第8步验证事务状态
val (updatedProducers, completedTxns, maybeDuplicate) = analyzeAndValidateProducerState(
logOffsetMetadata, validRecords, origin)
maybeDuplicate.foreach { duplicate =&gt;
appendInfo.firstOffset = Some(duplicate.firstOffset)
appendInfo.lastOffset = duplicate.lastOffset
appendInfo.logAppendTime = duplicate.timestamp
appendInfo.logStartOffset = logStartOffset
return appendInfo
}
// 第9步执行真正的消息写入操作主要调用日志段对象的append方法实现
segment.append(largestOffset = appendInfo.lastOffset,
largestTimestamp = appendInfo.maxTimestamp,
shallowOffsetOfMaxTimestamp = appendInfo.offsetOfMaxTimestamp,
records = validRecords)
// 第10步更新LEO对象其中LEO值是消息集合中最后一条消息位移值+1
// 前面说过LEO值永远指向下一条不存在的消息
updateLogEndOffset(appendInfo.lastOffset + 1)
// 第11步更新事务状态
for (producerAppendInfo &lt;- updatedProducers.values) {
producerStateManager.update(producerAppendInfo)
}
for (completedTxn &lt;- completedTxns) {
val lastStableOffset = producerStateManager.lastStableOffset(completedTxn)
segment.updateTxnIndex(completedTxn, lastStableOffset)
producerStateManager.completeTxn(completedTxn)
}
producerStateManager.updateMapEndOffset(appendInfo.lastOffset + 1)
maybeIncrementFirstUnstableOffset()
trace(s&quot;Appended message set with last offset: ${appendInfo.lastOffset}, &quot; +
s&quot;first offset: ${appendInfo.firstOffset}, &quot; +
s&quot;next offset: ${nextOffsetMetadata.messageOffset}, &quot; +
s&quot;and messages: $validRecords&quot;)
// 是否需要手动落盘。一般情况下我们不需要设置Broker端参数log.flush.interval.messages
// 落盘操作交由操作系统来完成。但某些情况下,可以设置该参数来确保高可靠性
if (unflushedMessages &gt;= config.flushInterval)
flush()
// 第12步返回写入结果
appendInfo
}
}
}
```
这些步骤里有没有需要你格外注意的呢我希望你重点关注下第1步即Kafka如何校验消息重点是看**针对不同的消息格式版本Kafka是如何做校验的**。
说起消息校验你还记得上一讲我们提到的LogAppendInfo类吗它就是一个普通的POJO类里面几乎保存了待写入消息集合的所有信息。我们来详细了解一下。
```
case class LogAppendInfo(var firstOffset: Option[Long],
var lastOffset: Long, // 消息集合最后一条消息的位移值
var maxTimestamp: Long, // 消息集合最大消息时间戳
var offsetOfMaxTimestamp: Long, // 消息集合最大消息时间戳所属消息的位移值
var logAppendTime: Long, // 写入消息时间戳
var logStartOffset: Long, // 消息集合首条消息的位移值
// 消息转换统计类,里面记录了执行了格式转换的消息数等数据
var recordConversionStats: RecordConversionStats,
sourceCodec: CompressionCodec, // 消息集合中消息使用的压缩器Compressor类型比如是Snappy还是LZ4
targetCodec: CompressionCodec, // 写入消息时需要使用的压缩器类型
shallowCount: Int, // 消息批次数,每个消息批次下可能包含多条消息
validBytes: Int, // 写入消息总字节数
offsetsMonotonic: Boolean, // 消息位移值是否是顺序增加的
lastOffsetOfFirstBatch: Long, // 首个消息批次中最后一条消息的位移
recordErrors: Seq[RecordError] = List(), // 写入消息时出现的异常列表
errorMessage: String = null) { // 错误码
......
}
```
大部分字段的含义很明确,这里我稍微提一下**lastOffset**和**lastOffsetOfFirstBatch**。
Kafka消息格式经历了两次大的变迁目前是0.11.0.0版本引入的Version 2消息格式。我们没有必要详细了解这些格式的变迁你只需要知道在0.11.0.0版本之后,**lastOffset和lastOffsetOfFirstBatch都是指向消息集合的最后一条消息即可**。它们的区别主要体现在0.11.0.0之前的版本。
append方法调用analyzeAndValidateRecords方法对消息集合进行校验并生成对应的LogAppendInfo对象其流程如下
```
private def analyzeAndValidateRecords(records: MemoryRecords, origin: AppendOrigin): LogAppendInfo = {
var shallowMessageCount = 0
var validBytesCount = 0
var firstOffset: Option[Long] = None
var lastOffset = -1L
var sourceCodec: CompressionCodec = NoCompressionCodec
var monotonic = true
var maxTimestamp = RecordBatch.NO_TIMESTAMP
var offsetOfMaxTimestamp = -1L
var readFirstMessage = false
var lastOffsetOfFirstBatch = -1L
for (batch &lt;- records.batches.asScala) {
// 消息格式Version 2的消息批次起始位移值必须从0开始
if (batch.magic &gt;= RecordBatch.MAGIC_VALUE_V2 &amp;&amp; origin == AppendOrigin.Client &amp;&amp; batch.baseOffset != 0)
throw new InvalidRecordException(s&quot;The baseOffset of the record batch in the append to $topicPartition should &quot; +
s&quot;be 0, but it is ${batch.baseOffset}&quot;)
if (!readFirstMessage) {
if (batch.magic &gt;= RecordBatch.MAGIC_VALUE_V2)
firstOffset = Some(batch.baseOffset) // 更新firstOffset字段
lastOffsetOfFirstBatch = batch.lastOffset // 更新lastOffsetOfFirstBatch字段
readFirstMessage = true
}
// 一旦出现当前lastOffset不小于下一个batch的lastOffset说明上一个batch中有消息的位移值大于后面batch的消息
// 这违反了位移值单调递增性
if (lastOffset &gt;= batch.lastOffset)
monotonic = false
// 使用当前batch最后一条消息的位移值去更新lastOffset
lastOffset = batch.lastOffset
// 检查消息批次总字节数大小是否超限即是否大于Broker端参数max.message.bytes值
val batchSize = batch.sizeInBytes
if (batchSize &gt; config.maxMessageSize) {
brokerTopicStats.topicStats(topicPartition.topic).bytesRejectedRate.mark(records.sizeInBytes)
brokerTopicStats.allTopicsStats.bytesRejectedRate.mark(records.sizeInBytes)
throw new RecordTooLargeException(s&quot;The record batch size in the append to $topicPartition is $batchSize bytes &quot; +
s&quot;which exceeds the maximum configured value of ${config.maxMessageSize}.&quot;)
}
// 执行消息批次校验包括格式是否正确以及CRC校验
if (!batch.isValid) {
brokerTopicStats.allTopicsStats.invalidMessageCrcRecordsPerSec.mark()
throw new CorruptRecordException(s&quot;Record is corrupt (stored crc = ${batch.checksum()}) in topic partition $topicPartition.&quot;)
}
// 更新maxTimestamp字段和offsetOfMaxTimestamp
if (batch.maxTimestamp &gt; maxTimestamp) {
maxTimestamp = batch.maxTimestamp
offsetOfMaxTimestamp = lastOffset
}
// 累加消息批次计数器以及有效字节数更新shallowMessageCount字段
shallowMessageCount += 1
validBytesCount += batchSize
// 从消息批次中获取压缩器类型
val messageCodec = CompressionCodec.getCompressionCodec(batch.compressionType.id)
if (messageCodec != NoCompressionCodec)
sourceCodec = messageCodec
}
// 获取Broker端设置的压缩器类型即Broker端参数compression.type值。
// 该参数默认值是producer表示sourceCodec用的什么压缩器targetCodec就用什么
val targetCodec = BrokerCompressionCodec.getTargetCompressionCodec(config.compressionType, sourceCodec)
// 最后生成LogAppendInfo对象并返回
LogAppendInfo(firstOffset, lastOffset, maxTimestamp, offsetOfMaxTimestamp, RecordBatch.NO_TIMESTAMP, logStartOffset,
RecordConversionStats.EMPTY, sourceCodec, targetCodec, shallowMessageCount, validBytesCount, monotonic, lastOffsetOfFirstBatch)
}
```
**2.读取操作**
说完了append方法下面我们聊聊read方法。
read方法的流程相对要简单一些首先来看它的方法签名
```
def read(startOffset: Long,
maxLength: Int,
isolation: FetchIsolation,
minOneMessage: Boolean): FetchDataInfo = {
......
}
```
它接收4个参数含义如下
- startOffset即从Log对象的哪个位移值开始读消息。
- maxLength即最多能读取多少字节。
- isolation设置读取隔离级别主要控制能够读取的最大位移值多用于Kafka事务。
- minOneMessage即是否允许至少读一条消息。设想如果消息很大超过了maxLength正常情况下read方法永远不会返回任何消息。但如果设置了该参数为trueread方法就保证至少能够返回一条消息。
read方法的返回值是FetchDataInfo类也是一个POJO类里面最重要的数据就是读取的消息集合其他数据还包括位移等元数据信息。
下面我们来看下read方法的流程。
```
def read(startOffset: Long,
maxLength: Int,
isolation: FetchIsolation,
minOneMessage: Boolean): FetchDataInfo = {
maybeHandleIOException(s&quot;Exception while reading from $topicPartition in dir ${dir.getParent}&quot;) {
trace(s&quot;Reading $maxLength bytes from offset $startOffset of length $size bytes&quot;)
val includeAbortedTxns = isolation == FetchTxnCommitted
// 读取消息时没有使用Monitor锁同步机制因此这里取巧了用本地变量的方式把LEO对象保存起来避免争用race condition
val endOffsetMetadata = nextOffsetMetadata
val endOffset = nextOffsetMetadata.messageOffset
if (startOffset == endOffset) // 如果从LEO处开始读取那么自然不会返回任何数据直接返回空消息集合即可
return emptyFetchDataInfo(endOffsetMetadata, includeAbortedTxns)
// 找到startOffset值所在的日志段对象。注意要使用floorEntry方法
var segmentEntry = segments.floorEntry(startOffset)
// return error on attempt to read beyond the log end offset or read below log start offset
// 满足以下条件之一将被视为消息越界即你要读取的消息不在该Log对象中
// 1. 要读取的消息位移超过了LEO值
// 2. 没找到对应的日志段对象
// 3. 要读取的消息在Log Start Offset之下同样是对外不可见的消息
if (startOffset &gt; endOffset || segmentEntry == null || startOffset &lt; logStartOffset)
throw new OffsetOutOfRangeException(s&quot;Received request for offset $startOffset for partition $topicPartition, &quot; +
s&quot;but we only have log segments in the range $logStartOffset to $endOffset.&quot;)
// 查看一下读取隔离级别设置。
// 普通消费者能够看到[Log Start Offset, 高水位值)之间的消息
// 事务型消费者只能看到[Log Start Offset, Log Stable Offset]之间的消息。Log Stable Offset(LSO)是比LEO值小的位移值为Kafka事务使用
// Follower副本消费者能够看到[Log Start OffsetLEO)之间的消息
val maxOffsetMetadata = isolation match {
case FetchLogEnd =&gt; nextOffsetMetadata
case FetchHighWatermark =&gt; fetchHighWatermarkMetadata
case FetchTxnCommitted =&gt; fetchLastStableOffsetMetadata
}
// 如果要读取的起始位置超过了能读取的最大位置,返回空的消息集合,因为没法读取任何消息
if (startOffset &gt; maxOffsetMetadata.messageOffset) {
val startOffsetMetadata = convertToOffsetMetadataOrThrow(startOffset)
return emptyFetchDataInfo(startOffsetMetadata, includeAbortedTxns)
}
// 开始遍历日志段对象,直到读出东西来或者读到日志末尾
while (segmentEntry != null) {
val segment = segmentEntry.getValue
val maxPosition = {
if (maxOffsetMetadata.segmentBaseOffset == segment.baseOffset) {
maxOffsetMetadata.relativePositionInSegment
} else {
segment.size
}
}
// 调用日志段对象的read方法执行真正的读取消息操作
val fetchInfo = segment.read(startOffset, maxLength, maxPosition, minOneMessage)
if (fetchInfo == null) { // 如果没有返回任何消息,去下一个日志段对象试试
segmentEntry = segments.higherEntry(segmentEntry.getKey)
} else { // 否则返回
return if (includeAbortedTxns)
addAbortedTransactions(startOffset, segmentEntry, fetchInfo)
else
fetchInfo
}
}
// 已经读到日志末尾还是没有数据返回,只能返回空消息集合
FetchDataInfo(nextOffsetMetadata, MemoryRecords.EMPTY)
}
}
```
## 总结
今天我重点讲解了Kafka的Log对象以及常见的操作。我们复习一下。
1. **高水位管理**Log对象定义了高水位对象以及管理它的各种操作主要包括更新和读取。
1. **日志段管理**作为日志段的容器Log对象保存了很多日志段对象。你需要重点掌握这些日志段对象被组织在一起的方式以及Kafka Log对象是如何对它们进行管理的。
1. **关键位移值管理**主要涉及对Log Start Offset和LEO的管理。这两个位移值是Log对象非常关键的字段。比如副本管理、状态机管理等高阶功能都要依赖于它们。
1. **读写操作**日志读写是实现Kafka消息引擎基本功能的基石。虽然你不需要掌握每行语句的含义但你至少要明白大体的操作流程。
讲到这里Kafka Log部分的源码我就介绍完了。我建议你特别关注下高水位管理和读写操作部分的代码特别是后者并且结合我今天讲的内容重点分析下这两部分的实现原理。最后我用一张思维导图来帮助你理解和记忆Log源码中的这些常见操作
<img src="https://static001.geekbang.org/resource/image/d0/99/d0cb945d7284f09ab2b6ffa764190399.jpg" alt="">
## 课后讨论
你能为Log对象添加一个方法统计介于高水位值和LEO值之间的消息总数吗
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,366 @@
<audio id="audio" title="04 | 索引改进的二分查找算法在Kafka索引的应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/eb/54/eb42991816f9ddad5d462d8155407354.mp3"></audio>
你好我是胡夕。今天我来带你学习一下Kafka源码中的索引对象以及改进版二分查找算法Binary Search Algorithm在索引中的应用。
## 为什么要阅读索引源码?
坦率地说你在Kafka中直接接触索引或索引文件的场景可能不是很多。索引是一个很神秘的组件Kafka官方文档也没有怎么提过它。你可能会说既然这样我还有必要读索引对象的源码吗其实是非常有必要的我给你分享一个真实的例子。
有一次我用Kafka的DumpLogSegments类去查看底层日志文件和索引文件的内容时发现了一个奇怪的现象——查看日志文件的内容不需要sudo权限而查看索引文件的内容必须要有sudo权限如下所示
```
$ sudo ./kafka-run-class.sh kafka.tools.DumpLogSegments --files ./00000000000000000000.index
Dumping 00000000000000000000.index
offset: 0 position: 0
$ ./kafka-run-class.sh kafka.tools.DumpLogSegments --files 00000000000000000000.index
Dumping 00000000000000000000.index
Exception in thread &quot;main&quot; java.io.FileNotFoundException: 00000000000000000000.index (Permission denied)
......
```
看了索引源码之后我才知道原来Kafka读取索引文件时使用的打开方式是rw。实际上读取文件不需要w权限只要r权限就行了。这显然是Kafka的一个Bug。你看通过阅读源码我找到了问题的根本原因还顺便修复了Kafka的一个问题[KAFKA-5104](https://issues.apache.org/jira/browse/KAFKA-5104))。
除了能帮我们解决实际问题之外,索引这个组件的源码还有一个亮点,那就是**它应用了耳熟能详的二分查找算法来快速定位索引项**。关于算法,我一直觉得很遗憾的是,**我们平时太注重算法本身,却忽略了它们在实际场景中的应用**。
比如说我们学习了太多的排序算法但是对于普通的应用开发人员来说亲自使用这些算法完成编程任务的机会实在太少了。说起数组排序你可能只记得调用Collections.sort方法了但它底层应用了什么排序算法其实并不清楚。
难得的是Kafka的索引组件中应用了二分查找算法而且社区还针对Kafka自身的特点对其进行了改良。这难道不值得我们好好学上一学吗话不多说现在我们就开始学习吧。
## 索引类图及源文件组织架构
在Kafka源码中跟索引相关的源码文件有5个它们都位于core包的/src/main/scala/kafka/log路径下。我们一一来看下。
- AbstractIndex.scala它定义了最顶层的抽象类这个类封装了所有索引类型的公共操作。
- LazyIndex.scala它定义了AbstractIndex上的一个包装类实现索引项延迟加载。这个类主要是为了提高性能。
- OffsetIndex.scala定义位移索引保存“&lt;位移值,文件磁盘物理位置&gt;”对。
- TimeIndex.scala定义时间戳索引保存“&lt;时间戳,位移值&gt;”对。
- TransactionIndex.scala定义事务索引为已中止事务Aborted Transcation保存重要的元数据信息。只有启用Kafka事务后这个索引才有可能出现。
这些类的关系如下图所示:
<img src="https://static001.geekbang.org/resource/image/34/e8/347480a2d1ae659d0ecf590b01d091e8.jpg" alt="">
其中OffsetIndex、TimeIndex和TransactionIndex都继承了AbstractIndex类而上层的LazyIndex仅仅是包装了一个AbstractIndex的实现类用于延迟加载。就像我之前说的LazyIndex的作用是为了提升性能并没有什么功能上的改进。
所以今天我先和你讲一讲AbstractIndex这个抽象父类的代码。下节课我再重点和你分享具体的索引实现类。
## AbstractIndex代码结构
我们先来看下AbstractIndex的类定义
```
abstract class AbstractIndex(@volatile var file: File, val baseOffset: Long, val maxIndexSize: Int = -1, val writable: Boolean) extends Closeable {
......
}
```
AbstractIndex定义了4个属性字段。由于是一个抽象基类它的所有子类自动地继承了这4个字段。也就是说Kafka所有类型的索引对象都定义了这些属性。我先给你解释下这些属性的含义。
1. **索引文件**file。每个索引对象在磁盘上都对应了一个索引文件。你可能注意到了这个字段是var型说明它是可以被修改的。难道索引对象还能动态更换底层的索引文件吗是的。自1.1.0版本之后Kafka允许迁移底层的日志路径所以索引文件自然要是可以更换的。
1. **起始位移值**baseOffset。索引对象对应日志段对象的起始位移值。举个例子如果你查看Kafka日志路径的话就会发现日志文件和索引文件都是成组出现的。比如说如果日志文件是00000000000000000123.log正常情况下一定还有一组索引文件00000000000000000123.index、00000000000000000123.timeindex等。这里的“123”就是这组文件的起始位移值也就是baseOffset值。
1. **索引文件最大字节数**maxIndexSize。它控制索引文件的最大长度。Kafka源码传入该参数的值是Broker端参数segment.index.bytes的值即10MB。这就是在默认情况下所有Kafka索引文件大小都是10MB的原因。
1. **索引文件打开方式**writable。“True”表示以“读写”方式打开“False”表示以“只读”方式打开。如果我没记错的话这个参数应该是我加上去的就是为了修复我刚刚提到的那个Bug。
AbstractIndex是抽象的索引对象类。可以说它是承载索引项的容器而每个继承它的子类负责定义具体的索引项结构。比如OffsetIndex的索引项是&lt;位移值,物理磁盘位置&gt;TimeIndex的索引项是&lt;时间戳,位移值&gt;对。基于这样的设计理念AbstractIndex类中定义了一个抽象方法entrySize来表示不同索引项的大小如下所示
```
protected def entrySize: Int
```
子类实现该方法时需要给定自己索引项的大小对于OffsetIndex而言该值就是8对于TimeIndex而言该值是12如下所示
```
// OffsetIndex
override def entrySize = 8
// TimeIndex
override def entrySize = 12
```
说到这儿你肯定会问为什么是8和12呢我来解释一下。
在OffsetIndex中位移值用4个字节来表示物理磁盘位置也用4个字节来表示所以总共是8个字节。你可能会说位移值不是长整型吗应该是8个字节才对啊。
还记得AbstractIndex已经保存了baseOffset了吗这里的位移值实际上是相对于baseOffset的相对位移值即真实位移值减去baseOffset的值。下节课我会给你重点讲一下它这里你只需要知道**使用相对位移值能够有效地节省磁盘空间**就行了。而Broker端参数log.segment.bytes是整型这说明Kafka中每个日志段文件的大小不会超过2^32即4GB这就说明同一个日志段文件上的位移值减去baseOffset的差值一定在整数范围内。因此源码只需要4个字节保存就行了。
同理TimeIndex中的时间戳类型是长整型占用8个字节位移依然使用相对位移值占用4个字节因此总共需要12个字节。
如果有人问你Kafka中的索引底层的实现原理是什么你可以大声地告诉他**内存映射文件即Java中的MappedByteBuffer**。
使用内存映射文件的主要优势在于它有很高的I/O性能特别是对于索引这样的小文件来说由于文件内存被直接映射到一段虚拟内存上访问内存映射文件的速度要快于普通的读写文件速度。
另外在很多操作系统中比如Linux这段映射的内存区域实际上就是内核的页缓存Page Cache。这就意味着里面的数据不需要重复拷贝到用户态空间避免了很多不必要的时间、空间消耗。
在AbstractIndex中这个MappedByteBuffer就是名为mmap的变量。接下来我用注释的方式带你深入了解下这个mmap的主要流程。
```
@volatile
protected var mmap: MappedByteBuffer = {
// 第1步创建索引文件
val newlyCreated = file.createNewFile()
// 第2步以writable指定的方式读写方式或只读方式打开索引文件
val raf = if (writable) new RandomAccessFile(file, &quot;rw&quot;) else new RandomAccessFile(file, &quot;r&quot;)
try {
if(newlyCreated) {
if(maxIndexSize &lt; entrySize) // 预设的索引文件大小不能太小,如果连一个索引项都保存不了,直接抛出异常
throw new IllegalArgumentException(&quot;Invalid max index size: &quot; + maxIndexSize)
// 第3步设置索引文件长度roundDownToExactMultiple计算的是不超过maxIndexSize的最大整数倍entrySize
// 比如maxIndexSize=1234567entrySize=8那么调整后的文件长度为1234560
raf.setLength(roundDownToExactMultiple(maxIndexSize, entrySize))
}
// 第4步更新索引长度字段_length
_length = raf.length()
// 第5步创建MappedByteBuffer对象
val idx = {
if (writable)
raf.getChannel.map(FileChannel.MapMode.READ_WRITE, 0, _length)
else
raf.getChannel.map(FileChannel.MapMode.READ_ONLY, 0, _length)
}
/* set the position in the index for the next entry */
// 第6步如果是新创建的索引文件将MappedByteBuffer对象的当前位置置成0
// 如果索引文件已存在将MappedByteBuffer对象的当前位置设置成最后一个索引项所在的位置
if(newlyCreated)
idx.position(0)
else
idx.position(roundDownToExactMultiple(idx.limit(), entrySize))
// 第7步返回创建的MappedByteBuffer对象
idx
} finally {
CoreUtils.swallow(raf.close(), AbstractIndex) // 关闭打开索引文件句柄
}
}
```
这些代码最主要的作用就是创建mmap对象。要知道AbstractIndex其他大部分的操作都是和mmap相关。
我举两个简单的小例子。
例1如果我们要计算索引对象中当前有多少个索引项那么只需要执行下列计算即可
```
protected var _entries: Int = mmap.position() / entrySize
```
例2如果我们要计算索引文件最多能容纳多少个索引项只要定义下面的变量就行了
```
private[this] var _maxEntries: Int = mmap.limit() / entrySize
```
再进一步,有了这两个变量,我们就能够很容易地编写一个方法,来判断当前索引文件是否已经写满:
```
def isFull: Boolean = _entries &gt;= _maxEntries
```
总之,**AbstractIndex中最重要的就是这个mmap变量了**。事实上AbstractIndex继承类实现添加索引项的主要逻辑也就是**向mmap中添加对应的字段**。
## 写入索引项
下面这段代码是OffsetIndex的append方法用于向索引文件中写入新索引项。
```
def append(offset: Long, position: Int): Unit = {
inLock(lock) {
// 第1步判断索引文件未写满
require(!isFull, &quot;Attempt to append to a full index (size = &quot; + _entries + &quot;).&quot;)
// 第2步必须满足以下条件之一才允许写入索引项
// 条件1当前索引文件为空
// 条件2要写入的位移大于当前所有已写入的索引项的位移——Kafka规定索引项中的位移值必须是单调增加的
if (_entries == 0 || offset &gt; _lastOffset) {
trace(s&quot;Adding index entry $offset =&gt; $position to ${file.getAbsolutePath}&quot;)
mmap.putInt(relativeOffset(offset)) // 第3步A向mmap中写入相对位移值
mmap.putInt(position) // 第3步B向mmap中写入物理位置信息
// 第4步更新其他元数据统计信息如当前索引项计数器_entries和当前索引项最新位移值_lastOffset
_entries += 1
_lastOffset = offset
// 第5步执行校验。写入的索引项格式必须符合要求即索引项个数*单个索引项占用字节数匹配当前文件物理大小,否则说明文件已损坏
require(_entries * entrySize == mmap.position(), entries + &quot; entries but file position in index is &quot; + mmap.position() + &quot;.&quot;)
} else {
// 如果第2步中两个条件都不满足不能执行写入索引项操作抛出异常
throw new InvalidOffsetException(s&quot;Attempt to append an offset ($offset) to position $entries no larger than&quot; +
s&quot; the last offset appended (${_lastOffset}) to ${file.getAbsolutePath}.&quot;)
}
}
}
```
我使用一张图来总结下append方法的执行流程希望可以帮你更快速地掌握它的实现
<img src="https://static001.geekbang.org/resource/image/23/ea/236f0731ff2799f0902ff7293cf6ddea.jpg" alt="">
## 查找索引项
索引项的写入逻辑并不复杂难点在于如何查找索引项。AbstractIndex定义了抽象方法**parseEntry**用于查找给定的索引项,如下所示:
```
protected def parseEntry(buffer: ByteBuffer, n: Int): IndexEntry
```
这里的“n”表示要查找给定ByteBuffer中保存的第n个索引项在Kafka中也称第n个槽。IndexEntry是源码定义的一个接口里面有两个方法indexKey和indexValue分别返回不同类型索引的&lt;KeyValue&gt;对。
OffsetIndex实现parseEntry的逻辑如下
```
override protected def parseEntry(buffer: ByteBuffer, n: Int): OffsetPosition = {
OffsetPosition(baseOffset + relativeOffset(buffer, n), physical(buffer, n))
}
```
OffsetPosition是实现IndexEntry的实现类Key就是之前说的位移值而Value就是物理磁盘位置值。所以这里你能看到代码调用了relativeOffset(buffer, n) + baseOffset计算出绝对位移值之后调用physical(buffer, n)计算物理磁盘位置,最后将它们封装到一起作为一个独立的索引项返回。
**我建议你去看下relativeOffset和physical方法的实现看看它们是如何计算相对位移值和物理磁盘位置信息的**
有了parseEntry方法我们就能够根据给定的n来查找索引项了。但是这里还有个问题需要解决那就是我们如何确定要找的索引项在第n个槽中呢其实本质上这是一个算法问题也就是如何从一组已排序的数中快速定位符合条件的那个数。
## 二分查找算法
到目前为止从已排序数组中寻找某个数字最快速的算法就是二分查找了它能做到O(lgN)的时间复杂度。Kafka的索引组件就应用了二分查找算法。
我先给出原版的实现算法代码。
```
private def indexSlotRangeFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchEntity): (Int, Int) = {
// 第1步如果当前索引为空直接返回&lt;-1,-1&gt;对
if(_entries == 0)
return (-1, -1)
// 第2步要查找的位移值不能小于当前最小位移值
if(compareIndexEntry(parseEntry(idx, 0), target, searchEntity) &gt; 0)
return (-1, 0)
// binary search for the entry
// 第3步执行二分查找算法
var lo = 0
var hi = _entries - 1
while(lo &lt; hi) {
val mid = ceil(hi/2.0 + lo/2.0).toInt
val found = parseEntry(idx, mid)
val compareResult = compareIndexEntry(found, target, searchEntity)
if(compareResult &gt; 0)
hi = mid - 1
else if(compareResult &lt; 0)
lo = mid
else
return (mid, mid)
}
(lo, if (lo == _entries - 1) -1 else lo + 1)
```
这段代码的核心是第3步的二分查找算法。熟悉Binary Search的话你对这段代码一定不会感到陌生。
讲到这里似乎一切很完美Kafka索引应用二分查找算法快速定位待查找索引项位置之后调用parseEntry来读取索引项。不过这真的就是无懈可击的解决方案了吗
## 改进版二分查找算法
显然不是我前面说过了大多数操作系统使用页缓存来实现内存映射而目前几乎所有的操作系统都使用LRULeast Recently Used或类似于LRU的机制来管理页缓存。
Kafka写入索引文件的方式是在文件末尾追加写入而几乎所有的索引查询都集中在索引的尾部。这么来看的话LRU机制是非常适合Kafka的索引访问场景的。
这里有个问题是当Kafka在查询索引的时候原版的二分查找算法并没有考虑到缓存的问题因此很可能会导致一些不必要的缺页中断Page Fault。此时Kafka线程会被阻塞等待对应的索引项从物理磁盘中读出并放入到页缓存中。
下面我举个例子来说明一下这个情况。假设Kafka的某个索引占用了操作系统页缓存13个页Page如果待查找的位移值位于最后一个页上也就是Page 12那么标准的二分查找算法会依次读取页号0、6、9、11和12具体的推演流程如下所示
<img src="https://static001.geekbang.org/resource/image/e4/85/e4fc56a301b13afae4dda303e5366085.jpg" alt="">
通常来说一个页上保存了成百上千的索引项数据。随着索引文件不断被写入Page #12不断地被填充新的索引项。如果此时索引查询方都来自ISR副本或Lag很小的消费者那么这些查询大多集中在对Page #12的查询因此Page #0、6、9、11、12一定经常性地被源码访问。也就是说这些页一定保存在页缓存上。后面当新的索引项填满了Page #12页缓存就会申请一个新的Page来保存索引项即Page #13
现在最新索引项保存在Page #13中。如果要查找最新索引项原版二分查找算法将会依次访问Page #0、7、10、12和13。此时问题来了Page 7和10已经很久没有被访问过了它们大概率不在页缓存中因此一旦索引开始征用Page #13就会发生Page Fault等待那些冷页数据从磁盘中加载到页缓存。根据国外用户的测试这种加载过程可能长达1秒。
显然这是一个普遍的问题即每当索引文件占用Page数发生变化时就会强行变更二分查找的搜索路径从而出现不在页缓存的冷数据必须要加载到页缓存的情形而这种加载过程是非常耗时的。
基于这个问题社区提出了改进版的二分查找策略也就是缓存友好的搜索算法。总体的思路是代码将所有索引项分成两个部分热区Warm Area和冷区Cold Area然后分别在这两个区域内执行二分查找算法如下图所示
<img src="https://static001.geekbang.org/resource/image/e1/8e/e135f0a6b3fb43ead51db4ddbad1638e.jpg" alt="">
乍一看,该算法并没有什么高大上的改进,仅仅是把搜寻区域分成了冷、热两个区域,然后有条件地在不同区域执行普通的二分查找算法罢了。实际上,这个改进版算法提供了一个重要的保证:**它能保证那些经常需要被访问的Page组合是固定的**。
想想刚才的例子同样是查询最热的那部分数据一旦索引占用了更多的Page要遍历的Page组合就会发生变化。这是导致性能下降的主要原因。
这个改进版算法的最大好处在于,**查询最热那部分数据所遍历的Page永远是固定的因此大概率在页缓存中从而避免无意义的Page Fault**。
下面我们来看实际的代码。我用注释的方式解释了改进版算法的实现逻辑。一旦你了解了冷区热区的分割原理,剩下的就不难了。
```
private def indexSlotRangeFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchEntity): (Int, Int) = {
// 第1步如果索引为空直接返回&lt;-1,-1&gt;对
if(_entries == 0)
return (-1, -1)
// 封装原版的二分查找算法
def binarySearch(begin: Int, end: Int) : (Int, Int) = {
// binary search for the entry
var lo = begin
var hi = end
while(lo &lt; hi) {
val mid = (lo + hi + 1) &gt;&gt;&gt; 1
val found = parseEntry(idx, mid)
val compareResult = compareIndexEntry(found, target, searchEntity)
if(compareResult &gt; 0)
hi = mid - 1
else if(compareResult &lt; 0)
lo = mid
else
return (mid, mid)
}
(lo, if (lo == _entries - 1) -1 else lo + 1)
}
// 第3步确认热区首个索引项位于哪个槽。_warmEntries就是所谓的分割线目前固定为8192字节处
// 如果是OffsetIndex_warmEntries = 8192 / 8 = 1024即第1024个槽
// 如果是TimeIndex_warmEntries = 8192 / 12 = 682即第682个槽
val firstHotEntry = Math.max(0, _entries - 1 - _warmEntries)
// 第4步判断target位移值在热区还是冷区
if(compareIndexEntry(parseEntry(idx, firstHotEntry), target, searchEntity) &lt; 0) {
return binarySearch(firstHotEntry, _entries - 1) // 如果在热区,搜索热区
}
// 第5步确保target位移值不能小于当前最小位移值
if(compareIndexEntry(parseEntry(idx, 0), target, searchEntity) &gt; 0)
return (-1, 0)
// 第6步如果在冷区搜索冷区
binarySearch(0, firstHotEntry)
```
## 总结
今天我带你详细学习了Kafka中的索引机制以及社区如何应用二分查找算法实现快速定位索引项。有两个重点你一定要记住。
1. AbstractIndex这是Kafka所有类型索引的抽象父类里面的mmap变量是实现索引机制的核心你一定要掌握它。
1. 改进版二分查找算法社区在标准原版的基础上对二分查找算法根据实际访问场景做了定制化的改进。你需要特别关注改进版在提升缓存性能方面做了哪些努力。改进版能够有效地提升页缓存的使用率从而在整体上降低物理I/O缓解系统负载瓶颈。你最好能够从索引这个维度去思考社区在这方面所做的工作。
<img src="https://static001.geekbang.org/resource/image/31/22/31e35198818b0bbc05ef333b5897b022.jpg" alt="">
实际上无论是AbstractIndex还是它使用的二分查找算法它们都属于Kafka索引共性的东西即所有Kafka索引都具备这些特点或特性。那么你是否想了解不同类型索引之间的区别呢比如位移索引和时间戳索引有哪些异同之处。这些问题的答案我会在下节课揭晓你千万不要错过。
## 课后讨论
目前冷区和热区的分割线设定在8192字节处请结合源码注释以及你自己的理解讲一讲为什么要设置成8192
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,229 @@
<audio id="audio" title="05 | 索引(下):位移索引和时间戳索引的区别是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/97/9ab31ac33d92b925f577e8fc4aa48797.mp3"></audio>
你好,我是胡夕。今天,我们继续说索引那些事儿。
在上节课我带你重点学习了Kafka源码中索引的抽象父类AbstractIndex。我分析了AbstractIndex类的大体对象结构还介绍了社区改进版的二分查找算法在Kafka索引上的应用。
前面说过Kafka索引类型有三大类位移索引、时间戳索引和已中止事务索引。相比于最后一类索引前两类索引的出镜率更高一些。在Kafka的数据路径下你肯定看到过很多.index和.timeindex后缀的文件。不知你是否有过这样的疑问“这些文件是用来做什么的呢” 现在我可以明确告诉你:.index文件就是Kafka中的位移索引文件而.timeindex文件则是时间戳索引文件。
那么,位移索引和时间戳索引到底是做什么用的呢?它们之间的区别是什么?今天,我就为你揭晓这些问题的答案。
## 位移索引
在学习Kafka的任何一类索引的时候我们都要关注两个问题
1. 索引中的索引项是如何定义的?
1. 如何向索引写入新的索引项?
看到这里,你可能会很疑惑:“等等,难道我们不需要关心如何查询索引吗?” 当然需要啦!上节课我们不是讲过二分查找算法在索引中的应用了吗?如果你觉得有点生疏了,那就赶快先去复习一下吧。
现在,我们先来看下索引项的定义。
### 索引项的定义
位移索引也就是所谓的OffsetIndex它可是一个老资历的组件了。如果我没记错的话国内大面积使用Kafka应该是在0.8时代。从那个时候开始OffsetIndex就已经存在了。每当Consumer需要从主题分区的某个位置开始读取消息时Kafka就会用到OffsetIndex直接定位物理文件位置从而避免了因为从头读取消息而引入的昂贵的I/O操作。
在上节课,我提到过,不同索引类型保存不同的&lt;Key, Value&gt;对。就OffsetIndex而言Key就是消息的相对位移Value是保存该消息的日志段文件中该消息第一个字节的物理文件位置。
这里我来具体解释一下相对位移的含义。还记得AbstractIndex类中的抽象方法entrySize吗它定义了单个&lt;Key, Value&gt;对所用的字节数。对于OffsetIndex来说entrySize就是8如OffsetIndex.scala中定义的那样
```
override def entrySize = 8
```
为什么是8呢相对位移是一个整型Integer占用4个字节物理文件位置也是一个整型同样占用4个字节因此总共是8个字节。
那相对位移是什么值呢我们知道Kafka中的消息位移值是一个长整型Long应该占用8个字节才对。在保存OffsetIndex的&lt;Key, Value&gt;对时Kafka做了一些优化。每个OffsetIndex对象在创建时都已经保存了对应日志段对象的起始位移因此OffsetIndex索引项没必要保存完整的8字节位移值。相反地它只需要保存与起始位移的差值Delta就够了而这个差值是可以被整型容纳的。这种设计可以让OffsetIndex每个索引项都节省4个字节。
举个简单的例子。假设一个索引文件保存了1000个索引项使用相对位移值就能节省大约4MB的空间这是不是一件很划算的事情呢
OffsetIndex定义了专门的方法用于将一个Long型的位移值转换成相对位移如下所示
```
def relativeOffset(offset: Long): Int = {
val relativeOffset = toRelative(offset)
if (relativeOffset.isEmpty)
// 如果无法转换成功(比如差值超过了整型表示范围),则抛出异常
throw new IndexOffsetOverflowException(s&quot;Integer overflow for offset: $offset (${file.getAbsoluteFile})&quot;)
relativeOffset.get
}
```
relativeOffset方法调用了父类的toRelative方法执行真正的转换。我们来看一下toRelative方法的实现。
```
private def toRelative(offset: Long): Option[Int] = {
val relativeOffset = offset - baseOffset
if (relativeOffset &lt; 0 || relativeOffset &gt; Int.MaxValue)
None
else
Some(relativeOffset.toInt)
}
```
逻辑很简单第一步是计算给定的offset值与baseOffset的差值第二步是校验该差值不能是负数或不能超过整型表示范围。如果校验通过就直接返回该差值作为相对位移值否则就返回None表示转换失败。
<img src="https://static001.geekbang.org/resource/image/c2/00/c259aff07a71aa4fe16165f423b3d600.jpg" alt="">
现在你知道OffsetIndex中的索引项为什么是8个字节以及位移值是如何被转换成相对位移了吧
当读取OffsetIndex时源码还需要将相对位移值还原成之前的完整位移。这个是在parseEntry方法中实现的。
```
override protected def parseEntry(buffer: ByteBuffer, n: Int): OffsetPosition = {
OffsetPosition(baseOffset + relativeOffset(buffer, n), physical(buffer, n))
}
```
我来给你解释下具体的实现方法。
这个方法返回一个OffsetPosition类型。该类有两个方法分别返回索引项的Key和Value。
**这里的parseEntry方法就是要构造OffsetPosition所需的Key和Value**。Key是索引项中的完整位移值**代码使用baseOffset + relativeOffset(buffer, n)的方式将相对位移值还原成完整位移值**Value是这个位移值上消息在日志段文件中的物理位置代码调用physical方法计算这个物理位置并把它作为Value。
最后parseEntry方法把Key和Value封装到一个OffsetPosition实例中然后将这个实例返回。
由于索引文件的总字节数就是索引项字节数乘以索引项数因此代码结合entrySize和buffer.getInt方法能够轻松地计算出第n个索引项所处的物理文件位置。这就是physical方法做的事情。
### 写入索引项
好了有了这些基础下面的内容就很容易理解了。我们来看下OffsetIndex中最重要的操作——**写入索引项append方法的实现**。
```
def append(offset: Long, position: Int): Unit = {
inLock(lock) {
// 索引文件如果已经写满,直接抛出异常
require(!isFull, &quot;Attempt to append to a full index (size = &quot; + _entries + &quot;).&quot;)
// 要保证待写入的位移值offset比当前索引文件中所有现存的位移值都要大
// 这主要是为了维护索引的单调增加性
if (_entries == 0 || offset &gt; _lastOffset) {
trace(s&quot;Adding index entry $offset =&gt; $position to ${file.getAbsolutePath}&quot;)
mmap.putInt(relativeOffset(offset)) // 向mmap写入相对位移值
mmap.putInt(position) // 向mmap写入物理文件位置
_entries += 1 // 更新索引项个数
_lastOffset = offset // 更新当前索引文件最大位移值
// 确保写入索引项格式符合要求
require(_entries * entrySize == mmap.position(), s&quot;$entries entries but file position in index is ${mmap.position()}.&quot;)
} else {
throw new InvalidOffsetException(s&quot;Attempt to append an offset ($offset) to position $entries no larger than&quot; +
s&quot; the last offset appended (${_lastOffset}) to ${file.getAbsolutePath}.&quot;)
}
}
}
```
append方法接收两个参数**Long型的位移值**和**Integer型的物理文件位置**。**该方法最重要的两步就是分别向mmap写入相对位移值和物理文件位置**。我使用一张图来总结下append方法的执行流程
<img src="https://static001.geekbang.org/resource/image/17/e6/176bec3d45790509c0587614be7f61e6.jpg" alt="">
除了append方法索引还有一个常见的操作截断操作Truncation。**截断操作是指,将索引文件内容直接裁剪掉一部分**。比如OffsetIndex索引文件中当前保存了100个索引项我想只保留最开始的40个索引项。源码定义了truncateToEntries方法来实现这个需求
```
private def truncateToEntries(entries: Int): Unit = {
inLock(lock) {
_entries = entries
mmap.position(_entries * entrySize)
_lastOffset = lastEntry.offset
debug(s&quot;Truncated index ${file.getAbsolutePath} to $entries entries;&quot; +
s&quot; position is now ${mmap.position()} and last offset is now ${_lastOffset}&quot;)
}
}
```
这个方法接收entries参数表示**要截取到哪个槽**主要的逻辑实现是调用mmap的position方法。源码中的_entries * entrySize就是mmap要截取到的字节处。
下面我来说说OffsetIndex的使用方式。
既然OffsetIndex被用来快速定位消息所在的物理文件位置那么必然需要定义一个方法执行对应的查询逻辑。这个方法就是lookup。
```
def lookup(targetOffset: Long): OffsetPosition = {
maybeLock(lock) {
val idx = mmap.duplicate // 使用私有变量复制出整个索引映射区
// largestLowerBoundSlotFor方法底层使用了改进版的二分查找算法寻找对应的槽
val slot = largestLowerBoundSlotFor(idx, targetOffset, IndexSearchType.KEY)
// 如果没找到返回一个空的位置即物理文件位置从0开始表示从头读日志文件
// 否则返回slot槽对应的索引项
if(slot == -1)
OffsetPosition(baseOffset, 0)
else
parseEntry(idx, slot)
}
}
```
我把主要的逻辑以注释的方式加到了代码中。该方法返回的是不大于给定位移值targetOffset的最大位移值以及对应的物理文件位置。你大致可以把这个方法理解为位移值的FLOOR函数。
## 时间戳索引
说完了OffsetIndex我们来看另一大类索引时间戳索引即TimeIndex。与OffsetIndex类似我们重点关注TimeIndex中索引项的定义以及如何写入TimeIndex索引项。
### 索引项的定义
与OffsetIndex不同的是TimeIndex保存的是&lt;时间戳,相对位移值&gt;对。时间戳需要一个长整型来保存相对位移值使用Integer来保存。因此TimeIndex单个索引项需要占用12个字节。这也揭示了一个重要的事实**在保存同等数量索引项的基础上TimeIndex会比OffsetIndex占用更多的磁盘空间**。
### 写入索引项
TimeIndex也有append方法只不过它叫作maybeAppend。我们来看下它的实现逻辑。
```
def maybeAppend(timestamp: Long, offset: Long, skipFullCheck: Boolean = false): Unit = {
inLock(lock) {
if (!skipFullCheck)
// 如果索引文件已写满,抛出异常
require(!isFull, &quot;Attempt to append to a full time index (size = &quot; + _entries + &quot;).&quot;)
// 确保索引单调增加性
if (_entries != 0 &amp;&amp; offset &lt; lastEntry.offset)
throw new InvalidOffsetException(s&quot;Attempt to append an offset ($offset) to slot ${_entries} no larger than&quot; +
s&quot; the last offset appended (${lastEntry.offset}) to ${file.getAbsolutePath}.&quot;)
// 确保时间戳的单调增加性
if (_entries != 0 &amp;&amp; timestamp &lt; lastEntry.timestamp)
throw new IllegalStateException(s&quot;Attempt to append a timestamp ($timestamp) to slot ${_entries} no larger&quot; +
s&quot; than the last timestamp appended (${lastEntry.timestamp}) to ${file.getAbsolutePath}.&quot;)
if (timestamp &gt; lastEntry.timestamp) {
trace(s&quot;Adding index entry $timestamp =&gt; $offset to ${file.getAbsolutePath}.&quot;)
mmap.putLong(timestamp) // 向mmap写入时间戳
mmap.putInt(relativeOffset(offset)) // 向mmap写入相对位移值
_entries += 1 // 更新索引项个数
_lastEntry = TimestampOffset(timestamp, offset) // 更新当前最新的索引项
require(_entries * entrySize == mmap.position(), s&quot;${_entries} entries but file position in index is ${mmap.position()}.&quot;)
}
}
}
```
和OffsetIndex类似向TimeIndex写入索引项的主体逻辑是向mmap分别写入时间戳和相对位移值。只不过**除了校验位移值的单调增加性之外TimeIndex还会确保顺序写入的时间戳也是单调增加的**。
说到这里我想到我当年读到这段代码时候的一个想法。那个时候这段代码还没有加上时间戳单调增加的校验逻辑我灵机一动萌发了向TimeIndex写入一个过期时间戳的想法。一番动手操作之后我真的向TimeIndex索引文件中写入了一个过期时间戳和位移。
你猜结果怎样?结果是引发了消费者端程序的彻底混乱。这是因为,当消费者端程序根据时间戳信息去过滤待读取的消息时,它读到了这个过期的时间戳并拿到了错误的位移值,因此返回了错误的数据。
为此我还给社区提交了一个Jira当时被驳回了——理由是不允许向TimeIndex写入过期时间戳。跟你说这个趣事儿只是想说明有的时候读源码会诱发很多灵感或奇思妙想而这些东西是你在平时使用过程中不会想到的。这也算是阅读源码的一大收获吧。
## 区别
讲到这里这节课就接近尾声了。最后我用一张表格汇总下OffsetIndex和TimeIndex的特点和区别希望能够帮助你更好地理解和消化今天的重点内容。
<img src="https://static001.geekbang.org/resource/image/a3/78/a359ce4e81eb073a9ebed2979082b578.jpg" alt="">
## 总结
今天我带你详细分析了OffsetIndex和TimeIndex以及它们的不同之处。虽然OffsetIndex和TimeIndex是不同类型的索引但Kafka内部是把二者结合使用的。通常的流程是先使用TimeIndex寻找满足时间戳要求的消息位移值然后再利用OffsetIndex去定位该位移值所在的物理文件位置。因此它们其实是合作的关系。
最后,我还想提醒你一点:**不要对索引文件做任何修改!**我碰到过因用户擅自重命名索引文件从而导致Broker崩溃无法启动的场景。另外虽然Kafka能够重建索引但是随意地删除索引文件依然是一个很危险的操作。在生产环境中我建议你尽量不要执行这样的操作。
## 课后讨论
OffsetIndex中的lookup方法实现了类似于FLOOR函数的位移查找逻辑。你能否对应写一个类似于CEILING函数的位移查找逻辑即返回不小于给定位移值targetOffset的最小位移值和对应的物理文件位置
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,10 @@
你好,我是胡夕。
我们的课程已经更新一半了,今天,我们来进行一场期中考试。
我出了一套测试题共有15道单选题和5道多选题满分100核心考点都出自前面15节课。我建议你认真地做一下这套题目。如果有做错的地方可以及时复习一下。如果有不明白的欢迎随时在留言区提问我会知无不言。
还等什么?我们一起来做下这套题吧!
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=165&amp;exam_id=378)

View File

@@ -0,0 +1,15 @@
<audio id="audio" title="期末测试 | 一套习题,测试你的掌握程度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/5a/90cd43ea98aeed13dfb0f1ed853eab5a.mp3"></audio>
你好,我是胡夕。
咱们课程的核心内容都已经更新完了在临近告别前我还给你准备了一份期末测试题这套试卷共有5道单选题和15道多选题满分100核心考点都出自前面讲到的所有重要知识希望可以帮助你进行一场自测。
除此之外,我在想,在结课之前,我还可以给你讲些什么。
我能想到的,就是再跟你聊一聊,结课以后你应该如何继续阅读源码。我根据自己的经验,总结提炼了一些建议,会在结束语里一起分享给你。
另外,我也很想知道你对这门课的建议,所以我还在结束语里给你准备了一份[问卷](https://jinshuju.net/f/a88osf)(现在也可以填写),还有一些礼物。欢迎你在问卷里聊一聊你的想法,也许就有机会获得礼物或者是课程阅码哦。
好了,话不多说,请你来做一做这套期末测试题吧,我们结束语见!
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=194&amp;exam_id=484)

View File

@@ -0,0 +1,304 @@
<audio id="audio" title="27 | 消费者组元数据(上):消费者组都有哪些元数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bd/04/bd23f952e3fe9e8d397ae69fddac8c04.mp3"></audio>
你好,我是胡夕。从今天这节课开始,我们进入到最后一个模块的源码学习:消费者组管理模块。
在这个模块中我将会带你详细阅读Kafka消费者组在Broker端的源码实现包括消费者组元数据的定义与管理、组元数据管理器、内部主题__consumer_offsets和重要的组件GroupCoordinator。
我先来给你简单介绍下这4部分的功能以方便你对消费者组管理有一个大概的理解。
- 消费者组元数据这部分源码主要包括GroupMetadata和MemberMetadata。这两个类共同定义了消费者组的元数据都由哪些内容构成。
- 组元数据管理器由GroupMetadataManager类定义可被视为消费者组的管理引擎提供了消费者组的增删改查功能。
- __consumer_offsetsKafka的内部主题。除了我们熟知的消费者组提交位移记录功能之外它还负责保存消费者组的注册记录消息。
- GroupCoordinator组协调者组件提供通用的组成员管理和位移管理。
我把这4部分源码的功能梳理到了一张思维导图中你可以保存下来随时查阅
<img src="https://static001.geekbang.org/resource/image/2e/d8/2e2f823737fe479a853204a1cb5f8dd8.jpg" alt="">
今天,我们首先学习消费者组元数据的源码实现,这是我们理解消费者组工作机制和深入学习消费者组管理组件的基础。除此之外,掌握这部分代码对我们还有什么实际意义吗?
当然有了。我想你肯定对下面这条命令不陌生吧它是查询消费者组状态的命令行工具。我们在输出中看到了GROUP、COORDINATOR、ASSIGNMENT-STRATEGY、STATE和MEMBERS等数据。实际上这些数据就是消费者组元数据的一部分。
```
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group mygroup --verbose --state
GROUP COORDINATOR (ID) ASSIGNMENT-STRATEGY STATE #MEMBERS
mygroup 172.25.4.76:9092 (0) range Stable 2
```
所以你看,我们日常使用的命令和源码知识的联系是非常紧密的,弄明白了今天的内容,之后你在实际使用一些命令行工具时,就能理解得更加透彻了。
好了,我们现在就正式开始今天的学习吧。
就像我前面说的元数据主要是由GroupMetadata和MemberMetadata两个类组成它们分别位于GroupMetadata.scala和MemberMetadata.scala这两个源码文件中。从它们的名字上也可以看出来前者是保存消费者组的元数据后者是保存消费者组下成员的元数据。
由于一个消费者组下有多个成员因此一个GroupMetadata实例会对应于多个MemberMetadata实例。接下来我们先学习下MemberMetadata.scala源文件。
## 组成员元数据MemberMetadata
MemberMetadata.scala源文件位于coordinator.group包下。事实上coordinator.group包下的所有源代码都是与消费者组功能息息相关的。下图是coordinator.group包的源码文件列表你可以看到MemberMetdata.scala和稍后我们要学到的GroupMetadata.scala都在其中。
<img src="https://static001.geekbang.org/resource/image/26/49/263b1a4cd251bcd312a2c1a640167049.png" alt="">
从这个包结构图中我们还可以发现后面几节课中要学习的源码类比如GroupCoordinator、GroupMetadataManager也都在里面。当然了你一定也发现了coordinator包下还有个transcation包里面保存了Kafka事务相关的所有源代码。如果你想深入学习事务机制的话可以去阅读下这个包下的源代码。
现在我们聚焦到MemberMetadata.scala文件包括3个类和对象。
- MemberSummary类组成员概要数据提取了最核心的元数据信息。上面例子中工具行命令返回的结果就是这个类提供的数据。
- MemberMetadata伴生对象仅仅定义了一个工具方法供上层组件调用。
- MemberMetadata类消费者组成员的元数据。Kafka为消费者组成员定义了很多数据一会儿我们将会详细学习。
按照难易程度我们从最简单的MemberSummary类开始学起。
### MemberSummary类
MemberSummary类就是组成员元数据的一个概要数据类它的代码本质上是一个POJO类仅仅承载数据没有定义任何逻辑。代码如下
```
case class MemberSummary(
memberId: String, // 成员ID由Kafka自动生成
groupInstanceId: Option[String], // Consumer端参数group.instance.id值
clientId: String, // client.id参数值
clientHost: String, // Consumer端程序主机名
metadata: Array[Byte], // 消费者组成员使用的分配策略
assignment: Array[Byte]) // 成员订阅分区
```
可以看到这个类定义了6个字段我来详细解释下。
- memberId标识消费者组成员的ID这个ID是Kafka自动生成的规则是consumer-组ID-&lt;序号&gt;-<uuid>。虽然现在社区有关于是否放开这个限制的讨论即是否允许用户自己设定这个ID但目前它还是硬编码的不能让你设置。</uuid>
- groupInstanceId消费者组静态成员的ID。静态成员机制的引入能够规避不必要的消费者组Rebalance操作。它是非常新且高阶的功能这里你只要稍微知道它的含义就可以了。如果你对此感兴趣建议你去官网看看group.instance.id参数的说明。
- clientId消费者组成员配置的client.id参数。由于memberId不能被设置因此你可以用这个字段来区分消费者组下的不同成员。
- clientHost运行消费者程序的主机名。它记录了这个客户端是从哪台机器发出的消费请求。
- metadata标识消费者组成员分区分配策略的字节数组由消费者端参数partition.assignment.strategy值设定默认的RangeAssignor策略是按照主题平均分配分区。
- assignment保存分配给该成员的订阅分区。每个消费者组都要选出一个Leader消费者组成员负责给所有成员分配消费方案。之后Kafka将制定好的分配方案序列化成字节数组赋值给assignment分发给各个成员。
总之MemberSummary类是成员概要数据的容器类似于Java中的POJO类不涉及任何操作逻辑所以还是很好理解的。
### MemberMetadata伴生对象
接下来我们学习MemberMetadata伴生对象的代码。它只定义了一个plainProtocolSet方法供上层组件调用。这个方法只做一件事儿即从一组给定的分区分配策略详情中提取出分区分配策略的名称并将其封装成一个集合对象然后返回
```
private object MemberMetadata {
// 提取分区分配策略集合
def plainProtocolSet(supportedProtocols: List[(String, Array[Byte])]) = supportedProtocols.map(_._1).toSet
}
```
我举个例子说明下。如果消费者组下有3个成员它们的partition.assignment.strategy参数分别设置成RangeAssignor、RangeAssignor和RoundRobinAssignor那么plainProtocolSet方法的返回值就是集合[RangeAssignorRoundRobinAssignor]。实际上,它经常被用来统计一个消费者组下的成员到底配置了多少种分区分配策略。
### MemberMetadata类
现在我们看下MemberMetadata类的源码。首先我们看下**该类的构造函数以及字段定义**,了解下一个成员的元数据都有哪些。
```
@nonthreadsafe
private[group] class MemberMetadata(
var memberId: String,
val groupId: String,
val groupInstanceId: Option[String],
val clientId: String,
val clientHost: String,
val rebalanceTimeoutMs: Int, // Rebalane操作超时时间
val sessionTimeoutMs: Int, // 会话超时时间
val protocolType: String, // 对消费者组而言,是&quot;consumer&quot;
// 成员配置的多套分区分配策略
var supportedProtocols: List[(String, Array[Byte])]) {
// 分区分配方案
var assignment: Array[Byte] = Array.empty[Byte]
var awaitingJoinCallback: JoinGroupResult =&gt; Unit = null
var awaitingSyncCallback: SyncGroupResult =&gt; Unit = null
var isLeaving: Boolean = false
var isNew: Boolean = false
val isStaticMember: Boolean = groupInstanceId.isDefined
var heartbeatSatisfied: Boolean = false
......
}
```
MemberMetadata类保存的数据很丰富在它的构造函数中除了包含MemberSummary类定义的6个字段外还定义了4个新字段。
- rebalanceTimeoutMsRebalance操作的超时时间即一次Rebalance操作必须在这个时间内完成否则被视为超时。这个字段的值是Consumer端参数max.poll.interval.ms的值。
- sessionTimeoutMs会话超时时间。当前消费者组成员依靠心跳机制“保活”。如果在会话超时时间之内未能成功发送心跳组成员就被判定成“下线”从而触发新一轮的Rebalance。这个字段的值是Consumer端参数session.timeout.ms的值。
- protocolType直译就是协议类型。它实际上标识的是消费者组被用在了哪个场景。这里的场景具体有两个第一个是作为普通的消费者组使用该字段对应的值就是consumer第二个是供Kafka Connect组件中的消费者使用该字段对应的值是connect。当然不排除后续社区会增加新的协议类型。但现在你只要知道它是用字符串的值标识应用场景就足够了。除此之外该字段并无太大作用。
- supportedProtocols标识成员配置的多组分区分配策略。目前Consumer端参数partition.assignment.strategy的类型是List说明你可以为消费者组成员设置多组分配策略因此这个字段也是一个List类型每个元素是一个元组Tuple。元组的第一个元素是策略名称第二个元素是序列化后的策略详情。
除了构造函数中的10个字段之外该类还定义了7个额外的字段用于保存元数据和判断状态。这些扩展字段都是var型变量说明它们的值是可以变更的。MemberMetadata源码正是依靠这些字段来不断地调整组成员的元数据信息和状态。
我选择了5个比较重要的扩展字段和你介绍下。
- assignment保存分配给该成员的分区分配方案。
- awaitingJoinCallback表示组成员是否正在等待加入组。
- awaitingSyncCallback表示组成员是否正在等待GroupCoordinator发送分配方案。
- isLeaving表示组成员是否发起“退出组”的操作。
- isNew表示是否是消费者组下的新成员。
以上就是MemberMetadata类的构造函数以及字段定义了。它定义的方法都是操作这些元数据的而且大多都是逻辑很简单的操作。这里我选取metadata方法带你熟悉下它们的编码实现风格。你可以在课后自行阅读其他的方法代码掌握它们的工作原理。
我们看下metadata方法的代码
```
def metadata(protocol: String): Array[Byte] = {
// 从配置的分区分配策略中寻找给定策略
supportedProtocols.find(_._1 == protocol) match {
case Some((_, metadata)) =&gt; metadata
case None =&gt;
throw new IllegalArgumentException(&quot;Member does not support protocol&quot;)
}
}
```
它实现的逻辑是:从该成员配置的分区分配方案列表中寻找给定策略的详情。如果找到,就直接返回详情字节数组数据,否则,就抛出异常。怎么样,是不是很简单?
## 组元数据GroupMetadata类
说完了组成员元数据类我们进入到组元数据类GroupMetadata的学习。它位于coordinator.group包下的同名scala文件下。
GroupMetadata管理的是消费者组而不是消费者组成员级别的元数据所以它的代码结构要比MemberMetadata类复杂得多。我先画一张思维导图帮你梳理下它的代码结构。
<img src="https://static001.geekbang.org/resource/image/c8/yy/c860782ca34a103efef3e6d0147ce5yy.jpg" alt="">
总体上来看GroupMetadata.scala文件由6部分构成。
- GroupState类定义了消费者组的状态空间。当前有5个状态分别是Empty、PreparingRebalance、CompletingRebalance、Stable和Dead。其中Empty表示当前无成员的消费者组PreparingRebalance表示正在执行加入组操作的消费者组CompletingRebalance表示等待Leader成员制定分配方案的消费者组Stable表示已完成Rebalance操作可正常工作的消费者组Dead表示当前无成员且元数据信息被删除的消费者组。
- GroupMetadata类组元数据类。这是该scala文件下最重要的类文件也是我们今天要学习的重点内容。
- GroupMetadata伴生对象该对象提供了创建GroupMetadata实例的方法。
- GroupOverview类定义了非常简略的消费者组概览信息。
- GroupSummary类与MemberSummary类类似它定义了消费者组的概要信息。
- CommitRecordMetadataAndOffset类保存写入到位移主题中的消息的位移值以及其他元数据信息。这个类的主要职责就是保存位移值因此我就不展开说它的详细代码了。
接下来我们依次看下这些代码结构中都保存了哪些元数据信息。我们从最简单的GroupState类开始。
### GroupState类及实现对象
GroupState类定义了消费者组的状态。这个类及其实现对象Stable的代码如下
```
// GroupState trait
private[group] sealed trait GroupState {
// 合法前置状态
val validPreviousStates: Set[GroupState]
}
// Stable状态
private[group] case object Stable extends GroupState {
val validPreviousStates: Set[GroupState] = Set(CompletingRebalance)
}
......
```
这里我只展示了Stable状态的代码其他4个状态的代码都差不多。为了方便你理解消费者组之间的状态流转我绘制了一张完整的状态流转图。
<img src="https://static001.geekbang.org/resource/image/47/85/479e5a91684b6eb625104a894d353585.jpg" alt="">
你需要记住的是一个消费者组从创建到正常工作它的状态流转路径是Empty -&gt; PreparingRebalance -&gt; CompletingRebalance -&gt; Stable。
### GroupOverview类
接下来我们看下GroupOverview类的代码。就像我刚才说的这是一个非常简略的组概览信息。当我们在命令行运行kafka-consumer-groups.sh --list的时候Kafka就会创建GroupOverview实例返回给命令行。
我们来看下它的代码:
```
case class GroupOverview(
groupId: String, // 组ID信息即group.id参数值
protocolType: String, // 消费者组的协议类型
state: String) // 消费者组的状态
```
怎么样很简单吧。GroupOverview类封装了最基础的组数据包括组ID、协议类型和状态信息。如果你熟悉Java Web开发的话可以把GroupOverview和GroupMetadata的关系理解为DAO和DTO的关系。
### GroupSummary类
它的作用和GroupOverview非常相似只不过它保存的数据要稍微多一点。我们看下它的代码
```
case class GroupSummary(
state: String, // 消费者组状态
protocolType: String, // 协议类型
protocol: String, // 消费者组选定的分区分配策略
members: List[MemberSummary]) // 成员元数据
```
GroupSummary类有4个字段它们的含义也都很清晰看字段名就能理解。你需要关注的是**members字段**它是一个MemberSummary类型的列表里面保存了消费者组所有成员的元数据信息。通过这个字段我们可以看到**消费者组元数据和组成员元数据是1对多的关系**。
### GroupMetadata类
最后我们看下GroupMetadata类的源码。我们先看下该类构造函数所需的字段和自定义的扩展元数据
```
@nonthreadsafe
private[group] class GroupMetadata(
val groupId: String, // 组ID
initialState: GroupState, // 消费者组初始状态
time: Time) extends Logging {
type JoinCallback = JoinGroupResult =&gt; Unit
// 组状态
private var state: GroupState = initialState
// 记录状态最近一次变更的时间戳
var currentStateTimestamp: Option[Long] = Some(time.milliseconds())
var protocolType: Option[String] = None
var protocolName: Option[String] = None
var generationId = 0
// 记录消费者组的Leader成员可能不存在
private var leaderId: Option[String] = None
// 成员元数据列表信息
private val members = new mutable.HashMap[String, MemberMetadata]
// 静态成员Id列表
private val staticMembers = new mutable.HashMap[String, String]
private var numMembersAwaitingJoin = 0
// 分区分配策略支持票数
private val supportedProtocols = new mutable.HashMap[String, Integer]().withDefaultValue(0)
// 保存消费者组订阅分区的提交位移值
private val offsets = new mutable.HashMap[TopicPartition, CommitRecordMetadataAndOffset]
// 消费者组订阅的主题列表
private var subscribedTopics: Option[Set[String]] = None
......
}
```
GroupMetadata类定义的字段非常多也正因为这样它保存的数据是最全的绝对担得起消费者组元数据类的称号。
除了我们之前反复提到的字段外它还定义了很多其他字段。不过有些字段要么是与事务相关的元数据要么是属于中间状态的临时元数据不属于核心的元数据我们不需要花很多精力去学习它们。我们要重点关注的是上面的代码中所展示的字段这些是GroupMetadata类最重要的字段。
- currentStateTimestamp记录最近一次状态变更的时间戳用于确定位移主题中的过期消息。位移主题中的消息也要遵循Kafka的留存策略所有当前时间与该字段的差值超过了留存阈值的消息都被视为“已过期”Expired
- generationId消费组Generation号。Generation等同于消费者组执行过Rebalance操作的次数每次执行Rebalance时Generation数都要加1。
- leaderId消费者组中Leader成员的Member ID信息。当消费者组执行Rebalance过程时需要选举一个成员作为Leader负责为所有成员制定分区分配方案。在Rebalance早期阶段这个Leader可能尚未被选举出来。这就是leaderId字段是Option类型的原因。
- members保存消费者组下所有成员的元数据信息。组元数据是由MemberMetadata类建模的因此members字段是按照Member ID分组的MemberMetadata类。
- offsets保存按照主题分区分组的位移主题消息位移值的HashMap。Key是主题分区Value是前面讲过的CommitRecordMetadataAndOffset类型。当消费者组成员向Kafka提交位移时源码都会向这个字段插入对应的记录。
- subscribedTopics保存消费者组订阅的主题列表用于帮助从offsets字段中过滤订阅主题分区的位移值。
- supportedProtocols保存分区分配策略的支持票数。它是一个HashMap类型其中Key是分配策略的名称Value是支持的票数。前面我们说过每个成员可以选择多个分区分配策略因此假设成员A选择[“range”“round-robin”]、B选择[“range”]、C选择[“round-robin”“sticky”]那么这个字段就有3项分别是&lt;“range”2&gt;&lt;“round-robin”2&gt;&lt;“sticky”1&gt;
这些扩展字段和构造函数中的字段,共同构建出了完整的消费者组元数据。就我个人而言,我认为这些字段中最重要的就是**members和offsets**它们分别保存了组内所有成员的元数据以及这些成员提交的位移值。这样看的话这两部分数据不就是一个消费者组最关心的3件事吗**组里面有多少个成员**、**每个成员都负责做什么**、**它们都做到了什么程度**。
## 总结
今天我带你深入到了GroupMetadata.scala和MemberMetadata.scala这两个源码文件中学习了消费者组元数据和组成员元数据的定义。它们封装了一个消费者组及其成员的所有数据。后续的GroupCoordinator和其他消费者组组件都会大量利用这部分元数据执行消费者组的管理。
为了让你更好地掌握今天的内容,我们来回顾下这节课的重点。
- 消费者组元数据包括组元数据和组成员元数据两部分分别由GroupMetadata和MemberMetadata类表征。
- MemberMetadata类保存组成员元数据比如组ID、Consumer主机名、协议类型等。同时它还提供了MemberSummary类封装了组成员元数据的概要信息。
- GroupMetadata类保存组元数据包括组状态、组成员元数据列表等等。
- 1对多关系组元数据与组成员元数据是1对多的关系。这是因为每个消费者组下存在若干个组成员。
<img src="https://static001.geekbang.org/resource/image/14/1f/14a63dda57facee5f686ea539848131f.jpg" alt="">
今天这节课的逻辑不是特别复杂我们重点学习了消费者组元数据的构成几乎未曾涉及元数据的操作。在下节课我们将继续在这两个scala文件中探索去学习操作这些元数据的方法实现。
但我要再次强调的是,今天学习的这些方法是上层组件调用的基础。如果你想彻底了解消费者组的工作原理,就必须先把这部分基础“铺平夯实”了,这样你才能借由它们到达“完全掌握消费者组实现源码”的目的地。
## 课后讨论
请你思考下这节课最开始的工具行命令输出中的ASSIGNMENT-STRATEGY项对应于咱们今天学习的哪一项元数据呢
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,388 @@
<audio id="audio" title="28 | 消费者组元数据Kafka如何管理这些元数据" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/12/84/129fd863d8d850f4ab5b6a0603e02784.mp3"></audio>
你好,我是胡夕。今天我们继续学习消费者组元数据。
学完上节课之后我们知道Kafka定义了非常多的元数据那么这就必然涉及到对元数据的管理问题了。
这些元数据的类型不同管理策略也就不一样。这节课我将从消费者组状态、成员、位移和分区分配策略四个维度对这些元数据进行拆解带你一起了解下Kafka管理这些元数据的方法。
这些方法定义在MemberMetadata和GroupMetadata这两个类中其中GroupMetadata类中的方法最为重要是我们要重点学习的对象。在后面的课程中你会看到这些方法会被上层组件GroupCoordinator频繁调用因此它们是我们学习Coordinator组件代码的前提条件你一定要多花些精力搞懂它们。
## 消费者组状态管理方法
消费者组状态是很重要的一类元数据。管理状态的方法,要做的事情也就是设置和查询。这些方法大多比较简单,所以我把它们汇总在一起,直接介绍给你。
```
// GroupMetadata.scala
// 设置/更新状态
def transitionTo(groupState: GroupState): Unit = {
assertValidTransition(groupState) // 确保是合法的状态转换
state = groupState // 设置状态到给定状态
currentStateTimestamp = Some(time.milliseconds() // 更新状态变更时间戳
// 查询状态
def currentState = state
// 判断消费者组状态是指定状态
def is(groupState: GroupState) = state == groupState
// 判断消费者组状态不是指定状态
def not(groupState: GroupState) = state != groupState
// 消费者组能否Rebalance的条件是当前状态是PreparingRebalance状态的合法前置状态
def canRebalance = PreparingRebalance.validPreviousStates.contains(state)
```
**1.transitionTo方法**
transitionTo方法的作用是**将消费者组状态变更成给定状态**。在变更前代码需要确保这次变更必须是合法的状态转换。这是依靠每个GroupState实现类定义的**validPreviousStates集合**来完成的。只有在这个集合中的状态,才是合法的前置状态。简单来说,只有集合中的这些状态,才能转换到当前状态。
同时,该方法还会**更新状态变更的时间戳字段**。Kafka有个定时任务会定期清除过期的消费者组位移数据它就是依靠这个时间戳字段来判断过期与否的。
**2.canRebalance方法**
它用于判断消费者组是否能够开启Rebalance操作。判断依据是**当前状态是否是PreparingRebalance状态的合法前置状态**。只有**Stable**、**CompletingRebalance**和**Empty**这3类状态的消费者组才有资格开启Rebalance。
**3.is和not方法**
至于is和not方法它们分别判断消费者组的状态与给定状态吻合还是不吻合主要被用于**执行状态校验**。特别是is方法被大量用于上层调用代码中执行各类消费者组管理任务的前置状态校验工作。
总体来说,管理消费者组状态数据,依靠的就是这些方法,还是很简单的吧?
## 成员管理方法
在介绍管理消费者组成员的方法之前我先帮你回忆下GroupMetadata中保存成员的字段。GroupMetadata中使用members字段保存所有的成员信息。该字段是一个HashMapKey是成员的member ID字段Value是MemberMetadata类型该类型保存了成员的元数据信息。
所谓的管理成员也就是添加成员add方法、移除成员remove方法和查询成员has、get、size方法等。接下来我们就逐一来学习。
### 添加成员
先说添加成员的方法add。add方法的主要逻辑是将成员对象添加到members字段同时更新其他一些必要的元数据比如Leader成员字段、分区分配策略支持票数等。下面是add方法的源码
```
def add(member: MemberMetadata, callback: JoinCallback = null): Unit = {
// 如果是要添加的第一个消费者组成员
if (members.isEmpty)
// 就把该成员的procotolType设置为消费者组的protocolType
this.protocolType = Some(member.protocolType)
// 确保成员元数据中的groupId和组Id相同
assert(groupId == member.groupId)
// 确保成员元数据中的protoclType和组protocolType相同
assert(this.protocolType.orNull == member.protocolType)
// 确保该成员选定的分区分配策略与组选定的分区分配策略相匹配
assert(supportsProtocols(member.protocolType, MemberMetadata.plainProtocolSet(member.supportedProtocols)))
// 如果尚未选出Leader成员
if (leaderId.isEmpty)
// 把该成员设定为Leader成员
leaderId = Some(member.memberId)
// 将该成员添加进members
members.put(member.memberId, member)
// 更新分区分配策略支持票数
member.supportedProtocols.foreach{ case (protocol, _) =&gt; supportedProtocols(protocol) += 1 }
// 设置成员加入组后的回调逻辑
member.awaitingJoinCallback = callback
// 更新已加入组的成员数
if (member.isAwaitingJoin)
numMembersAwaitingJoin += 1
}
```
我再画一张流程图,帮助你更直观地理解这个方法的作用。
<img src="https://static001.geekbang.org/resource/image/67/d5/679b34908b9d3cf7e10905fcf96e69d5.jpg" alt="">
我再具体解释一下这个方法的执行逻辑。
第一步add方法要判断members字段是否包含已有成员。如果没有就说明要添加的成员是该消费者组的第一个成员那么就令该成员协议类型protocolType成为组的protocolType。我在上节课中讲过对于普通的消费者而言protocolType就是字符串"consumer"。如果不是首个成员,就进入到下一步。
第二步add方法会连续进行三次校验分别确保**待添加成员的组ID、protocolType**和组配置一致,以及该成员选定的分区分配策略与组选定的分区分配策略相匹配。如果这些校验有任何一个未通过,就会立即抛出异常。
第三步判断消费者组的Leader成员是否已经选出了。如果还没有选出就将该成员设置成Leader成员。当然了如果Leader已经选出了自然就不需要做这一步了。需要注意的是这里的Leader和我们在学习副本管理器时学到的Leader副本是不同的概念。这里的Leader成员是指**消费者组下的一个成员**。该成员负责为所有成员制定分区分配方案,制定方法的依据,就是消费者组选定的分区分配策略。
第四步更新消费者组分区分配策略支持票数。关于supportedProtocols字段的含义我在上节课的末尾用一个例子形象地进行了说明这里就不再重复说了。如果你没有印象了可以再复习一下。
最后一步,设置成员加入组后的回调逻辑,同时更新已加入组的成员数。至此,方法结束。
作为关键的成员管理方法之一add方法是实现消费者组Rebalance流程至关重要的一环。每当Rebalance开启第一大步——加入组的操作时本质上就是在利用这个add方法实现新成员入组的逻辑。
### 移除成员
有add方法自然也就有remove方法下面是remove方法的完整源码
```
def remove(memberId: String): Unit = {
// 从members中移除给定成员
members.remove(memberId).foreach { member =&gt;
// 更新分区分配策略支持票数
member.supportedProtocols.foreach{ case (protocol, _) =&gt; supportedProtocols(protocol) -= 1 }
// 更新已加入组的成员数
if (member.isAwaitingJoin)
numMembersAwaitingJoin -= 1
}
// 如果该成员是Leader选择剩下成员列表中的第一个作为新的Leader成员
if (isLeader(memberId))
leaderId = members.keys.headOption
}
```
remove方法比add要简单一些。**首先**代码从members中移除给定成员。**之后**,更新分区分配策略支持票数,以及更新已加入组的成员数。**最后**代码判断该成员是否是Leader成员如果是的话就选择成员列表中尚存的第一个成员作为新的Leader成员。
### 查询成员
查询members的方法有很多大多都是很简单的场景。我给你介绍3个比较常见的。
```
def has(memberId: String) = members.contains(memberId)
def get(memberId: String) = members(memberId)
def size = members.size
```
- has方法判断消费者组是否包含指定成员
- get方法获取指定成员对象
- size方法统计总成员数。
其它的查询方法逻辑也都很简单比如allMemberMetadata、rebalanceTimeoutMs等等我就不多讲了。课后你可以自行阅读下重点是体会这些方法利用members都做了什么事情。
## 位移管理方法
除了组状态和成员管理之外GroupMetadata还有一大类管理功能就是**管理消费者组的提交位移**Committed Offsets主要包括添加和移除位移值。
不过在学习管理位移的功能之前我再带你回顾一下保存位移的offsets字段的定义。毕竟接下来我们要学习的方法主要操作的就是这个字段。
```
private val offsets = new mutable.HashMap[TopicPartition, CommitRecordMetadataAndOffset]
```
它是HashMap类型Key是TopicPartition类型表示一个主题分区而Value是CommitRecordMetadataAndOffset类型。该类封装了位移提交消息的位移值。
在详细阅读位移管理方法之前,我先解释下这里的“位移”和“位移提交消息”。
消费者组需要向Coordinator提交已消费消息的进度在Kafka中这个进度有个专门的术语叫作提交位移。Kafka使用它来定位消费者组要消费的下一条消息。那么提交位移在Coordinator端是如何保存的呢它实际上是保存在内部位移主题中。提交的方式是消费者组成员向内部主题写入符合特定格式的事件消息这类消息就是所谓的位移提交消息Commit Record。关于位移提交消息的事件格式我会在第30讲具体介绍这里你可以暂时不用理会。而这里所说的CommitRecordMetadataAndOffset类就是标识位移提交消息的地方。我们看下它的代码
```
case class CommitRecordMetadataAndOffset(appendedBatchOffset: Option[Long], offsetAndMetadata: OffsetAndMetadata) {
def olderThan(that: CommitRecordMetadataAndOffset): Boolean = appendedBatchOffset.get &lt; that.appendedBatchOffset.get
}
```
这个类的构造函数有两个参数。
- appendedBatchOffset保存的是位移主题消息自己的位移值
- offsetAndMetadata保存的是位移提交消息中保存的消费者组的位移值。
### 添加位移值
在GroupMetadata中有3个向offsets中添加订阅分区的已消费位移值的方法分别是initializeOffsets、onOffsetCommitAppend和completePendingTxnOffsetCommit。
initializeOffsets方法的代码非常简单如下所示
```
def initializeOffsets(
offsets: collection.Map[TopicPartition, CommitRecordMetadataAndOffset],
pendingTxnOffsets: Map[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]]): Unit = {
this.offsets ++= offsets
this.pendingTransactionalOffsetCommits ++= pendingTxnOffsets
}
```
它仅仅是将给定的一组订阅分区提交位移值加到offsets中。当然同时它还会更新pendingTransactionalOffsetCommits字段。
不过由于这个字段是给Kafka事务机制使用的因此你只需要关注这个方法的第一行语句就行了。当消费者组的协调者组件启动时它会创建一个异步任务定期地读取位移主题中相应消费者组的提交位移数据并把它们加载到offsets字段中。
我们再来看第二个方法onOffsetCommitAppend的代码。
```
def onOffsetCommitAppend(topicPartition: TopicPartition, offsetWithCommitRecordMetadata: CommitRecordMetadataAndOffset): Unit = {
if (pendingOffsetCommits.contains(topicPartition)) {
if (offsetWithCommitRecordMetadata.appendedBatchOffset.isEmpty)
throw new IllegalStateException(&quot;Cannot complete offset commit write without providing the metadata of the record &quot; +
&quot;in the log.&quot;)
// offsets字段中没有该分区位移提交数据或者
// offsets字段中该分区对应的提交位移消息在位移主题中的位移值小于待写入的位移值
if (!offsets.contains(topicPartition) || offsets(topicPartition).olderThan(offsetWithCommitRecordMetadata))
// 将该分区对应的提交位移消息添加到offsets中
offsets.put(topicPartition, offsetWithCommitRecordMetadata)
}
pendingOffsetCommits.get(topicPartition) match {
case Some(stagedOffset) if offsetWithCommitRecordMetadata.offsetAndMetadata == stagedOffset =&gt;
pendingOffsetCommits.remove(topicPartition)
case _ =&gt;
}
}
```
该方法在提交位移消息被成功写入后调用。主要判断的依据是offsets中是否已包含该主题分区对应的消息值或者说offsets字段中该分区对应的提交位移消息在位移主题中的位移值是否小于待写入的位移值。如果是的话就把该主题已提交的位移值添加到offsets中。
第三个方法completePendingTxnOffsetCommit的作用是完成一个待决事务Pending Transaction的位移提交。所谓的待决事务就是指正在进行中、还没有完成的事务。在处理待决事务的过程中可能会出现将待决事务中涉及到的分区的位移值添加到offsets中的情况。不过由于该方法是与Kafka事务息息相关的你不需要重点掌握这里我就不展开说了。
### 移除位移值
offsets中订阅分区的已消费位移值也是能够被移除的。你还记得Kafka主题中的消息有默认的留存时间设置吗位移主题是普通的Kafka主题所以也要遵守相应的规定。如果当前时间与已提交位移消息时间戳的差值超过了Broker端参数offsets.retention.minutes值Kafka就会将这条记录从offsets字段中移除。这就是方法removeExpiredOffsets要做的事情。
这个方法的代码有点长为了方便你掌握我分块给你介绍下。我先带你了解下它的内部嵌套类方法getExpireOffsets然后再深入了解它的实现逻辑这样你就能很轻松地掌握Kafka移除位移值的代码原理了。
首先,该方法定义了一个内部嵌套方法**getExpiredOffsets**,专门用于获取订阅分区过期的位移值。我们来阅读下源码,看看它是如何做到的。
```
def getExpiredOffsets(
baseTimestamp: CommitRecordMetadataAndOffset =&gt; Long,
subscribedTopics: Set[String] = Set.empty): Map[TopicPartition, OffsetAndMetadata] = {
// 遍历offsets中的所有分区过滤出同时满足以下3个条件的所有分区
// 条件1分区所属主题不在订阅主题列表之内
// 条件2该主题分区已经完成位移提交
// 条件3该主题分区在位移主题中对应消息的存在时间超过了阈值
offsets.filter {
case (topicPartition, commitRecordMetadataAndOffset) =&gt;
!subscribedTopics.contains(topicPartition.topic()) &amp;&amp;
!pendingOffsetCommits.contains(topicPartition) &amp;&amp; {
commitRecordMetadataAndOffset
.offsetAndMetadata.expireTimestamp match {
case None =&gt;
currentTimestamp - baseTimestamp(commitRecordMetadataAndOffset) &gt;= offsetRetentionMs
case Some(expireTimestamp) =&gt;
currentTimestamp &gt;= expireTimestamp
}
}
}.map {
// 为满足以上3个条件的分区提取出commitRecordMetadataAndOffset中的位移值
case (topicPartition, commitRecordOffsetAndMetadata) =&gt;
(topicPartition, commitRecordOffsetAndMetadata.offsetAndMetadata)
}.toMap
}
```
该方法接收两个参数。
- baseTimestamp它是一个函数类型接收CommitRecordMetadataAndOffset类型的字段然后计算时间戳并返回
- subscribedTopics即订阅主题集合默认是空。
方法开始时代码从offsets字段中过滤出同时满足3个条件的所有分区。
**条件1**:分区所属主题不在订阅主题列表之内。当方法传入了不为空的主题集合时,就说明该消费者组此时正在消费中,正在消费的主题是不能执行过期位移移除的。
**条件2**主题分区已经完成位移提交那种处于提交中状态也就是保存在pendingOffsetCommits字段中的分区不予考虑。
**条件3**该主题分区在位移主题中对应消息的存在时间超过了阈值。老版本的Kafka消息直接指定了过期时间戳因此只需要判断当前时间是否越过了这个过期时间。但是目前新版Kafka判断过期与否主要是**基于消费者组状态**。如果是Empty状态过期的判断依据就是当前时间与组变为Empty状态时间的差值是否超过Broker端参数offsets.retention.minutes值如果不是Empty状态就看当前时间与提交位移消息中的时间戳差值是否超过了offsets.retention.minutes值。如果超过了就视为已过期对应的位移值需要被移除如果没有超过就不需要移除了。
当过滤出同时满足这3个条件的分区后提取出它们对应的位移值对象并返回。
学过了getExpiredOffsets方法代码的实现之后removeExpiredOffsets方法剩下的代码就很容易理解了。
```
def removeExpiredOffsets(
currentTimestamp: Long, offsetRetentionMs: Long): Map[TopicPartition, OffsetAndMetadata] = {
// getExpiredOffsets方法代码......
// 调用getExpiredOffsets方法获取主题分区的过期位移
val expiredOffsets: Map[TopicPartition, OffsetAndMetadata] = protocolType match {
case Some(_) if is(Empty) =&gt;
getExpiredOffsets(
commitRecordMetadataAndOffset =&gt; currentStateTimestamp .getOrElse(commitRecordMetadataAndOffset.offsetAndMetadata.commitTimestamp)
)
case Some(ConsumerProtocol.PROTOCOL_TYPE) if subscribedTopics.isDefined =&gt;
getExpiredOffsets(
_.offsetAndMetadata.commitTimestamp,
subscribedTopics.get
)
case None =&gt;
getExpiredOffsets(_.offsetAndMetadata.commitTimestamp)
case _ =&gt;
Map()
}
if (expiredOffsets.nonEmpty)
debug(s&quot;Expired offsets from group '$groupId': ${expiredOffsets.keySet}&quot;)
// 将过期位移对应的主题分区从offsets中移除
offsets --= expiredOffsets.keySet
// 返回主题分区对应的过期位移
expiredOffsets
}
```
代码根据消费者组的protocolType类型和组状态调用getExpiredOffsets方法同时决定传入什么样的参数
- 如果消费者组状态是Empty就传入组变更为Empty状态的时间若该时间没有被记录则使用提交位移消息本身的写入时间戳来获取过期位移
- 如果是普通的消费者组类型,且订阅主题信息已知,就传入提交位移消息本身的写入时间戳和订阅主题集合共同确定过期位移值;
- 如果protocolType为None就表示这个消费者组其实是一个Standalone消费者依然是传入提交位移消息本身的写入时间戳来决定过期位移值
- 如果消费者组的状态不符合刚刚说的这些情况,那就说明,没有过期位移值需要被移除。
当确定了要被移除的位移值集合后代码会将它们从offsets中移除然后返回这些被移除的位移值信息。至此方法结束。
## 分区分配策略管理方法
最后我们讨论下消费者组分区分配策略的管理也就是字段supportedProtocols的管理。supportedProtocols是分区分配策略的支持票数这个票数在添加成员、移除成员时会进行相应的更新。
消费者组每次Rebalance的时候都要重新确认本次Rebalance结束之后要使用哪个分区分配策略因此就需要特定的方法来对这些票数进行统计把票数最多的那个策略作为新的策略。
GroupMetadata类中定义了两个方法来做这件事情分别是candidateProtocols和selectProtocol方法。
### 确认消费者组支持的分区分配策略集
首先来看candidateProtocols方法。它的作用是**找出组内所有成员都支持的分区分配策略集**。代码如下:
```
private def candidateProtocols: Set[String] = {
val numMembers = members.size // 获取组内成员数
// 找出支持票数=总成员数的策略,返回它们的名称
supportedProtocols.filter(_._2 == numMembers).map(_._1).toSet
}
```
该方法首先会获取组内的总成员数然后找出supportedProtocols中那些支持票数等于总成员数的分配策略并返回它们的名称。**支持票数等于总成员数的意思,等同于所有成员都支持该策略**。
### 选出消费者组的分区消费分配策略
接下来我们看下selectProtocol方法它的作用是**选出消费者组的分区消费分配策略**。
```
def selectProtocol: String = {
// 如果没有任何成员,自然无法确定选用哪个策略
if (members.isEmpty)
throw new IllegalStateException(&quot;Cannot select protocol for empty group&quot;)
// 获取所有成员都支持的策略集合
val candidates = candidateProtocols
// 让每个成员投票,票数最多的那个策略当选
val (protocol, _) = allMemberMetadata
.map(_.vote(candidates))
.groupBy(identity)
.maxBy { case (_, votes) =&gt; votes.size }
protocol
}
```
这个方法首先会判断组内是否有成员。如果没有任何成员自然就无法确定选用哪个策略了方法就会抛出异常并退出。否则的话代码会调用刚才的candidateProtocols方法获取所有成员都支持的策略集合然后让每个成员投票票数最多的那个策略当选。
你可能会好奇这里的vote方法是怎么实现的呢其实它就是简单地查找而已。我举一个简单的例子来帮助你理解。
比如candidates字段的值是[“策略A”“策略B”]成员1支持[“策略B”“策略A”]成员2支持[“策略A”“策略B”“策略C”]成员3支持[“策略D”“策略B”“策略A”]那么vote方法会将candidates与每个成员的支持列表进行比对找出成员支持列表中第一个包含在candidates中的策略。因此对于这个例子来说成员1投票策略B成员2投票策略A成员3投票策略B。可以看到投票的结果是策略B是两票策略A是1票。所以selectProtocol方法返回策略B作为新的策略。
有一点你需要注意,**成员支持列表中的策略是有顺序的**。这就是说,[“策略B”“策略A”]和[“策略A”“策略B”]是不同的,成员会倾向于选择靠前的策略。
## 总结
今天我们结合GroupMetadata源码学习了Kafka对消费者组元数据的管理主要包括组状态、成员、位移和分区分配策略四个维度。我建议你在课下再仔细地阅读一下这些管理数据的方法对照着源码和注释走一遍完整的操作流程。
另外在这两节课中我没有谈及待决成员列表Pending Members和待决位移Pending Offsets的管理因为这两个元数据项属于中间临时状态因此我没有展开讲不理解这部分代码的话也不会影响我们理解消费者组元数据以及Coordinator是如何使用它们的。不过我建议你可以阅读下与它们相关的代码部分。要知道Kafka是非常喜欢引用中间状态变量来管理各类元数据或状态的。
现在,我们再来简单回顾下这节课的重点。
- 消费者组元数据管理:主要包括对组状态、成员、位移和分区分配策略的管理。
- 组状态管理transitionTo方法负责设置状态is、not和get方法用于查询状态。
- 成员管理add、remove方法用于增减成员has和get方法用于查询特定成员。
- 分区分配策略管理定义了专属方法selectProtocols用于在每轮Rebalance时选举分区分配策略。
<img src="https://static001.geekbang.org/resource/image/a3/e7/a3eafee6b5d17b97f7661c24ccdcd4e7.jpg" alt="">
至此我们花了两节课的时间详细地学习了消费者组元数据及其管理方法的源码。这些操作元数据的方法被上层调用方GroupCoordinator大量使用就像我在开头提到的如果现在我们不彻底掌握这些元数据被操作的手法等我们学到GroupCoordinator代码时就会感到有些吃力所以你一定要好好地学习这两节课。有了这些基础等到学习GroupCoordinator源码时你就能更加深刻地理解它的底层实现原理了。
## 课后讨论
在讲到MemberMetadata时我说过每个成员都有自己的Rebalance超时时间设置那么Kafka是怎么确认消费者组使用哪个成员的超时时间作为整个组的超时时间呢
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,533 @@
<audio id="audio" title="29 | GroupMetadataManager组元数据管理器是个什么东西" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4f/13/4f3cbe72afcd2cde53yy3a10deb18213.mp3"></audio>
你好我是胡夕。今天我们学习GroupMetadataManager类的源码。从名字上来看它是组元数据管理器但是从它提供的功能来看我更愿意将它称作消费者组管理器因为它定义的方法提供的都是添加消费者组、移除组、查询组这样组级别的基础功能。
不过这个类的知名度不像KafkaController、GroupCoordinator那么高你之前可能都没有听说过它。但是它其实是非常重要的消费者组管理类。
GroupMetadataManager类是在消费者组Coordinator组件被创建时被实例化的。这就是说每个Broker在启动过程中都会创建并维持一个GroupMetadataManager实例以实现对该Broker负责的消费者组进行管理。更重要的是生产环境输出日志中的与消费者组相关的大多数信息都和它息息相关。
我举一个简单的例子。你应该见过这样的日志输出:
```
Removed ××× expired offsets in ××× milliseconds.
```
这条日志每10分钟打印一次。你有没有想过它为什么要这么操作呢其实这是由GroupMetadataManager类创建的定时任务引发的。如果你不清楚GroupMetadataManager的原理虽然暂时不会影响你使用但是一旦你在实际环境中看到了有关消费者组的错误日志仅凭日志输出你是无法定位错误原因的。要解决这个问题就只有一个办法**通过阅读源码,彻底搞懂底层实现原理,做到以不变应万变**。
关于这个类,最重要的就是要掌握它是如何管理消费者组的,以及它对内部位移主题的操作方法。这两个都是重磅功能,我们必须要吃透它们的原理,这也是我们这三节课的学习重点。今天,我们先学习它的类定义和管理消费者组的方法。
# 类定义与字段
GroupMetadataManager类定义在coordinator.group包下的同名scala文件中。这个类的代码将近1000行逐行分析的话显然效率不高也没有必要。所以我从类定义和字段、重要方法两个维度给出主要逻辑的代码分析。下面的代码是该类的定义以及我选取的重要字段信息。
```
// brokerId所在Broker的Id
// interBrokerProtocolVersionBroker端参数inter.broker.protocol.version值
// config: 内部位移主题配置类
// replicaManager: 副本管理器类
// zkClient: ZooKeeper客户端
class GroupMetadataManager(
brokerId: Int,
interBrokerProtocolVersion: ApiVersion,
config: OffsetConfig,
replicaManager: ReplicaManager,
zkClient: KafkaZkClient,
time: Time,
metrics: Metrics) extends Logging with KafkaMetricsGroup {
// 压缩器类型。向位移主题写入消息时执行压缩操作
private val compressionType: CompressionType = CompressionType.forId(config.offsetsTopicCompressionCodec.codec)
// 消费者组元数据容器保存Broker管理的所有消费者组的数据
private val groupMetadataCache = new Pool[String, GroupMetadata]
// 位移主题下正在执行加载操作的分区
private val loadingPartitions: mutable.Set[Int] = mutable.Set()
// 位移主题下完成加载操作的分区
private val ownedPartitions: mutable.Set[Int] = mutable.Set()
// 位移主题总分区数
private val groupMetadataTopicPartitionCount = getGroupMetadataTopicPartitionCount
......
}
```
这个类的构造函数需要7个参数后面的time和metrics只是起辅助作用因此我重点解释一下前5个参数的含义。
- brokerId这个参数我们已经无比熟悉了。它是所在Broker的ID值也就是broker.id参数值。
- interBrokerProtocolVersion保存Broker间通讯使用的请求版本。它是Broker端参数inter.broker.protocol.version值。这个参数的主要用途是**确定位移主题消息格式的版本**。
- config这是一个OffsetConfig类型。该类型定义了与位移管理相关的重要参数比如位移主题日志段大小设置、位移主题备份因子、位移主题分区数配置等。
- replicaManager副本管理器类。GroupMetadataManager类使用该字段实现获取分区对象、日志对象以及写入分区消息的目的。
- zkClientZooKeeper客户端。该类中的此字段只有一个目的从ZooKeeper中获取位移主题的分区数。
除了构造函数所需的字段,该类还定义了其他关键字段,我给你介绍几个非常重要的。
**1.compressionType**
**压缩器类型**。Kafka向位移主题写入消息前可以选择对消息执行压缩操作。是否压缩取决于Broker端参数offsets.topic.compression.codec值默认是不进行压缩。如果你的位移主题占用的磁盘空间比较多的话可以考虑启用压缩以节省资源。
**2.groupMetadataCache**
**该字段是GroupMetadataManager类上最重要的属性它****保存这个Broker上GroupCoordinator组件管理的所有消费者组元数据。**它的Key是消费者组名称Value是消费者组元数据也就是GroupMetadata。源码通过该字段实现对消费者组的添加、删除和遍历操作。
**3.loadingPartitions**
**位移主题下正在执行加载操作的分区号集合**。这里需要注意两点首先这些分区都是位移主题分区也就是__consumer_offsets主题下的分区其次所谓的加载是指读取位移主题消息数据填充GroupMetadataCache字段的操作。
**4.ownedPartitions**
**位移主题下完成加载操作的分区号集合**。与loadingPartitions类似的是该字段保存的分区也是位移主题下的分区。和loadingPartitions不同的是它保存的分区都是**已经完成加载操作**的分区。
**5.groupMetadataTopicPartitionCount**
**位移主题的分区数**。它是Broker端参数offsets.topic.num.partitions的值默认是50个分区。若要修改分区数除了变更该参数值之外你也可以手动创建位移主题并指定不同的分区数。
在这些字段中groupMetadataCache是最重要的GroupMetadataManager类大量使用该字段实现对消费者组的管理。接下来我们就重点学习一下该类是如何管理消费者组的。
# 重要方法
管理消费者组包含两个方面对消费者组元数据的管理以及对消费者组位移的管理。组元数据和组位移都是Coordinator端重要的消费者组管理对象。
## 消费者组元数据管理
消费者组元数据管理分为查询获取组信息、添加组、移除组和加载组信息。从代码复杂度来讲,查询获取、移除和添加的逻辑相对简单,加载的过程稍微费事些。我们先说说查询获取。
### 查询获取消费者组元数据
GroupMetadataManager类中查询及获取组数据的方法有很多。大多逻辑简单你一看就能明白比如下面的getGroup方法和getOrMaybeCreateGroup方法
```
// getGroup方法返回给定消费者组的元数据信息。
// 若该组信息不存在返回None
def getGroup(groupId: String): Option[GroupMetadata] = {
Option(groupMetadataCache.get(groupId))
}
// getOrMaybeCreateGroup方法返回给定消费者组的元数据信息。
// 若不存在则视createIfNotExist参数值决定是否需要添加该消费者组
def getOrMaybeCreateGroup(groupId: String, createIfNotExist: Boolean): Option[GroupMetadata] = {
if (createIfNotExist)
// 若不存在且允许添加则添加一个状态是Empty的消费者组元数据对象
Option(groupMetadataCache.getAndMaybePut(groupId, new GroupMetadata(groupId, Empty, time)))
else
Option(groupMetadataCache.get(groupId))
}
```
GroupMetadataManager类的上层组件GroupCoordinator会大量使用这两个方法来获取给定消费者组的数据。这两个方法都会返回给定消费者组的元数据信息但是它们之间是有区别的。
对于getGroup方法而言如果该组信息不存在就返回None而这通常表明消费者组确实不存在或者是该组对应的Coordinator组件变更到其他Broker上了。
而对于getOrMaybeCreateGroup方法而言若组信息不存在就根据createIfNotExist参数值决定是否需要添加该消费者组。而且getOrMaybeCreateGroup方法是在消费者组第一个成员加入组时被调用的用于把组创建出来。
在GroupMetadataManager类中还有一些地方也散落着组查询获取的逻辑。不过它们与这两个方法中的代码大同小异很容易理解课下你可以自己阅读下。
### 移除消费者组元数据
接下来我们看下如何移除消费者组信息。当Broker卸任某些消费者组的Coordinator角色时它需要将这些消费者组从groupMetadataCache中全部移除掉这就是removeGroupsForPartition方法要做的事情。我们看下它的源码
```
def removeGroupsForPartition(offsetsPartition: Int,
onGroupUnloaded: GroupMetadata =&gt; Unit): Unit = {
// 位移主题分区
val topicPartition = new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, offsetsPartition)
info(s&quot;Scheduling unloading of offsets and group metadata from $topicPartition&quot;)
// 创建异步任务,移除组信息和位移信息
scheduler.schedule(topicPartition.toString, () =&gt; removeGroupsAndOffsets)
// 内部方法,用于移除组信息和位移信息
def removeGroupsAndOffsets(): Unit = {
var numOffsetsRemoved = 0
var numGroupsRemoved = 0
inLock(partitionLock) {
// 移除ownedPartitions中特定位移主题分区记录
ownedPartitions.remove(offsetsPartition)
// 遍历所有消费者组信息
for (group &lt;- groupMetadataCache.values) {
// 如果该组信息保存在特定位移主题分区中
if (partitionFor(group.groupId) == offsetsPartition) {
// 执行组卸载逻辑
onGroupUnloaded(group)
// 关键步骤将组信息从groupMetadataCache中移除
groupMetadataCache.remove(group.groupId, group)
// 把消费者组从producer对应的组集合中移除
removeGroupFromAllProducers(group.groupId)
// 更新已移除组计数器
numGroupsRemoved += 1
// 更新已移除位移值计数器
numOffsetsRemoved += group.numOffsets
}
}
}
info(s&quot;Finished unloading $topicPartition. Removed $numOffsetsRemoved cached offsets &quot; +
s&quot;and $numGroupsRemoved cached groups.&quot;)
}
}
```
该方法的主要逻辑是先定义一个内部方法removeGroupsAndOffsets然后创建一个异步任务调用该方法来执行移除消费者组信息和位移信息。
那么,怎么判断要移除哪些消费者组呢?这里的依据就是**传入的位移主题分区**。每个消费者组及其位移的数据都只会保存在位移主题的一个分区下。一旦给定了位移主题分区那么元数据保存在这个位移主题分区下的消费者组就要被移除掉。removeGroupsForPartition方法传入的offsetsPartition参数表示Leader发生变更的位移主题分区因此这些分区保存的消费者组都要从该Broker上移除掉。
具体的执行逻辑是什么呢?我来解释一下。
首先异步任务从ownedPartitions中移除给定位移主题分区。
其次,遍历消费者组元数据缓存中的所有消费者组对象,如果消费者组正是在给定位移主题分区下保存的,就依次执行下面的步骤。
- 第1步调用onGroupUnloaded方法执行组卸载逻辑。这个方法的逻辑是上层组件GroupCoordinator传过来的。它主要做两件事情将消费者组状态变更到Dead状态封装异常表示Coordinator已发生变更然后调用回调函数返回。
- 第2步把消费者组信息从groupMetadataCache中移除。这一步非常关键目的是彻底清除掉该组的“痕迹”。
- 第3步把消费者组从producer对应的组集合中移除。这里的producer是给Kafka事务用的。
- 第4步增加已移除组计数器。
- 第5步更新已移除位移值计数器。
到这里,方法结束。
### 添加消费者组元数据
下面我们学习添加消费者组的管理方法即addGroup。它特别简单仅仅是调用putIfNotExists将给定组添加进groupMetadataCache中而已。代码如下
```
def addGroup(group: GroupMetadata): GroupMetadata = {
val currentGroup = groupMetadataCache.putIfNotExists(group.groupId, group)
if (currentGroup != null) {
currentGroup
} else {
group
}
}
```
### 加载消费者组元数据
现在轮到相对复杂的加载消费者组了。GroupMetadataManager类中定义了一个loadGroup方法执行对应的加载过程。
```
private def loadGroup(
group: GroupMetadata, offsets: Map[TopicPartition, CommitRecordMetadataAndOffset],
pendingTransactionalOffsets: Map[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]]): Unit = {
trace(s&quot;Initialized offsets $offsets for group ${group.groupId}&quot;)
// 初始化消费者组的位移信息
group.initializeOffsets(offsets, pendingTransactionalOffsets.toMap)
// 调用addGroup方法添加消费者组
val currentGroup = addGroup(group)
if (group != currentGroup)
debug(s&quot;Attempt to load group ${group.groupId} from log with generation ${group.generationId} failed &quot; +
s&quot;because there is already a cached group with generation ${currentGroup.generationId}&quot;)
}
```
该方法的逻辑有两步。
第1步通过initializeOffsets方法将位移值添加到offsets字段标识的消费者组提交位移元数据中实现加载消费者组订阅分区提交位移的目的。
第2步调用addGroup方法将该消费者组元数据对象添加进消费者组元数据缓存实现加载消费者组元数据的目的。
## 消费者组位移管理
除了消费者组的管理GroupMetadataManager类的另一大类功能是提供消费者组位移的管理主要包括位移数据的保存和查询。我们总说位移主题是保存消费者组位移信息的地方。实际上**当消费者组程序在查询位移时Kafka总是从内存中的位移缓存数据查询而不会直接读取底层的位移主题数据。**
### 保存消费者组位移
storeOffsets方法负责保存消费者组位移。该方法的代码很长我先画一张图来展示下它的完整流程帮助你建立起对这个方法的整体认知。接下来我们再从它的方法签名和具体代码两个维度来具体了解一下它的执行逻辑。
<img src="https://static001.geekbang.org/resource/image/76/e6/76116b323c0c7b024ebe95c3c08e6ae6.jpg" alt="">
我先给你解释一下保存消费者组位移的全部流程。
**首先**storeOffsets方法要过滤出满足特定条件的待保存位移信息。是否满足特定条件要看validateOffsetMetadataLength方法的返回值。这里的特定条件是指位移提交记录中的自定义数据大小要小于Broker端参数offset.metadata.max.bytes的值默认值是4KB。
如果没有一个分区满足条件就构造OFFSET_METADATA_TOO_LARGE异常并调用回调函数。这里的回调函数执行发送位移提交Response的动作。
倘若有分区满足了条件,**接下来**方法会判断当前Broker是不是该消费者组的Coordinator如果不是的话就构造NOT_COORDINATOR异常并提交给回调函数如果是的话就构造位移主题消息并将消息写入进位移主题下。
**然后**调用一个名为putCacheCallback的内置方法填充groupMetadataCache中各个消费者组元数据中的位移值**最后**,调用回调函数返回。
接下来我们结合代码来查看下storeOffsets方法的实现逻辑。
首先我们看下它的方法签名。既然是保存消费者组提交位移的,那么,我们就要知道上层调用方都给这个方法传入了哪些参数。
```
// group消费者组元数据
// consumerId消费者组成员ID
// offsetMetadata待保存的位移值按照分区分组
// responseCallback处理完成后的回调函数
// producerId事务型Producer ID
// producerEpoch事务型Producer Epoch值
def storeOffsets(
group: GroupMetadata,
consumerId: String,
offsetMetadata: immutable.Map[TopicPartition, OffsetAndMetadata],
responseCallback: immutable.Map[TopicPartition, Errors] =&gt; Unit,
producerId: Long = RecordBatch.NO_PRODUCER_ID,
producerEpoch: Short = RecordBatch.NO_PRODUCER_EPOCH): Unit = {
......
}
```
这个方法接收6个参数它们的含义我都用注释的方式标注出来了。producerId和producerEpoch这两个参数是与Kafka事务相关的你简单了解下就行。我们要重点掌握前面4个参数的含义。
- group消费者组元数据信息。该字段的类型就是我们之前学到的GroupMetadata类。
- consumerId消费者组成员ID仅用于DEBUG调试。
- offsetMetadata待保存的位移值按照分区分组。
- responseCallback位移保存完成后需要执行的回调函数。
接下来我们看下storeOffsets的代码。为了便于你理解我删除了与Kafka事务操作相关的部分。
```
// 过滤出满足特定条件的待保存位移数据
val filteredOffsetMetadata = offsetMetadata.filter { case (_, offsetAndMetadata) =&gt;
validateOffsetMetadataLength(offsetAndMetadata.metadata)
}
......
val isTxnOffsetCommit = producerId != RecordBatch.NO_PRODUCER_ID
// 如果没有任何分区的待保存位移满足特定条件
if (filteredOffsetMetadata.isEmpty) {
// 构造OFFSET_METADATA_TOO_LARGE异常并调用responseCallback返回
val commitStatus = offsetMetadata.map { case (k, _) =&gt; k -&gt; Errors.OFFSET_METADATA_TOO_LARGE }
responseCallback(commitStatus)
None
} else {
// 查看当前Broker是否为给定消费者组的Coordinator
getMagic(partitionFor(group.groupId)) match {
// 如果是Coordinator
case Some(magicValue) =&gt;
val timestampType = TimestampType.CREATE_TIME
val timestamp = time.milliseconds()
// 构造位移主题的位移提交消息
val records = filteredOffsetMetadata.map { case (topicPartition, offsetAndMetadata) =&gt;
val key = GroupMetadataManager.offsetCommitKey(group.groupId, topicPartition)
val value = GroupMetadataManager.offsetCommitValue(offsetAndMetadata, interBrokerProtocolVersion)
new SimpleRecord(timestamp, key, value)
}
val offsetTopicPartition = new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, partitionFor(group.groupId))
// 为写入消息创建内存Buffer
val buffer = ByteBuffer.allocate(AbstractRecords.estimateSizeInBytes(magicValue, compressionType, records.asJava))
if (isTxnOffsetCommit &amp;&amp; magicValue &lt; RecordBatch.MAGIC_VALUE_V2)
throw Errors.UNSUPPORTED_FOR_MESSAGE_FORMAT.exception(&quot;Attempting to make a transaction offset commit with an invalid magic: &quot; + magicValue)
val builder = MemoryRecords.builder(buffer, magicValue, compressionType, timestampType, 0L, time.milliseconds(),
producerId, producerEpoch, 0, isTxnOffsetCommit, RecordBatch.NO_PARTITION_LEADER_EPOCH)
records.foreach(builder.append)
val entries = Map(offsetTopicPartition -&gt; builder.build())
// putCacheCallback函数定义......
if (isTxnOffsetCommit) {
......
} else {
group.inLock {
group.prepareOffsetCommit(offsetMetadata)
}
}
// 写入消息到位移主题同时调用putCacheCallback方法更新消费者元数据
appendForGroup(group, entries, putCacheCallback)
// 如果是Coordinator
case None =&gt;
// 构造NOT_COORDINATOR异常并提交给responseCallback方法
val commitStatus = offsetMetadata.map {
case (topicPartition, _) =&gt;
(topicPartition, Errors.NOT_COORDINATOR)
}
responseCallback(commitStatus)
None
}
}
```
我为方法的关键步骤都标注了注释具体流程前面我也介绍过了应该很容易理解。不过这里还需要注意两点也就是appendForGroup和putCacheCallback方法。前者是向位移主题写入消息后者是填充元数据缓存的。我们结合代码来学习下。
appendForGroup方法负责写入消息到位移主题同时传入putCacheCallback方法更新消费者元数据。以下是它的代码
```
private def appendForGroup(
group: GroupMetadata,
records: Map[TopicPartition, MemoryRecords],
callback: Map[TopicPartition, PartitionResponse] =&gt; Unit): Unit = {
replicaManager.appendRecords(
timeout = config.offsetCommitTimeoutMs.toLong,
requiredAcks = config.offsetCommitRequiredAcks,
internalTopicsAllowed = true,
origin = AppendOrigin.Coordinator,
entriesPerPartition = records,
delayedProduceLock = Some(group.lock),
responseCallback = callback)
}
```
可以看到该方法就是调用ReplicaManager的appendRecords方法将消息写入到位移主题中。
下面我们再关注一下putCacheCallback方法的实现也就是将写入的位移值填充到缓存中。我先画一张图来展示下putCacheCallback的逻辑。
<img src="https://static001.geekbang.org/resource/image/bc/42/bc2fcf199a685a5cc6d32846c53c3042.jpg" alt="">
现在,我们结合代码,学习下它的逻辑实现。
```
def putCacheCallback(responseStatus: Map[TopicPartition, PartitionResponse]): Unit = {
// 确保消息写入到指定位移主题分区,否则抛出异常
if (responseStatus.size != 1 || !responseStatus.contains(offsetTopicPartition))
throw new IllegalStateException(&quot;Append status %s should only have one partition %s&quot;
.format(responseStatus, offsetTopicPartition))
// 更新已提交位移数指标
offsetCommitsSensor.record(records.size)
val status = responseStatus(offsetTopicPartition)
val responseError = group.inLock {
// 写入结果没有错误
if (status.error == Errors.NONE) {
// 如果不是Dead状态
if (!group.is(Dead)) {
filteredOffsetMetadata.foreach { case (topicPartition, offsetAndMetadata) =&gt;
if (isTxnOffsetCommit)
......
else
// 调用GroupMetadata的onOffsetCommitAppend方法填充元数据
group.onOffsetCommitAppend(topicPartition, CommitRecordMetadataAndOffset(Some(status.baseOffset), offsetAndMetadata))
}
}
Errors.NONE
// 写入结果有错误
} else {
if (!group.is(Dead)) {
......
filteredOffsetMetadata.foreach { case (topicPartition, offsetAndMetadata) =&gt;
if (isTxnOffsetCommit)
group.failPendingTxnOffsetCommit(producerId, topicPartition)
else
// 取消未完成的位移消息写入
group.failPendingOffsetWrite(topicPartition, offsetAndMetadata)
}
}
......
// 确认异常类型
status.error match {
case Errors.UNKNOWN_TOPIC_OR_PARTITION
| Errors.NOT_ENOUGH_REPLICAS
| Errors.NOT_ENOUGH_REPLICAS_AFTER_APPEND =&gt;
Errors.COORDINATOR_NOT_AVAILABLE
case Errors.NOT_LEADER_FOR_PARTITION
| Errors.KAFKA_STORAGE_ERROR =&gt;
Errors.NOT_COORDINATOR
case Errors.MESSAGE_TOO_LARGE
| Errors.RECORD_LIST_TOO_LARGE
| Errors.INVALID_FETCH_SIZE =&gt;
Errors.INVALID_COMMIT_OFFSET_SIZE
case other =&gt; other
}
}
}
// 利用异常类型构建提交返回状态
val commitStatus = offsetMetadata.map { case (topicPartition, offsetAndMetadata) =&gt;
if (validateOffsetMetadataLength(offsetAndMetadata.metadata))
(topicPartition, responseError)
else
(topicPartition, Errors.OFFSET_METADATA_TOO_LARGE)
}
// 调用回调函数
responseCallback(commitStatus)
}
```
putCacheCallback方法的主要目的是将多个消费者组位移值填充到GroupMetadata的offsets元数据缓存中。
**首先**,该方法要确保位移消息写入到指定位移主题分区,否则就抛出异常。
**之后**,更新已提交位移数指标,然后判断写入结果是否有错误。
如果没有错误只要组状态不是Dead状态就调用GroupMetadata的onOffsetCommitAppend方法填充元数据。onOffsetCommitAppend方法的主体逻辑是将消费者组订阅分区的位移值写入到offsets字段保存的集合中。当然如果状态是Dead则什么都不做。
如果刚才的写入结果有错误那么就通过failPendingOffsetWrite方法取消未完成的位移消息写入。
**接下来**代码要将日志写入的异常类型转换成表征提交状态错误的异常类型。具体来说就是将UNKNOWN_TOPIC_OR_PARTITION、NOT_LEADER_FOR_PARTITION和MESSAGE_TOO_LARGE这样的异常转换到COORDINATOR_NOT_AVAILABLE和NOT_COORDINATOR这样的异常。之后再将这些转换后的异常封装进commitStatus字段中传给回调函数。
**最后**,调用回调函数返回。至此,方法结束。
好了保存消费者组位移信息的storeOffsets方法我们就学完了它的关键逻辑是构造位移主题消息并写入到位移主题然后将位移值填充到消费者组元数据中。
### 查询消费者组位移
现在我再说说查询消费者组位移也就是getOffsets方法的代码实现。比起storeOffsets这个方法要更容易理解。我们看下它的源码
```
def getOffsets(
groupId: String,
requireStable: Boolean,
topicPartitionsOpt: Option[Seq[TopicPartition]]): Map[TopicPartition, PartitionData] = {
......
// 从groupMetadataCache字段中获取指定消费者组的元数据
val group = groupMetadataCache.get(groupId)
// 如果没有组数据,返回空数据
if (group == null) {
topicPartitionsOpt.getOrElse(Seq.empty[TopicPartition]).map { topicPartition =&gt;
val partitionData = new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
Optional.empty(), &quot;&quot;, Errors.NONE)
topicPartition -&gt; partitionData
}.toMap
// 如果存在组数据
} else {
group.inLock {
// 如果组处于Dead状态则返回空数据
if (group.is(Dead)) {
topicPartitionsOpt.getOrElse(Seq.empty[TopicPartition]).map { topicPartition =&gt;
val partitionData = new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
Optional.empty(), &quot;&quot;, Errors.NONE)
topicPartition -&gt; partitionData
}.toMap
} else {
val topicPartitions = topicPartitionsOpt.getOrElse(group.allOffsets.keySet)
topicPartitions.map { topicPartition =&gt;
if (requireStable &amp;&amp; group.hasPendingOffsetCommitsForTopicPartition(topicPartition)) {
topicPartition -&gt; new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
Optional.empty(), &quot;&quot;, Errors.UNSTABLE_OFFSET_COMMIT)
} else {
val partitionData = group.offset(topicPartition) match {
// 如果没有该分区位移数据,返回空数据
case None =&gt;
new PartitionData(OffsetFetchResponse.INVALID_OFFSET,
Optional.empty(), &quot;&quot;, Errors.NONE)
// 从消费者组元数据中返回指定分区的位移数据
case Some(offsetAndMetadata) =&gt;
new PartitionData(offsetAndMetadata.offset,
offsetAndMetadata.leaderEpoch, offsetAndMetadata.metadata, Errors.NONE)
}
topicPartition -&gt; partitionData
}
}.toMap
}
}
}
}
```
getOffsets方法首先会读取groupMetadataCache中的组元数据如果不存在对应的记录则返回空数据集如果存在就接着判断组是否处于Dead状态。
如果是Dead状态就说明消费者组已经被销毁了位移数据也被视为不可用了依然返回空数据集若状态不是Dead就提取出消费者组订阅的分区信息再依次为它们获取对应的位移数据并返回。至此方法结束。
# 总结
今天我们学习了GroupMetadataManager类的源码。作为消费者组管理器它负责管理消费者组的方方面面。其中非常重要的两个管理功能是消费者组元数据管理和消费者组位移管理分别包括查询获取、移除、添加和加载消费者组元数据以及保存和查询消费者组位移这些方法是上层组件GroupCoordinator倚重的重量级功能载体你一定要彻底掌握它们。
我画了一张思维导图,帮助你复习一下今天的重点内容。
<img src="https://static001.geekbang.org/resource/image/eb/5a/eb8fe45e1d152e2ac9cb52c81390265a.jpg" alt="">
实际上GroupMetadataManager类的地位举足轻重。虽然它在Coordinator组件中不显山不露水但却是一些线上问题的根源所在。
我再跟你分享一个小案例。
之前我碰到过一个问题在消费者组成员超多的情况下无法完成位移加载这导致Consumer端总是接收到Marking the coordinator dead的错误。
当时我查遍各种资料都无法定位问题最终还是通过阅读源码发现是这个类的doLoadGroupsAndOffsets方法中创建的buffer过小导致的。后来通过调大offsets.load.buffer.size参数值我们顺利地解决了问题。
试想一下如果当时没有阅读这部分的源码仅凭日志我们肯定无法解决这个问题。因此我们花三节课的时间专门阅读GroupMetadataManager类源码是非常值得的。下节课我将带你继续研读GroupMetadataManager源码去探寻有关位移主题的那些代码片段。
# 课后讨论
请思考这样一个问题在什么场景下需要移除GroupMetadataManager中保存的消费者组记录
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,310 @@
<audio id="audio" title="30 | GroupMetadataManager位移主题保存的只是位移吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/49/1e/498106d7ca8a7d82470d6bd49ed5451e.mp3"></audio>
你好,我是胡夕。今天,我们学习位移主题管理的源码。
位移主题即__consumer_offsets是Kafka的两大内部主题之一另一个内部主题是管理Kafka事务的名字是__transaction_state用于保存Kafka事务的状态信息
Kafka创建位移主题的目的是**保存消费者组的注册消息和提交位移消息**。前者保存能够标识消费者组的身份信息后者保存消费者组消费的进度信息。在Kafka源码中GroupMetadataManager类定义了操作位移主题消息类型以及操作位移主题的方法。该主题下都有哪些消息类型是我们今天学习的重点。
说到位移主题你是否对它里面的消息内容感到很好奇呢我见过很多人直接使用kafka-console-consumer命令消费该主题想要知道里面保存的内容可输出的结果却是一堆二进制乱码。其实如果你不阅读今天的源码是无法知晓如何通过命令行工具查询该主题消息的内容的。因为这些知识只包含在源码中官方文档并没有涉及到。
好了我不卖关子了。简单来说你在运行kafka-console-consumer命令时必须指定`--formatter "kafka.coordinator.group.GroupMetadataManager\$OffsetsMessageFormatter"`才能查看提交的位移消息数据。类似地你必须指定GroupMetadataMessageFormatter才能读取消费者组的注册消息数据。
今天我们就来学习位移主题下的这两大消息类型。除此之外我还会给你介绍消费者组是如何寻找自己的Coordinator的。毕竟对位移主题进行读写的前提就是要能找到正确的Coordinator所在。
## 消息类型
位移主题有两类消息:**消费者组注册消息**Group Metadata和**消费者组的已提交位移消息**Offset Commit。很多人以为位移主题里面只保存消费者组位移这是错误的它还保存了消费者组的注册信息或者说是消费者组的元数据。这里的元数据主要是指消费者组名称以及成员分区消费分配方案。
在分别介绍这两类消息的实现代码之前我们先看下Kafka为它们定义的公共服务代码。毕竟它们是这两类消息都会用到的代码组件。这些公共代码主要由两部分组成GroupTopicPartition类和BaseKey接口。
我们首先来看POJO类**GroupTopicPartition**。它的作用是封装&lt;消费者组名,主题,分区号&gt;的三元组,代码如下:
```
case class GroupTopicPartition(group: String, topicPartition: TopicPartition) {
def this(group: String, topic: String, partition: Int) =
this(group, new TopicPartition(topic, partition))
// toString方法......
}
```
显然,这个类就是一个数据容器类。我们后面在学习已提交位移消息时,还会看到它的身影。
其次是**BaseKey接口**它表示位移主题的两类消息的Key类型。强调一下无论是该主题下的哪类消息都必须定义Key。这里的BaseKey接口定义的就是这两类消息的Key类型。我们看下它的代码
```
trait BaseKey{
def version: Short // 消息格式版本
def key: Any // 消息key
}
```
这里的version是Short型的消息格式版本。随着Kafka代码的不断演进位移主题的消息格式也在不断迭代因此这里出现了版本号的概念。至于key字段它保存的是实际的Key值。在Scala中Any类型类似于Java中的Object类表示该值可以是任意类型。稍后讲到具体的消息类型时你就会发现这两类消息的Key类型其实是不同的数据类型。
好了基础知识铺垫完了有了对GroupTopicPartition和BaseKey的理解你就能明白位移主题的具体消息类型是如何构造Key的。
接下来我们开始学习具体消息类型的实现代码包括注册消息、提交位移消息和Tombstone消息。由于消费者组必须要先向Coordinator组件注册然后才能提交位移所以我们先阅读注册消息的代码。
### 注册消息
所谓的注册消息,就是指消费者组向位移主题写入注册类的消息。该类消息的写入时机有两个。
- **所有成员都加入组后**Coordinator向位移主题写入注册消息只是该消息不含分区消费分配方案
- **Leader成员发送方案给Coordinator后**当Leader成员将分区消费分配方案发给Coordinator后Coordinator写入携带分配方案的注册消息。
我们首先要知道注册消息的Key是如何定义以及如何被封装到消息里的。
Key的定义在GroupMetadataKey类代码中
```
case class GroupMetadataKey(version: Short, key: String) extends BaseKey {
override def toString: String = key
}
```
该类的key字段是一个字符串类型保存的是消费者组的名称。可见**注册消息的Key就是消费者组名**。
GroupMetadataManager对象有个groupMetadataKey方法负责将注册消息的Key转换成字节数组用于后面构造注册消息。这个方法的代码如下
```
def groupMetadataKey(group: String): Array[Byte] = {
val key = new Struct(CURRENT_GROUP_KEY_SCHEMA)
key.set(GROUP_KEY_GROUP_FIELD, group)
// 构造一个ByteBuffer对象容纳version和key数据
val byteBuffer = ByteBuffer.allocate(2 /* version */ + key.sizeOf)
byteBuffer.putShort(CURRENT_GROUP_KEY_SCHEMA_VERSION)
key.writeTo(byteBuffer)
byteBuffer.array()
}
```
该方法首先会接收消费者组名构造ByteBuffer对象然后依次向Buffer写入Short型的消息格式版本以及消费者组名最后返回该Buffer底层的字节数组。
你不用关心这里的格式版本变量以及Struct类型都是怎么实现的因为它们不是我们理解位移主题内部原理的关键。你需要掌握的是**注册消息的Key和Value都是怎么定义的**。
接下来我们就来了解下消息体Value的代码实现。既然有groupMetadataKey方法那么源码也提供了相应的groupMetadataValue方法。它的目的是**将消费者组重要的元数据写入到字节数组**。我们看下它的代码实现:
```
def groupMetadataValue(
groupMetadata: GroupMetadata, // 消费者组元数据对象
assignment: Map[String, Array[Byte]], // 分区消费分配方案
apiVersion: ApiVersion // Kafka API版本号
): Array[Byte] = {
// 确定消息格式版本以及格式结构
val (version, value) = {
if (apiVersion &lt; KAFKA_0_10_1_IV0)
(0.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V0))
else if (apiVersion &lt; KAFKA_2_1_IV0)
(1.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V1))
else if (apiVersion &lt; KAFKA_2_3_IV0)
(2.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V2))
else
(3.toShort, new Struct(GROUP_METADATA_VALUE_SCHEMA_V3))
}
// 依次写入消费者组主要的元数据信息
// 包括协议类型、Generation ID、分区分配策略和Leader成员ID
value.set(PROTOCOL_TYPE_KEY, groupMetadata.protocolType.getOrElse(&quot;&quot;))
value.set(GENERATION_KEY, groupMetadata.generationId)
value.set(PROTOCOL_KEY, groupMetadata.protocolName.orNull)
value.set(LEADER_KEY, groupMetadata.leaderOrNull)
// 写入最近一次状态变更时间戳
if (version &gt;= 2)
value.set(CURRENT_STATE_TIMESTAMP_KEY, groupMetadata.currentStateTimestampOrDefault)
// 写入各个成员的元数据信息
// 包括成员ID、client.id、主机名以及会话超时时间
val memberArray = groupMetadata.allMemberMetadata.map { memberMetadata =&gt;
val memberStruct = value.instance(MEMBERS_KEY)
memberStruct.set(MEMBER_ID_KEY, memberMetadata.memberId)
memberStruct.set(CLIENT_ID_KEY, memberMetadata.clientId)
memberStruct.set(CLIENT_HOST_KEY, memberMetadata.clientHost)
memberStruct.set(SESSION_TIMEOUT_KEY, memberMetadata.sessionTimeoutMs)
// 写入Rebalance超时时间
if (version &gt; 0)
memberStruct.set(REBALANCE_TIMEOUT_KEY, memberMetadata.rebalanceTimeoutMs)
// 写入用于静态消费者组管理的Group Instance ID
if (version &gt;= 3)
memberStruct.set(GROUP_INSTANCE_ID_KEY, memberMetadata.groupInstanceId.orNull)
// 必须定义分区分配策略,否则抛出异常
val protocol = groupMetadata.protocolName.orNull
if (protocol == null)
throw new IllegalStateException(&quot;Attempted to write non-empty group metadata with no defined protocol&quot;)
// 写入成员消费订阅信息
val metadata = memberMetadata.metadata(protocol)
memberStruct.set(SUBSCRIPTION_KEY, ByteBuffer.wrap(metadata))
val memberAssignment = assignment(memberMetadata.memberId)
assert(memberAssignment != null)
// 写入成员消费分配信息
memberStruct.set(ASSIGNMENT_KEY, ByteBuffer.wrap(memberAssignment))
memberStruct
}
value.set(MEMBERS_KEY, memberArray.toArray)
// 向Buffer依次写入版本信息和以上写入的元数据信息
val byteBuffer = ByteBuffer.allocate(2 /* version */ + value.sizeOf)
byteBuffer.putShort(version)
value.writeTo(byteBuffer)
// 返回Buffer底层的字节数组
byteBuffer.array()
}
```
代码比较长,我结合一张图来帮助你理解这个方法的执行逻辑。
<img src="https://static001.geekbang.org/resource/image/03/07/032bdb247859f796a5ca21c3db710007.jpg" alt="">
第1步代码根据传入的apiVersion字段确定要使用哪个格式版本并创建对应版本的结构体Struct来保存这些元数据。apiVersion的取值是Broker端参数inter.broker.protocol.version的值。你打开Kafka官网的话就可以看到这个参数的值永远指向当前最新的Kafka版本。
第2步代码依次向结构体写入消费者组的协议类型Protocol Type、Generation ID、分区分配策略Protocol Name和Leader成员ID。在学习GroupMetadata时我说过对于普通的消费者组而言协议类型就是"consumer"字符串,分区分配策略可能是"range""round-robin"等。之后代码还会为格式版本≥2的结构体写入消费者组状态最近一次变更的时间戳。
第3步遍历消费者组的所有成员为每个成员构建专属的结构体对象并依次向结构体写入成员的ID、Client ID、主机名以及会话超时时间信息。对于格式版本≥0的结构体代码要写入成员配置的Rebalance超时时间而对于格式版本≥3的结构体代码还要写入用于静态消费者组管理的Group Instance ID。待这些都做完之后groupMetadataValue方法必须要确保消费者组选出了分区分配策略否则就抛出异常。再之后方法依次写入成员消费订阅信息和成员消费分配信息。
第4步代码向Buffer依次写入版本信息和刚刚说到的写入的元数据信息并返回Buffer底层的字节数组。至此方法逻辑结束。
关于注册消息Key和Value的内容我就介绍完了。为了帮助你更直观地理解注册消息到底包含了什么数据我再用一张图向你展示一下它们的构成。
<img src="https://static001.geekbang.org/resource/image/4d/8f/4d5ecbdc21d5bb29d054443e31eab28f.jpg" alt="">
这张图完整地总结了groupMetadataKey和groupMetadataValue方法要生成的注册消息内容。灰色矩形中的字段表示可选字段有可能不会包含在Value中。
### 已提交位移消息
接下来我们再学习一下提交位移消息的Key和Value构成。
OffsetKey类定义了提交位移消息的Key值代码如下
```
case class OffsetKey(version: Short, key: GroupTopicPartition) extends BaseKey {
override def toString: String = key.toString
}
```
可见这类消息的Key是一个GroupTopicPartition类型也就是&lt;消费者组名,主题,分区号&gt;三元组。
offsetCommitKey方法负责将这个三元组转换成字节数组用于后续构造提交位移消息。
```
def offsetCommitKey(
group: String, // 消费者组名
topicPartition: TopicPartition // 主题 + 分区号
): Array[Byte] = {
// 创建结构体,依次写入消费者组名、主题和分区号
val key = new Struct(CURRENT_OFFSET_KEY_SCHEMA)
key.set(OFFSET_KEY_GROUP_FIELD, group)
key.set(OFFSET_KEY_TOPIC_FIELD, topicPartition.topic)
key.set(OFFSET_KEY_PARTITION_FIELD, topicPartition.partition)
// 构造ByteBuffer写入格式版本和结构体
val byteBuffer = ByteBuffer.allocate(2 /* version */ + key.sizeOf)
byteBuffer.putShort(CURRENT_OFFSET_KEY_SCHEMA_VERSION)
key.writeTo(byteBuffer)
// 返回字节数组
byteBuffer.array()
}
```
该方法接收三元组中的数据然后创建一个结构体对象依次写入消费者组名、主题和分区号。接下来构造ByteBuffer写入格式版本和结构体最后返回它底层的字节数组。
说完了Key我们看下Value的定义。
offsetCommitValue方法决定了Value中都有哪些元素我们一起看下它的代码。这里我只列出了最新版本对应的结构体对象其他版本要写入的元素大同小异课下你可以阅读下其他版本的结构体内容也就是我省略的if分支下的代码。
```
def offsetCommitValue(offsetAndMetadata: OffsetAndMetadata,
apiVersion: ApiVersion): Array[Byte] = {
// 确定消息格式版本以及创建对应的结构体对象
val (version, value) = {
if (......) {
......
} else {
val value = new Struct(OFFSET_COMMIT_VALUE_SCHEMA_V3)
// 依次写入位移值、Leader Epoch值、自定义元数据以及时间戳
value.set(
OFFSET_VALUE_OFFSET_FIELD_V3, offsetAndMetadata.offset)
value.set(OFFSET_VALUE_LEADER_EPOCH_FIELD_V3,
offsetAndMetadata.leaderEpoch.orElse(RecordBatch.NO_PARTITION_LEADER_EPOCH))
value.set(OFFSET_VALUE_METADATA_FIELD_V3, offsetAndMetadata.metadata)
value.set(OFFSET_VALUE_COMMIT_TIMESTAMP_FIELD_V3, offsetAndMetadata.commitTimestamp)
(3, value)
}
}
// 构建ByteBuffer写入消息格式版本和结构体
val byteBuffer = ByteBuffer.allocate(2 /* version */ + value.sizeOf)
byteBuffer.putShort(version.toShort)
value.writeTo(byteBuffer)
// 返回ByteBuffer底层字节数组
byteBuffer.array()
}
```
offsetCommitValue方法首先确定消息格式版本以及创建对应的结构体对象。对于当前最新版本V3而言结构体的元素包括位移值、Leader Epoch值、自定义元数据和时间戳。如果我们使用Java Consumer API的话那么在提交位移时这个自定义元数据一般是空。
接下来构建ByteBuffer写入消息格式版本和结构体。
最后返回ByteBuffer底层字节数组。
与注册消息的消息体相比提交位移消息的Value要简单得多。我再用一张图展示一下提交位移消息的Key、Value构成。
<img src="https://static001.geekbang.org/resource/image/90/bd/90f52b9fbf2b8daced15717aafdd24bd.jpg" alt="">
### Tombstone消息
关于位移主题Kafka源码中还存在一类消息那就是Tombstone消息。其实它并没有任何稀奇之处就是Value为null的消息。因此注册消息和提交位移消息都有对应的Tombstone消息。这个消息的主要作用是让Kafka识别哪些Key对应的消息是可以被删除的有了它Kafka就能保证内部位移主题不会持续增加磁盘占用空间。
你可以看下下面两行代码它们分别表示两类消息对应的Tombstone消息。
```
// 提交位移消息对应的Tombstone消息
tombstones += new SimpleRecord(timestamp, commitKey, null)
// 注册消息对应的Tombstone消息
tombstones += new SimpleRecord(timestamp, groupMetadataKey, null)
```
无论是哪类消息,**它们的Value字段都是null**。一旦注册消息中出现了Tombstone消息就表示Kafka可以将该消费者组元数据从位移主题中删除一旦提交位移消息中出现了Tombstone就表示Kafka能够将该消费者组在某主题分区上的位移提交数据删除。
## 如何确定Coordinator
接下来我们要再学习一下位移主题和消费者组Coordinator之间的关系。**Coordinator组件是操作位移主题的唯一组件它在内部对位移主题进行读写操作**。
每个Broker在启动时都会启动Coordinator组件但是一个消费者组只能被一个Coordinator组件所管理。Kafka是如何确定哪台Broker上的Coordinator组件为消费者组服务呢答案是位移主题某个特定分区Leader副本所在的Broker被选定为指定消费者组的Coordinator。
那么这个特定分区是怎么计算出来的呢我们来看GroupMetadataManager类的partitionFor方法代码
```
def partitionFor(groupId: String): Int = Utils.abs(groupId.hashCode) % groupMetadataTopicPartitionCount
```
看到了吧,消费者组名哈希值与位移主题分区数求模的绝对值结果,就是该消费者组要写入位移主题的目标分区。
假设位移主题默认是50个分区我们的消费者组名是“testgroup”因此Math.abs(“testgroup”.hashCode % 50)的结果是27那么目标分区号就是27。也就是说这个消费者组的注册消息和提交位移消息都会写入到位移主题的分区27中而分区27的Leader副本所在的Broker就成为该消费者组的Coordinator。
## 总结
Kafka内部位移主题是Coordinator端用来保存和记录消费者组信息的重要工具。具体而言消费者组信息包括消费者组元数据以及已提交位移它们分别对应于我们今天讲的位移主题中的注册消息和已提交位移消息。前者定义了消费者组的元数据信息包括组名、成员列表和分区消费分配方案后者则是消费者组各个成员提交的位移值。这两部分信息共同构成了位移主题的消息类型。
除了消息类型我还介绍了消费者组确定Coordinator端的代码。明白了这一点下次你的消费者组成员出现问题的时候你就会知道要去哪台Broker上去查找相应的日志了。
我们来回顾一下这节课的重点。
- 位移主题即__consumer_offsets。该主题是内部主题默认有50个分区Kafka负责将其创建出来因此你不需要亲自执行创建主题操作。
- 消息类型:位移主题分为注册消息和已提交位移消息。
- Tombstone消息Value为null的位移主题消息用于清除消费者组已提交的位移值和注册信息。
- Coordinator确认原则消费者组名的哈希值与位移主题分区数求模的绝对值即为目标分区目标分区Leader副本所在的Broker即为Coordinator。
<img src="https://static001.geekbang.org/resource/image/03/e8/03843d5742157064dbb8bd227b9fb7e8.jpg" alt="">
定义了消息格式明确了Coordinator下一步就是Coordinator对位移主题进行读写操作了。具体来说就是构建今天我们所学的两类消息并将其序列化成字节数组写入到位移主题以及从位移主题中读取出字节数组并反序列化成对应的消息类型。下节课我们一起研究下这个问题。
## 课后讨论
请你根据今天的内容用kafka-console-consumer脚本去读取一下你线上环境中位移主题的已提交位移消息并结合readOffsetMessageValue方法的源码说一下输出中的每个字段都是什么含义。
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,379 @@
<audio id="audio" title="31 | GroupMetadataManager查询位移时不用读取位移主题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/e8/cc65d5cd5e47f8262f73f51940008be8.mp3"></audio>
你好,我是胡夕。
上节课,我们学习了位移主题中的两类消息:**消费者组注册消息**和**消费者组已提交位移消息**。今天,我们接着学习位移主题,重点是掌握写入位移主题和读取位移主题。
我们总说,位移主题是个神秘的主题,除了它并非我们亲自创建之外,它的神秘之处还体现在,它的读写也不由我们控制。默认情况下,我们没法向这个主题写入消息,而且直接读取该主题的消息时,看到的更是一堆乱码。因此,今天我们学习一下读写位移主题,这正是去除它神秘感的重要一步。
## 写入位移主题
我们先来学习一下位移主题的写入。在[第29讲](https://time.geekbang.org/column/article/257053)学习storeOffsets方法时我们已经学过了appendForGroup方法。Kafka定义的两类消息类型都是由它写入的。在源码中storeGroup方法调用它写入消费者组注册消息storeOffsets方法调用它写入已提交位移消息。
首先我们需要知道storeGroup方法它的作用是**向Coordinator注册消费者组**。我们看下它的代码实现:
```
def storeGroup(group: GroupMetadata,
groupAssignment: Map[String, Array[Byte]],
responseCallback: Errors =&gt; Unit): Unit = {
// 判断当前Broker是否是该消费者组的Coordinator
getMagic(partitionFor(group.groupId)) match {
// 如果当前Broker不是Coordinator
case Some(magicValue) =&gt;
val timestampType = TimestampType.CREATE_TIME
val timestamp = time.milliseconds()
// 构建注册消息的Key
val key = GroupMetadataManager.groupMetadataKey(group.groupId)
// 构建注册消息的Value
val value = GroupMetadataManager.groupMetadataValue(group, groupAssignment, interBrokerProtocolVersion)
// 使用Key和Value构建待写入消息集合
val records = {
val buffer = ByteBuffer.allocate(AbstractRecords.estimateSizeInBytes(magicValue, compressionType,
Seq(new SimpleRecord(timestamp, key, value)).asJava))
val builder = MemoryRecords.builder(buffer, magicValue, compressionType, timestampType, 0L)
builder.append(timestamp, key, value)
builder.build()
}
// 计算要写入的目标分区
val groupMetadataPartition = new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, partitionFor(group.groupId))
val groupMetadataRecords = Map(groupMetadataPartition -&gt; records)
val generationId = group.generationId
// putCacheCallback方法填充Cache
......
// 向位移主题写入消息
appendForGroup(group, groupMetadataRecords, putCacheCallback)
// 如果当前Broker不是Coordinator
case None =&gt;
// 返回NOT_COORDINATOR异常
responseCallback(Errors.NOT_COORDINATOR)
None
}
}
```
为了方便你理解我画一张图来展示一下storeGroup方法的逻辑。
<img src="https://static001.geekbang.org/resource/image/a6/47/a6248981bc588722c09e38d6f1294447.jpg" alt="">
storeGroup方法的第1步是调用getMagic方法来判断当前Broker是否是该消费者组的Coordinator组件。判断的依据是尝试去获取位移主题目标分区的底层日志对象。如果能够获取到就说明当前Broker是Coordinator程序进入到下一步反之则表明当前Broker不是Coordinator就构造一个NOT_COORDINATOR异常返回。
第2步调用我们上节课学习的groupMetadataKey和groupMetadataValue方法去构造注册消息的Key和Value字段。
第3步使用Key和Value构建待写入消息集合。这里的消息集合类是MemoryRecords。
当前建模Kafka消息集合的类有两个。
- MemoryRecords表示内存中的消息集合
- FileRecords表示磁盘文件中的消息集合。
这两个类的源码不是我们学习的重点你只需要知道它们的含义就行了。不过我推荐你课下阅读一下它们的源码它们在clients工程中这可以进一步帮助你理解Kafka如何在内存和磁盘上保存消息。
第4步调用partitionFor方法计算要写入的位移主题目标分区。
第5步调用appendForGroup方法将待写入消息插入到位移主题的目标分区下。至此方法返回。
需要提一下的是在上面的代码中我省略了putCacheCallback方法的源码我们在第29讲已经详细地学习过它了。它的作用就是当消息被写入到位移主题后填充Cache。
可以看到写入位移主题和写入其它的普通主题并无差别。Coordinator会构造符合规定格式的消息数据并把它们传给storeOffsets和storeGroup方法由它们执行写入操作。因此我们可以认为Coordinator相当于位移主题的消息生产者。
## 读取位移主题
其实除了生产者这个角色以外Coordinator还扮演了消费者的角色也就是读取位移主题。跟写入相比读取操作的逻辑更加复杂一些不光体现在代码长度上更体现在消息读取之后的处理上。
首先,我们要知道,什么时候需要读取位移主题。
你可能会觉得当消费者组查询位移时会读取该主题下的数据。其实不然。查询位移时Coordinator只会从GroupMetadata元数据缓存中查找对应的位移值而不会读取位移主题。真正需要读取位移主题的时机**是在当前Broker当选Coordinator**也就是Broker成为了位移主题某分区的Leader副本时。
一旦当前Broker当选为位移主题某分区的Leader副本它就需要将它内存中的元数据缓存填充起来因此需要读取位移主题。在代码中这是由**scheduleLoadGroupAndOffsets**方法完成的。该方法会创建一个异步任务来读取位移主题消息并填充缓存。这个异步任务要执行的逻辑就是loadGroupsAndOffsets方法。
如果你翻开loadGroupsAndOffsets方法的源码就可以看到它本质上是调用doLoadGroupsAndOffsets方法实现的位移主题读取。下面我们就重点学习下这个方法。
这个方法的代码很长,为了让你能够更加清晰地理解它,我先带你了解下它的方法签名,然后再给你介绍具体的实现逻辑。
首先我们来看它的方法签名以及内置的一个子方法logEndOffset。
```
private def doLoadGroupsAndOffsets(topicPartition: TopicPartition, onGroupLoaded: GroupMetadata =&gt; Unit): Unit = {
// 获取位移主题指定分区的LEO值
// 如果当前Broker不是该分区的Leader副本则返回-1
def logEndOffset: Long = replicaManager.getLogEndOffset(topicPartition).getOrElse(-1L)
......
}
```
doLoadGroupsAndOffsets方法顾名思义它要做两件事请加载消费者组加载消费者组的位移。再强调一遍所谓的加载就是指读取位移主题下的消息并将这些信息填充到缓存中。
该方法接收两个参数第一个参数topicPartition是位移主题目标分区第二个参数onGroupLoaded是加载完成后要执行的逻辑这个逻辑是在上层组件中指定的我们不需要掌握它的实现这不会影响我们学习位移主题的读取。
doLoadGroupsAndOffsets还定义了一个内置子方法logEndOffset。它的目的很简单就是**获取位移主题指定分区的LEO值如果当前Broker不是该分区的Leader副本就返回-1**。
这是一个特别重要的事实因为Kafka依靠它来判断分区的Leader副本是否发生变更。一旦发生变更那么在当前Broker执行logEndOffset方法的返回值就是-1此时Broker就不再是Leader副本了。
doLoadGroupsAndOffsets方法会**读取位移主题目标分区的日志对象**,并执行核心的逻辑动作,代码如下:
```
......
replicaManager.getLog(topicPartition) match {
// 如果无法获取到日志对象
case None =&gt;
warn(s&quot;Attempted to load offsets and group metadata from $topicPartition, but found no log&quot;)
case Some(log) =&gt;
// 核心逻辑......
```
我把核心的逻辑分成3个部分来介绍。
- 第1部分初始化4个列表+读取位移主题;
- 第2部分处理读到的数据并填充4个列表
- 第3部分分别处理这4个列表。
在具体讲解这个方法所做的事情之前,我先画一张流程图,从宏观层面展示一下这个流程。
<img src="https://static001.geekbang.org/resource/image/d0/fb/d03d553361f14695917f6b62528008fb.jpg" alt="">
### 第1部分
首先,我们来学习一下第一部分的代码,完成了对位移主题的读取操作。
```
// 已完成位移值加载的分区列表
val loadedOffsets = mutable.Map[GroupTopicPartition, CommitRecordMetadataAndOffset]()
// 处于位移加载中的分区列表只用于Kafka事务
val pendingOffsets = mutable.Map[Long, mutable.Map[GroupTopicPartition, CommitRecordMetadataAndOffset]]()
// 已完成组信息加载的消费者组列表
val loadedGroups = mutable.Map[String, GroupMetadata]()
// 待移除的消费者组列表
val removedGroups = mutable.Set[String]()
// 保存消息集合的ByteBuffer缓冲区
var buffer = ByteBuffer.allocate(0)
// 位移主题目标分区日志起始位移值
var currOffset = log.logStartOffset
// 至少要求读取一条消息
var readAtLeastOneRecord = true
// 当前读取位移&lt;LEO且至少要求读取一条消息且GroupMetadataManager未关闭
while (currOffset &lt; logEndOffset &amp;&amp; readAtLeastOneRecord &amp;&amp; !shuttingDown.get()) {
// 读取位移主题指定分区
val fetchDataInfo = log.read(currOffset,
maxLength = config.loadBufferSize,
isolation = FetchLogEnd,
minOneMessage = true)
// 如果无消息可读,则不再要求至少读取一条消息
readAtLeastOneRecord = fetchDataInfo.records.sizeInBytes &gt; 0
// 创建消息集合
val memRecords = fetchDataInfo.records match {
case records: MemoryRecords =&gt; records
case fileRecords: FileRecords =&gt;
val sizeInBytes = fileRecords.sizeInBytes
val bytesNeeded = Math.max(config.loadBufferSize, sizeInBytes)
if (buffer.capacity &lt; bytesNeeded) {
if (config.loadBufferSize &lt; bytesNeeded)
warn(s&quot;Loaded offsets and group metadata from $topicPartition with buffer larger ($bytesNeeded bytes) than &quot; +
s&quot;configured offsets.load.buffer.size (${config.loadBufferSize} bytes)&quot;)
buffer = ByteBuffer.allocate(bytesNeeded)
} else {
buffer.clear()
}
fileRecords.readInto(buffer, 0)
MemoryRecords.readableRecords(buffer)
}
......
}
```
**首先**这部分代码创建了4个列表。
- loadedOffsets已完成位移值加载的分区列表
- pendingOffsets位移值加载中的分区列表
- loadedGroups已完成组信息加载的消费者组列表
- removedGroups待移除的消费者组列表。
**之后**代码又创建了一个ByteBuffer缓冲区用于保存消息集合。**接下来**,计算位移主题目标分区的日志起始位移值,这是要读取的起始位置。**再之后**,代码定义了一个布尔类型的变量,该变量表示本次至少要读取一条消息。
这些初始化工作都做完之后代码进入到while循环中。循环的条件有3个而且需要同时满足
- 读取位移值小于日志LEO值
- 布尔变量值是True
- GroupMetadataManager未关闭。
只要满足这3个条件代码就会一直执行while循环下的语句逻辑。整个while下的逻辑被分成了3个步骤我们现在学习的第1部分代码包含了前两步。最后一步在第3部分中实现即处理上面的这4个列表。我们先看前两步。
第1步是**读取位移主题目标分区的日志对象**,从日志中取出真实的消息数据。读取日志这个操作,是使用我们在[第3讲](https://time.geekbang.org/column/article/225993)中学过的Log.read方法完成的。当读取到完整的日志之后doLoadGroupsAndOffsets方法会查看返回的消息集合如果一条消息都没有返回则取消“至少要求读取一条消息”的限制即把刚才的布尔变量值设置为False。
第2步是根据上一步获取到的消息数据创建保存在内存中的消息集合对象也就是MemoryRecords对象。
由于doLoadGroupsAndOffsets方法要将读取的消息填充到缓存中因此这里必须做出MemoryRecords类型的消息集合。这就是第二路case分支要将FileRecords转换成MemoryRecords类型的原因。
至此第1部分逻辑完成。这一部分的产物就是成功地从位移主题目标分区读取到消息然后转换成MemoryRecords对象等待后续处理。
### 第2部分
现在代码进入到第2部分**处理消息集合**。
值得注意的是这部分代码依然在while循环下我们看下它是如何实现的
```
// 遍历消息集合的每个消息批次(RecordBatch)
memRecords.batches.forEach { batch =&gt;
val isTxnOffsetCommit = batch.isTransactional
// 如果是控制类消息批次
// 控制类消息批次属于Kafka事务范畴这里不展开讲
if (batch.isControlBatch) {
......
} else {
// 保存消息批次第一条消息的位移值
var batchBaseOffset: Option[Long] = None
// 遍历消息批次下的所有消息
for (record &lt;- batch.asScala) {
// 确保消息必须有Key否则抛出异常
require(record.hasKey, &quot;Group metadata/offset entry key should not be null&quot;)
// 记录消息批次第一条消息的位移值
if (batchBaseOffset.isEmpty)
batchBaseOffset = Some(record.offset)
// 读取消息Key
GroupMetadataManager.readMessageKey(record.key) match {
// 如果是OffsetKey说明是提交位移消息
case offsetKey: OffsetKey =&gt;
......
val groupTopicPartition = offsetKey.key
// 如果该消息没有Value
if (!record.hasValue) {
if (isTxnOffsetCommit)
pendingOffsets(batch.producerId)
.remove(groupTopicPartition)
else
// 将目标分区从已完成位移值加载的分区列表中移除
loadedOffsets.remove(groupTopicPartition)
} else {
val offsetAndMetadata = GroupMetadataManager.readOffsetMessageValue(record.value)
if (isTxnOffsetCommit)
pendingOffsets(batch.producerId).put(groupTopicPartition, CommitRecordMetadataAndOffset(batchBaseOffset, offsetAndMetadata))
else
// 将目标分区加入到已完成位移值加载的分区列表
loadedOffsets.put(groupTopicPartition, CommitRecordMetadataAndOffset(batchBaseOffset, offsetAndMetadata))
}
// 如果是GroupMetadataKey说明是注册消息
case groupMetadataKey: GroupMetadataKey =&gt;
val groupId = groupMetadataKey.key
val groupMetadata = GroupMetadataManager.readGroupMessageValue(groupId, record.value, time)
// 如果消息Value不为空
if (groupMetadata != null) {
// 把该消费者组从待移除消费者组列表中移除
removedGroups.remove(groupId)
// 将消费者组加入到已完成加载的消费组列表
loadedGroups.put(groupId, groupMetadata)
// 如果消息Value为空说明是Tombstone消息
} else {
// 把该消费者组从已完成加载的组列表中移除
loadedGroups.remove(groupId)
// 将消费者组加入到待移除消费组列表
removedGroups.add(groupId)
}
// 如果是未知类型的Key抛出异常
case unknownKey =&gt;
throw new IllegalStateException(s&quot;Unexpected message key $unknownKey while loading offsets and group metadata&quot;)
}
}
}
// 更新读取位置到消息批次最后一条消息的位移值+1等待下次while循环
currOffset = batch.nextOffset
}
```
这一部分的主要目的是处理上一步获取到的消息集合然后把相应数据添加到刚刚说到的4个列表中具体逻辑是代码遍历消息集合的每个消息批次Record Batch。我来解释一下这个流程。
**首先**判断该批次是否是控制类消息批次如果是就执行Kafka事务专属的一些逻辑。由于我们不讨论Kafka事务因此这里我就不详细展开了。如果不是就进入到下一步。
**其次**,遍历该消息批次下的所有消息,并依次执行下面的步骤。
第1步记录消息批次中第一条消息的位移值。
第2步读取消息Key并判断Key的类型判断的依据如下
- 如果是提交位移消息就判断消息有无Value。如果没有那么方法将目标分区从已完成位移值加载的分区列表中移除如果有则将目标分区加入到已完成位移值加载的分区列表中。
- 如果是注册消息依然是判断消息有无Value。如果存在Value就把该消费者组从待移除消费者组列表中移除并加入到已完成加载的消费组列表如果不存在Value就说明这是一条Tombstone消息那么代码把该消费者组从已完成加载的组列表中移除并加入到待移除消费组列表。
- 如果是未知类型的Key就直接抛出异常。
最后更新读取位置等待下次while循环这个位置就是整个消息批次中最后一条消息的位移值+1。
至此这部分代码宣告结束它的主要产物就是被填充了的4个列表。那么第3部分就要开始处理这4个列表了。
### 第3部分
最后一部分的完整代码如下:
```
// 处理loadedOffsets
val (groupOffsets, emptyGroupOffsets) = loadedOffsets
.groupBy(_._1.group)
.map { case (k, v) =&gt;
// 提取出&lt;组名,主题名,分区号&gt;与位移值对
k -&gt; v.map { case (groupTopicPartition, offset) =&gt; (groupTopicPartition.topicPartition, offset) }
}.partition { case (group, _) =&gt; loadedGroups.contains(group) }
......
// 处理loadedGroups
loadedGroups.values.foreach { group =&gt;
// 提取消费者组的已提交位移
val offsets = groupOffsets.getOrElse(group.groupId, Map.empty[TopicPartition, CommitRecordMetadataAndOffset])
val pendingOffsets = pendingGroupOffsets.getOrElse(group.groupId, Map.empty[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]])
debug(s&quot;Loaded group metadata $group with offsets $offsets and pending offsets $pendingOffsets&quot;)
// 为已完成加载的组执行加载组操作
loadGroup(group, offsets, pendingOffsets)
// 为已完成加载的组执行加载组操作之后的逻辑
onGroupLoaded(group)
}
(emptyGroupOffsets.keySet ++ pendingEmptyGroupOffsets.keySet).foreach { groupId =&gt;
val group = new GroupMetadata(groupId, Empty, time)
val offsets = emptyGroupOffsets.getOrElse(groupId, Map.empty[TopicPartition, CommitRecordMetadataAndOffset])
val pendingOffsets = pendingEmptyGroupOffsets.getOrElse(groupId, Map.empty[Long, mutable.Map[TopicPartition, CommitRecordMetadataAndOffset]])
debug(s&quot;Loaded group metadata $group with offsets $offsets and pending offsets $pendingOffsets&quot;)
// 为空的消费者组执行加载组操作
loadGroup(group, offsets, pendingOffsets)
// 为空的消费者执行加载组操作之后的逻辑
onGroupLoaded(group)
}
// 处理removedGroups
removedGroups.foreach { groupId =&gt;
if (groupMetadataCache.contains(groupId) &amp;&amp; !emptyGroupOffsets.contains(groupId))
throw new IllegalStateException(s&quot;Unexpected unload of active group $groupId while &quot; +
s&quot;loading partition $topicPartition&quot;)
}
```
**首先**代码对loadedOffsets进行分组将那些已经完成组加载的消费者组位移值分到一组保存在字段groupOffsets中将那些有位移值但没有对应组信息的分成另外一组也就是字段emptyGroupOffsets保存的数据。
**其次**代码为loadedGroups中的所有消费者组执行加载组操作以及加载之后的操作onGroupLoaded。还记得吧loadedGroups中保存的都是已完成组加载的消费者组。这里的onGroupLoaded是上层调用组件Coordinator传入的。它主要的作用是处理消费者组下所有成员的心跳超时设置并指定下一次心跳的超时时间。
**再次**代码为emptyGroupOffsets的所有消费者组创建空的消费者组元数据然后执行和上一步相同的组加载逻辑以及加载后的逻辑。
**最后**代码检查removedGroups中的所有消费者组确保它们不能出现在消费者组元数据缓存中否则将抛出异常。
至此doLoadGroupsAndOffsets方法的逻辑全部完成。经过调用该方法后Coordinator成功地读取了位移主题目标分区下的数据并把它们填充到了消费者组元数据缓存中。
## 总结
今天我们重点学习了GroupMetadataManager类中读写位移主题的方法代码。Coordinator会使用这些方法对位移主题进行操作实现对消费者组的管理。写入操作比较简单它和一般的消息写入并无太大区别而读取操作相对复杂一些。更重要的是和我们的直观理解可能相悖的是Kafka在查询消费者组已提交位移时是不会读取位移主题的而是直接从内存中的消费者组元数据缓存中查询。这一点你一定要重点关注。
我们来简单回顾一下这节课的重点。
- 读写方法appendForGroup方法负责写入位移主题doLoadGroupsAndOffsets负责读取位移主题并加载组信息和位移值。
- 查询消费者组位移:查询位移时不读取位移主题,而是读取消费者组元数据缓存。
<img src="https://static001.geekbang.org/resource/image/19/3b/19304a381e75783fd584dyye5cc0733b.jpg" alt="">
至此GroupMetadataManager类的重要源码我们就学完了。作为一个有着将近1000行代码而且集这么多功能于一身的大文件这个类的代码绝对值得你多读几遍。
除了我们集中介绍的这些功能之外GroupMetadataManager类其实还是连接GroupMetadata和Coordinator的重要纽带Coordinator利用GroupMetadataManager类实现操作GroupMetadata的目的。
我刚开始学习这部分源码的时候居然不清楚GroupMetadata和GroupMetadataManager的区别是什么。现在经过这3节课的内容相信你已经知道GroupMetadata建模的是元数据信息而GroupMetadataManager类建模的是管理元数据的方法也是管理内部位移主题的唯一组件。以后碰到任何有关位移主题的问题你都可以直接到这个类中去寻找答案。
## 课后讨论
其实除了读写位移主题之外GroupMetadataManager还提供了清除位移主题数据的方法。代码中的cleanGroupMetadata就是做这个事儿的。请你结合源码分析一下cleanGroupMetadata方法的流程。
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,409 @@
<audio id="audio" title="32 | GroupCoordinator在Rebalance中Coordinator如何处理成员入组" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/93/7cefc2ca03365f3469717e23350e7693.mp3"></audio>
你好我是胡夕。不知不觉间课程已经接近尾声了最后这两节课我们来学习一下消费者组的Rebalance流程是如何完成的。
提到Rebalance你的第一反应一定是“爱恨交加”。毕竟如果使用得当它能够自动帮我们实现消费者之间的负载均衡和故障转移但如果配置失当我们就可能触碰到它被诟病已久的缺陷耗时长而且会出现消费中断。
在使用消费者组的实践中你肯定想知道应该如何避免Rebalance。如果你不了解Rebalance的源码机制的话就很容易掉进它无意中铺设的“陷阱”里。
举个小例子。有些人认为Consumer端参数session.timeout.ms决定了完成一次Rebalance流程的最大时间。这种认知是不对的实际上这个参数是用于检测消费者组成员存活性的即如果在这段超时时间内没有收到该成员发给Coordinator的心跳请求则把该成员标记为Dead而且要显式地将其从消费者组中移除并触发新一轮的Rebalance。而真正决定单次Rebalance所用最大时长的参数是Consumer端的**max.poll.interval.ms**。显然,如果没有搞懂这部分的源码,你就没办法为这些参数设置合理的数值。
总体而言, Rebalance的流程大致分为两大步加入组JoinGroup和组同步SyncGroup
**加入组是指消费者组下的各个成员向Coordinator发送JoinGroupRequest请求加入进组的过程**。这个过程有一个超时时间如果有成员在超时时间之内无法完成加入组操作它就会被排除在这轮Rebalance之外。
组同步是指当所有成员都成功加入组之后Coordinator指定其中一个成员为Leader然后将订阅分区信息发给Leader成员。接着所有成员包括Leader成员向Coordinator发送SyncGroupRequest请求。需要注意的是**只有Leader成员发送的请求中包含了订阅分区消费分配方案在其他成员发送的请求中这部分的内容为空**。当Coordinator接收到分配方案后会通过向成员发送响应的方式通知各个成员要消费哪些分区。
当组同步完成后Rebalance宣告结束。此时消费者组处于正常工作状态。
今天我们就学习下第一大步也就是加入组的源码实现它们位于GroupCoordinator.scala文件中。下节课我们再深入地学习组同步的源码实现。
要搞懂加入组的源码机制我们必须要学习4个方法分别是handleJoinGroup、doUnknownJoinGroup、doJoinGroup和addMemberAndRebalance。handleJoinGroup是执行加入组的顶层方法被KafkaApis类调用该方法依据给定消费者组成员是否了设置成员ID来决定是调用doUnknownJoinGroup还是doJoinGroup前者对应于未设定成员ID的情形后者对应于已设定成员ID的情形。而这两个方法都会调用addMemberAndRebalance执行真正的加入组逻辑。为了帮助你理解它们之间的交互关系我画了一张图借用它展示了这4个方法的调用顺序。
<img src="https://static001.geekbang.org/resource/image/b7/20/b7ed79cbf4eba29b39f32015b527c220.jpg" alt="">
## handleJoinGroup方法
如果你翻开KafkaApis.scala这个API入口文件就可以看到处理JoinGroupRequest请求的方法是handleJoinGroupRequest。而它的主要逻辑就是**调用GroupCoordinator的handleJoinGroup方法来处理消费者组成员发送过来的加入组请求所以我们要具体学习一下handleJoinGroup方法**。先看它的方法签名:
```
def handleJoinGroup(
groupId: String, // 消费者组名
memberId: String, // 消费者组成员ID
groupInstanceId: Option[String], // 组实例ID用于标识静态成员
requireKnownMemberId: Boolean, // 是否需要成员ID不为空
clientId: String, // client.id值
clientHost: String, // 消费者程序主机名
rebalanceTimeoutMs: Int, // Rebalance超时时间,默认是max.poll.interval.ms值
sessionTimeoutMs: Int, // 会话超时时间
protocolType: String, // 协议类型
protocols: List[(String, Array[Byte])], // 按照分配策略分组的订阅分区
responseCallback: JoinCallback // 回调函数
): Unit = {
......
}
```
这个方法的参数有很多,我介绍几个比较关键的。接下来在阅读其他方法的源码时,你还会看到这些参数,所以,这里你一定要提前掌握它们的含义。
- groupId消费者组名。
- memberId消费者组成员ID。如果成员是新加入的那么该字段是空字符串。
- groupInstanceId这是社区于2.4版本引入的静态成员字段。静态成员的引入可以有效避免因系统升级或程序更新而导致的Rebalance场景。它属于比较高阶的用法而且目前还没有被大规模使用因此这里你只需要简单了解一下它的作用。另外后面在讲其他方法时我会直接省略静态成员的代码我们只关注核心逻辑就行了。
- requireKnownMemberId是否要求成员ID不为空即是否要求成员必须设置ID的布尔字段。这个字段如果为True的话那么Kafka要求消费者组成员必须设置ID。未设置ID的成员会被拒绝加入组。直到它设置了ID之后才能重新加入组。
- clientId消费者端参数client.id值。Coordinator使用它来生成memberId。memberId的格式是clientId值-UUID。
- clientHost消费者程序的主机名。
- rebalanceTimeoutMsRebalance超时时间。如果在这个时间段内消费者组成员没有完成加入组的操作就会被禁止入组。
- sessionTimeoutMs会话超时时间。如果消费者组成员无法在这段时间内向Coordinator汇报心跳那么将被视为“已过期”从而引发新一轮Rebalance。
- responseCallback完成加入组之后的回调逻辑方法。当消费者组成员成功加入组之后需要执行该方法。
说完了方法签名,我们看下它的主体代码:
```
// 验证消费者组状态的合法性
validateGroupStatus(groupId, ApiKeys.JOIN_GROUP).foreach { error =&gt;
responseCallback(JoinGroupResult(memberId, error))
return
}
// 确保sessionTimeoutMs介于
// [group.min.session.timeout.ms值group.max.session.timeout.ms值]之间
// 否则抛出异常,表示超时时间设置无效
if (sessionTimeoutMs &lt; groupConfig.groupMinSessionTimeoutMs ||
sessionTimeoutMs &gt; groupConfig.groupMaxSessionTimeoutMs) {
responseCallback(JoinGroupResult(memberId, Errors.INVALID_SESSION_TIMEOUT))
} else {
// 消费者组成员ID是否为空
val isUnknownMember = memberId == JoinGroupRequest.UNKNOWN_MEMBER_ID
// 获取消费者组信息,如果组不存在,就创建一个新的消费者组
groupManager.getOrMaybeCreateGroup(groupId, isUnknownMember) match {
case None =&gt;
responseCallback(JoinGroupResult(memberId, Errors.UNKNOWN_MEMBER_ID))
case Some(group) =&gt;
group.inLock {
// 如果该消费者组已满员
if (!acceptJoiningMember(group, memberId)) {
// 移除该消费者组成员
group.remove(memberId)
group.removeStaticMember(groupInstanceId)
// 封装异常表明组已满员
responseCallback(JoinGroupResult(
JoinGroupRequest.UNKNOWN_MEMBER_ID,
Errors.GROUP_MAX_SIZE_REACHED))
// 如果消费者组成员ID为空
} else if (isUnknownMember) {
// 为空ID成员执行加入组操作
doUnknownJoinGroup(group, groupInstanceId, requireKnownMemberId, clientId, clientHost, rebalanceTimeoutMs, sessionTimeoutMs, protocolType, protocols, responseCallback)
} else {
// 为非空ID成员执行加入组操作
doJoinGroup(group, memberId, groupInstanceId, clientId, clientHost, rebalanceTimeoutMs, sessionTimeoutMs, protocolType, protocols, responseCallback)
}
// 如果消费者组正处于PreparingRebalance状态
if (group.is(PreparingRebalance)) {
// 放入Purgatory等待后面统一延时处理
joinPurgatory.checkAndComplete(GroupKey(group.groupId))
}
}
}
}
```
为了方便你更直观地理解,我画了一张图来说明它的完整流程。
<img src="https://static001.geekbang.org/resource/image/4b/89/4b4624d5cced2be6a77c7659e048b089.jpg" alt="">
第1步调用validateGroupStatus方法验证消费者组状态的合法性。所谓的合法性也就是消费者组名groupId不能为空以及JoinGroupRequest请求发送给了正确的Coordinator这两者必须同时满足。如果没有通过这些检查那么handleJoinGroup方法会封装相应的错误并调用回调函数返回。否则就进入到下一步。
第2步代码检验sessionTimeoutMs的值是否介于[group.min.session.timeout.msgroup.max.session.timeout.ms]之间,如果不是,就认定该值是非法值,从而封装一个对应的异常调用回调函数返回,这两个参数分别表示消费者组允许配置的最小和最大会话超时时间;如果是的话,就进入下一步。
第3步代码获取当前成员的ID信息并查看它是否为空。之后通过GroupMetadataManager获取消费者组的元数据信息如果该组的元数据信息存在则进入到下一步如果不存在代码会看当前成员ID是否为空如果为空就创建一个空的元数据对象然后进入到下一步如果不为空则返回None。一旦返回了NonehandleJoinGroup方法会封装“未知成员ID”的异常调用回调函数返回。
第4步检查当前消费者组是否已满员。该逻辑是通过**acceptJoiningMember方法**实现的。这个方法根据**消费者组状态**确定是否满员。这里的消费者组状态有三种。
**状态一**如果是Empty或Dead状态肯定不会是满员直接返回True表示可以接纳申请入组的成员
**状态二**如果是PreparingRebalance状态那么批准成员入组的条件是必须满足一下两个条件之一。
- 该成员是之前已有的成员,且当前正在等待加入组;
- 当前等待加入组的成员数小于Broker端参数group.max.size值。
只要满足这两个条件中的任意一个,当前消费者组成员都会被批准入组。
**状态三**:如果是其他状态,那么,入组的条件是**该成员是已有成员或者是当前组总成员数小于Broker端参数group.max.size值**。需要注意的是,这里比较的是**组当前的总成员数**而不是等待入组的成员数这是因为一旦Rebalance过渡到CompletingRebalance之后没有完成加入组的成员就会被移除。
倘若成员不被批准入组那么代码需要将该成员从元数据缓存中移除同时封装“组已满员”的异常并调用回调函数返回如果成员被批准入组则根据Member ID是否为空就执行doUnknownJoinGroup或doJoinGroup方法执行加入组的逻辑。
第5步是尝试完成JoinGroupRequest请求的处理。如果消费者组处于PreparingRebalance状态那么就将该请求放入Purgatory尝试立即完成如果是其它状态则无需将请求放入Purgatory。毕竟我们处理的是加入组的逻辑而此时消费者组的状态应该要变更到PreparingRebalance后Rebalance才能完成加入组操作。当然如果延时请求不能立即完成则交由Purgatory统一进行延时处理。
至此handleJoinGroup逻辑结束。
实际上我们可以看到真正执行加入组逻辑的是doUnknownJoinGroup和doJoinGroup这两个方法。那么接下来我们就来学习下这两个方法。
## doUnknownJoinGroup方法
如果是全新的消费者组成员加入组那么就需要为它们执行doUnknownJoinGroup方法因为此时它们的Member ID尚未生成。
除了memberId之外该方法的输入参数与handleJoinGroup方法几乎一模一样我就不一一地详细介绍了我们直接看它的源码。为了便于你理解我省略了关于静态成员以及DEBUG/INFO调试的部分代码。
```
group.inLock {
// Dead状态
if (group.is(Dead)) {
// 封装异常调用回调函数返回
responseCallback(JoinGroupResult(
JoinGroupRequest.UNKNOWN_MEMBER_ID,
Errors.COORDINATOR_NOT_AVAILABLE))
// 成员配置的协议类型/分区消费分配策略与消费者组的不匹配
} else if (!group.supportsProtocols(protocolType, MemberMetadata.plainProtocolSet(protocols))) {
responseCallback(JoinGroupResult(JoinGroupRequest.UNKNOWN_MEMBER_ID, Errors.INCONSISTENT_GROUP_PROTOCOL))
} else {
// 根据规则为该成员创建成员ID
val newMemberId = group.generateMemberId(clientId, groupInstanceId)
// 如果配置了静态成员
if (group.hasStaticMember(groupInstanceId)) {
......
// 如果要求成员ID不为空
} else if (requireKnownMemberId) {
......
group.addPendingMember(newMemberId)
addPendingMemberExpiration(group, newMemberId, sessionTimeoutMs)
responseCallback(JoinGroupResult(newMemberId, Errors.MEMBER_ID_REQUIRED))
} else {
......
// 添加成员
addMemberAndRebalance(rebalanceTimeoutMs, sessionTimeoutMs, newMemberId, groupInstanceId,
clientId, clientHost, protocolType, protocols, group, responseCallback)
}
}
}
```
为了方便你理解,我画了一张图来展示下这个方法的流程。
<img src="https://static001.geekbang.org/resource/image/49/95/497aef4be2afa50f34ddc99a6788b695.jpg" alt="">
首先,代码会检查消费者组的状态。
如果是Dead状态则封装异常然后调用回调函数返回。你可能会觉得奇怪既然是向该组添加成员为什么组状态还能是Dead呢实际上这种情况是可能的。因为在成员加入组的同时可能存在另一个线程已经把组的元数据信息从Coordinator中移除了。比如组对应的Coordinator发生了变更移动到了其他的Broker上此时代码封装一个异常返回给消费者程序后者会去寻找最新的Coordinator然后重新发起加入组操作。
如果状态不是Dead就检查该成员的协议类型以及分区消费分配策略是否与消费者组当前支持的方案匹配如果不匹配依然是封装异常然后调用回调函数返回。这里的匹配与否是指成员的协议类型与消费者组的是否一致以及成员设定的分区消费分配策略是否被消费者组下的其它成员支持。
如果这些检查都顺利通过接着代码就会为该成员生成成员ID生成规则是clientId-UUID。这便是generateMemberId方法做的事情。然后handleJoinGroup方法会根据requireKnownMemberId的取值来决定下面的逻辑路径
- 如果该值为True则将该成员加入到待决成员列表Pending Member List然后封装一个异常以及生成好的成员ID将该成员的入组申请“打回去”令其分配好了成员ID之后再重新申请
- 如果为False则无需这么苛刻直接调用addMemberAndRebalance方法将其加入到组中。至此handleJoinGroup方法结束。
通常来说如果你没有启用静态成员机制的话requireKnownMemberId的值是True这是由KafkaApis中handleJoinGroupRequest方法的这行语句决定的
```
val requireKnownMemberId = joinGroupRequest.version &gt;= 4 &amp;&amp; groupInstanceId.isEmpty
```
可见, 如果你使用的是比较新的Kafka客户端版本而且没有配置过Consumer端参数group.instance.id的话那么这个字段的值就是True这说明Kafka要求消费者成员加入组时必须要分配好成员ID。
关于addMemberAndRebalance方法的源码一会儿在学习doJoinGroup方法时我再给你具体解释。
## doJoinGroup方法
接下来我们看下doJoinGroup方法。这是为那些设置了成员ID的成员执行加入组逻辑的方法。它的输入参数全部承袭自handleJoinGroup方法输入参数你应该已经很熟悉了因此我们直接看它的源码实现。由于代码比较长我分成两个部分来介绍。同时我再画一张图帮助你理解整个方法的逻辑。
<img src="https://static001.geekbang.org/resource/image/46/4f/4658881317dc5d8afdeb3bac07cfae4f.jpg" alt="">
### 第1部分
这部分主要做一些校验和条件检查。
```
// 如果是Dead状态封装COORDINATOR_NOT_AVAILABLE异常调用回调函数返回
if (group.is(Dead)) {
responseCallback(JoinGroupResult(memberId, Errors.COORDINATOR_NOT_AVAILABLE))
// 如果协议类型或分区消费分配策略与消费者组的不匹配
// 封装INCONSISTENT_GROUP_PROTOCOL异常调用回调函数返回
} else if (!group.supportsProtocols(protocolType, MemberMetadata.plainProtocolSet(protocols))) {
responseCallback(JoinGroupResult(memberId, Errors.INCONSISTENT_GROUP_PROTOCOL))
// 如果是待决成员由于这次分配了成员ID故允许加入组
} else if (group.isPendingMember(memberId)) {
if (groupInstanceId.isDefined) {
......
} else {
......
// 令其加入组
addMemberAndRebalance(rebalanceTimeoutMs, sessionTimeoutMs, memberId, groupInstanceId,
clientId, clientHost, protocolType, protocols, group, responseCallback)
}
} else {
// 第二部分代码......
}
```
doJoinGroup方法开头和doUnkwownJoinGroup非常类似也是判断是否处于Dead状态并且检查协议类型和分区消费分配策略是否与消费者组的相匹配。
不同的是doJoinGroup要判断当前申请入组的成员是否是待决成员。如果是的话那么这次成员已经分配好了成员ID因此就直接调用addMemberAndRebalance方法令其入组如果不是的话那么方法进入到第2部分即处理一个非待决成员的入组申请。
### 第2部分
代码如下:
```
// 获取该成员的元数据信息
val member = group.get(memberId)
group.currentState match {
// 如果是PreparingRebalance状态
case PreparingRebalance =&gt;
// 更新成员信息并开始准备Rebalance
updateMemberAndRebalance(group, member, protocols, responseCallback)
// 如果是CompletingRebalance状态
case CompletingRebalance =&gt;
// 如果成员以前申请过加入组
if (member.matches(protocols)) {
// 直接返回当前组信息
responseCallback(JoinGroupResult(
members = if (group.isLeader(memberId)) {
group.currentMemberMetadata
} else {
List.empty
},
memberId = memberId,
generationId = group.generationId,
protocolType = group.protocolType,
protocolName = group.protocolName,
leaderId = group.leaderOrNull,
error = Errors.NONE))
// 否则更新成员信息并开始准备Rebalance
} else {
updateMemberAndRebalance(group, member, protocols, responseCallback)
}
// 如果是Stable状态
case Stable =&gt;
val member = group.get(memberId)
// 如果成员是Leader成员或者成员变更了分区分配策略
if (group.isLeader(memberId) || !member.matches(protocols)) {
// 更新成员信息并开始准备Rebalance
updateMemberAndRebalance(group, member, protocols, responseCallback)
} else {
responseCallback(JoinGroupResult(
members = List.empty,
memberId = memberId,
generationId = group.generationId,
protocolType = group.protocolType,
protocolName = group.protocolName,
leaderId = group.leaderOrNull,
error = Errors.NONE))
}
// 如果是其它状态,封装异常调用回调函数返回
case Empty | Dead =&gt;
warn(s&quot;Attempt to add rejoining member $memberId of group ${group.groupId} in &quot; +
s&quot;unexpected group state ${group.currentState}&quot;)
responseCallback(JoinGroupResult(memberId, Errors.UNKNOWN_MEMBER_ID))
}
```
这部分代码的**第1步**,是获取要加入组成员的元数据信息。
**第2步**是查询消费者组的当前状态。这里有4种情况。
<li>
如果是PreparingRebalance状态就说明消费者组正要开启Rebalance流程那么调用updateMemberAndRebalance方法更新成员信息并开始准备Rebalance即可。
</li>
<li>
如果是CompletingRebalance状态那么就判断一下该成员的分区消费分配策略与订阅分区列表是否和已保存记录中的一致如果相同就说明该成员已经应该发起过加入组的操作并且Coordinator已经批准了只是该成员没有收到因此针对这种情况代码构造一个JoinGroupResult对象直接返回当前的组信息给成员。但是如果protocols不相同那么就说明成员变更了订阅信息或分配策略就要调用updateMemberAndRebalance方法更新成员信息并开始准备新一轮Rebalance。
</li>
<li>
如果是Stable状态那么就判断该成员是否是Leader成员或者是它的订阅信息或分配策略发生了变更。如果是这种情况就调用updateMemberAndRebalance方法强迫一次新的Rebalance。否则的话返回当前组信息给该成员即可通知它们可以发起Rebalance的下一步操作。
</li>
<li>
如果这些状态都不是而是Empty或Dead状态那么就封装UNKNOWN_MEMBER_ID异常并调用回调函数返回。
</li>
可以看到这部分代码频繁地调用updateMemberAndRebalance方法。如果你翻开它的代码会发现它仅仅做两件事情。
- 更新组成员信息调用GroupMetadata的updateMember方法来更新消费者组成员
- 准备Rebalance这一步的核心思想是将消费者组状态变更到PreparingRebalance然后创建DelayedJoin对象并交由Purgatory等待延时处理加入组操作。
这个方法的代码行数不多而且逻辑很简单就是变更消费者组状态以及处理延时请求并放入Purgatory因此我不展开说了你可以自行阅读下这部分代码。
## addMemberAndRebalance方法
现在我们学习下doUnknownJoinGroup和doJoinGroup方法都会用到的addMemberAndRebalance方法。从名字上来看它的作用有两个
- 向消费者组添加成员;
- 准备Rebalance。
```
private def addMemberAndRebalance(
rebalanceTimeoutMs: Int,
sessionTimeoutMs: Int,
memberId: String,
groupInstanceId: Option[String],
clientId: String,
clientHost: String,
protocolType: String,
protocols: List[(String, Array[Byte])],
group: GroupMetadata,
callback: JoinCallback): Unit = {
// 创建MemberMetadata对象实例
val member = new MemberMetadata(
memberId, group.groupId, groupInstanceId,
clientId, clientHost, rebalanceTimeoutMs,
sessionTimeoutMs, protocolType, protocols)
// 标识该成员是新成员
member.isNew = true
// 如果消费者组准备开启首次Rebalance设置newMemberAdded为True
if (group.is(PreparingRebalance) &amp;&amp; group.generationId == 0)
group.newMemberAdded = true
// 将该成员添加到消费者组
group.add(member, callback)
// 设置下次心跳超期时间
completeAndScheduleNextExpiration(group, member, NewMemberJoinTimeoutMs)
if (member.isStaticMember) {
info(s&quot;Adding new static member $groupInstanceId to group ${group.groupId} with member id $memberId.&quot;)
group.addStaticMember(groupInstanceId, memberId)
} else {
// 从待决成员列表中移除
group.removePendingMember(memberId)
}
// 准备开启Rebalance
maybePrepareRebalance(group, s&quot;Adding new member $memberId with group instance id $groupInstanceId&quot;)
}
```
这个方法的参数列表虽然很长,但我相信,你对它们已经非常熟悉了,它们都是承袭自其上层调用方法的参数。
我来介绍一下这个方法的执行逻辑。
**第1步**该方法会根据传入参数创建一个MemberMetadata对象实例并设置isNew字段为True标识其是一个新成员。isNew字段与心跳设置相关联你可以阅读下MemberMetadata的hasSatisfiedHeartbeat方法的代码搞明白该字段是如何帮助Coordinator确认消费者组成员心跳的。
**第2步**代码会判断消费者组是否是首次开启Rebalance。如果是的话就把newMemberAdded字段设置为True如果不是则无需执行这个赋值操作。这个字段的作用是Kafka为消费者组Rebalance流程做的一个性能优化。大致的思想是在消费者组首次进行Rebalance时让Coordinator多等待一段时间从而让更多的消费者组成员加入到组中以免后来者申请入组而反复进行Rebalance。这段多等待的时间就是Broker端参数**group.initial.rebalance.delay.ms的值**。这里的newMemberAdded字段就是用于判断是否需要多等待这段时间的一个变量。
我们接着说回addMemberAndRebalance方法。该方法的**第3步**是调用GroupMetadata的add方法将新成员信息加入到消费者组元数据中同时设置该成员的下次心跳超期时间。
**第4步**,代码将该成员从待决成员列表中移除。毕竟,它已经正式加入到组中了,就不需要待在待决列表中了。
**第5步**调用maybePrepareRebalance方法准备开启Rebalance。
## 总结
至此我们学完了Rebalance流程的第一大步也就是加入组的源码学习。在这一步中你要格外注意**加入组时是区分有无消费者组成员ID**。对于未设定成员ID的分支代码调用doUnkwonwJoinGroup为成员生成ID信息对于已设定成员ID的分支则调用doJoinGroup方法。而这两个方法底层都是调用addMemberAndRebalance方法实现将消费者组成员添加进组的逻辑。
我们来简单回顾一下这节课的重点。
- Rebalance流程包括JoinGroup和SyncGroup两大步。
- handleJoinGroup方法Coordinator端处理成员加入组申请的方法。
- Member Id成员ID。Kafka源码根据成员ID的有无决定调用哪种加入组逻辑方法比如doUnknownJoinGroup或doJoinGroup方法。
- addMemberAndRebalance方法实现加入组功能的实际方法用于完成“加入组+开启Rebalance”这两个操作。
<img src="https://static001.geekbang.org/resource/image/41/01/41212f50defaffd79b04f851a278eb01.jpg" alt="">
当所有成员都成功加入到组之后所有成员会开启Rebalance的第二大步组同步。在这一步中成员会发送SyncGroupRequest请求给Coordinator。那么Coordinator又是如何应对的呢咱们下节课见分晓。
## 课后讨论
今天我们曾多次提到maybePrepareRebalance方法从名字上看它并不一定会开启Rebalance。那么你能否结合源码说说看到底什么情况下才能开启Rebalance
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,273 @@
<audio id="audio" title="33 | GroupCoordinator在Rebalance中如何进行组同步" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/5e/b4ab98241cba50f41626476877bd7a5e.mp3"></audio>
你好我是胡夕。今天我们继续学习消费者组Rebalance流程这节课我们重点学习这个流程的第2大步也就是组同步。
组同步也就是成员向Coordinator发送SyncGroupRequest请求等待Coordinator发送分配方案。在GroupCoordinator类中负责处理这个请求的入口方法就是handleSyncGroup。它进一步调用doSyncGroup方法完成组同步的逻辑。后者除了给成员下发分配方案之外还需要在元数据缓存中注册组消息以及把组状态变更为Stable。一旦完成了组同步操作Rebalance宣告结束消费者组开始正常工作。
接下来我们就来具体学习下组同步流程的实现逻辑。我们先从顶层的入口方法handleSyncGroup方法开始学习**该方法被KafkaApis类的handleSyncGroupRequest方法调用用于处理消费者组成员发送的SyncGroupRequest请求**。顺着这个入口方法我们会不断深入下沉到具体实现组同步逻辑的私有化方法doSyncGroup。
## handleSyncGroup方法
我们从handleSyncGroup的方法签名开始学习代码如下
```
def handleSyncGroup(
groupId: String, // 消费者组名
generation: Int, // 消费者组Generation号
memberId: String, // 消费者组成员ID
protocolType: Option[String], // 协议类型
protocolName: Option[String], // 分区消费分配策略名称
groupInstanceId: Option[String], // 静态成员Instance ID
groupAssignment: Map[String, Array[Byte]], // 按照成员分组的分配方案
responseCallback: SyncCallback // 回调函数
): Unit = {
......
}
```
该方法总共定义了8个参数你可以看下注释了解它们的含义我重点介绍6个比较关键的参数。
- **groupId**:消费者组名,标识这个成员属于哪个消费者组。
- **generation**消费者组Generation号。Generation类似于任期的概念标识了Coordinator负责为该消费者组处理的Rebalance次数。每当有新的Rebalance开启时Generation都会自动加1。
- **memberId**消费者组成员ID。该字段由Coordinator根据一定的规则自动生成。具体的规则上节课我们已经学过了我就不多说了。总体而言成员ID的值不是由你直接指定的但是你可以通过client.id参数间接影响该字段的取值。
- **protocolType**标识协议类型的字段这个字段可能的取值有两个consumer和connect。对于普通的消费者组而言这个字段的取值就是consumer该字段是Option类型因此实际的取值是Some(“consumer”)Kafka Connect组件中也会用到消费者组机制那里的消费者组的取值就是connect。
- **protocolName**消费者组选定的分区消费分配策略名称。这里的选择方法就是我们之前学到的GroupMetadata.selectProtocol方法。
- **groupAssignment**按照成员ID分组的分配方案。需要注意的是**只有Leader成员发送的SyncGroupRequest请求才包含这个方案**因此Coordinator在处理Leader成员的请求时该字段才有值。
你可能已经注意到了protocolType和protocolName都是Option类型这说明它们的取值可能是None即表示没有值。这是为什么呢
目前这两个字段的取值其实都是Coordinator帮助消费者组确定的也就是在Rebalance流程的上一步加入组中确定的。
如果成员成功加入组那么Coordinator会给这两个字段赋上正确的值并封装进JoinGroupRequest的Response里发送给消费者程序。一旦消费者拿到了Response中的数据就提取出这两个字段的值封装进SyncGroupRequest请求中再次发送给Coordinator。
如果成员没有成功加入组那么Coordinator会将这两个字段赋值成None加到Response中。因此在这里的handleSyncGroup方法中它们的类型就是Option。
说完了handleSyncGroup的方法签名我们看下它的代码
```
// 验证消费者状态及合法性
validateGroupStatus(groupId, ApiKeys.SYNC_GROUP) match {
// 如果未通过合法性检查且错误原因是Coordinator正在加载
// 那么封装REBALANCE_IN_PROGRESS异常并调用回调函数返回
case Some(error) if error == Errors.COORDINATOR_LOAD_IN_PROGRESS =&gt;
responseCallback(SyncGroupResult(Errors.REBALANCE_IN_PROGRESS))
// 如果是其它错误,则封装对应错误,并调用回调函数返回
case Some(error) =&gt; responseCallback(SyncGroupResult(error))
case None =&gt;
// 获取消费者组元数据
groupManager.getGroup(groupId) match {
// 如果未找到则封装UNKNOWN_MEMBER_ID异常并调用回调函数返回
case None =&gt;
responseCallback(SyncGroupResult(Errors.UNKNOWN_MEMBER_ID))
// 如果找到的话则调用doSyncGroup方法执行组同步任务
case Some(group) =&gt; doSyncGroup(
group, generation, memberId, protocolType, protocolName,
groupInstanceId, groupAssignment, responseCallback)
}
}
```
为了方便你理解,我画了一张流程图来说明此方法的主体逻辑。
<img src="https://static001.geekbang.org/resource/image/a2/a7/a252eb065397fc8a78e92b26fe2fc6a7.jpg" alt="">
handleSyncGroup方法首先会调用上一节课我们学习过的validateGroupStatus方法校验消费者组状态及合法性。这些检查项包括
1. 消费者组名不能为空;
1. Coordinator组件处于运行状态
1. Coordinator组件当前没有执行加载过程
1. SyncGroupRequest请求发送给了正确的Coordinator组件。
前两个检查项很容易理解,我重点解释一下最后两项的含义。
当Coordinator变更到其他Broker上时需要从内部位移主题中读取消息数据并填充到内存上的消费者组元数据缓存这就是所谓的加载。
- 如果Coordinator变更了那么发送给老Coordinator所在Broker的请求就失效了因为它没有通过第4个检查项即发送给正确的Coordinator
- 如果发送给了正确的Coordinator但此时Coordinator正在执行加载过程那么它就没有通过第3个检查项因为Coordinator尚不能对外提供服务要等加载完成之后才可以。
代码对消费者组依次执行上面这4项校验一旦发现有项目校验失败validateGroupStatus方法就会将检查失败的原因作为结果返回。如果是因为Coordinator正在执行加载就意味着**本次Rebalance的所有状态都丢失了**。这里的状态,指的是消费者组下的成员信息。那么,此时最安全的做法,是**让消费者组重新从加入组开始**因此代码会封装REBALANCE_IN_PROGRESS异常然后调用回调函数返回。一旦消费者组成员接收到此异常就会知道它至少找到了正确的Coordinator只需要重新开启Rebalance而不需要在开启Rebalance之前再大费周章地去定位Coordinator组件了。但如果是其它错误就封装该错误然后调用回调函数返回。
倘若消费者组通过了以上校验那么代码就会获取该消费者组的元数据信息。如果找不到对应的元数据就封装UNKNOWN_MEMBER_ID异常之后调用回调函数返回如果找到了元数据信息就调用doSyncGroup方法执行真正的组同步逻辑。
显然接下来我们应该学习doSyncGroup方法的源码了这才是真正实现组同步功能的地方。
## doSyncGroup方法
doSyncGroup方法接收的输入参数与它的调用方法handleSyncGroup如出一辙所以这里我就不再展开讲了我们重点关注一下它的源码实现。
鉴于它的代码很长,我把它拆解成两个部分,并配以流程图进行介绍。
- 第1部分主要**对消费者组做各种校验**,如果没有通过校验,就封装对应的异常给回调函数;
- 第2部分**根据不同的消费者组状态选择不同的执行逻辑**。你需要特别关注一下在CompletingRebalance状态下代码是如何实现组同步的。
我先给出第1部分的流程图你可以先看一下对这个流程有个整体的感知。
<img src="https://static001.geekbang.org/resource/image/f0/4b/f0d4274cc5d37663fb3d0da6b0af954b.jpg" alt="">
下面,我们来看这部分的代码:
```
if (group.is(Dead)) {
responseCallback(
SyncGroupResult(Errors.COORDINATOR_NOT_AVAILABLE))
} else if (group.isStaticMemberFenced(memberId, groupInstanceId, &quot;sync-group&quot;)) {
responseCallback(SyncGroupResult(Errors.FENCED_INSTANCE_ID))
} else if (!group.has(memberId)) {
responseCallback(SyncGroupResult(Errors.UNKNOWN_MEMBER_ID))
} else if (generationId != group.generationId) {
responseCallback(SyncGroupResult(Errors.ILLEGAL_GENERATION))
} else if (protocolType.isDefined &amp;&amp; !group.protocolType.contains(protocolType.get)) {
responseCallback(SyncGroupResult(Errors.INCONSISTENT_GROUP_PROTOCOL))
} else if (protocolName.isDefined &amp;&amp; !group.protocolName.contains(protocolName.get)) {
responseCallback(SyncGroupResult(Errors.INCONSISTENT_GROUP_PROTOCOL))
} else {
// 第2部分源码......
}
```
可以看到代码非常工整全是if-else类型的判断。
**首先**这部分代码会判断消费者组的状态是否是Dead。如果是的话就说明该组的元数据信息已经被其他线程从Coordinator中移除了这很可能是因为Coordinator发生了变更。此时最佳的做法是**拒绝该成员的组同步操作**封装COORDINATOR_NOT_AVAILABLE异常显式告知它去寻找最新Coordinator所在的Broker节点然后再尝试重新加入组。
接下来的isStaticMemberFenced方法判断是有关静态成员的我们可以不用理会。
**之后**代码判断memberId字段标识的成员是否属于这个消费者组。如果不属于的话就封装UNKNOWN_MEMBER_ID异常并调用回调函数返回如果属于的话则继续下面的判断。
**再之后**,代码**判断成员的Generation是否和消费者组的相同**。如果不同的话则封装ILLEGAL_GENERATION异常给回调函数如果相同的话则继续下面的判断。
接下来,代码**判断成员和消费者组的协议类型是否一致**。如果不一致则封装INCONSISTENT_GROUP_PROTOCOL异常给回调函数如果一致就进行下一步。
**最后**,判断**成员和消费者组的分区消费分配策略是否一致**。如果不一致同样封装INCONSISTENT_GROUP_PROTOCOL异常给回调函数。
如果这些都一致则顺利进入到第2部分。在开始之前我依然用一张图来展示一下这里的实现逻辑。
<img src="https://static001.geekbang.org/resource/image/91/bf/9184344e316c3cb5e6e797c1b574acbf.jpg" alt="">
进入到这部分之后,代码要做什么事情,完全**取决于消费者组的当前状态**。如果消费者组处于CompletingRebalance状态这部分代码要做的事情就比较复杂我们一会儿再说现在先看除了这个状态之外的逻辑代码。
```
group.currentState match {
case Empty =&gt;
// 封装UNKNOWN_MEMBER_ID异常调用回调函数返回
responseCallback(SyncGroupResult(Errors.UNKNOWN_MEMBER_ID))
case PreparingRebalance =&gt;
// 封装REBALANCE_IN_PROGRESS异常调用回调函数返回
responseCallback(SyncGroupResult(Errors.REBALANCE_IN_PROGRESS))
case CompletingRebalance =&gt;
// 下面详细展开......
case Stable =&gt;
// 获取消费者组成员元数据
val memberMetadata = group.get(memberId)
// 封装组协议类型、分配策略、成员分配方案,调用回调函数返回
responseCallback(SyncGroupResult(group.protocolType, group.protocolName, memberMetadata.assignment, Errors.NONE))
// 设定成员下次心跳时间
completeAndScheduleNextHeartbeatExpiration(group, group.get(memberId))
case Dead =&gt;
// 抛出异常
throw new IllegalStateException(s&quot;Reached unexpected condition for Dead group ${group.groupId}&quot;)
}
```
如果消费者组的当前状态是Empty或PreparingRebalance那么代码会封装对应的异常给回调函数供其调用。
如果是Stable状态则说明此时消费者组已处于正常工作状态无需进行组同步的操作。因此在这种情况下简单返回消费者组当前的分配方案给回调函数供它后面发送给消费者组成员即可。
如果是Dead状态那就说明这是一个异常的情况了因为理论上不应该为处于Dead状态的组执行组同步因此代码只能选择抛出IllegalStateException异常让上层方法处理。
如果这些状态都不是那么消费者组就只能处于CompletingRebalance状态这也是执行组同步操作时消费者组最有可能处于的状态。因此这部分的逻辑要复杂一些我们看下代码
```
// 为该消费者组成员设置组同步回调函数
group.get(memberId).awaitingSyncCallback = responseCallback
// 组Leader成员发送的SyncGroupRequest请求需要特殊处理
if (group.isLeader(memberId)) {
info(s&quot;Assignment received from leader for group ${group.groupId} for generation ${group.generationId}&quot;)
// 如果有成员没有被分配任何消费方案,则创建一个空的方案赋给它
val missing = group.allMembers.diff(groupAssignment.keySet)
val assignment = groupAssignment ++ missing.map(_ -&gt; Array.empty[Byte]).toMap
if (missing.nonEmpty) {
warn(s&quot;Setting empty assignments for members $missing of ${group.groupId} for generation ${group.generationId}&quot;)
}
// 把消费者组信息保存在消费者组元数据中,并且将其写入到内部位移主题
groupManager.storeGroup(group, assignment, (error: Errors) =&gt; {
group.inLock {
// 如果组状态是CompletingRebalance以及成员和组的generationId相同
if (group.is(CompletingRebalance) &amp;&amp; generationId == group.generationId) {
// 如果有错误
if (error != Errors.NONE) {
// 清空分配方案并发送给所有成员
resetAndPropagateAssignmentError(group, error)
// 准备开启新一轮的Rebalance
maybePrepareRebalance(group, s&quot;error when storing group assignment during SyncGroup (member: $memberId)&quot;)
// 如果没错误
} else {
// 在消费者组元数据中保存分配方案并发送给所有成员
setAndPropagateAssignment(group, assignment)
// 变更消费者组状态到Stable
group.transitionTo(Stable)
}
}
}
})
groupCompletedRebalanceSensor.record()
}
```
第1步为该消费者组成员设置组同步回调函数。我们总说回调函数其实它的含义很简单也就是将传递给回调函数的数据通过Response的方式发送给消费者组成员。
第2步判断当前成员是否是消费者组的Leader成员。如果不是Leader成员方法直接结束因为只有Leader成员的groupAssignment字段才携带了分配方案其他成员是没有分配方案的如果是Leader成员则进入到下一步。
第3步为没有分配到任何分区的成员创建一个空的分配方案并赋值给这些成员。这一步的主要目的是构造一个统一格式的分配方案字段assignment。
第4步调用storeGroup方法保存消费者组信息到消费者组元数据同时写入到内部位移主题中。一旦完成这些动作则进入到下一步。
第5步在组状态是CompletingRebalance而且成员和组的Generation ID相同的情况下就判断一下刚刚的storeGroup操作过程中是否出现过错误
- 如果有错误则清空分配方案并发送给所有成员同时准备开启新一轮的Rebalance
- 如果没有错误则在消费者组元数据中保存分配方案然后发送给所有成员并将消费者组状态变更到Stable。
倘若组状态不是CompletingRebalance或者是成员和组的Generation ID不相同这就说明消费者组可能开启了新一轮的Rebalance那么此时就不能继续给成员发送分配方案。
至此CompletingRebalance状态下的组同步操作完成。总结一下组同步操作完成了以下3件事情
1. 将包含组成员分配方案的消费者组元数据,添加到消费者组元数据缓存以及内部位移主题中;
1. 将分配方案通过SyncGroupRequest响应的方式下发给组下所有成员。
1. 将消费者组状态变更到Stable。
我建议你对照着代码自行寻找并阅读一下完成这3件事情的源码这不仅有助于你复习下今天所学的内容还可以帮你加深对源码的理解。阅读的时候你思考一下这些代码的含义是否真的如我所说。如果你有不一样的理解欢迎写在留言区我们可以开放式讨论。
## 总结
今天我们重点学习了Rebalance流程的第2步也就是组同步。至此关于Rebalance的完整流程我们就全部学完了。
Rebalance流程是Kafka提供的一个非常关键的消费者组功能。由于它非常重要所以社区在持续地对它进行着改进包括引入增量式的Rebalance以及静态成员等。我们在这两节课学的Rebalance流程是理解这些高级功能的基础。如果你不清楚Rebalance过程中的这些步骤都是做什么的你就无法深入地掌握增量式Rebalance或静态成员机制所做的事情。
因此我建议你结合上节课的内容好好学习一下消费者组的Rebalance彻底弄明白一个消费者组成员是如何参与其中并最终完成Rebalance过程的。
我们来回顾一下这节课的重点。
- 组同步成员向Coordinator发送SyncGroupRequest请求以获取分配方案。
- handleSyncGroup方法接收KafkaApis发来的SyncGroupRequest请求体数据执行组同步逻辑。
- doSyncGroup方法真正执行组同步逻辑的方法执行组元数据保存、分配方案下发以及状态变更操作。
<img src="https://static001.geekbang.org/resource/image/fc/e9/fccc73c2867102f2ec6e8e3666f101e9.jpg" alt="">
讲到这里Coordinator组件的源码我就介绍完了。在这个模块中我们基本上还是践行“自上而下+自下而上”的学习方式。我们先从最低层次的消费者组元数据类开始学习逐渐上浮到它的管理器类GroupMetadataManager类以及顶层类GroupCoordinator类。接着在学习Rebalance流程时我们反其道而行之先从GroupCoordinator类的入口方法进行拆解又逐渐下沉到GroupMetadataManager和更底层的GroupMetadata以及MemberMetadata。
如果你追随着课程的脚步一路走来,你就会发现,我经常采用这种方式讲解源码。我希望,你在日后的源码学习中,也可以多尝试运用这种方法。所谓择日不如撞日,我今天就给你推荐一个课后践行此道的绝佳例子。
我建议你去阅读下clients工程中的实现消息、消息批次以及消息集合部分的源码也就是Record、RecordBatch和Records这些接口和类的代码去反复实践“自上而下”和“自下而上”这两种阅读方法。
其实这种方式不只适用于Kafka源码在阅读其他框架的源码时也可以采用这种方式。希望你可以不断总结经验最终提炼出一套适合自己的学习模式。
## 课后讨论
Coordinator不会将所有消费者组的所有成员的分配方案下发给单个成员这就是说成员A看不到成员B的分区消费分配方案。那么你能找出来源码中的哪行语句做了这件事情吗
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,105 @@
<audio id="audio" title="特别放送(一)| 经典的Kafka学习资料有哪些" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/0e/8959b8e7cb714bba239f4a60ac1ba60e.mp3"></audio>
你好,我是胡夕。我们的课程已经更新一段时间了,你每节课都按时学了吗?如果你一直跟着学习的话,相信你一定会有很多收获的。
当然了,我也知道,在学习源码的过程中,除了有进步的快乐,可能还会有一些痛苦,毕竟,源码并不是那么容易就可以掌握的。
如果你在学习的过程中遇到任何问题都可以给我留言我会尽快回复你帮助你解决问题。如果你发现自己被一些不好的情绪包围了除了要努力坚持以外我建议你学着从这种情绪中暂时跳脱出来让自己转换到一些轻松的话题上。今天我要讲的特别放送的内容就非常让人放松因为我会给你分享一些Kafka的学习资料。
实际上在更新的这段时间里经常有同学问我“老师我想更多地了解下Kafka你能给我推荐一些相关的学习资料吗”今天我就借着这个特别放送的环节专门为你搜罗了各种Kafka学习资料包括书籍、视频、博客等一切影音像资料我还把它们做成了清单一起分享给你。
这些资料的深浅程度不一样,有的偏重于基础理论,有的深入底层架构,有的侧重于实际案例,有的是分享最佳实践。
如果你期望得到实际使用上的指导那么可以重点关注下我提到的社区维护的文档以及各类Kafka实战书籍。如果你对Kafka源码的学习兴趣更加浓厚那么这节课里的各类大神级的博客以及视频资料是你需要重点关注的。因为他们经常会直接给出源码级的分析学习这类资料既能开拓我们的思路与视野也能加深我们对源码知识的理解可以说是具有多重好处。
总之,我建议你基于自身的学习目标与兴趣,有针对性地利用这些资料。
我把这份清单大体分为英文资料和中文资料两大部分,我先给出收集到的所有英文资料清单。
## 英文资料
1.[Apache Kafka官方网站](https://kafka.apache.org/documentation/)
我不知道你有没有认真地读过官网上面的文字这里面的所有内容都是出自Kafka Committer之手文字言简意赅而且内容翔实丰富。我推荐你重点研读一下其中的**Configuration篇**、**Operations篇**以及**Security篇**特别是Configuration中的参数部分。熟练掌握这些关键的参数配置是进阶学习Kafka的必要条件。
2.Confluent公司自己维护的[官方文档](http://docs.confluent.io/current/)
Confluent公司是Kafka初创团队创建的商业化公司主要是为了提供基于Kafka的商业化解决方案。我们经常把他们提供的产品称为Confluent Kafka。就我个人的感觉而言这个公司的官网质量要比社区版即Apache Kafka官网上乘特别是关于Security和Kafka Streams两部分的介绍明显高出社区版一筹。因此我推荐你重点学习Confluent官网上关于[Security配置](https://docs.confluent.io/current/security/index.html)和[Kafka Streams组件](https://docs.confluent.io/current/streams/index.html)的文档。
3.Kafka的[Jira列表](https://issues.apache.org/jira/issues/?filter=-4&amp;jql=project%20%3D%20KAFKA%20ORDER%20BY%20created%20DESC)也就是我们俗称的Bug列表
你可以在这个页面上搜索自己在实际环境中碰到的Kafka异常名称然后结合自己的Kafka版本来看这样的话你通常能判断出该异常是不是由已知Bug造成的从而避免浪费更多的时间去定位问题原因。另外你还可以通过认领Jira的方式来为社区贡献代码。后面我会单独再用一节课的时间给你具体介绍一下为社区贡献代码的完整流程。
4.Kafka的[KIP网站](https://cwiki.apache.org/confluence/display/KAFKA/Kafka+Improvement+Proposals)
KIP的完整名称是Kafka Improvement Proposals即Kafka的新功能提案。在这里你能够了解到Kafka社区关于新功能的所有提案与相关讨论。有意思的是有些KIP的讨论要比KIP本身精彩得多。针对各种新功能全球开发者在这里审思明辨彼此讨论有时协同互利有时针锋相对实在是酣畅淋漓。KIP的另一大魅力则在于它非常民主——**任何人都能申请新功能提案,将自己的想法付诸实践**。
5.StackOverflow的[Kafka专区](https://stackoverflow.com/questions/tagged/apache-kafka?sort=newest&amp;pageSize=15)
大名鼎鼎的StackOverflow网站我就不过多介绍了。这上面的Kafka问题五花八门而且难度参差不齐不过你通常都能找到你想要的答案。同时如果你对Kafka非常了解不妨尝试回答一些问题不仅能赚取一些积分还能和全球的使用者一起交流实在是一举两得。
6.[Confluent博客](https://www.confluent.io/blog/)
这里面的文章含金量特别高你一定要仔细研读下。举个简单的例子我已经被太多人问过这样的问题了“一个Kafka集群到底能支撑多少分区”其实我们都知道这种问题是没有标准答案的你需要了解的是原理碰巧Confluent博客上就有一篇这样的原理性解释[文章](https://www.confluent.jp/blog/apache-kafka-supports-200k-partitions-per-cluster/)是Kafka创始人饶军写的你不妨看一看。
7.Kafka社区非公开的各种资料这包括社区维护的[Confluence文档](https://cwiki.apache.org/confluence/display/KAFKA/Index)和Google Docs
你几乎能在Confluence Space中找到所有的Kafka设计文档其中关于Controller和新版Clients设计的文章非常值得一读而Google Docs主要是两篇一篇是Kafka事务的[详细设计文档](https://docs.google.com/document/d/11Jqy_GjUGtdXJK94XGsEIK7CP1SnQGdp2eF0wSw9ra8/edit),另一篇是[Controller重设计文档](https://docs.google.com/document/d/1rLDmzDOGQQeSiMANP0rC2RYp_L7nUGHzFD9MQISgXYM/edit)。这两篇是我目前所见过的最详细的Kafka设计文档。国内的很多Kafka书籍和博客多是援引这两篇文章甚至是直接翻译的足见它们的价值非凡。
8.Kafka社区的[Twitter首页](https://twitter.com/apachekafka)和Confluent的[Twitter首页](https://twitter.com/confluentinc)
你可能会说Twitter算哪门子学习资料啊但实际上很多时候你就是能够在这上面搜罗到有价值的Kafka文章特别是Confluent的Twitter它会定期推送关于Kafka最佳实践方面的内容。每次看到这类文章 我都有一种意外淘到宝藏的感觉。我给你举个例子Kafka Twitter在2019年10月12日转载了一篇名为[**Exploit Apache Kafkas Message Format to Save Storage and Bandwidth**](https://medium.com/swlh/exploit-apache-kafkas-message-format-to-save-storage-and-bandwidth-7e0c533edf26) 的文章,这里面的内容水准很高,读起来非常过瘾,我建议你好好读一读。
9.YouTube上的[Kafka视频](https://www.youtube.com/results?search_query=apache+kafka&amp;sp=EgIIAw%253D%253D)
这些视频内容主要包括Kafka原理的讲解、业界牛人分享等。有的时候你会发现我们对文字类资料的掌握程度远不如看视频来得深入。如果你的英语基础还不错的话我推荐你重点关注下YouTube上的这些分享视频。
好了上面这九个资料就是我总结的Kafka英文学习资料。总体上看这些资料都不要求太高的英文基础。即使是像YouTube上的英文视频也是支持实时翻译的你不用担心出现无法理解内容的情况。
接下来,我来给出中文资料清单。
## 中文资料
首先我给出我认为比较好的五本Apache Kafka书籍。
1.[《Kafka权威指南》](https://book.douban.com/subject/27665114/)
这本书是“Kafka Definitive Guide”的中译本。实际上这本书的中英两个版本我都看过应该说中文版翻译得很棒你直接看中译本就行了。这本书虽然很薄但它包含的内容几乎囊括了Kafka的方方面面而且这本书由Committer执笔质量上无可挑剔。
2.[《Kafka技术内幕》](https://book.douban.com/subject/27179953/)
这本书出版后一跃成为市面上能见到的Kafka最好书籍。这本书当得起“技术内幕”这四个字里面很多Kafka原理的讲解清晰而深入我自己读起来觉得收获很大。
3.[《深入理解Kafka核心设计与实践原理》](https://book.douban.com/subject/30437872/)
我与这本书的作者相识他同时精通Kafka和RabbitMQ可以说是消息中间件领域内的大家。这本书成书于2019年是目前市面上最新的一本Kafka书籍。我推荐你买来读一下。
4.[《Kafka Streams实战》](https://book.douban.com/subject/33425155/)
这本书是“Kafka Streams in action”的中译本由Kafka Committer撰写而成。该书是我见到的**最深入讲解Kafka Streams的书籍**。如果你想学习基于Kafka Streams的实时流处理那么这本书是不能不看的。
5.[《Apache Kafka实战》](https://book.douban.com/subject/30221096/)
我这本书是2018年出版的和之前那些面向Kafka设计原理的国内佳作不同的是该书以讨论Kafka实际应用为主。我在里面分享了我这几年参与Kafka社区以及在使用Kafka的过程中积累的各种“江湖杂技”。如果你以使用为主那么我推荐你看下这本书。
书籍的推荐告一段落,下面,我再介绍三个网站给你。
第一个是OrcHome。据我所知OrcHome是国人自己维护的一个Kafka教程网站。这个网站最具特色的是它有个[Kafka问答区](https://www.orchome.com/kafka/issues),你可以在这上面提问,会有人专门负责解答你提出的问题。
第二个是我本人的[博客](https://www.cnblogs.com/huxi2b/)。这个博客里大多是关于Kafka或者是其他大数据技术的原创文章。另外我也会定期分享转载一些国内外的优秀博客。
第三个是知乎的[Kafka专区](https://www.zhihu.com/topic/20012159/newest)。和StackOverflow不同的是这个专区上的问题多以理论探讨为主。通常大家对于这类问题的解答还是很踊跃的我也经常在这里回复问题。如果你对Kafka的某些原理想要做深入的了解不妨在知乎的这个专区上提出你的问题我相信很快就会有人回复的。
## 总结
好了上面的这些内容就是我总结的Kafka学习资料清单希望它们对你是有帮助的。我把它们整理在了一张表格里你可以重点看下。
<img src="https://static001.geekbang.org/resource/image/4d/b9/4d773e45c4a3f86c5d9e86bb4a7ac7b9.jpg" alt="">
另外我强烈建议你把社区官网和Confluent官网文档仔仔细细地读几遍我保证你一定会有很大的收获毕竟相比于清单上的其他项官网文档是最最权威的第一手资料。
## 课后讨论
最后我也请你分享一下你自己的Kafka学习书单、网站、影音像资料以及好的学习方法。
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,87 @@
<audio id="audio" title="特别放送(三)| 我是怎么度过日常一天的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7d/42/7dd05b26e49465f76f1d1ce5c4d24342.mp3"></audio>
你好,我是胡夕。
今天,我们暂时抛开冗长的代码,轻松一下,我和你分享一个课程以外的话题——我是怎么度过日常的一天的。
当然了,这并不是一节说教课。也许,你看完之后,会有一种看“老干部”的既视感:每日在固定的时辰起床和睡觉,午后清茶一杯,再加上老旧钢笔留下的几抹笔迹,无论寒暑。
很多人说,自律是最顶级的自由。我并不敢轻言说我的这种生活就叫自律,但我的的确确乐在其中,不能自拔。我很享受这种规律的生活带给我的安全感和节奏感。我很感谢这样的日复一日,也感谢我自己。
子曰:“己所不欲,勿施于人。”但我认为,**己之所欲,亦勿施于人**。
<img src="https://static001.geekbang.org/resource/image/95/32/95c29962376394299e944aa3b7a06532.png" alt="">
“每个人应该有属于自己的人生过自己想要的生活。”我对这句话深以为然。因此我告诫自己今天的分享绝非是要向你推销某种生活方式。它就是一种经历、一段总结希望你能从中获得一些灵感得到一点启迪。我尽力保证把它写得生动有趣不至于让你直接跳到晚上11点上床睡觉的那部分。毕竟睡觉好像也没什么可写的哈哈。
### 早餐时补充足够的蛋白质是高效工作的关键
“When your dreams come alive youre unstoppable. Take a shot, chase the sun, find the beautiful.”清晨6:30伴随着 **Dream it possible** 的音乐闹钟,我开启了新的一天。穿上衣服洗漱完毕后,开始吃早餐。
我建议你一定要好好对待早餐。有些人认为,早餐吃多了,上午容易困。在我看来,这是不成立的,因为即使你不吃早餐,上午也是会困的。**瞌睡与否取决于你要做的工作,与早餐无关**。
早餐的种类请尽量丰富一些,最起码要有一些肉类或蛋白质,因为对于我们这些脑力工作者来说,补充足够的蛋白质是维持上午高效脑力工作的基础。
<img src="https://static001.geekbang.org/resource/image/54/27/5408922d21a558a770fc7fdfc1ef7227.jpg" alt="">
吃过了早餐时间大约来到了7点半。面对北京早高峰拥挤的车流我已然放弃了开车去上班的念头。60分钟的地铁时光不长不短正好可以让我补个回笼觉。哦是正好让我有足够的时间去学点别的东西。
绝大多数情况下在我踏上地铁车厢的那一刻我就习惯打开手机里的英语学习软件来一个10分钟的考研单词速记。之后是个固定节目听3集EnglishPod唤醒一下早晨还未彻底清醒的耳朵。待这些都做完之后剩下的时间就比较随性了。大体上我都是在Kindle中度过的。
我很喜欢看书在地铁上手拿着Kindle用食指滑屏翻书的动作会让我有种“自己是个知识分子”的感觉虽然我觉得我本来就是。当然我并不排斥纸质书。事实上最近我在地铁上看得最多的就是一本名为《狄仁杰传》的纸质书。
平素里我到公司很早一般在8点40左右。如果你是在国企或机关单位那么请忽略这里的“很早”这是针对互联网行业而言的。我个人非常珍视早晨的这段时间因为在这段时间内我的工作效率是最高的。所以无论在哪里工作我都愿意很早到公司。
一般情况下,这么早到公司的人应该是很“孤单”的,毕竟,可能很多同事依然还在路上,离公司近的甚至还在床上。安静的氛围能够让我从地铁站的喧嚣中解脱出来,身心快速得到放松。此刻,有利于专心工作的客观条件均已形成,因而我会立即进入工作状态。
令人遗憾的是,对于工作,我本人尚未修炼出那种如痴如醉的专注状态。站如松、坐如钟似的钉在座位上好几个小时忙于工作,我暂时还做不到。幸好,世间有一种巨高效的工作方法:**番茄钟工作法**也就是每工作25分钟再休息5分钟然后开始新一轮的25分钟如此循环往复。
现在对于非会议类的任何工作我都谨守番茄钟法则。这套工作理念再配以合适的App真是无往而不利帮助我极大地提升了专注力和执行力因此我总能完成预定的工作目标。
### 高效的会议,是与会人都聚焦于核心议题,而不是进行发散讨论
由于职位的缘故在一天当中我经常要参加很多公司级的多人会议。即使是在没什么人来的8、9点钟一两个这样的会议就可能填满本就已经非常短暂的上午时间了 。于是,研究高效的会议组织方式,就成了摆在我面前的重要课题。
根据平日的实践,我比较推崇**亚马逊会议模式**。这是亚马逊创始人杰夫·贝索斯发明的。它的最大特点是:**静默开场**。
什么叫静默开场呢?简单来说就是,在会议开始前,会议组织者要把即将讨论的主要议题及内容以文档的方式发给所有与会者。会议开始后,所有与会者仔细阅读这些议题和内容,这就是所谓的静默开场。
这通常会持续20~30分钟在这段时间里与会者针对讨论议题实时给出评审意见并通过文字回复的方式与其他与会者互动。得益于目前先进的多人在线文档编辑工具亚马逊会议模式能够让所有与会者很好地聚焦会议议题从而保证了在座所有人的注意力始终关注在核心内容上而不是进行发散式讨论。
另外开会结束后一定要创建具有明确负责人的待办项。一个有意思的现象是如果待办项不指定由谁来完成那么最后“倒霉的”就是那个会议组织者谁让ta组织会议的呢因此在我们公司4人以上的会议结束后必须要明确后续待办项以及相应的负责人。
### 如果每天的工作只是循规蹈矩、萧规曹随,又怎么能在芸芸众生之中脱颖而出呢?
不论是日常工作还是开会中午一般都能很快地到来。经过一上午的饱满工作我多半早已饥肠辘辘了。我个人是没有午睡习惯的因此在饱餐战饭之后我通常会利用这段时间学点工作以外的东西比如学习Kafka源码一是为了换换脑子二是充分利用这段时间去提高自己。
有人说超越别人的时间是在工作的8小时之外我深表赞同。如果每天的工作只是循规蹈矩、萧规曹随我们又怎么能在芸芸众生之中脱颖而出呢当然这里有一句毒鸡汤给到你纵然我们拼命努力大概率我们也不会有多大成功。然而就像哈维尔的名言所说“我们坚持一件事情并不是因为这样做了会有效果而是坚信这样做是对的。”
我很珍惜午后的这段闲暇时光。通常我会泡上最喜欢的绿茶拿出老旧钢笔和A4空白纸翻出最近喜爱的技术文档或商业报告一笔一划地记录下此刻的学习心得。
我一直认为自己不算是个聪明人,我发现,只有把要理解的知识写下来,我才会记得牢固。我也很喜欢这样的学习方式。书写记录的过程会诱发我对所学知识的思考,这也算是难得的学习收获了。另外,日复一日的书写,帮我练就了一手好字。有时候,我会觉得,这是比学习本身更大的成就。
<img src="https://static001.geekbang.org/resource/image/ae/74/aea0e818a38cf776ccf705d61f5d2274.jpeg" alt="">
### 很多决定哪有正确与否,我们要做的就是尽力使我们的决定变得正确
短暂的午休结束之后,繁忙而冗长的下午工作便开始了。
通常来说我要一直忙到6点半左右。这中间充斥着各类会议将原本整整齐齐的4个多小时切割得七零八落。不过我想说的是这才是工作本身的模样。坦率地说之前我会因为没有整块的时间去做事而感到沮丧现在每每想来都觉得自己过于矫情。在战场上哪有时间让你准备充分再去战斗的呢
同理,我们在公司业务上快速做的每个决定,哪有什么正确与否,无一不是通过后续的努力奋斗让它们变得正确罢了。现在我非常感恩这种快节奏、碎片化的业务驱动型工作,因为它让我能够尽可能快速地做业务决策。
我个人不太喜欢制定详尽的计划之后再下场干活的按部就班的做事风格,尤其是在瞬息万变的互联网环境中。当下,市场压根没给业务决策人员深思熟虑的时间窗口。事儿是干出来的,路是蹚出来的,而不是做计划做出来的。
其实,技术也是这么个道理。想学什么技术,卷起袖子立马学就好了,不用去搞什么学习路线图,也不用花精力去思考什么捷径,更不用对这个决定患得患失。很多时候,我们都没有到拼理论拼方法的阶段,我们只需要比别人多努力一点点,就已经能够领先他们很多了。
吃过晚饭之后,我大致会复制中饭后的自主学习模式。
待时钟越过了7点半我会重新投入到工作中。如果没有特别的事情需要加班我会在晚上8点左右结束工作收工回家。
与来时在地铁用Kindle读书不同每天的归家路上我通常会花一些时间反思下当天的言行。比如一天当中哪些话说得不好哪些事儿办得不美哪些人未好好对待。虽远未及“每日三省吾身”的境界但这的的确确是我的每日必修课。
可能因为是水瓶座的关系吧,我特别喜欢独处的时光。《百年孤独》里的那句话太令人认同了:“生命中曾经有过的所有喧嚣,都将用寂寞来偿还。”拥有对抗孤独的能力是一个人成熟的重要标志。我喜欢在独处的时光中提升自己的成熟度。
时钟拨到22点时街道寂静一天之中我最喜欢的1个小时到来了。我会在这一小时里面做很多种类的事情研究技术、学习金融当然还有专栏的写稿与录音。有的时候我甚至什么都不做只是打开手机的冥想软件呆呆地想上许久……一小时倏然而逝但我会期盼着它第二日的准时到达。
好了终于要说到23点睡觉了。那我去睡了我们下节课见Zzzzz…

View File

@@ -0,0 +1,82 @@
<audio id="audio" title="特别放送(二)| 一篇文章带你了解参与开源社区的全部流程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/f8/50e6d5e29bb7039e5acf0077a921a8f8.mp3"></audio>
你好我是胡夕。从课程上线开始我就经常被问到这样一个问题“我想参与到Apache Kafka社区应该怎么做呢”今天我就为你梳理一下参与Apache开源社区特别是Kafka社区的最佳实践步骤。
参照着这套最佳实践你能够轻松地参与到社区的日常开发中甚至是成为社区Contributor中的一员。即使你暂时不打算加入社区开发在你遇到Kafka难题时这套实践法则也可以帮助你快速地找到适当的资源节省你的时间成本。比如说在诊断很多Kafka问题时社区邮件组和Jira列表往往是比搜索引擎更好的工具。
大体上说我们参与开源社区通常需要完成5步如下图所示
<img src="https://static001.geekbang.org/resource/image/a0/11/a025813482db2a3e93fb6b4574e38011.jpg" alt="">
虽然我在上图为每一步都标记了序号,但这并不表示它们之间是有先后关联顺序的,你完全可以按照任意顺序去实现它们。接下来,我就带你一步步梳理这五个方面。
## 1.精通Kafka源代码
你需要非常了解Kafka的源代码毕竟我们参与到开源社区的目的就是贡献我们自己的代码。不管是要修复Bug还是想要为Kafka新增任何功能你都要提前了解相关的源码才能进行。我们目前的这门课就是帮你完成此事的。认真学完这个课程你至少可以对Kafka服务器端的源代码有一个深入而广泛的了解为你后续贡献这部分代码奠定基础。
## 2.订阅Kafka邮件组
在我看来,这是参与开源社区最重要的一步,没有之一!**订阅邮件组是及时了解社区动态最快速、最有效的手段**。Kafka邮件组的活跃程度在整个Apache社区名列前茅。
根据[Apache社区2018年度报告](https://blogs.apache.org/foundation/entry/the-apache-software-foundation-announces37)我们可以知道Kafka的两个邮件组users和dev分别排在最活跃邮件组Top5的第4名和第2名足见社区的人气之高。
Kafka邮件组主要包含两个users@kafka.apache.org和dev@kafka.apache.org。Kafka用户在使用过程中碰到任何问题都可以向第一个邮件组发送邮件询问而第二个邮件组主要用于进行Kafka开发的各种讨论。就我个人的经验而言dev这个邮件组的含金量要更高一些因为上面有很多针对Kafka设计以及改进建议的讨论和交流非常值得一看。
订阅这些邮件组的方法很简单你需要给这两个邮件组对应的订阅邮件地址发送邮件。比如你要订阅dev邮件组那么可以发送一封名为“Subscribe to Kafka dev mailing list”的邮件给dev-subscribe@kafka.apache.org这样你就每天都能收到dev邮件组的邮件了。同理你需要发送邮件给users-subscribe@kafka.apache.org来订阅users邮件组。
订阅成功之后你就可以给users和dev邮件组发送邮件、询问问题了。不过你要记住这两个邮件组的区别。
- users讨论Kafka**使用**方面的问题。
- dev讨论Kafka**开发**方面的问题。
需要注意的是这两个邮件组每天的邮件数量非常多如果你觉得不胜其烦可以取消订阅。取消方法与订阅方法类似只是你要发送邮件给对应的取消订阅邮件地址users对应的取消订阅邮件地址是users-unsubscribe@kafka.apache.orgdev对应的是dev-unsubscribe@kafka.apache.org。
## 3.开通Jira权限
当前Kafka使用[Jira](https://issues.apache.org/jira/issues/?filter=-4&amp;jql=project%20%3D%20KAFKA%20ORDER%20BY%20created%20DESC)进行项目管理负责Kafka的Bug管理和新功能提案的追踪。如果你要参与到Kafka社区Jira权限是不能不开通的。开通权限是什么意思呢这主要是指你要申请一个Jira的账号。账号申请完成之后你还需要发送邮件给users@kafka.apache.org申请将你的Jira ID加入到Kafka的Contributor列表中。只有Jira ID被加入到Contributor列表你才有资格认领Jira ticket也就是我们常说的修Bug。
## 4.开通KIP权限
在之前的特别放送里,我提到过[KIP](https://cwiki.apache.org/confluence/display/KAFKA/Kafka+Improvement+Proposals)也就是Kafka新功能提案。通常情况下你不需要任何权限就能浏览这些新功能提案。不过如果你要提交自己的提案那你就必须要提前申请KIP账号权限了具体的做法是向dev@kafka.apache.org邮箱发送申请邮件。
我举个例子你可以编写一封邮件名字为“Granting permission for Create KIP”正文是“Please grant permission for Create KIP to wiki ID: &lt;your_id&gt;然后发送给dev邮件组。大约1到2个工作日之后你会收到一封邮件告知你的ID已被赋予了权限。
这个时候,你再次进入到[KIP页面](https://cwiki.apache.org/confluence/display/KAFKA/Kafka+Improvement+Proposals)点击Create KIP按钮按照页面目录结构撰写新功能提案即可。值得注意的是**一旦你创建了自己的提案页面在主KIP页面你还需要修改两个地方**。
首先,你必须更新**下一个KIP序号**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/ff/cd/ffa044b45fa05f5065b45cf72b95f2cd.png" alt="">
其次,你必须把你的提案页面地址增加到**KIPs under discussion**表格下并注明状态Under Discussion。
除此之外你还需要发送一封邮件给dev邮件组向社区声明你创建了一个KIP想要发起新一轮的讨论。下图展示的是一封我之前负责某KIP时发起的讨论邮件
<img src="https://static001.geekbang.org/resource/image/ce/b6/cebc8828a9f6070a48beb60554c5c6b6.png" alt="">
把这些都做完之后剩下的事情就是等待社区开发者对此KIP的讨论了你需要做的就是随时回答大家提出的各种问题然后向大家解释为什么这个KIP是很重要的功能。
## 5.提交Pull RequestPR
参与社区的最后一步也是最关键的一步就是向社区提交我们自己的Pull Request即PR。目前社区的PR大体分为两类普通PR和MINOR PR。
我先说下MINOR PR。顾名思义MINOR PR就是改动微不足道的那类PR比如你在阅读Kafka源码注释时发现某个单词拼写错了或者是变量命名不清晰那么针对这类问题你就可以直接给社区提一个PR。这类PR在命名时社区有个约定俗成的规范就是**它们要以“MINOR”开头**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/c5/03/c5db8ef3ed8e3a038086e3adb66ceb03.png" alt="">
给社区贡献MINOR PR是不需要事先创建Jira ticket的这也是这类PR的一个特点。
另一类PR就是普通PR了。要提交这类PR前你必须保证要修复的问题在Jira中存在对应的ticket并且最好确保Jira的Assignee是你自己。如果Assignee不是你自己那说明社区中有其他人正在研究这个问题你最好不要抢别人家的劳动果实。如果你知道了如何修复该问题可以在对应的Jira ticket下留言礼貌地询问是否可以把这个ticket让你来修复。如果原Assignee同意了你的请求你再提交自己的PR也不迟。
一旦提交了PR之后你的代码会被社区开发者进行Code Review并提出很多修改意见你需要小心谨慎地对待这些Comment并处理它们。当Committer认为你的代码满足了要求之后他们会留言LGTM的字样表明Look Good To Me然后通知你代码可以被合并到主干分支了。这个时候你就正式成为了Apache Kafka的Contributor。
## 总结
今天我给你介绍了参与Kafka社区的最佳实践。我希望在学完这节课之后你可以根据这个教程一步一步地实现成为Kafka Contributor的目标甚至是成为下一个社区Committer当然每个人自身的实际目标和期望不同你也可以有针对性、有限度地参与到社区中以满足你的实际需求。
最后,我还想跟你分享一个国人参与开源社区的建议:**不要只和国内开发者进行对话,要多多和国外开发者进行交流合作**。我发现很多国内开发者只会去找Kafka社区中的华人帮忙解决问题其实社区中的国外开发人员也是非常友善和易于沟通的我建议你也多和他们聊一聊相信你会有更多不一样的收获。
## 课后讨论
你想参与Kafka社区甚至是开源社区的初衷或者你过往参与开源社区的一些有趣经历你觉得做这些事情最大的意义或价值是什么
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="特别放送(五) | Kafka 社区的重磅功能:移除 ZooKeeper 依赖" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/da/53/da7d86cf8e73ab0e0144a5f550d57753.mp3"></audio>
你好我是胡夕。今天我们来聊聊Kafka社区的一个重磅功能**移除ZooKeeper依赖**。
Kafka从诞生开始就跟ZooKeeper紧紧地绑在了一起。可以这么说没有ZooKeeper就没有Kafka今天的成功。
但是随着Kafka的不断完善和演进社区逐渐发现在ZooKeeper和Kafka结合使用的过程中一些问题慢慢地显现了出来。比如说ZooKeeper并不适合于频繁的写操作但Kafka 0.8时代的消费者就是利用ZooKeeper来保存其位移信息的。因此移除ZooKeeper并使用Kafka内部主题的方式保存位移就从根本上规避了ZooKeeper的这个弊病。
摆脱ZooKeeper依赖的另一个好处在于这**能让Kafka变成一个独立的框架**。这样以后在使用Kafka时就不需要再额外维护一套ZooKeeper集群了。显然安装、运维和调优一套分布式集群的代价是很高的能够去除这样的依赖当然是一件好事。
讲到这里我猜你一定很想知道社区究竟打算怎么移除ZooKeeper呢别急我带你一步步来看下社区的计划。
## Clients演进
首先我们来看两张图。这两张图总结了0.8.x版本和0.11.x版本是否真的是从0.11版本开始的变化并不重要)及后续版本的功能变迁。
<img src="https://static001.geekbang.org/resource/image/f3/3a/f362b8977ab64c1b086862a42c049f3a.jpg" alt="">
在Kafka 0.8时代Kafka有3个Clients端分别是
- Producer负责向Kafka写消息
- Consumer负责从Kafka读消息
- Admin Tool执行各种运维任务比如创建或删除主题等。
其中Consumer的位移数据保存在ZooKeeper上因此Consumer端的位移提交和位移获取操作都需要访问ZooKeeper。另外Admin Tool执行运维操作也要访问ZooKeeper比如在对应的ZooKeeper znode上创建一个临时节点然后由预定义的Watch触发相应的处理逻辑。
后来随着Kafka的演进社区引入了位移主题__consumer_offsets同时定义了OffsetFetch和OffsetCommit等新的RPC协议。这样一来Consumer的位移提交和位移获取操作与位移主题直接交互从而避免了对ZooKeeper的访问。
除此之外社区还引入了新的运维工具AdminClient以及相应的CreateTopics、DeleteTopics、AlterConfigs等RPC协议替换了原先的Admin Tool。于是创建和删除主题这样的运维操作也完全移动到Kafka这一端来做就像上面的第二张图展示的那样。
到这里Kafka的3个Clients端基本上都不需要和ZooKeeper交互了。应该说移除ZooKeeper的工作完成了一大半不过依然还有一部分工作要在ZooKeeper的帮助下完成那就是Consumer的Rebalance操作。
在0.8时代Consumer Group的管理是交由ZooKeeper完成的包括**组成员的管理**和**订阅分区的分配**。这个设计在新版的Consumer中也得到了修正——**全部的Group管理操作交由Kafka Broker端新引入的Coordinator组件来完成**。要完成这些工作Broker端新增了很多RPC协议比如JoinGroup、SyncGroup、Heartbeat、LeaveGroup等。
此时Kafka的Java Clients端除了AdminClient还要依赖ZooKeeper之外所有其他的组件全部摆脱了对ZooKeeper的依赖。
之后社区引入了Kafka安全层实现了对用户的认证和授权。这个额外的安全层也是不需要访问ZooKeeper的因此之前依赖ZooKeeper的Clients端是无法“享用”这个安全层的。一旦启用新版Clients都需要首先接入这一层而且要在通过审核之后才能访问到Broker如下图所示
<img src="https://static001.geekbang.org/resource/image/3a/1a/3a11e19b0072b880ef5e13d296bb751a.jpg" alt="">
这么做的好处就是**统一了Clients端访问Broker端的模式**即通过定义一套跨语言RPC协议栈实现Clients端与Broker端的服务连接。这样一来不同的语言开发者只需要按照这套规范开发自己语言的RPC协议就能实现与Kafka Broker端的交互了。如果后面需要实现更多的功能社区只需要定义新的RPC协议就行了。同时新引入的安全层负责对这套RPC协议进行安全校验统一了访问模式。另外这些协议都是版本化的versioned能够独立地进行演进同时也兼顾了兼容性方面的考量。
## Broker间交互
说完了Clients端我们说下Broker端的现状。目前应该说Kafka Broker端对ZooKeeper是重度依赖的主要表现在以下几个方面
- Broker注册管理
- ACL安全层配置管理
- 动态参数管理;
- 副本ISR管理
- Controller选举。
我们拿一张图来说明。
<img src="https://static001.geekbang.org/resource/image/36/e7/36d1738674d272c01af86f2c5e06f6e7.png" alt="">
图中有4个Broker节点和1个ZooKeeper左上角的Broker充当Controller的角色。当前所有的Broker启动后都必须维持与ZooKeeper的会话。Kafka依赖于这个会话实现Broker端的注册。而且Kafka集群中的所有配置信息、副本信息、主题信息也都保存在ZooKeeper上。最后Controller与集群中的每个Broker都维持了一个TCP长连接用于向这些Broker发送RPC请求。当前的Controller RPC类型主要有3大类
1. LeaderAndIsr主要用于向集群广播主题分区Leader和ISR的变更情况比如对应的Broker应该是特定分区的Leader还是Follower
1. StopReplica向集群广播执行停止副本的命令
1. UpdateMetadata向集群广播执行变更元数据信息的命令。
图中还新增了一个AlterISR RPC这是KIP-497要实现的新RPC协议。现阶段Kafka各个主题的ISR信息全部保存在ZooKeeper中。如果后续要舍弃ZooKeeper就必须将这些信息从ZooKeeper中移出来放在Controller端来做。同时还要在程序层面支持对ISR的管理。因此社区计划在KIP-497上增加AlterISR协议。对了还要提一句当前Controller的选举也是依靠ZooKeeper完成的。
所以后面Broker端的演进可能和Clients端的路线差不多首先是把Broker与ZooKeeper的交互全部干掉只让Controller与ZooKeeper进行交互而其他所有Broker都只与Controller交互如下图所示
<img src="https://static001.geekbang.org/resource/image/2f/e9/2fb41e8ab62cdf402c7cb56d681627e9.png" alt="">
看上去,这种演进路线社区已经走得轻车熟路了,但实际上还有一些遗留问题,需要解决。
### Broker Liveness
首先就是Broker的liveness问题也就是Kafka如何判断一个Broker到底是否存活在目前的设计中Broker的生存性监测完全依赖于与ZooKeeper之间的会话。一旦会话超时或断开Controller自动触发ZooKeeper端的Watch来移除该Broker并对它上面的分区做善后处理。如果移除了ZooKeeperKafka应该采用什么机制来判断Broker的生存性是一个问题。
### Network Partition
**如何防范网络分区Network Partition**也是一个需要讨论的话题。当前可能出现的Network Partition有4种
1. 单个Broker完全与集群隔离
1. Broker间无法通讯
1. Broker与ZooKeeper无法通讯
1. Broker与Controller无法通讯。
下面4张图分别展示了这4种情况
<img src="https://static001.geekbang.org/resource/image/24/c7/24df41ac85ca244b674dbe84f4d6bcc7.png" alt=""><br>
<img src="https://static001.geekbang.org/resource/image/c2/88/c27c86320d961816516b75634fd67d88.png" alt="">
我们分别来讨论下。
情况一单Broker与集群的其他Broker隔离这其实并不算太严重的问题。当前的设计已经能够保证很好地应对这种情况了。一旦Broker被隔离Controller会把它从集群中摘除虽然可用性降低了但是整个集群的一致性依然能够得到保证。
情况二Broker间无法通讯可能的后果是消息的备份机制无法执行Kafka要收缩ISR**依然是可用性上的降低,但是一致性状态并没有被破坏**。
情况三Broker无法与ZooKeeper通讯。Broker能正常运转它只是无法与ZooKeeper进行通讯。这个时候我们说该Broker处于僵尸状态即所谓的Zoobie状态。在社区的Jira中因Zoobie状态引入的一致性Bug一直没有断过社区这几年也一直在修正这方面的问题主要对抗的机制就是**fencing**比如Leader Epoch。
情况四Broker无法与Controller通讯。在这种情况下所有的元数据更新通道被堵死即使这个Broker依然是healthy的它保存的元数据信息也可能是非常过期的。这样的话连接这个Broker的Clients端可能会看到各种非常古怪的问题。我之前回答过类似的问题你可以点击[链接](https://www.zhihu.com/question/313683699/answer/609887054)看一下。
这种情况比较复杂我就再多唠叨几句。实际上针对这种情况目前社区也没有太好的解决办法主要的原因是Broker的liveness完全是交由ZooKeeper来做的。一旦Broker与ZooKeeper之间的交互没有问题其他原因导致的liveness问题就无法彻底规避。
第四类Network Partition引入了一个经典的场景**元数据不一致**。目前每个Broker都缓存了一份集群的元数据信息这份数据是异步更新的。当第四类Partition发生的时候Broker端缓存的元数据信息必然与Controller的不同步这就会造成各种各样的问题。
下面我简单介绍一下元数据更新的过程。主要流程就是Controller启动时会同步从ZooKeeper上拉取集群全量的元数据信息之后再以异步的方式同步给其他Broker。其他Broker与Controller之间的同步往往有一个时间差也就是说Clients访问的元数据可能并不是最新的。我个人认为现在社区很多flaky test failure都是这个原因导致的。
事实上在实际使用过程中有很多场景是Broker端的元数据与Controller端永远不同步。通常情况下如果我们不重启Broker的话那么这个Broker上的元数据将永远“错误”下去。
好在社区还给出了一个最后的“大招”: **登录到ZooKeeper SHELL手动执行rmr /controller强迫Controller重选举然后重新加载元数据并给所有Broker重刷一份**。不过,我怀疑,在实际生产环境中是否有人真的要这么干,毕竟这样做的代价不小,而且最关键的是,这么做依然可能存在两个问题:
1. 我们如何确保Controller和Broker的数据是一致的
1. 加载元数据的过程通常很慢。
这里我详细说说第二点,即加载元数据的性能问题。
总体来说加载元数据是一个O(N)时间复杂度的过程这里的N就是你集群中总的分区数。考虑到Controller从ZooKeeper加载之后还要推给其他的Broker那么做这件事的总的时间复杂度就是O(N * M)其中M是集群中Broker的数量。可以想见当M和N都很大时在集群中广播元数据不是一个很快的过程。
考虑到刚刚我们提到的所有问题当Kafka抛弃了ZooKeeper之后社区如何解决它们呢总体的思路就是**Metadata as an Event Log + Controller quorum**。我们先说metadata as an event log。
## Metadata as an Event Log
如果你读过Jay Kreps的《I ❤️ Logs》你应该有感触整个Kafka的架构其实都是构建在Log上的。每个Topic的分区本质上就是一个Commit Log但元数据信息的保存却不是Log形式。在现有的架构设计中你基本上可以认为元数据的数据结构是KV形式的。这一次社区采用了与消息相同的数据保存方式也就是将元数据作为Log的方式保存起来如下表所示
<img src="https://static001.geekbang.org/resource/image/7y/d2/7yyce6c9266a6814c82b95623de5ced2.jpg" alt="">
利用Kafka自身的Log机制保存元数据的做法有以下4个优点
- **高可用性**每次元数据的变更都被当作是一条消息保存在Log中而这个Log可以被视为一个普通的Kafka主题被备份到多台Broker上。
- **顺序性**Log的一个好处在于它有清晰的前后顺序关系即**每个事件发生的时间是可以排序的**,配合以恰当的处理逻辑,我们就能保证,对元数据变更的处理是按照变更发生的时间进行顺序处理的,不出现乱序的情形。
- **增量同步性**利用Log机制之后Broker间同步元数据能够采用同步增量数据delta的方式无需每次都同步全量数据。目前Kafka Broker间同步元数据都是全量状态同步的。前面说过了当集群分区数很大时这个开销是很可观的。如果我们能够只同步增量状态势必能极大地降低同步成本。
- **可监控性**Log提供了丰富的监控指标。我们根据这些指标能够轻易地获取到元数据同步的进度。
采用Log机制后其他Broker像是一个普通的Consumer从Controller拉取元数据变更消息或事件。由于每个Broker都是一个Consumer所以它们会维护自己的消费位移如下图所示
<img src="https://static001.geekbang.org/resource/image/6f/a7/6fed3629c35f413f8fa1bda543610fa7.png" alt="">
在这种设计下Controller所在的Broker必须要承担起所有元数据Topic的管理工作包括创建Topic、管理Topic分区的Leader以及为每个元数据变更创建相应的事件等。既然社区选择和__consumer_offsets类似的处理方式一个很自然的问题就是这个元数据Topic的管理是否能够复用Kafka现有的副本机制答案是不可行。理由是现有的副本机制依赖于Controller因此Kafka没法依靠现有的副本机制来实现Controller。按照我们的俗语来说这有点“鸡生蛋、蛋生鸡“的问题属于典型的循环依赖。
为了实现这个Kafka需要一套Leader选举协议而这套协议或算法是不依赖于Controller的即它是一个自管理的集群quorum抱歉在分布式领域内特别是分布式共识算法领域中针对quorum的恰当翻译我目前还未找到因此直接使用quorum原词了。最终社区决定采用Raft来实现这组quorum。这就是上面我们提到的第二个解决思路Controller quorum。
## Controller Quorum
与借助Controller帮忙选择Leader不同Raft是让自己的节点自行选择Leader并最终令所有节点达成共识。对选择Controller而言这是一个很好的特性。其实Kafka现有的备份机制已经跟Raft很接近了。你可以看一下下面这张表格简单对它们进行下对比。
<img src="https://static001.geekbang.org/resource/image/2b/73/2bb605df5969f7160ec3b0e7b1cce273.jpeg" alt="">
一眼扫过去你会发现其实Kafka的备份机制和Raft很类似比如Kafka中的offset其实就是Raft中的indexepoch对应于term。当然Raft中采用半数机制来确保消息被提交以及Leader选举而Kafka设计了ISR机制来实现这两点。总体来说社区认为只需要对备份机制做一些小改动应该就可以很容易地切换到Raft-based算法了。
下面这张图展示的Controller quorum可能更加直观
<img src="https://static001.geekbang.org/resource/image/e7/fd/e7b060b49yy1ba7776879e90bc672dfd.jpg" alt="">
整个Controller quorum类似于一个小的集群。和ZooKeeper类似这个quorum通常是3台或5台机器不需要让Kafka中的每个Broker都自动成为这个quorum中的一个节点。
该quorum里面有一个Leader负责处理Clients端发来的读写请求这个Leader就是Kafka中的active Controller。根据ZooKeeper的Zab协议Leader处理所有的写请求而Follower是可以处理读请求的。当写请求发送给Follower后Follower会将该请求转发给Leader处理。
不过我猜Kafka应该不会这样实现它应该只会让Leader即active Controller处理所有的读写请求而Clients端也就是其他Broker压根就不会发送读写请求给Follower。在这一点上这种设计和现有的Kafka请求处理机制是一致的。
现在还需要解决一个问题即Leader是怎么被选出来的既然是Raft-based那么采用的也是Raft算法中的Leader选举策略。让Raft选出的Leader称为active Controller。网上有很多关于Raft选主的文章我就不再赘述了有兴趣的可以读一读Raft的论文[**In Search of an Understandable Consensus Algorithm(Extended Version)**](https://ramcloud.atlassian.net/wiki/download/attachments/6586375/raft.pdf)。
这套Raft quorum有2个好处。
第一个好处就是它天然提供了低延时的Failover因此Leader的切换会非常得迅速和及时因为理论上不再有元数据加载的过程了所有的元数据现在都同步保存Follower节点的内存中它已经有其他Broker需要拉取的所有元数据信息了
更酷的是它避免了现在机制中一旦Controller切换就要全量拉取元数据的低效行为Broker不需要重新拉取之前已经“消费”的元数据变更消息只需要从新Leader继续“消费”即可。
另一个好处在于采用了这套机制后Kafka可以**做元数据的缓存**了metadata caching即Broker能够把元数据保存在磁盘上。同时就像刚才说的Broker只需读取它关心的那部分数据即可。另外和现在的snapshot机制类似如果一个Broker保存的元数据落后Controller太多或者是一个全新的BrokerKafka甚至可以像Raft那样直接发送一个snapshot文件快速令其追上进度。当然在大多数情况下Broker只需要拉取delta增量数据就行了。
## 总结
基于以上的这些解决方案社区打算分三步来完成对ZooKeeper的依赖
1. 第一步移除Clients端对ZooKeeper的依赖。这一步基本上已经完成了除了目前AdminClient还有少量的API依赖ZooKeeper之外其他Clients端应该说都不需要访问ZooKeeper了。
1. 第二步移除Broker端的ZooKeeper依赖。这主要包括移除Broker端需要访问ZooKeeper的代码以及增加新的Broker端API比如前面说的AlterISR等最后是将对ZooKeeper的访问全部集中在controller端。
1. 最后一步实现controller quorum也就是实现Raft-based的quorum负责controller的选举。
应该说移除ZooKeeper的功能算是近几年社区最为重磅的提案了。这个提案涉及组件之广、历时之长、复杂程度之高在社区中非常罕见。一旦后续完整地实现了这个功能Apache Kafka将极大地提升自己的可维护性以一个更加“清爽”的形象出现在我们面前。至于最后的效果如何就让我们拭目以待吧。
## 课后讨论
我在前面提到社区打算自己写一套Raft-based的算法来实现Controller的选举你觉得为什么社区不直接采用第三方成熟的Raft库来实现呢
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,205 @@
<audio id="audio" title="特别放送(四)| 20道经典的Kafka面试题详解" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/0b/a82d1f9a1c21f46d4f9350ba6574f10b.mp3"></audio>
你好我是胡夕。这一期的“特别放送”我想跟你分享一些常见的Kafka面试题。
无论是作为面试官还是应聘者我都接触过很多Kafka面试题。有的题目侧重于基础的概念考核有的关注实际场景下的解决方案有的属于“炫技式”有的可算是深入思考后的“灵魂拷问”。“炫技”类的问题属于冷门的Kafka组件知识考核而“灵魂拷问”类的问题大多是对Kafka设计原理的深入思考有很高的技术难度。
每类题目的应对方法其实不太一样。今天我就按照这4种类别具体讲解20道面试题。不过我不打算只给出答案我会把面试题的考核初衷也一并写出来。同时我还会给你分享一些面试小技巧希望能够帮你更顺利地获取心仪的offer。
那么,话不多说,我们现在开始吧。
## 基础题目
### 1.Apache Kafka是什么
这是一道很常见的题目,看似很无聊,其实考核的知识点很多。
首先它考验的是你对Kafka的定位认知是否准确。Apache Kafka一路发展到现在已经由最初的分布式提交日志系统逐渐演变成了实时流处理框架。因此这道题你最好这么回答**Apach Kafka是一款分布式流处理框架用于实时构建流处理应用。它有一个核心的功能广为人知即作为企业级的消息引擎被广泛使用。**
其实这里暗含了一个小技巧。Kafka被定位为实时流处理框架在国内的接受度还不是很高因此在回答这道题的时候你一定要先明确它的流处理框架地位这样能给面试官留下一个很专业的印象。
### 2.什么是消费者组?
从某种程度上说这可是个“送命题”。消费者组是Kafka独有的概念如果面试官问这个就说明他对此是有一定了解的。我先给出标准答案**关于它的定义官网上的介绍言简意赅即消费者组是Kafka提供的可扩展且具有容错性的消费者机制。**切记,一定要加上前面那句,以显示你对官网很熟悉。
另外,你最好再解释下消费者组的原理:**在Kafka中消费者组是一个由多个消费者实例构成的组。多个实例共同订阅若干个主题实现共同消费。同一个组下的每个实例都配置有相同的组ID被分配不同的订阅分区。当某个实例挂掉的时候其他实例会自动地承担起它负责消费的分区。**
此时,又有一个小技巧给到你:消费者组的题目,能够帮你在某种程度上掌控下面的面试方向。
- 如果你擅长**位移值原理**,就不妨再提一下消费者组的位移提交机制;
- 如果你擅长Kafka **Broker**可以提一下消费者组与Broker之间的交互
- 如果你擅长与消费者组完全不相关的**Producer**那么就可以这么说“消费者组要消费的数据完全来自于Producer端生产的消息我对Producer还是比较熟悉的。”
使用这个策略的话,面试官可能会被你的话术所影响,偏离他之前想问的知识路径。当然了,如果你什么都不擅长,那就继续往下看题目吧。
### 3.在Kafka中ZooKeeper的作用是什么
这是一道能够帮助你脱颖而出的题目。碰到这个题目,请在心中暗笑三声。
先说标准答案:**目前Kafka使用ZooKeeper存放集群元数据、成员管理、Controller选举以及其他一些管理类任务。之后等KIP-500提案完成后Kafka将完全不再依赖于ZooKeeper。**
记住,**一定要突出“目前”**以彰显你非常了解社区的演进计划。“存放元数据”是指主题分区的所有数据都保存在ZooKeeper中且以它保存的数据为权威其他“人”都要与它保持对齐。“成员管理”是指Broker节点的注册、注销以及属性变更等等。“Controller选举”是指选举集群Controller而其他管理类任务包括但不限于主题删除、参数配置等。
不过抛出KIP-500也可能是个双刃剑。碰到非常资深的面试官他可能会进一步追问你KIP-500是做的。一言以蔽之**KIP-500思想是使用社区自研的基于Raft的共识算法替代ZooKeeper实现Controller自选举**。你可能会担心,如果他继续追问的话,该怎么办呢?别怕,在下一期“特别发送”,我会专门讨论这件事。
### 4.解释下Kafka中位移offset的作用
这也是一道常见的面试题。位移概念本身并不复杂,你可以这么回答:**在Kafka中每个主题分区下的每条消息都被赋予了一个唯一的ID数值用于标识它在分区中的位置。这个ID数值就被称为位移或者叫偏移量。一旦消息被写入到分区日志它的位移值将不能被修改。**
答完这些之后你还可以把整个面试方向转移到你希望的地方。常见方法有以下3种
- 如果你深谙Broker底层日志写入的逻辑可以强调下消息在日志中的存放格式
- 如果你明白位移值一旦被确定不能修改可以强调下“Log Cleaner组件都不能影响位移值”这件事情
- 如果你对消费者的概念还算熟悉,可以再详细说说位移值和消费者位移值之间的区别。
### 5.阐述下Kafka中的领导者副本Leader Replica和追随者副本Follower Replica的区别
这道题表面上是考核你对Leader和Follower区别的理解但很容易引申到Kafka的同步机制上。因此我建议你主动出击一次性地把隐含的考点也答出来也许能够暂时把面试官“唬住”并体现你的专业性。
你可以这么回答:**Kafka副本当前分为领导者副本和追随者副本。只有Leader副本才能对外提供读写服务响应Clients端的请求。Follower副本只是采用拉PULL的方式被动地同步Leader副本中的数据并且在Leader副本所在的Broker宕机后随时准备应聘Leader副本。**
通常来说回答到这个程度其实才只说了60%,因此,我建议你再回答两个额外的加分项。
- **强调Follower副本也能对外提供读服务**。自Kafka 2.4版本开始社区通过引入新的Broker端参数允许Follower副本有限度地提供读服务。
- **强调Leader和Follower的消息序列在实际场景中不一致**。很多原因都可能造成Leader和Follower保存的消息序列不一致比如程序Bug、网络问题等。这是很严重的错误必须要完全规避。你可以补充下之前确保一致性的主要手段是高水位机制但高水位值无法保证Leader连续变更场景下的数据一致性因此社区引入了Leader Epoch机制来修复高水位值的弊端。关于“Leader Epoch机制”国内的资料不是很多它的普及度远不如高水位不妨大胆地把这个概念秀出来力求惊艳一把。上一季专栏的[第27节课](https://time.geekbang.org/column/article/112118)讲的就是Leader Epoch机制的原理推荐你赶紧去学习下。
## 实操题目
### 6.如何设置Kafka能接收的最大消息的大小
这道题除了要回答消费者端的参数设置之外一定要加上Broker端的设置这样才算完整。毕竟如果Producer都不能向Broker端发送数据很大的消息又何来消费一说呢因此你需要同时设置Broker端参数和Consumer端参数。
- Broker端参数message.max.bytes、max.message.bytes主题级别和replica.fetch.max.bytes。
- Consumer端参数fetch.message.max.bytes。
Broker端的最后一个参数比较容易遗漏。我们必须调整Follower副本能够接收的最大消息的大小否则副本同步就会失败。因此把这个答出来的话就是一个加分项。
### 7.监控Kafka的框架都有哪些
其实目前业界并没有公认的解决方案各家都有各自的监控之道。所以面试官其实是在考察你对监控框架的了解广度或者说你是否知道很多能监控Kafka的框架或方法。下面这些就是Kafka发展历程上比较有名气的监控系统。
- **Kafka Manager**应该算是最有名的专属Kafka监控框架了是独立的监控系统。
- **Kafka Monitor**LinkedIn开源的免费框架支持对集群进行系统测试并实时监控测试结果。
- **CruiseControl**也是LinkedIn公司开源的监控框架用于实时监测资源使用率以及提供常用运维操作等。无UI界面只提供REST API。
- **JMX监控**由于Kafka提供的监控指标都是基于JMX的因此市面上任何能够集成JMX的框架都可以使用比如Zabbix和Prometheus。
- **已有大数据平台自己的监控体系**像Cloudera提供的CDH这类大数据平台天然就提供Kafka监控方案。
- **JMXTool**社区提供的命令行工具能够实时监控JMX指标。答上这一条属于绝对的加分项因为知道的人很少而且会给人一种你对Kafka工具非常熟悉的感觉。如果你暂时不了解它的用法可以在命令行以无参数方式执行一下`kafka-run-class.sh kafka.tools.JmxTool`,学习下它的用法。
### 8.Broker的Heap Size如何设置
如何设置Heap Size的问题其实和Kafka关系不大它是一类非常通用的面试题目。一旦你应对不当面试方向很有可能被引到JVM和GC上去那样的话你被问住的几率就会增大。因此我建议你简单地介绍一下Heap Size的设置方法并把重点放在Kafka Broker堆大小设置的最佳实践上。
比如,你可以这样回复:**任何Java进程JVM堆大小的设置都需要仔细地进行考量和测试。一个常见的做法是以默认的初始JVM堆大小运行程序当系统达到稳定状态后手动触发一次Full GC然后通过JVM工具查看GC后的存活对象大小。之后将堆大小设置成存活对象总大小的1.5~2倍。对于Kafka而言这个方法也是适用的。不过业界有个最佳实践那就是将Broker的Heap Size固定为6GB。经过很多公司的验证这个大小是足够且良好的**。
### 9.如何估算Kafka集群的机器数量
这道题目考查的是**机器数量和所用资源之间的关联关系**。所谓资源也就是CPU、内存、磁盘和带宽。
通常来说CPU和内存资源的充足是比较容易保证的因此你需要从磁盘空间和带宽占用两个维度去评估机器数量。
在预估磁盘的占用时你一定不要忘记计算副本同步的开销。如果一条消息占用1KB的磁盘空间那么在有3个副本的主题中你就需要3KB的总空间来保存这条消息。显式地将这些考虑因素答出来能够彰显你考虑问题的全面性是一个难得的加分项。
对于评估带宽来说常见的带宽有1Gbps和10Gbps但你要切记**这两个数字仅仅是最大值**。因此你最好和面试官确认一下给定的带宽是多少。然后明确阐述出当带宽占用接近总带宽的90%时,丢包情形就会发生。这样能显示出你的网络基本功。
### 10.Leader总是-1怎么破
在生产环境中你一定碰到过“某个主题分区不能工作了”的情形。使用命令行查看状态的话会发现Leader是-1于是你使用各种命令都无济于事最后只能用“重启大法”。
但是,有没有什么办法,可以不重启集群,就能解决此事呢?这就是此题的由来。
我直接给答案:**删除ZooKeeper节点/controller触发Controller重选举。Controller重选举能够为所有主题分区重刷分区状态可以有效解决因不一致导致的Leader不可用问题**。我几乎可以断定,当面试官问出此题时,要么就是他真的不知道怎么解决在向你寻求答案,要么他就是在等你说出这个答案。所以,**千万别一上来就说“来个重启”之类的话**。
## 炫技式题目
### 11.LEO、LSO、AR、ISR、HW都表示什么含义
在我看来,这纯属无聊的炫技。试问我不知道又能怎样呢?!不过既然问到了,我们就统一说一说。
- **LEO**Log End Offset。日志末端位移值或末端偏移量表示日志下一条待插入消息的位移值。举个例子如果日志有10条消息位移值从0开始那么第10条消息的位移值就是9。此时LEO = 10。
- **LSO**Log Stable Offset。这是Kafka事务的概念。如果你没有使用到事务那么这个值不存在其实也不是不存在只是设置成一个无意义的值。该值控制了事务型消费者能够看到的消息范围。它经常与Log Start Offset即日志起始位移值相混淆因为有些人将后者缩写成LSO这是不对的。在Kafka中LSO就是指代Log Stable Offset。
- **AR**Assigned Replicas。AR是主题被创建后分区创建时被分配的副本集合副本个数由副本因子决定。
- **ISR**In-Sync Replicas。Kafka中特别重要的概念指代的是AR中那些与Leader保持同步的副本集合。在AR中的副本可能不在ISR中但Leader副本天然就包含在ISR中。关于ISR还有一个常见的面试题目是**如何判断副本是否应该属于ISR**。目前的判断依据是:**Follower副本的LEO落后Leader LEO的时间是否超过了Broker端参数replica.lag.time.max.ms值**。如果超过了副本就会被从ISR中移除。
- **HW**高水位值High watermark。这是控制消费者可读取消息范围的重要字段。一个普通消费者只能“看到”Leader副本上介于Log Start Offset和HW不含之间的所有消息。水位以上的消息是对消费者不可见的。关于HW问法有很多我能想到的最高级的问法就是让你完整地梳理下Follower副本拉取Leader副本、执行同步机制的详细步骤。这就是我们的第20道题的题目一会儿我会给出答案和解析。
### 12.Kafka能手动删除消息吗
其实Kafka不需要用户手动删除消息。它本身提供了留存策略能够自动删除过期消息。当然它是支持手动删除消息的。因此你最好从这两个维度去回答。
- 对于设置了Key且参数cleanup.policy=compact的主题而言我们可以构造一条&lt;Keynull&gt;的消息发送给Broker依靠Log Cleaner组件提供的功能删除掉该Key的消息。
- 对于普通主题而言我们可以使用kafka-delete-records命令或编写程序调用Admin.deleteRecords方法来删除消息。这两种方法殊途同归底层都是调用Admin的deleteRecords方法通过将分区Log Start Offset值抬高的方式间接删除消息。
### 13.__consumer_offsets是做什么用的
这是一个内部主题公开的官网资料很少涉及到。因此我认为此题属于面试官炫技一类的题目。你要小心这里的考点该主题有3个重要的知识点你一定要全部答出来才会显得对这块知识非常熟悉。
- 它是一个内部主题无需手动干预由Kafka自行管理。当然我们可以创建该主题。
- 它的主要作用是负责注册消费者以及保存位移值。可能你对保存位移值的功能很熟悉,但其实**该主题也是保存消费者元数据的地方。千万记得把这一点也回答上**。另外,这里的消费者泛指消费者组和独立消费者,而不仅仅是消费者组。
- Kafka的GroupCoordinator组件提供对该主题完整的管理功能包括该主题的创建、写入、读取和Leader维护等。
### 14.分区Leader选举策略有几种
分区的Leader副本选举对用户是完全透明的它是由Controller独立完成的。你需要回答的是在哪些场景下需要执行分区Leader选举。每一种场景对应于一种选举策略。当前Kafka有4种分区Leader选举策略。
- **OfflinePartition Leader选举**每当有分区上线时就需要执行Leader选举。所谓的分区上线可能是创建了新分区也可能是之前的下线分区重新上线。这是最常见的分区Leader选举场景。
- **ReassignPartition Leader选举**当你手动运行kafka-reassign-partitions命令或者是调用Admin的alterPartitionReassignments方法执行分区副本重分配时可能触发此类选举。假设原来的AR是[123]Leader是1当执行副本重分配后副本集合AR被设置成[456]显然Leader必须要变更此时会发生Reassign Partition Leader选举。
- **PreferredReplicaPartition Leader选举**当你手动运行kafka-preferred-replica-election命令或自动触发了Preferred Leader选举时该类策略被激活。所谓的Preferred Leader指的是AR中的第一个副本。比如AR是[321]那么Preferred Leader就是3。
- **ControlledShutdownPartition Leader选举**当Broker正常关闭时该Broker上的所有Leader副本都会下线因此需要为受影响的分区执行相应的Leader选举。
这4类选举策略的大致思想是类似的即从AR中挑选首个在ISR中的副本作为新Leader。当然个别策略有些微小差异。不过回答到这种程度应该足以应付面试官了。毕竟微小差别对选举Leader这件事的影响很小。
### 15.Kafka的哪些场景中使用了零拷贝Zero Copy
Zero Copy是特别容易被问到的高阶题目。在Kafka中体现Zero Copy使用场景的地方有两处**基于mmap的索引**和**日志文件读写所用的TransportLayer**。
先说第一个。索引都是基于MappedByteBuffer的也就是让用户态和内核态共享内核态的数据缓冲区此时数据不需要复制到用户态空间。不过mmap虽然避免了不必要的拷贝但不一定就能保证很高的性能。在不同的操作系统下mmap的创建和销毁成本可能是不一样的。很高的创建和销毁开销会抵消Zero Copy带来的性能优势。由于这种不确定性在Kafka中只有索引应用了mmap最核心的日志并未使用mmap机制。
再说第二个。TransportLayer是Kafka传输层的接口。它的某个实现类使用了FileChannel的transferTo方法。该方法底层使用sendfile实现了Zero Copy。对Kafka而言如果I/O通道使用普通的PLAINTEXT那么Kafka就可以利用Zero Copy特性直接将页缓存中的数据发送到网卡的Buffer中避免中间的多次拷贝。相反如果I/O通道启用了SSL那么Kafka便无法利用Zero Copy特性了。
## 深度思考题
### 16.Kafka为什么不支持读写分离
这道题目考察的是你对Leader/Follower模型的思考。
Leader/Follower模型并没有规定Follower副本不可以对外提供读服务。很多框架都是允许这么做的只是Kafka最初为了避免不一致性的问题而采用了让Leader统一提供服务的方式。
不过,在开始回答这道题时,你可以率先亮出观点:**自Kafka 2.4之后Kafka提供了有限度的读写分离也就是说Follower副本能够对外提供读服务。**
说完这些之后,你可以再给出之前的版本不支持读写分离的理由。
- **场景不适用**。读写分离适用于那种读负载很大而写操作相对不频繁的场景可Kafka不属于这样的场景。
- **同步机制**。Kafka采用PULL方式实现Follower的同步因此Follower与Leader存在不一致性窗口。如果允许读Follower副本就势必要处理消息滞后Lagging的问题。
### 17.如何调优Kafka
回答任何调优问题的第一步,就是**确定优化目标,并且定量给出目标**这点特别重要。对于Kafka而言常见的优化目标是吞吐量、延时、持久性和可用性。每一个方向的优化思路都是不同的甚至是相反的。
确定了目标之后还要明确优化的维度。有些调优属于通用的优化思路比如对操作系统、JVM等的优化有些则是有针对性的比如要优化Kafka的TPS。我们需要从3个方向去考虑。
- **Producer端**增加batch.size、linger.ms启用压缩关闭重试等。
- **Broker端**增加num.replica.fetchers提升Follower同步TPS避免Broker Full GC等。
- **Consumer**增加fetch.min.bytes等
### 18.Controller发生网络分区Network PartitioningKafka会怎么样
这道题目能够诱发我们对分布式系统设计、CAP理论、一致性等多方面的思考。不过针对故障定位和分析的这类问题我建议你首先言明“实用至上”的观点即不论怎么进行理论分析永远都要以实际结果为准。一旦发生Controller网络分区那么第一要务就是查看集群是否出现“脑裂”即同时出现两个甚至是多个Controller组件。这可以根据Broker端监控指标ActiveControllerCount来判断。
现在我们分析下一旦出现这种情况Kafka会怎么样。
由于Controller会给Broker发送3类请求即LeaderAndIsrRequest、StopReplicaRequest和UpdateMetadataRequest因此一旦出现网络分区这些请求将不能顺利到达Broker端。这将影响主题的创建、修改、删除操作的信息同步表现为集群仿佛僵住了一样无法感知到后面的所有操作。因此网络分区通常都是非常严重的问题要赶快修复。
### 19.Java Consumer为什么采用单线程来获取消息
在回答之前,如果先把这句话说出来,一定会加分:**Java Consumer是双线程的设计。一个线程是用户主线程负责获取消息另一个线程是心跳线程负责向Kafka汇报消费者存活情况。将心跳单独放入专属的线程能够有效地规避因消息处理速度慢而被视为下线的“假死”情况。**
单线程获取消息的设计能够避免阻塞式的消息获取方式。单线程轮询方式容易实现异步非阻塞式,这样便于将消费者扩展成支持实时流处理的操作算子。因为很多实时流处理操作算子都不能是阻塞式的。另外一个可能的好处是,可以简化代码的开发。多线程交互的代码是非常容易出错的。
### 20.简述Follower副本消息同步的完整流程
首先Follower发送FETCH请求给Leader。接着Leader会读取底层日志文件中的消息数据再更新它内存中的Follower副本的LEO值更新为FETCH请求中的fetchOffset值。最后尝试更新分区高水位值。Follower接收到FETCH响应之后会把消息写入到底层日志接着更新LEO和HW值。
Leader和Follower的HW值更新时机是不同的Follower的HW更新永远落后于Leader的HW。这种时间上的错配是造成各种不一致的原因。
好了,今天的面试题分享就先到这里啦。你有遇到过什么经典的面试题吗?或者是有什么好的面试经验吗?
欢迎你在留言区分享。如果你觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。

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方法请你分析下它是做什么用的以及它何时被调用
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,59 @@
<audio id="audio" title="结束语 | 源码学习,我们才刚上路呢" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/54/12/5472e88c1899bbc0759e256122yy6612.mp3"></audio>
你好我是胡夕。《Kafka核心源码解读》课到今天就告一段落了又到了和你说再见的时候。
当初接下这个任务时,我记得小编和我说,咱们这个课程将会是极客时间的第一个源码类的专栏课,我顿时觉得既自豪又忐忑。
自豪的是我能够作为首个源码专栏课作者给你分享Kafka源码的学习心得忐忑的是源码并不好讲如果讲解的粒度太粗就会流于表面不够深入而粒度太细的话就很容易陷入流水账似的代码走读这其中的度需要仔细拿捏才行。
不过,我依然还是愿意试一试。
因为我始终觉得,现在的编程教育对于阅读源码的重视程度远远不足。一直以来,学校或各类培训机构只看重编程技能的灌输,而忽略了带领大家一同学习业界流行框架的源码编写方法,但这恰恰是非常关键的。如果你没有阅读过大量质量上乘的代码,那么,你就不可能写出优秀的代码。
就像美国作家斯蒂芬·金所说“If you dont have time to read, you dont have the time (or the tools) to write. Simple as that.” 虽然这句话是关于写作的,但是对于我们写代码来说,同样也是适用的。学习比自己更有经验的人写出的源码,一定会直接或间接地影响你自己的编码水平。
我曾在[开篇词](https://time.geekbang.org/column/article/222935)中提到过阅读源码的3个好处但实际上对于一个每天都要写代码的程序员来说**我们阅读源码的最大目的,就是能够写出更好的代码**。这么多年阅读Kafka源码的经历让我对此深有体会因此我很想把我的阅读经验整理出来分享给你尽可能帮你更加科学高效地阅读源码从而写出更好的代码。
从2019年12月开始筹备这门课到今天交付完成共经历了近八个月的时间我精心挑选并讲解了Kafka Broker端主要组件的核心源码。我个人觉得总体上还是成功的。有很多模块的源码分析我自己在写的时候都收获良多。因此我在想如果你认真地学完了前面的内容应该也是有些收获的。
当然,无论你眼下掌握了多少,这些在过去更新的知识就停留在过去了,等着你时时去翻阅,去消化,去借助它们解决现在以及未来的问题。此刻,我们就要暂时跟它们说再见了,我想再和你聊一聊未来的事情,未来学习源码的事情。
课程结束后你应该如何继续你的Kafka源码阅读计划呢我根据自己的经验再给你分享一些建议。
**首先我建议你研读一下Kafka Clients端的代码**
你可能会问难道不是应该先阅读Broker端其他组件的代码吗为什么要读Clients端的代码实际上Clients工程包含的已经不仅仅是客户端的源码了它还包含了各类支撑服务器端组件的基础代码比如通信通道、消息集合等等。了解它们的实现可以补齐你对Kafka代码框架的整体认知。**通读并搞懂Clients和Core工程下的代码是精通Kafka实现原理的必要步骤**。
**其次把课程中涉及到组件的测试用例代码学习一遍最好是打上断点边运行边Debug**。很多时候,光看组件实现源码,还是会很难理解它真正运行起来是什么样的,而测试用例就能够很好地带领我们一探究竟。
我举个真实的例子。当初我自己学习Log Cleaner源码时就非常吃力。我把LogCleaner类看了很多遍却始终不得要领。后来我突然想到不是有个LogCleanerTest吗于是我就翻开里面的各种test×××方法挨个儿琢磨终于明白了这个组件实际的工作原理。因此我想特别提醒你一句千万不要忽视测试用例代码的威力。
**最后你一定要读一读Log Cleaner组件的代码**
它主要用于完成带Key消息的日志清理在其他场合的出镜机会不多所以我并没有专门在课程里讲它但是它其实也是Broker端非常重要的组件。
我们熟知的位移主题,就是依靠这个机制,定期删除过期位移消息和过期注册消息的。事实上,清除过期数据或重复数据,是任何一个数据存储系统都要解决的关键课题。这个组件的代码量不多,你可以在很短的时间内掌握它。
Okay现在阅读的方向有了但你依然可能会遇到一个难题那就是看不懂怎么办
虽然我给出了一些源码阅读方法,但是,毕竟,理解别人写的代码是一件很有挑战的事情,因此,我的答案是:“别着急!”
刚开始时你可能压根不知道某个方法或类的代码是做什么的这是非常正常的现象。这个时候你要做的就是初步了解一下代码结构和轮廓知道有哪些类类中定义了哪些API。这就像学习写作一样努力搞懂一些知识范畴之外的代码就可以扩大你的代码功力的外延。如果一直徜徉在简单、枯燥、易理解的代码海洋中就很难真正地有所进步。
同时,你也不要幻想,代码只读一遍就可以了!阅读源码这件事,一定要重复很多次之后,才会有“质”的变化。
不知道你是否听说过“洋葱源码阅读法”,它指的是,我们读源码,就要像剥洋葱一样,第一遍的阅读仅仅是获取整体项目结构和概要,接下来的多遍阅读,才会逐步揭开代码的本质。切记:**每次只剥一层**。
除此之外,如果说我还有什么私藏的方法,那就是亲自动手去修改源码。
实际上只有你对阅读的代码进行了一些改进或变更才能真正地体会到阅读源码的快乐。因此在阅读Kafka或其他框架的源码时你不妨亲自动手改一改代码做一些实验Debug一下去体会一下你所做的改动给Kafka带来的变化。相信我你会爱上这个过程的。
**最后,我还想说的一点是,不要恐惧**。阻碍很多人阅读源码的第一大原因就是恐惧。一想到有那么多行代码要读,就本能地竖起了放弃的大旗。其实,再宏大的框架,也是一行行代码堆砌起来的。我一直秉承着一句七字箴言:**你敢写我就敢读**!现在我把它送给你,希望在你想要放弃的时候,把它拿出来,鼓励一下自己。
好了,这一季的课程就到这里结束吧。今天没有太多华丽的表达,全部都是一些朴实的建议。其实,所有的一切,我其实都只是想说,源码学习,我们才刚刚上路。
阅读源码仿佛一场漫长的登山之旅,半山腰很挤,我们山顶上见。
我给你准备了一份[结课问卷](https://jinshuju.net/f/a88osf)希望你能在问卷里聊一聊你对这门课的看法。欢迎你点击下面的图片用12分钟时间填写问卷期待你畅所欲言。
[<img src="https://static001.geekbang.org/resource/image/13/1a/13edef1ac4708f68b31d98cd93c8051a.jpg" alt="">](https://jinshuju.net/f/a88osf)

View File

@@ -0,0 +1,352 @@
<audio id="audio" title="06 | 请求通道如何实现Kafka请求队列" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/10/27/10062b1488ba1dacaf12e89c3f717f27.mp3"></audio>
你好,我是胡夕。日志模块我们已经讲完了,你掌握得怎样了呢?如果你在探索源码的过程中碰到了问题,记得在留言区里写下你的困惑,我保证做到知无不言。
现在让我们开启全新的“请求处理模块”的源码学习之旅。坦率地讲这是我自己给Kafka源码划分的模块在源码中可没有所谓的“请求处理模块”的提法。但我始终觉得这样划分能够帮助你清晰地界定不同部分的源码的作用可以让你的阅读更有针对性学习效果也会事半功倍。
在这个模块我会带你了解请求处理相关的重点内容包括请求处理通道、请求处理全流程分析和请求入口类分析等。今天我们先来学习下Kafka是如何实现请求队列的。源码中位于core/src/main/scala/kafka/network下的RequestChannel.scala文件是主要的实现类。
当我们说到Kafka服务器端也就是Broker的时候往往会说它承担着消息持久化的功能但本质上它其实就是**一个不断接收外部请求、处理请求然后发送处理结果的Java进程**。
你可能会觉得奇怪Broker不是用Scala语言编写的吗怎么这里又是Java进程了呢这是因为Scala代码被编译之后生成.class文件它和Java代码被编译后的效果是一样的因此Broker启动后也仍然是一个普通的Java进程。
**高效地保存排队中的请求是确保Broker高处理性能的关键**。既然这样那你一定很想知道Broker上的请求队列是怎么实现的呢接下来我们就一起看下**Broker底层请求对象的建模**和**请求队列的实现原理**以及Broker**请求处理方面的核心监控指标**。
目前Broker与Clients进行交互主要是基于Request/Response机制所以我们很有必要学习一下源码是如何建模或定义Request和Response的。
## 请求Request
我们先来看一下RequestChannel源码中的Request定义代码。
```
sealed trait BaseRequest
case object ShutdownRequest extends BaseRequest
class Request(val processor: Int,
val context: RequestContext,
val startTimeNanos: Long,
memoryPool: MemoryPool,
@volatile private var buffer: ByteBuffer,
metrics: RequestChannel.Metrics) extends BaseRequest {
......
}
```
简单提一句Scala语言中的“trait”关键字大致类似于Java中的interface接口。从代码中我们可以知道BaseRequest是一个trait接口定义了基础的请求类型。它有两个实现类**ShutdownRequest类**和**Request类**。
ShutdownRequest仅仅起到一个标志位的作用。当Broker进程关闭时请求处理器RequestHandler在第9讲我会具体讲到它会发送ShutdownRequest到专属的请求处理线程。该线程接收到此请求后会主动触发一系列的Broker关闭逻辑。
Request则是真正定义各类Clients端或Broker端请求的实现类。它定义的属性包括processor、context、startTimeNanos、memoryPool、buffer和metrics。下面我们一一来看。
### processor
processor是Processor线程的序号即**这个请求是由哪个Processor线程接收处理的**。Broker端参数num.network.threads控制了Broker每个监听器上创建的Processor线程数。
假设你的listeners配置为PLAINTEXT://localhost:9092,SSL://localhost:9093那么在默认情况下Broker启动时会创建6个Processor线程每3个为一组分别给listeners参数中设置的两个监听器使用每组的序号分别是0、1、2。
你可能会问为什么要保存Processor线程序号呢这是因为当Request被后面的I/O线程处理完成后还要依靠Processor线程发送Response给请求发送方因此Request中必须记录它之前是被哪个Processor线程接收的。另外这里我们要先明确一点**Processor线程仅仅是网络接收线程不会执行真正的Request请求处理逻辑**那是I/O线程负责的事情。
### context
**context是用来标识请求上下文信息的**。Kafka源码中定义了RequestContext类顾名思义它保存了有关Request的所有上下文信息。RequestContext类定义在clients工程中下面是它主要的逻辑代码。我用注释的方式解释下主体代码的含义。
```
public class RequestContext implements AuthorizableRequestContext {
public final RequestHeader header; // Request头部数据主要是一些对用户不可见的元数据信息如Request类型、Request API版本、clientId等
public final String connectionId; // Request发送方的TCP连接串标识由Kafka根据一定规则定义主要用于表示TCP连接
public final InetAddress clientAddress; // Request发送方IP地址
public final KafkaPrincipal principal; // Kafka用户认证类用于认证授权
public final ListenerName listenerName; // 监听器名称可以是预定义的监听器如PLAINTEXT也可自行定义
public final SecurityProtocol securityProtocol; // 安全协议类型目前支持4种PLAINTEXT、SSL、SASL_PLAINTEXT、SASL_SSL
public final ClientInformation clientInformation; // 用户自定义的一些连接方信息
// 从给定的ByteBuffer中提取出Request和对应的Size值
public RequestAndSize parseRequest(ByteBuffer buffer) {
......
}
// 其他Getter方法
......
}
```
### startTimeNanos
**startTimeNanos记录了Request对象被创建的时间主要用于各种时间统计指标的计算**
请求对象中的很多JMX指标特别是时间类的统计指标都需要使用startTimeNanos字段。你要注意的是**它是以纳秒为单位的时间戳信息,可以实现非常细粒度的时间统计精度**。
### memoryPool
memoryPool表示源码定义的一个非阻塞式的内存缓冲区主要作用是**避免Request对象无限使用内存**。
当前该内存缓冲区的接口类和实现类分别是MemoryPool和SimpleMemoryPool。你可以重点关注下SimpleMemoryPool的tryAllocate方法看看它是怎么为Request对象分配内存的。
### buffer
buffer是真正保存Request对象内容的字节缓冲区。Request发送方必须按照Kafka RPC协议规定的格式向该缓冲区写入字节否则将抛出InvalidRequestException异常。**这个逻辑主要是由RequestContext的parseRequest方法实现的**。
```
public RequestAndSize parseRequest(ByteBuffer buffer) {
if (isUnsupportedApiVersionsRequest()) {
// 不支持的ApiVersions请求类型被视为是V0版本的请求并且不做解析操作直接返回
ApiVersionsRequest apiVersionsRequest = new ApiVersionsRequest(new ApiVersionsRequestData(), (short) 0, header.apiVersion());
return new RequestAndSize(apiVersionsRequest, 0);
} else {
// 从请求头部数据中获取ApiKeys信息
ApiKeys apiKey = header.apiKey();
try {
// 从请求头部数据中获取版本信息
short apiVersion = header.apiVersion();
// 解析请求
Struct struct = apiKey.parseRequest(apiVersion, buffer);
AbstractRequest body = AbstractRequest.parseRequest(apiKey, apiVersion, struct);
// 封装解析后的请求对象以及请求大小返回
return new RequestAndSize(body, struct.sizeOf());
} catch (Throwable ex) {
// 解析过程中出现任何问题都视为无效请求,抛出异常
throw new InvalidRequestException(&quot;Error getting request for apiKey: &quot; + apiKey +
&quot;, apiVersion: &quot; + header.apiVersion() +
&quot;, connectionId: &quot; + connectionId +
&quot;, listenerName: &quot; + listenerName +
&quot;, principal: &quot; + principal, ex);
}
}
}
```
就像前面说过的,这个方法的主要目的是**从ByteBuffer中提取对应的Request对象以及它的大小**。
首先代码会判断该Request是不是Kafka不支持的ApiVersions请求版本。如果是不支持的就直接构造一个V0版本的ApiVersions请求然后返回。否则的话就继续下面的代码。
这里我稍微解释一下ApiVersions请求的作用。当Broker接收到一个ApiVersionsRequest的时候它会返回Broker当前支持的请求类型列表包括请求类型名称、支持的最早版本号和最新版本号。如果你查看Kafka的bin目录的话你应该能找到一个名为kafka-broker-api-versions.sh的脚本工具。它的实现原理就是构造ApiVersionsRequest对象然后发送给对应的Broker。
你可能会问如果是ApiVersions类型的请求代码中为什么要判断一下它的版本呢这是因为和处理其他类型请求不同的是Kafka必须保证版本号比最新支持版本还要高的ApiVersions请求也能被处理。这主要是考虑到了客户端和服务器端版本的兼容问题。客户端发送请求给Broker的时候很可能不知道Broker到底支持哪些版本的请求它需要使用ApiVersionsRequest去获取完整的请求版本支持列表。但是如果不做这个判断Broker可能无法处理客户端发送的ApiVersionsRequest。
通过这个检查之后代码会从请求头部数据中获取ApiKeys信息以及对应的版本信息然后解析请求最后封装解析后的请求对象以及请求大小并返回。
### metrics
metrics是Request相关的各种监控指标的一个管理类。它里面构建了一个Map封装了所有的请求JMX指标。除了上面这些重要的字段属性之外Request类中的大部分代码都是与监控指标相关的后面我们再详细说。
## 响应Response
说完了Request代码我们再来说下Response。Kafka为Response定义了1个抽象父类和5个具体子类如下图所示
<img src="https://static001.geekbang.org/resource/image/a0/6a/a03ecdba3c118efbc3910b5a1badc96a.jpg" alt="">
看到这么多类,你可能会有点蒙,这些都是干吗的呢?别着急,现在我分别给你介绍下各个类的作用。
- Response定义Response的抽象基类。每个Response对象都包含了对应的Request对象。这个类里最重要的方法是onComplete方法用来实现每类Response被处理后需要执行的回调逻辑。
- SendResponseKafka大多数Request处理完成后都需要执行一段回调逻辑SendResponse就是保存返回结果的Response子类。里面最重要的字段是**onCompletionCallback**,即**指定处理完成之后的回调逻辑**。
- NoResponse有些Request处理完成后无需单独执行额外的回调逻辑。NoResponse就是为这类Response准备的。
- CloseConnectionResponse用于出错后需要关闭TCP连接的场景此时返回CloseConnectionResponse给Request发送方显式地通知它关闭连接。
- StartThrottlingResponse用于通知Broker的Socket Server组件后面几节课我会讲到它某个TCP连接通信通道开始被限流throttling
- EndThrottlingResponse与StartThrottlingResponse对应通知Broker的SocketServer组件某个TCP连接通信通道的限流已结束。
你可能又会问了“这么多类我都要掌握吗”其实是不用的。你只要了解SendResponse表示正常需要发送Response而NoResponse表示无需发送Response就可以了。至于CloseConnectionResponse它是用于标识关闭连接通道的Response。而后面两个Response类不是很常用它们仅仅在对Socket连接进行限流时才会派上用场这里我就不具体展开讲了。
Okay现在我们看下Response相关的代码部分。
```
abstract class Response(val request: Request) {
locally {
val nowNs = Time.SYSTEM.nanoseconds
request.responseCompleteTimeNanos = nowNs
if (request.apiLocalCompleteTimeNanos == -1L)
request.apiLocalCompleteTimeNanos = nowNs
}
def processor: Int = request.processor
def responseString: Option[String] = Some(&quot;&quot;)
def onComplete: Option[Send =&gt; Unit] = None
override def toString: String
}
```
这个抽象基类只有一个属性字段request。这就是说**每个Response对象都要保存它对应的Request对象**。我在前面说过onComplete方法是调用指定回调逻辑的地方。SendResponse类就是复写Override了这个方法如下所示
```
class SendResponse(request: Request,
val responseSend: Send,
val responseAsString: Option[String],
val onCompleteCallback: Option[Send =&gt; Unit])
extends Response(request) {
......
override def onComplete: Option[Send =&gt; Unit] = onCompleteCallback
}
```
这里的SendResponse类继承了Response父类并重新定义了onComplete方法。复写的逻辑很简单就是指定输入参数onCompleteCallback。其实方法本身没有什么可讲的反倒是这里的Scala语法值得多说几句。
Scala中的Unit类似于Java中的void而“Send =&gt; Unit”表示一个方法。这个方法接收一个Send类实例然后执行一段代码逻辑。Scala是函数式编程语言函数在Scala中是“一等公民”因此你可以把一个函数作为一个参数传给另一个函数也可以把函数作为结果返回。这里的onComplete方法就应用了第二种用法也就是把函数赋值给另一个函数并作为结果返回。这样做的好处在于你可以灵活地变更onCompleteCallback来实现不同的回调逻辑。
## RequestChannel
RequestChannel顾名思义就是传输Request/Response的通道。有了Request和Response的基础下面我们可以学习RequestChannel类的实现了。
我们先看下RequestChannel类的定义和重要的字段属性。
```
class RequestChannel(val queueSize: Int, val metricNamePrefix : String) extends KafkaMetricsGroup {
import RequestChannel._
val metrics = new RequestChannel.Metrics
private val requestQueue = new ArrayBlockingQueue[BaseRequest](queueSize)
private val processors = new ConcurrentHashMap[Int, Processor]()
val requestQueueSizeMetricName = metricNamePrefix.concat(RequestQueueSizeMetric)
val responseQueueSizeMetricName = metricNamePrefix.concat(ResponseQueueSizeMetric)
......
}
```
RequestChannel类实现了KafkaMetricsGroup trait后者封装了许多实用的指标监控方法比如newGauge方法用于创建数值型的监控指标newHistogram方法用于创建直方图型的监控指标。
**就RequestChannel类本身的主体功能而言它定义了最核心的3个属性requestQueue、queueSize和processors**。下面我分别解释下它们的含义。
每个RequestChannel对象实例创建时会定义一个队列来保存Broker接收到的各类请求这个队列被称为请求队列或Request队列。Kafka使用**Java提供的阻塞队列ArrayBlockingQueue**实现这个请求队列并利用它天然提供的线程安全性来保证多个线程能够并发安全高效地访问请求队列。在代码中这个队列由变量requestQueue定义。
而字段queueSize就是Request队列的最大长度。当Broker启动时SocketServer组件会创建RequestChannel对象并把Broker端参数queued.max.requests赋值给queueSize。因此在默认情况下每个RequestChannel上的队列长度是500。
字段processors封装的是RequestChannel下辖的Processor线程池。每个Processor线程负责具体的请求处理逻辑。下面我详细说说Processor的管理。
### Processor管理
上面代码中的第六行创建了一个Processor线程池——当然它是用Java的ConcurrentHashMap数据结构去保存的。Map中的Key就是前面我们说的processor序号而Value则对应具体的Processor线程对象。
这个线程池的存在告诉了我们一个事实:**当前Kafka Broker端所有网络线程都是在RequestChannel中维护的**。既然创建了线程池代码中必然要有管理线程池的操作。RequestChannel中的addProcessor和removeProcessor方法就是做这些事的。
```
def addProcessor(processor: Processor): Unit = {
// 添加Processor到Processor线程池
if (processors.putIfAbsent(processor.id, processor) != null)
warn(s&quot;Unexpected processor with processorId ${processor.id}&quot;)
newGauge(responseQueueSizeMetricName,
() =&gt; processor.responseQueueSize,
// 为给定Processor对象创建对应的监控指标
Map(ProcessorMetricTag -&gt; processor.id.toString))
}
def removeProcessor(processorId: Int): Unit = {
processors.remove(processorId) // 从Processor线程池中移除给定Processor线程
removeMetric(responseQueueSizeMetricName, Map(ProcessorMetricTag -&gt; processorId.toString)) // 移除对应Processor的监控指标
}
```
代码很简单基本上就是调用ConcurrentHashMap的putIfAbsent和remove方法分别实现增加和移除线程。每当Broker启动时它都会调用addProcessor方法向RequestChannel对象添加num.network.threads个Processor线程。
如果查询Kafka官方文档的话你就会发现num.network.threads这个参数的更新模式Update Mode是Cluster-wide。这就说明Kafka允许你动态地修改此参数值。比如Broker启动时指定num.network.threads为8之后你通过kafka-configs命令将其修改为3。显然这个操作会减少Processor线程池中的线程数量。在这个场景下removeProcessor方法会被调用。
### 处理Request和Response
除了Processor的管理之外RequestChannel的另一个重要功能是处理**Request和Response**具体表现为收发Request和发送Response。比如收发Request的方法有sendRequest和receiveRequest
```
def sendRequest(request: RequestChannel.Request): Unit = {
requestQueue.put(request)
}
def receiveRequest(timeout: Long): RequestChannel.BaseRequest =
requestQueue.poll(timeout, TimeUnit.MILLISECONDS)
def receiveRequest(): RequestChannel.BaseRequest =
requestQueue.take()
```
所谓的发送Request仅仅是将Request对象放置在Request队列中而已而接收Request则是从队列中取出Request。整个流程构成了一个迷你版的“生产者-消费者”模式然后依靠ArrayBlockingQueue的线程安全性来确保整个过程的线程安全如下所示
<img src="https://static001.geekbang.org/resource/image/b8/cc/b83a2856a7f8e7b895f47e277e007ecc.jpg" alt="">
对于Response而言则没有所谓的接收Response只有发送Response即sendResponse方法。sendResponse是啥意思呢其实就是把Response对象发送出去也就是将Response添加到Response队列的过程。
```
def sendResponse(response: RequestChannel.Response): Unit = {
if (isTraceEnabled) { // 构造Trace日志输出字符串
val requestHeader = response.request.header
val message = response match {
case sendResponse: SendResponse =&gt;
s&quot;Sending ${requestHeader.apiKey} response to client ${requestHeader.clientId} of ${sendResponse.responseSend.size} bytes.&quot;
case _: NoOpResponse =&gt;
s&quot;Not sending ${requestHeader.apiKey} response to client ${requestHeader.clientId} as it's not required.&quot;
case _: CloseConnectionResponse =&gt;
s&quot;Closing connection for client ${requestHeader.clientId} due to error during ${requestHeader.apiKey}.&quot;
case _: StartThrottlingResponse =&gt;
s&quot;Notifying channel throttling has started for client ${requestHeader.clientId} for ${requestHeader.apiKey}&quot;
case _: EndThrottlingResponse =&gt;
s&quot;Notifying channel throttling has ended for client ${requestHeader.clientId} for ${requestHeader.apiKey}&quot;
}
trace(message)
}
// 找出response对应的Processor线程即request当初是由哪个Processor线程处理的
val processor = processors.get(response.processor)
// 将response对象放置到对应Processor线程的Response队列中
if (processor != null) {
processor.enqueueResponse(response)
}
}
```
sendResponse方法的逻辑其实非常简单。
前面的一大段if代码块仅仅是构造Trace日志要输出的内容。根据不同类型的Response代码需要确定要输出的Trace日志内容。
接着代码会找出Response对象对应的Processor线程。当Processor处理完某个Request后会把自己的序号封装进对应的Response对象。一旦找出了之前是由哪个Processor线程处理的代码直接调用该Processor的enqueueResponse方法将Response放入Response队列中等待后续发送。
## 监控指标实现
RequestChannel类还定义了丰富的监控指标用于实时动态地监测Request和Response的性能表现。我们来看下具体指标项都有哪些。
```
object RequestMetrics {
val consumerFetchMetricName = ApiKeys.FETCH.name + &quot;Consumer&quot;
val followFetchMetricName = ApiKeys.FETCH.name + &quot;Follower&quot;
val RequestsPerSec = &quot;RequestsPerSec&quot;
val RequestQueueTimeMs = &quot;RequestQueueTimeMs&quot;
val LocalTimeMs = &quot;LocalTimeMs&quot;
val RemoteTimeMs = &quot;RemoteTimeMs&quot;
val ThrottleTimeMs = &quot;ThrottleTimeMs&quot;
val ResponseQueueTimeMs = &quot;ResponseQueueTimeMs&quot;
val ResponseSendTimeMs = &quot;ResponseSendTimeMs&quot;
val TotalTimeMs = &quot;TotalTimeMs&quot;
val RequestBytes = &quot;RequestBytes&quot;
val MessageConversionsTimeMs = &quot;MessageConversionsTimeMs&quot;
val TemporaryMemoryBytes = &quot;TemporaryMemoryBytes&quot;
val ErrorsPerSec = &quot;ErrorsPerSec&quot;
}
```
可以看到,指标有很多,不过别有压力,我们只要掌握几个重要的就行了。
- **RequestsPerSec**每秒处理的Request数用来评估Broker的繁忙状态。
- **RequestQueueTimeMs**计算Request在Request队列中的平均等候时间单位是毫秒。倘若Request在队列的等待时间过长你通常需要增加后端I/O线程的数量来加快队列中Request的拿取速度。
- **LocalTimeMs**计算Request实际被处理的时间单位是毫秒。一旦定位到这个监控项的值很大你就需要进一步研究Request被处理的逻辑了具体分析到底是哪一步消耗了过多的时间。
- **RemoteTimeMs**Kafka的读写请求PRODUCE请求和FETCH请求逻辑涉及等待其他Broker操作的步骤。RemoteTimeMs计算的就是等待其他Broker完成指定逻辑的时间。因为等待的是其他Broker因此被称为Remote Time。这个监控项非常重要Kafka生产环境中设置acks=all的Producer程序发送消息延时高的主要原因往往就是Remote Time高。因此如果你也碰到了这样的问题不妨先定位一下Remote Time是不是瓶颈。
- **TotalTimeMs**计算Request被处理的完整流程时间。**这是最实用的监控指标,没有之一!**毕竟我们通常都是根据TotalTimeMs来判断系统是否出现问题的。一旦发现了问题我们才会利用前面的几个监控项进一步定位问题的原因。
RequestChannel定义了updateMetrics方法用于实现监控项的更新因为逻辑非常简单我就不展开说了你可以自己阅读一下。
## 总结
好了,又到了总结时间。
今天我带你阅读了Kafka请求队列的实现源码。围绕这个问题我们学习了几个重点内容。
- Request定义了Kafka Broker支持的各类请求。
- Response定义了与Request对应的各类响应。
- RequestChannel实现了Kafka Request队列。
- 监控指标封装了与Request队列相关的重要监控指标。
<img src="https://static001.geekbang.org/resource/image/4b/15/4bf7b31f368743496018b3f21a528b15.jpg" alt="">
希望你结合今天所讲的内容思考一下Request和Response在请求通道甚至是SocketServer中的流转过程这将极大地帮助你了解Kafka是如何处理外部发送的请求的。当然如果你觉得这个有难度也不必着急因为后面我会专门用一节课来告诉你这些内容。
## 课后讨论
如果我想监控Request队列当前的使用情况比如当前已保存了多少个Request你可以结合源码指出应该使用哪个监控指标吗
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,532 @@
<audio id="audio" title="07 | SocketServerKafka到底是怎么应用NIO实现网络通信的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/8c/3f354d9a26a03aeb946bbefca5cbd88c.mp3"></audio>
你好我是胡夕。这节课我们来说说Kafka底层的NIO通信机制源码。
在谈到Kafka高性能、高吞吐量实现原理的时候很多人都对它使用了Java NIO这件事津津乐道。实际上搞懂“Kafka究竟是怎么应用NIO来实现网络通信的”不仅是我们掌握Kafka请求全流程处理的前提条件对我们了解Reactor模式的实现大有裨益而且还能帮助我们解决很多实际问题。
比如说当Broker处理速度很慢、需要优化的时候你只有明确知道SocketServer组件的工作原理才能制定出恰当的解决方案并有针对性地给出对应的调优参数。
那么今天我们就一起拿下这个至关重要的NIO通信机制吧。
## 网络通信层
在深入学习Kafka各个网络组件之前我们先从整体上看一下完整的网络通信层架构如下图所示
<img src="https://static001.geekbang.org/resource/image/52/e8/52c3226ad4736751b4b1ccfcb2a09ee8.jpg" alt="">
可以看出Kafka网络通信组件主要由两大部分构成**SocketServer**和**KafkaRequestHandlerPool**。
**SocketServer组件是核心**主要实现了Reactor模式用于处理外部多个Clients这里的Clients指的是广义的Clients可能包含Producer、Consumer或其他Broker的并发请求并负责将处理结果封装进Response中返还给Clients。
**KafkaRequestHandlerPool组件就是我们常说的I/O线程池**里面定义了若干个I/O线程用于执行真实的请求处理逻辑。
两者的交互点在于SocketServer中定义的RequestChannel对象和Processor线程。对了我所说的线程在代码中本质上都是Runnable类型不管是Acceptor类、Processor类还是后面我们会单独讨论的KafkaRequestHandler类。
讲到这里我稍微提示你一下。在第9节课我会给出KafkaRequestHandlerPool线程池的详细介绍。但你现在需要知道的是KafkaRequestHandlerPool线程池定义了多个KafkaRequestHandler线程而KafkaRequestHandler线程是真正处理请求逻辑的地方。和KafkaRequestHandler相比今天所说的Acceptor和Processor线程从某种意义上来说只能算是请求和响应的“搬运工”罢了。
了解了完整的网络通信层架构之后我们要重点关注一下SocketServer组件。**这个组件是Kafka网络通信层中最重要的子模块。它下辖的Acceptor线程、Processor线程和RequestChannel等对象都是实施网络通信的重要组成部分**。你可能会感到意外的是,这套线程组合在源码中有多套,分别具有不同的用途。在下节课,我会具体跟你分享一下,不同的线程组合会被应用到哪些实际场景中。
下面我们进入到SocketServer组件的学习。
## SocketServer概览
SocketServer组件的源码位于Kafka工程的core包下具体位置是src/main/scala/kafka/network路径下的SocketServer.scala文件。
SocketServer.scala可谓是元老级的源码文件了。在Kafka的源码演进历史中很多代码文件进进出出这个文件却一直“坚强地活着”而且还在不断完善。如果翻开它的Git修改历史你会发现它最早的修改提交历史可回溯到2011年8月足见它的资历之老。
目前SocketServer.scala文件是一个近2000行的大文件共有8个代码部分。我使用一张思维导图帮你梳理下
<img src="https://static001.geekbang.org/resource/image/18/18/18b39a0fe7817bf6c2344bf6b49eaa18.jpg" alt="">
乍一看组件有很多但你也不必担心我先对这些组件做个简单的介绍然后我们重点学习一下Acceptor类和Processor类的源码。毕竟**这两个类是实现网络通信的关键部件**。另外今天我给出的都是SocketServer组件的基本情况介绍下节课我再详细向你展示它的定义。
1.**AbstractServerThread类**这是Acceptor线程和Processor线程的抽象基类定义了这两个线程的公有方法如shutdown关闭线程等。我不会重点展开这个抽象类的代码但你要重点关注下CountDownLatch类在线程启动和线程关闭时的作用。
如果你苦于寻找Java线程安全编程的最佳实践案例那一定不要错过CountDownLatch这个类。Kafka中的线程控制代码大量使用了基于CountDownLatch的编程技术依托于它来实现优雅的线程启动、线程关闭等操作。因此我建议你熟练掌握它们并应用到你日后的工作当中去。
2.**Acceptor线程类**这是接收和创建外部TCP连接的线程。每个SocketServer实例只会创建一个Acceptor线程。它的唯一目的就是创建连接并将接收到的Request传递给下游的Processor线程处理。
3.**Processor线程类**这是处理单个TCP连接上所有请求的线程。每个SocketServer实例默认创建若干个num.network.threadsProcessor线程。Processor线程负责将接收到的Request添加到RequestChannel的Request队列上同时还负责将Response返还给Request发送方。
4.**Processor伴生对象类**仅仅定义了一些与Processor线程相关的常见监控指标和常量等如Processor线程空闲率等。
5.**ConnectionQuotas类**是控制连接数配额的类。我们能够设置单个IP创建Broker连接的最大数量以及单个Broker能够允许的最大连接数。
6.**TooManyConnectionsException类**SocketServer定义的一个异常类用于标识连接数配额超限情况。
7.**SocketServer类**实现了对以上所有组件的管理和操作如创建和关闭Acceptor、Processor线程等。
8.**SocketServer伴生对象类**定义了一些有用的常量同时明确了SocketServer组件中的哪些参数是允许动态修改的。
## Acceptor线程
经典的Reactor模式有个Dispatcher的角色接收外部请求并分发给下面的实际处理线程。在Kafka中这个Dispatcher就是Acceptor线程。
我们看下它的定义:
```
private[kafka] class Acceptor(val endPoint: EndPoint,
val sendBufferSize: Int,
val recvBufferSize: Int,
brokerId: Int,
connectionQuotas: ConnectionQuotas,
metricPrefix: String) extends AbstractServerThread(connectionQuotas) with KafkaMetricsGroup {
// 创建底层的NIO Selector对象
// Selector对象负责执行底层实际I/O操作如监听连接创建请求、读写请求等
private val nioSelector = NSelector.open()
// Broker端创建对应的ServerSocketChannel实例
// 后续把该Channel向上一步的Selector对象注册
val serverChannel = openServerSocket(endPoint.host, endPoint.port)
// 创建Processor线程池实际上是Processor线程数组
private val processors = new ArrayBuffer[Processor]()
private val processorsStarted = new AtomicBoolean
private val blockedPercentMeter = newMeter(s&quot;${metricPrefix}AcceptorBlockedPercent&quot;,
&quot;blocked time&quot;, TimeUnit.NANOSECONDS, Map(ListenerMetricTag -&gt; endPoint.listenerName.value))
......
}
```
从定义来看Acceptor线程接收5个参数其中比较重要的有3个。
- **endPoint**。它就是你定义的Kafka Broker连接信息比如PLAINTEXT://localhost:9092。Acceptor需要用到endPoint包含的主机名和端口信息创建Server Socket。
- **sendBufferSize**。它设置的是SocketOptions的SO_SNDBUF即用于设置出站Outbound网络I/O的底层缓冲区大小。该值默认是Broker端参数socket.send.buffer.bytes的值即100KB。
- **recvBufferSize**。它设置的是SocketOptions的SO_RCVBUF即用于设置入站Inbound网络I/O的底层缓冲区大小。该值默认是Broker端参数socket.receive.buffer.bytes的值即100KB。
说到这儿我想给你提一个优化建议。如果在你的生产环境中Clients与Broker的通信网络延迟很大比如RTT&gt;10ms那么我建议你调大控制缓冲区大小的两个参数也就是sendBufferSize和recvBufferSize。通常来说默认值100KB太小了。
除了类定义的字段Acceptor线程还有两个非常关键的自定义属性。
- **nioSelector**是Java NIO库的Selector对象实例也是后续所有网络通信组件实现Java NIO机制的基础。如果你不熟悉Java NIO那么我推荐你学习这个系列教程[Java NIO](http://tutorials.jenkov.com/java-nio/index.html)。
- **processors**网络Processor线程池。Acceptor线程在初始化时需要创建对应的网络Processor线程池。可见Processor线程是在Acceptor线程中管理和维护的。
既然如此那它就必须要定义相关的方法。Acceptor代码中提供了3个与Processor相关的方法分别是addProcessors、startProcessors和removeProcessors。鉴于它们的代码都非常简单我用注释的方式给出主体逻辑的步骤
### addProcessors
```
private[network] def addProcessors(
newProcessors: Buffer[Processor], processorThreadPrefix: String): Unit = synchronized {
processors ++= newProcessors // 添加一组新的Processor线程
if (processorsStarted.get) // 如果Processor线程池已经启动
startProcessors(newProcessors, processorThreadPrefix) // 启动新的Processor线程
}
```
### startProcessors
```
private[network] def startProcessors(processorThreadPrefix: String): Unit = synchronized {
if (!processorsStarted.getAndSet(true)) { // 如果Processor线程池未启动
startProcessors(processors, processorThreadPrefix) // 启动给定的Processor线程
}
}
private def startProcessors(processors: Seq[Processor], processorThreadPrefix: String): Unit = synchronized {
processors.foreach { processor =&gt; // 依次创建并启动Processor线程
// 线程命名规范processor线程前缀-kafka-network-thread-broker序号-监听器名称-安全协议-Processor序号
// 假设为序号为0的Broker设置PLAINTEXT://localhost:9092作为连接信息那么3个Processor线程名称分别为
// data-plane-kafka-network-thread-0-ListenerName(PLAINTEXT)-PLAINTEXT-0
// data-plane-kafka-network-thread-0-ListenerName(PLAINTEXT)-PLAINTEXT-1
// data-plane-kafka-network-thread-0-ListenerName(PLAINTEXT)-PLAINTEXT-2
KafkaThread.nonDaemon(s&quot;${processorThreadPrefix}-kafka-network-thread-$brokerId-${endPoint.listenerName}-${endPoint.securityProtocol}-${processor.id}&quot;, processor).start()
}
}
```
### removeProcessors
```
private[network] def removeProcessors(removeCount: Int, requestChannel: RequestChannel): Unit = synchronized {
// 获取Processor线程池中最后removeCount个线程
val toRemove = processors.takeRight(removeCount)
// 移除最后removeCount个线程
processors.remove(processors.size - removeCount, removeCount)
// 关闭最后removeCount个线程
toRemove.foreach(_.shutdown())
// 在RequestChannel中移除这些Processor
toRemove.foreach(processor =&gt; requestChannel.removeProcessor(processor.id))
}
```
为了更加形象地展示这些方法的逻辑我画了一张图它同时包含了这3个方法的执行流程如下图所示
<img src="https://static001.geekbang.org/resource/image/49/2b/494e5bac80f19a2533bb9e7b30003e2b.jpg" alt="">
刚才我们学到的addProcessors、startProcessors和removeProcessors方法是管理Processor线程用的。应该这么说有了这三个方法Acceptor类就具备了基本的Processor线程池管理功能。不过**Acceptor类逻辑的重头戏其实是run方法它是处理Reactor模式中分发逻辑的主要实现方法**。下面我使用注释的方式给出run方法的大体运行逻辑如下所示
```
def run(): Unit = {
//注册OP_ACCEPT事件
serverChannel.register(nioSelector, SelectionKey.OP_ACCEPT)
// 等待Acceptor线程启动完成
startupComplete()
try {
// 当前使用的Processor序号从0开始最大值是num.network.threads - 1
var currentProcessorIndex = 0
while (isRunning) {
try {
// 每500毫秒获取一次就绪I/O事件
val ready = nioSelector.select(500)
if (ready &gt; 0) { // 如果有I/O事件准备就绪
val keys = nioSelector.selectedKeys()
val iter = keys.iterator()
while (iter.hasNext &amp;&amp; isRunning) {
try {
val key = iter.next
iter.remove()
if (key.isAcceptable) {
// 调用accept方法创建Socket连接
accept(key).foreach { socketChannel =&gt;
var retriesLeft = synchronized(processors.length)
var processor: Processor = null
do {
retriesLeft -= 1
// 指定由哪个Processor线程进行处理
processor = synchronized {
currentProcessorIndex = currentProcessorIndex % processors.length
processors(currentProcessorIndex)
}
// 更新Processor线程序号
currentProcessorIndex += 1
} while (!assignNewConnection(socketChannel, processor, retriesLeft == 0)) // Processor是否接受了该连接
}
} else
throw new IllegalStateException(&quot;Unrecognized key state for acceptor thread.&quot;)
} catch {
case e: Throwable =&gt; error(&quot;Error while accepting connection&quot;, e)
}
}
}
}
catch {
case e: ControlThrowable =&gt; throw e
case e: Throwable =&gt; error(&quot;Error occurred&quot;, e)
}
}
} finally { // 执行各种资源关闭逻辑
debug(&quot;Closing server socket and selector.&quot;)
CoreUtils.swallow(serverChannel.close(), this, Level.ERROR)
CoreUtils.swallow(nioSelector.close(), this, Level.ERROR)
shutdownComplete()
}
}
```
看上去代码似乎有点多我再用一张图来说明一下run方法的主要处理逻辑吧。这里的关键点在于Acceptor线程会先为每个入站请求确定要处理它的Processor线程然后调用assignNewConnection方法令Processor线程创建与发送方的连接。
<img src="https://static001.geekbang.org/resource/image/1c/55/1c8320c702c1e18b37b992cadc61d555.jpg" alt="">
基本上Acceptor线程使用Java NIO的Selector + SocketChannel的方式循环地轮询准备就绪的I/O事件。这里的I/O事件主要是指网络连接创建事件即代码中的SelectionKey.OP_ACCEPT。一旦接收到外部连接请求Acceptor就会指定一个Processor线程并将该请求交由它让它创建真正的网络连接。总的来说Acceptor线程就做这么点事。
## Processor线程
下面我们进入到Processor线程源码的学习。
**如果说Acceptor是做入站连接处理的那么Processor代码则是真正创建连接以及分发请求的地方**。显然它要做的事情远比Acceptor要多得多。我先给出Processor线程的run方法你大致感受一下
```
override def run(): Unit = {
startupComplete() // 等待Processor线程启动完成
try {
while (isRunning) {
try {
configureNewConnections() // 创建新连接
// register any new responses for writing
processNewResponses() // 发送Response并将Response放入到inflightResponses临时队列
poll() // 执行NIO poll获取对应SocketChannel上准备就绪的I/O操作
processCompletedReceives() // 将接收到的Request放入Request队列
processCompletedSends() // 为临时Response队列中的Response执行回调逻辑
processDisconnected() // 处理因发送失败而导致的连接断开
closeExcessConnections() // 关闭超过配额限制部分的连接
} catch {
case e: Throwable =&gt; processException(&quot;Processor got uncaught exception.&quot;, e)
}
}
} finally { // 关闭底层资源
debug(s&quot;Closing selector - processor $id&quot;)
CoreUtils.swallow(closeAll(), this, Level.ERROR)
shutdownComplete()
}
}
```
run方法逻辑被切割得相当好各个子方法的边界非常清楚。因此从整体上看该方法呈现出了面向对象领域中非常难得的封装特性。我使用一张图来展示下该方法要做的事情
<img src="https://static001.geekbang.org/resource/image/1d/42/1d6f59036ea2797bfc1591f57980df42.jpg" alt="">
在详细说run方法之前我们先来看下Processor线程初始化时要做的事情。
每个Processor线程在创建时都会创建3个队列。注意这里的队列是广义的队列其底层使用的数据结构可能是阻塞队列也可能是一个Map对象而已如下所示
```
private val newConnections = new ArrayBlockingQueue[SocketChannel](connectionQueueSize)
private val inflightResponses = mutable.Map[String, RequestChannel.Response]()
private val responseQueue = new LinkedBlockingDeque[RequestChannel.Response]()
```
**队列一newConnections**
**它保存的是要创建的新连接信息**具体来说就是SocketChannel对象。这是一个默认上限是20的队列而且目前代码中硬编码了队列的长度因此你无法变更这个队列的长度。
每当Processor线程接收新的连接请求时都会将对应的SocketChannel放入这个队列。后面在创建连接时也就是调用configureNewConnections时就从该队列中取出SocketChannel然后注册新的连接。
**队列二inflightResponses**
严格来说这是一个临时Response队列。当Processor线程将Response返还给Request发送方之后还要将Response放入这个临时队列。
为什么需要这个临时队列呢这是因为有些Response回调逻辑要在Response被发送回发送方之后才能执行因此需要暂存在一个临时队列里面。这就是inflightResponses存在的意义。
**队列三responseQueue**
看名字我们就可以知道这是Response队列而不是Request队列。这告诉了我们一个事实**每个Processor线程都会维护自己的Response队列**而不是像网上的某些文章说的Response队列是线程共享的或是保存在RequestChannel中的。Response队列里面保存着需要被返还给发送方的所有Response对象。
好了了解了这些之后现在我们来深入地查看一下Processor线程的工作逻辑。根据run方法中的方法调用顺序我先来介绍下configureNewConnections方法。
### configureNewConnections
就像我前面所说的configureNewConnections负责处理新连接请求。接下来我用注释的方式给出这个方法的主体逻辑
```
private def configureNewConnections(): Unit = {
var connectionsProcessed = 0 // 当前已配置的连接数计数器
while (connectionsProcessed &lt; connectionQueueSize &amp;&amp; !newConnections.isEmpty) { // 如果没超配额并且有待处理新连接
val channel = newConnections.poll() // 从连接队列中取出SocketChannel
try {
debug(s&quot;Processor $id listening to new connection from ${channel.socket.getRemoteSocketAddress}&quot;)
// 用给定Selector注册该Channel
// 底层就是调用Java NIO的SocketChannel.register(selector, SelectionKey.OP_READ)
selector.register(connectionId(channel.socket), channel)
connectionsProcessed += 1 // 更新计数器
} catch {
case e: Throwable =&gt;
val remoteAddress = channel.socket.getRemoteSocketAddress
close(listenerName, channel)
processException(s&quot;Processor $id closed connection from $remoteAddress&quot;, e)
}
}
}
```
**该方法最重要的逻辑是调用selector的register来注册SocketChannel**。每个Processor线程都维护了一个Selector类实例。Selector类是社区提供的一个基于Java NIO Selector的接口用于执行非阻塞多通道的网络I/O操作。在核心功能上Kafka提供的Selector和Java提供的是一致的。
### processNewResponses
它负责发送Response给Request发送方并且将Response放入临时Response队列。处理逻辑如下
```
private def processNewResponses(): Unit = {
var currentResponse: RequestChannel.Response = null
while ({currentResponse = dequeueResponse(); currentResponse != null}) { // Response队列中存在待处理Response
val channelId = currentResponse.request.context.connectionId // 获取连接通道ID
try {
currentResponse match {
case response: NoOpResponse =&gt; // 无需发送Response
updateRequestMetrics(response)
trace(s&quot;Socket server received empty response to send, registering for read: $response&quot;)
handleChannelMuteEvent(channelId, ChannelMuteEvent.RESPONSE_SENT)
tryUnmuteChannel(channelId)
case response: SendResponse =&gt; // 发送Response并将Response放入inflightResponses
sendResponse(response, response.responseSend)
case response: CloseConnectionResponse =&gt; // 关闭对应的连接
updateRequestMetrics(response)
trace(&quot;Closing socket connection actively according to the response code.&quot;)
close(channelId)
case _: StartThrottlingResponse =&gt;
handleChannelMuteEvent(channelId, ChannelMuteEvent.THROTTLE_STARTED)
case _: EndThrottlingResponse =&gt;
handleChannelMuteEvent(channelId, ChannelMuteEvent.THROTTLE_ENDED)
tryUnmuteChannel(channelId)
case _ =&gt;
throw new IllegalArgumentException(s&quot;Unknown response type: ${currentResponse.getClass}&quot;)
}
} catch {
case e: Throwable =&gt;
processChannelException(channelId, s&quot;Exception while processing response for $channelId&quot;, e)
}
}
}
```
这里的关键是**SendResponse分支上的sendResponse方法**。这个方法的核心代码其实只有三行:
```
if (openOrClosingChannel(connectionId).isDefined) { // 如果该连接处于可连接状态
selector.send(responseSend) // 发送Response
inflightResponses += (connectionId -&gt; response) // 将Response加入到inflightResponses队列
}
```
### poll
严格来说上面提到的所有发送的逻辑都不是执行真正的发送。真正执行I/O动作的方法是这里的poll方法。
poll方法的核心代码就只有1行**selector.poll(pollTimeout)**。在底层它实际上调用的是Java NIO Selector的select方法去执行那些准备就绪的I/O操作不管是接收Request还是发送Response。因此你需要记住的是**poll方法才是真正执行I/O操作逻辑的地方**。
### processCompletedReceives
它是接收和处理Request的。代码如下
```
private def processCompletedReceives(): Unit = {
// 遍历所有已接收的Request
selector.completedReceives.asScala.foreach { receive =&gt;
try {
// 保证对应连接通道已经建立
openOrClosingChannel(receive.source) match {
case Some(channel) =&gt;
val header = RequestHeader.parse(receive.payload)
if (header.apiKey == ApiKeys.SASL_HANDSHAKE &amp;&amp; channel.maybeBeginServerReauthentication(receive, nowNanosSupplier))
trace(s&quot;Begin re-authentication: $channel&quot;)
else {
val nowNanos = time.nanoseconds()
// 如果认证会话已过期,则关闭连接
if (channel.serverAuthenticationSessionExpired(nowNanos)) {
debug(s&quot;Disconnecting expired channel: $channel : $header&quot;)
close(channel.id)
expiredConnectionsKilledCount.record(null, 1, 0)
} else {
val connectionId = receive.source
val context = new RequestContext(header, connectionId, channel.socketAddress,
channel.principal, listenerName, securityProtocol,
channel.channelMetadataRegistry.clientInformation)
val req = new RequestChannel.Request(processor = id, context = context,
startTimeNanos = nowNanos, memoryPool, receive.payload, requestChannel.metrics)
if (header.apiKey == ApiKeys.API_VERSIONS) {
val apiVersionsRequest = req.body[ApiVersionsRequest]
if (apiVersionsRequest.isValid) {
channel.channelMetadataRegistry.registerClientInformation(new ClientInformation(
apiVersionsRequest.data.clientSoftwareName,
apiVersionsRequest.data.clientSoftwareVersion))
}
}
// 核心代码将Request添加到Request队列
requestChannel.sendRequest(req)
selector.mute(connectionId)
handleChannelMuteEvent(connectionId, ChannelMuteEvent.REQUEST_RECEIVED)
}
}
case None =&gt;
throw new IllegalStateException(s&quot;Channel ${receive.source} removed from selector before processing completed receive&quot;)
}
} catch {
case e: Throwable =&gt;
processChannelException(receive.source, s&quot;Exception while processing request from ${receive.source}&quot;, e)
}
}
}
```
看上去代码有很多但其实最核心的代码就只有1行**requestChannel.sendRequest(req)**也就是将此Request放入Request队列。其他代码只是一些常规化的校验和辅助逻辑。
这个方法的意思是说,**Processor从底层Socket通道不断读取已接收到的网络请求然后转换成Request实例并将其放入到Request队列**。整个逻辑还是很简单的,对吧?
### processCompletedSends
它负责处理Response的回调逻辑。我之前说过Response需要被发送之后才能执行对应的回调逻辑这便是该方法代码要实现的功能
```
private def processCompletedSends(): Unit = {
// 遍历底层SocketChannel已发送的Response
selector.completedSends.asScala.foreach { send =&gt;
try {
// 取出对应inflightResponses中的Response
val response = inflightResponses.remove(send.destination).getOrElse {
throw new IllegalStateException(s&quot;Send for ${send.destination} completed, but not in `inflightResponses`&quot;)
}
updateRequestMetrics(response) // 更新一些统计指标
// 执行回调逻辑
response.onComplete.foreach(onComplete =&gt; onComplete(send))
handleChannelMuteEvent(send.destination, ChannelMuteEvent.RESPONSE_SENT)
tryUnmuteChannel(send.destination)
} catch {
case e: Throwable =&gt; processChannelException(send.destination,
s&quot;Exception while processing completed send to ${send.destination}&quot;, e)
}
}
}
```
这里通过调用Response对象的onComplete方法来实现回调函数的执行。
### processDisconnected
顾名思义,它就是处理已断开连接的。该方法的逻辑很简单,我用注释标注了主要的执行步骤:
```
private def processDisconnected(): Unit = {
// 遍历底层SocketChannel的那些已经断开的连接
selector.disconnected.keySet.asScala.foreach { connectionId =&gt;
try {
// 获取断开连接的远端主机名信息
val remoteHost = ConnectionId.fromString(connectionId).getOrElse {
throw new IllegalStateException(s&quot;connectionId has unexpected format: $connectionId&quot;)
}.remoteHost
// 将该连接从inflightResponses中移除同时更新一些监控指标
inflightResponses.remove(connectionId).foreach(updateRequestMetrics)
// 更新配额数据
connectionQuotas.dec(listenerName, InetAddress.getByName(remoteHost))
} catch {
case e: Throwable =&gt; processException(s&quot;Exception while processing disconnection of $connectionId&quot;, e)
}
}
}
```
比较关键的代码是需要从底层Selector中获取那些已经断开的连接之后把它们从inflightResponses中移除掉同时也要更新它们的配额数据。
### closeExcessConnections
这是Processor线程的run方法执行的最后一步即**关闭超限连接**。代码很简单:
```
private def closeExcessConnections(): Unit = {
// 如果配额超限了
if (connectionQuotas.maxConnectionsExceeded(listenerName)) {
// 找出优先关闭的那个连接
val channel = selector.lowestPriorityChannel()
if (channel != null)
close(channel.id) // 关闭该连接
}
}
```
所谓优先关闭是指在诸多TCP连接中找出最近未被使用的那个。这里“未被使用”就是说在最近一段时间内没有任何Request经由这个连接被发送到Processor线程。
## 总结
今天我带你了解了Kafka网络通信层的全貌大致介绍了核心组件SocketServer还花了相当多的时间研究SocketServer下的Acceptor和Processor线程代码。我们来简单总结一下。
- 网络通信层由SocketServer组件和KafkaRequestHandlerPool组件构成。
- SocketServer实现了Reactor模式用于高性能地并发处理I/O请求。
- SocketServer底层使用了Java的Selector实现NIO通信。
<img src="https://static001.geekbang.org/resource/image/41/51/41317d400ed096bbca8efadf43186f51.jpg" alt="">
在下节课我会重点介绍SocketServer处理不同类型Request所做的设计及其对应的代码。这是社区为了提高Broker处理控制类请求的重大举措也是为了改善Broker一致性所做的努力非常值得我们重点关注。
## 课后讨论
最后请思考这样一个问题为什么Request队列被设计成线程共享的而Response队列则是每个Processor线程专属的
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,392 @@
<audio id="audio" title="08 | SocketServer请求还要区分优先级" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/ab/7b5aa20bdd75b0d125e89ca827da80ab.mp3"></audio>
你好,我是胡夕。
在上节课我给你详细地介绍了Kafka网络层的架构以及SocketServer组件中的Acceptor线程和Processor线程是如何利用Java NIO实现网络通信的还简单提到了请求队列和响应队列。
今天我们接着说SocketServer源码重点学习下社区是如何对不同类型的请求进行优先级划分的。
## 案例分享
在Kafka中处理请求是不区分优先级的Kafka对待所有请求都一视同仁。**这种绝对公平的策略有时候是有问题的**。我跟你分享一个真实的案例,你就明白了。我敢保证,你在真实的线上系统中一定遇到过类似的问题。
曾经我们在生产环境中创建过一个单分区双副本的主题当时集群中的Broker A机器保存了分区的Leader副本Broker B保存了Follower副本。某天外部业务量激增导致Broker A瞬间积压了大量的未处理PRODUCE请求。更糟的是运维人员“不凑巧”地执行了一次Preferred Leader选举将Broker B显式地调整成了Leader。
这个时候问题就来了如果Producer程序把acks设置为all那么在LeaderAndIsr请求它是负责调整副本角色的比如Follower和Leader角色转换等之前积压的那些PRODUCE请求就无法正常完成了因为这些请求要一直等待ISR中所有Follower副本同步完成。
但是此时Broker B成为了Leader它上面的副本停止了拉取消息这就可能出现一种结果这些未完成的PRODUCE请求会一直保存在Broker A上的Purgatory缓存中。Leader/Follower的角色转换导致无法完成副本间同步所以这些请求无法被成功处理最终Broker A抛出超时异常返回给Producer程序。
值得一提的是Purgatory缓存是Broker端暂存延时请求的地方。课程后面我会详细介绍这个组件。
这个问题就是对请求不区分优先级造成的后来我们在SocketServer源码中确认了此事。同时结合阅读源码得到的知识我在Jira官网搜到了对应的[Jira ticket](https://issues.apache.org/jira/browse/KAFKA-4453),进而完整地了解了社区是如何解决该问题的。
其实这也是我非常推荐你深入学习Kafka的一个方法**根据实际环境中碰到的问题找到对应的源码,仔细阅读它,形成自己的解决思路,然后去社区印证自己方案的优劣**。在不断地循环这个过程的同时你会发现你对Kafka的代码越来越了解了而且能够很轻松地解决线上环境的各种问题。
说了这么多,相信你已经迫不及待地想要跟我一起阅读这部分源码了,那我们就正式开始吧。
## 必要术语和概念
在阅读SocketServer代码、深入学习请求优先级实现机制之前我们要先掌握一些基本概念这是我们理解后面内容的基础。
**1.Data plane和Control plane**
社区将Kafka请求类型划分为两大类**数据类请求和控制类请求**。Data plane和Control plane的字面意思是数据面和控制面各自对应数据类请求和控制类请求也就是说Data plane负责处理数据类请求Control plane负责处理控制类请求。
目前Controller与Broker交互的请求类型有3种**LeaderAndIsrRequest**、**StopReplicaRequest**和**UpdateMetadataRequest**。这3类请求属于控制类请求通常应该被赋予高优先级。像我们熟知的PRODUCE和FETCH请求就是典型的数据类请求。
**对这两大类请求区分处理是SocketServer源码实现的核心逻辑**
**2.监听器Listener**
目前,**源码区分数据类请求和控制类请求不同处理方式的主要途径,就是通过监听器**。也就是说,创建多组监听器分别来执行数据类和控制类请求的处理代码。
在Kafka中Broker端参数**listeners**和**advertised.listeners**就是用来配置监听器的。在源码中监听器使用EndPoint类来定义如下面代码所示
```
case class EndPoint(host: String, port: Int, listenerName: ListenerName, securityProtocol: SecurityProtocol) {
// 构造完整的监听器连接字符串
// 格式为:监听器名称://主机名:端口
// 比如PLAINTEXT://kafka-host:9092
def connectionString: String = {
val hostport =
if (host == null)
&quot;:&quot;+port
else
Utils.formatAddress(host, port)
listenerName.value + &quot;://&quot; + hostport
}
// clients工程下有一个Java版本的Endpoint类供clients端代码使用
// 此方法是构造Java版本的Endpoint类实例
def toJava: JEndpoint = {
new JEndpoint(listenerName.value, securityProtocol, host, port)
}
}
```
每个EndPoint对象定义了4个属性我们分别来看下。
- hostBroker主机名。
- portBroker端口号。
- listenerName监听器名字。目前预定义的名称包括PLAINTEXT、SSL、SASL_PLAINTEXT和SASL_SSL。Kafka允许你自定义其他监听器名称比如CONTROLLER、INTERNAL等。
- securityProtocol监听器使用的安全协议。Kafka支持4种安全协议分别是**PLAINTEXT**、**SSL**、**SASL_PLAINTEXT**和**SASL_SSL**。
这里简单提一下,**Broker端参数listener.security.protocol.map用于指定不同名字的监听器都使用哪种安全协议**。
我举个例子如果Broker端相应参数配置如下
```
listener.security.protocol.map=CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:SSL
listeners=CONTROLLER://192.1.1.8:9091,INTERNAL://192.1.1.8:9092,EXTERNAL://10.1.1.5:9093
```
那么这就表示Kafka配置了3套监听器名字分别是CONTROLLER、INTERNAL和EXTERNAL使用的安全协议分别是PLAINTEXT、PLAINTEXT和SSL。
有了这些基础知识接下来我们就可以看一下SocketServer是如何实现Data plane与Control plane的分离的。
当然在此之前我们要先了解下SocketServer的定义。
## SocketServer定义
首先我们来看下SocketServer类有哪些基础属性。我使用思维导图给你展示一下跟实现请求优先级相关的字段或属性
<img src="https://static001.geekbang.org/resource/image/95/e5/95e4a958d84263e4606ab096d5695be5.jpg" alt="">
这些字段都是啥意思呢?我们结合代码来看下。
```
class SocketServer(val config: KafkaConfig,
val metrics: Metrics,
val time: Time,
val credentialProvider: CredentialProvider)
extends Logging with KafkaMetricsGroup with BrokerReconfigurable {
// SocketServer实现BrokerReconfigurable trait表明SocketServer的一些参数配置是允许动态修改的
// 即在Broker不停机的情况下修改它们
// SocketServer的请求队列长度由Broker端参数queued.max.requests值而定默认值是500
private val maxQueuedRequests = config.queuedMaxRequests
......
// data-plane
private val dataPlaneProcessors = new ConcurrentHashMap[Int, Processor]() // 处理数据类请求的Processor线程池
// 处理数据类请求的Acceptor线程池每套监听器对应一个Acceptor线程
private[network] val dataPlaneAcceptors = new ConcurrentHashMap[EndPoint, Acceptor]()
// 处理数据类请求专属的RequestChannel对象
val dataPlaneRequestChannel = new RequestChannel(maxQueuedRequests, DataPlaneMetricPrefix)
// control-plane
// 用于处理控制类请求的Processor线程
// 注意目前定义了专属的Processor线程而非线程池处理控制类请求
private var controlPlaneProcessorOpt : Option[Processor] = None
private[network] var controlPlaneAcceptorOpt : Option[Acceptor] = None
// 处理控制类请求专属的RequestChannel对象
val controlPlaneRequestChannelOpt: Option[RequestChannel] = config.controlPlaneListenerName.map(_ =&gt; new RequestChannel(20, ControlPlaneMetricPrefix))
......
}
```
首先SocketServer类定义了一个maxQueuedRequests字段它定义了请求队列的最大长度。默认值是Broker端queued.max.requests参数值。
其次在上面的代码中你一定看到了SocketServer实现了BrokerReconfigurable接口在Scala中是trait。这就说明SocketServer中的某些配置是允许动态修改值的。如果查看SocketServer伴生对象类的定义的话你能找到下面这些代码
```
object SocketServer {
......
val ReconfigurableConfigs = Set(
KafkaConfig.MaxConnectionsPerIpProp,
KafkaConfig.MaxConnectionsPerIpOverridesProp,
KafkaConfig.MaxConnectionsProp)
......
}
```
根据这段代码我们可以知道Broker端参数max.connections.per.ip、max.connections.per.ip.overrides和max.connections是可以动态修改的。
另外在我们刚刚看的SocketServer定义的那段代码中Data plane和Control plane注释下面分别定义了一组变量即**Processor线程池**、**Acceptor线程池**和**RequestChannel**实例。
- Processor线程池即上节课提到的网络线程池负责将请求高速地放入到请求队列中。
- Acceptor线程池保存了SocketServer为每个监听器定义的Acceptor线程此线程负责分发该监听器上的入站连接建立请求。
- RequestChannel承载请求队列的请求处理通道。
严格地说对于Data plane来说线程池的说法是没有问题的因为Processor线程确实有很多个而Acceptor也可能有多个因为SocketServer会为每个EndPoint即每套监听器创建一个对应的Acceptor线程。
但是对于Control plane而言情况就不一样了。
细心的你一定发现了Control plane那组属性变量都是以Opt结尾的即它们都是Option类型。这说明了一个重要的事实你完全可以不使用Control plane套装即你可以让Kafka不区分请求类型就像2.2.0之前设计的那样。
但是一旦你开启了Control plane设置其Processor线程就只有1个Acceptor线程也是1个。另外你要注意它对应的RequestChannel里面的请求队列长度被硬编码成了20而不是一个可配置的值。这揭示了社区在这里所做的一个假设即**控制类请求的数量应该远远小于数据类请求,因而不需要为它创建线程池和较深的请求队列**。
## 创建Data plane所需资源
知道了SocketServer类的定义之后我们就可以开始学习SocketServer是如何为Data plane和Control plane创建所需资源的操作了。我们先来看为Data plane创建资源。
SocketServer的**createDataPlaneAcceptorsAndProcessors方法**负责为Data plane创建所需资源。我们看下它的实现
```
private def createDataPlaneAcceptorsAndProcessors(
dataProcessorsPerListener: Int, endpoints: Seq[EndPoint]): Unit = {
// 遍历监听器集合
endpoints.foreach { endpoint =&gt;
// 将监听器纳入到连接配额管理之下
connectionQuotas.addListener(config, endpoint.listenerName)
// 为监听器创建对应的Acceptor线程
val dataPlaneAcceptor = createAcceptor(endpoint, DataPlaneMetricPrefix)
// 为监听器创建多个Processor线程。具体数目由num.network.threads决定
addDataPlaneProcessors(dataPlaneAcceptor, endpoint, dataProcessorsPerListener)
// 将&lt;监听器Acceptor线程&gt;对保存起来统一管理
dataPlaneAcceptors.put(endpoint, dataPlaneAcceptor)
info(s&quot;Created data-plane acceptor and processors for endpoint : ${endpoint.listenerName}&quot;)
}
}
```
这段代码的逻辑非常清晰,我用一张图来解释说明下:
<img src="https://static001.geekbang.org/resource/image/b6/60/b6952e86566cdfe92d69e9d96a031560.jpg" alt="">
createDataPlaneAcceptorsAndProcessors方法会遍历你配置的所有监听器然后为每个监听器执行下面的逻辑。
1. 初始化该监听器对应的最大连接数计数器。后续这些计数器将被用来确保没有配额超限的情形发生。
1. 为该监听器创建Acceptor线程也就是调用Acceptor类的构造函数生成对应的Acceptor线程实例。
1. 创建Processor线程池。对于Data plane而言线程池的数量由Broker端参数num.network.threads决定。
1.&lt;监听器Acceptor线程&gt;对加入到Acceptor线程池统一管理。
切记源码会为每套用于Data plane的监听器执行以上这4步。
举个例子假设你配置listeners=PLAINTEXT://localhost:9092, SSL://localhost:9093那么在默认情况下源码会为PLAINTEXT和SSL这两套监听器分别创建一个Acceptor线程和一个Processor线程池。
需要注意的是,具体为哪几套监听器创建是依据配置而定的,最重要的是,**Kafka只会为Data plane所使的监听器创建这些资源**。至于如何指定监听器到底是为Data plane所用还是归Control plane我会再详细说明。
## 创建Control plane所需资源
前面说过了基于控制类请求的负载远远小于数据类请求负载的假设Control plane的配套资源只有1个Acceptor线程 + 1个Processor线程 + 1个深度是20的请求队列而已。和Data plane相比这些配置稍显寒酸不过在大部分情况下应该是够用了。
SocketServer提供了createControlPlaneAcceptorAndProcessor方法用于为Control plane创建所需资源源码如下
```
private def createControlPlaneAcceptorAndProcessor(
endpointOpt: Option[EndPoint]): Unit = {
// 如果为Control plane配置了监听器
endpointOpt.foreach { endpoint =&gt;
// 将监听器纳入到连接配额管理之下
connectionQuotas.addListener(config, endpoint.listenerName)
// 为监听器创建对应的Acceptor线程
val controlPlaneAcceptor = createAcceptor(endpoint, ControlPlaneMetricPrefix)
// 为监听器创建对应的Processor线程
val controlPlaneProcessor = newProcessor(nextProcessorId, controlPlaneRequestChannelOpt.get, connectionQuotas, endpoint.listenerName, endpoint.securityProtocol, memoryPool)
controlPlaneAcceptorOpt = Some(controlPlaneAcceptor)
controlPlaneProcessorOpt = Some(controlPlaneProcessor)
val listenerProcessors = new ArrayBuffer[Processor]()
listenerProcessors += controlPlaneProcessor
// 将Processor线程添加到控制类请求专属RequestChannel中
// 即添加到RequestChannel实例保存的Processor线程池中
controlPlaneRequestChannelOpt.foreach(
_.addProcessor(controlPlaneProcessor))
nextProcessorId += 1
// 把Processor对象也添加到Acceptor线程管理的Processor线程池中
controlPlaneAcceptor.addProcessors(listenerProcessors, ControlPlaneThreadPrefix)
info(s&quot;Created control-plane acceptor and processor for endpoint : ${endpoint.listenerName}&quot;)
}
}
```
我同样使用一张流程图来说明:
<img src="https://static001.geekbang.org/resource/image/69/1d/69ba01a158bcf63be4e7606feb95521d.jpg" alt="">
总体流程和createDataPlaneAcceptorsAndProcessors非常类似只是方法开头需要判断是否配置了用于Control plane的监听器。目前Kafka规定只能有1套监听器用于Control plane而不能像Data plane那样可以配置多套监听器。
如果认真看的话你会发现上面两张图中都没有提到启动Acceptor和Processor线程。那这些线程到底是在什么时候启动呢
实际上Processor和Acceptor线程是在启动SocketServer组件之后启动的具体代码在KafkaServer.scala文件的startup方法中如下所示
```
// KafkaServer.scala
def startup(): Unit = {
try {
info(&quot;starting&quot;)
......
// 创建SocketServer组件
socketServer = new SocketServer(config, metrics, time, credentialProvider)
// 启动SocketServer但不启动Processor线程
socketServer.startup(startProcessingRequests = false)
......
// 启动Data plane和Control plane的所有线程
socketServer.startProcessingRequests(authorizerFutures)
......
} catch {
......
}
}
```
还是没看到启动Acceptor和Processor线程的代码啊实际上SocketServer的startProcessingRequests方法就是启动这些线程的方法。我们看下这个方法的逻辑
```
def startProcessingRequests(authorizerFutures: Map[Endpoint, CompletableFuture[Void]] = Map.empty): Unit = {
info(&quot;Starting socket server acceptors and processors&quot;)
this.synchronized {
if (!startedProcessingRequests) {
// 启动处理控制类请求的Processor和Acceptor线程
startControlPlaneProcessorAndAcceptor(authorizerFutures)
// 启动处理数据类请求的Processor和Acceptor线程
startDataPlaneProcessorsAndAcceptors(authorizerFutures)
startedProcessingRequests = true
} else {
info(&quot;Socket server acceptors and processors already started&quot;)
}
}
info(&quot;Started socket server acceptors and processors&quot;)
}
```
如果在你的环境中你看不到startProcessingRequests方法不用感到惊慌。这是今年4月16日刚刚添加的方法。你需要使用git命令去拉取最新的Trunk分支代码就能看到这个方法了。
这个方法又进一步调用了startDataPlaneProcessorsAndAcceptors和startControlPlaneProcessorAndAcceptor方法分别启动Data plane的Control plane的线程。鉴于这两个方法的逻辑类似我们重点学习下startDataPlaneProcessorsAndAcceptors方法的实现。
```
private def startDataPlaneProcessorsAndAcceptors(
authorizerFutures: Map[Endpoint, CompletableFuture[Void]]): Unit = {
// 获取Broker间通讯所用的监听器默认是PLAINTEXT
val interBrokerListener = dataPlaneAcceptors.asScala.keySet
.find(_.listenerName == config.interBrokerListenerName)
.getOrElse(throw new IllegalStateException(s&quot;Inter-broker listener ${config.interBrokerListenerName} not found, endpoints=${dataPlaneAcceptors.keySet}&quot;))
val orderedAcceptors = List(dataPlaneAcceptors.get(interBrokerListener)) ++
dataPlaneAcceptors.asScala.filter { case (k, _) =&gt; k != interBrokerListener }.values
orderedAcceptors.foreach { acceptor =&gt;
val endpoint = acceptor.endPoint
// 启动Processor和Acceptor线程
startAcceptorAndProcessors(DataPlaneThreadPrefix, endpoint, acceptor, authorizerFutures)
}
}
```
该方法主要的逻辑是调用startAcceptorAndProcessors方法启动Acceptor和Processor线程。当然在此之前代码要获取Broker间通讯所用的监听器并找出该监听器对应的Acceptor线程以及它维护的Processor线程池。
好了现在我要告诉你到底是在哪里设置用于Control plane的监听器了。Broker端参数control.plane.listener.name就是用于设置Control plane所用的监听器的地方。
在默认情况下这个参数的值是空Null。Null的意思就是告诉Kafka不要启用请求优先级区分机制但如果你设置了这个参数Kafka就会利用它去listeners中寻找对应的监听器了。
我举个例子说明下。假设你的Broker端相应配置如下
```
listener.security.protocol.map=CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:SSL
listeners=CONTROLLER://192.1.1.8:9091,INTERNAL://192.1.1.8:9092,EXTERNAL://10.1.1.5:9093
control.plane.listener.name=CONTROLLER
```
那么名字是CONTROLLER的那套监听器将被用于Control plane。换句话说名字是INTERNAL和EXTERNAL的这两组监听器用于Data plane。在代码中Kafka是如何知道CONTROLLER这套监听器是给Control plane使用的呢简单来说这是通过KafkaConfig中的3个方法完成的。KafkaConfig类封装了Broker端所有参数的信息同时还定义了很多实用的工具方法。
现在,我结合上面的配置例子,用一张图的方式来说明这些代码方法的调用关系,以及主要方法的实现逻辑。
<img src="https://static001.geekbang.org/resource/image/f2/9c/f28bb3b2fc5c32fb05b3e585f7889e9c.jpg" alt="">
图中涉及3个方法它们的调用关系是自底向上即最下面的方法调用中间的方法中间的方法调用最上面的方法。现在我来具体解释下代码是怎么找到Control plane对应的监听器的。
首先代码要去获取Broker端参数control.plane.listener.name的值。在这个例子中该值是CONTROLLER字符串
之后读取Broker端参数listener.security.protocol.map值并找出CONTROLLER对应的安全认证协议。在这个例子中CONTROLLER对应的安全认证协议是PLAINTEXT。controlPlaneListenerName方法的作用是拿到这组值&lt;CONTROLLERPLAINTEXT&gt;对。
最后controlPlaneListener方法拿到这组值后取出监听器名称CONTROLLER去寻找Broker端参数listeners中对应的监听器。在这里这个监听器就是CONTROLLER://192.1.1.8:9091。这就是确认Control plane监听器完整的查找逻辑。
你可以打开KafkaConfig.scala文件依次找到这3个方法的实现代码。这里我们重点查看下getControlPlaneListenerNameAndSecurityProtocol方法的代码实现
```
private def getControlPlaneListenerNameAndSecurityProtocol: Option[(ListenerName, SecurityProtocol)] = {
// 查看Broker端参数control.plane.listener.name值
// 即是否启用了control plane监听器
Option(getString(KafkaConfig.ControlPlaneListenerNameProp)) match {
// 如果启用了
case Some(name) =&gt;
val listenerName = ListenerName.normalised(name)
// 必须同时设置Broker端参数listener.security.protocol.map
// 并从该参数值中提取出该监听器对应的安全认证协议
val securityProtocol = listenerSecurityProtocolMap.getOrElse(listenerName,
throw new ConfigException(s&quot;Listener with ${listenerName.value} defined in &quot; +
s&quot;${KafkaConfig.ControlPlaneListenerNameProp} not found in ${KafkaConfig.ListenerSecurityProtocolMapProp}.&quot;))
// 返回&lt;监听器名称,安全认证协议&gt;对
Some(listenerName, securityProtocol)
// 如果没有设置该参数值直接返回None说明没有启用control plane监听器
case None =&gt; None
}
}
```
这段代码的核心就是getString那一行即Kafka会提取名为ControlPlaneListenerNameProp参数的值而它就是control.plane.listener.name参数值。
拿到了这个参数值之后controlPlaneListener方法会记录下这个值然后把它传入到SocketServer的createControlPlaneAcceptorAndProcessor方法中。这样SocketServer就能知道你到底有没有为Control plane设置专属监听器了。
讲到这里Data plane和Control plane的内容我就说完了。现在我再来具体解释下它们和请求优先级之间的关系。
严格来说Kafka没有为请求设置数值型的优先级因此我们并不能把所有请求按照所谓的优先级进行排序。到目前为止Kafka仅仅实现了粗粒度的优先级处理即整体上把请求分为数据类请求和控制类请求两类而且没有为这两类定义可相互比较的优先级。那我们应该如何把刚刚说的所有东西和这里的优先级进行关联呢
通过刚刚的学习,我们知道,社区定义了多套监听器以及底层处理线程的方式来区分这两大类请求。虽然我们很难直接比较这两大类请求的优先级,但在实际应用中,由于数据类请求的数量要远多于控制类请求,因此,为控制类请求单独定义处理资源的做法,实际上就等同于拔高了控制类请求的优先处理权。从这个角度上来说,这套做法间接实现了优先级的区别对待。
## 总结
好了我们来小结一下。今天我们重点学习了社区实现不同类型请求优先级的方法。结合监听器的概念我们深入到SocketServer的源码中分析了Data plane和Control plane的实现原理。我们来回顾一下这节课的重点。
- Data plane负责处理数据类请求这类请求通常不需要高优先级处理。
- Control plane负责处理控制类请求这类请求需要高优先级处理。
- 监听器Kafka允许Broker定义多套监听器每套监听器可用于Data plane或Control plane。
- 优先级实现原理你要知道的是社区设计了两套资源分别处理Data plane和Control plane请求。
<img src="https://static001.geekbang.org/resource/image/ee/8c/eec8d1027bf77384d8d2fb8116af948c.jpg" alt="">
下节课我会带你串联起网络I/O层的所有组件并且结合源码带你深入理解一个请求在Kafka中是如何被处理的。敬请期待。
## 课后讨论
最后,我们来思考一个问题:如果不使用多套资源的方案,而是在请求队列这个层面进行改进,你觉得能够实现不同请求不同优先级的需求吗?比如说,将请求队列改造成支持抢占式的优先级队列方案,你可以说出这两个方案的优劣吗?
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,467 @@
<audio id="audio" title="09 | SocketServer请求处理全流程源码分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/74/baed9c709c9de3f084d51aedeeb93f74.mp3"></audio>
你好我是胡夕。前几节课我们花了很多时间学习SocketServer核心组件的源代码包括Acceptor线程、Processor线程也研究了Data plane和Control plane针对不同类型请求的处理方案。
今天我带你完整地梳理一下Kafka请求处理的全流程。这个全流程涉及到多个源码文件为了弄懂其中的原理我们必须在不同的方法间“跳来跳去”。比起学习单个源码文件将多个文件中的方法组合在一起串成完整流程要难得多因此你最好多花一些时间仔细研读一下跟这套流程相关的所有方法。
当然了,你可能有这样的疑问:“我为什么要关心请求被处理的流程呢?阅读这部分源码的意义是什么呢?”其实,**弄明白这部分原理非常有助于我们有针对性地调优Broker端请求处理的性能**。
举个例子Broker端有两个参数与这个流程相关分别是num.network.threads和num.io.threads。如果我们不掌握请求被处理的流程是没有办法有的放矢地调整这些参数的。
要知道Kafka官网可没有告诉我们什么是网络线程和I/O线程。如果不明白“请求是被网络线程接收并放入请求队列的”这件事我们就很可能犯这样的错误——当请求队列快满了的时候我们会以为是网络线程处理能力不够进而盲目地增加num.network.threads值但最终效果很可能是适得其反的。我相信在今天的课程结束之后你就会知道碰到这种情况的时候我们更应该增加的是num.io.threads的值。
num.io.threads参数表征的就是I/O线程池的大小。所谓的I/O线程池即KafkaRequestHandlerPool也称请求处理线程池。这节课我会先讲解**KafkaRequestHandlerPool源码**,再具体解析**请求处理全流程的代码**。
## KafkaRequestHandlerPool
**KafkaRequestHandlerPool是真正处理Kafka请求的地方**。切记Kafka中处理请求的类不是SocketServer也不是RequestChannel而是KafkaRequestHandlerPool。
它所在的文件是KafkaRequestHandler.scala位于core包的src/main/scala/kafka/server下。这是一个不到400行的小文件掌握起来并不难。
我先用一张图给你展示下这个文件里都有哪些组件:
<img src="https://static001.geekbang.org/resource/image/d3/f9/d3e7713bab984782dec557c534c558f9.jpg" alt="">
- **KafkaRequestHandler**请求处理线程类。每个请求处理线程实例负责从SocketServer的RequestChannel的请求队列中获取请求对象并进行处理。
- **KafkaRequestHandlerPool**:请求处理线程池,负责创建、维护、管理和销毁下辖的请求处理线程。
- **BrokerTopicMetrics**Broker端与主题相关的监控指标的管理类。
- **BrokerTopicStatsC**定义Broker端与主题相关的监控指标的管理操作。
- **BrokerTopicStatsO**BrokerTopicStats的伴生对象类定义Broker端与主题相关的监控指标比如常见的MessagesInPerSec和MessagesOutPerSec等。
我们重点看前两个组件的代码。后面的三个类或对象都是与监控指标相关的代码多为一些工具类方法或定义常量非常容易理解。所以我们不必在它们身上花费太多时间要把主要精力放在KafkaRequestHandler及其相关管理类的学习上。
### KafkaRequestHandler
首先,我们来看下它的定义:
```
// 关键字段说明
// id: I/O线程序号
// brokerId所在Broker序号即broker.id值
// totalHandlerThreadsI/O线程池大小
// requestChannel请求处理通道
// apisKafkaApis类用于真正实现请求处理逻辑的类
class KafkaRequestHandler(
id: Int,
brokerId: Int,
val aggregateIdleMeter: Meter,
val totalHandlerThreads: AtomicInteger,
val requestChannel: RequestChannel,
apis: KafkaApis,
time: Time) extends Runnable with Logging {
......
}
```
从定义可知KafkaRequestHandler是一个Runnable对象因此你可以把它当成是一个线程。每个KafkaRequestHandler实例都有4个关键的属性。
- **id**请求处理线程的序号类似于Processor线程的ID序号仅仅用于标识这是线程池中的第几个线程。
- **brokerId**Broker序号用于标识这是哪个Broker上的请求处理线程。
- **requestChannel**SocketServer中的请求通道对象。KafkaRequestHandler对象为什么要定义这个字段呢我们说过它是负责处理请求的类那请求保存在什么地方呢实际上请求恰恰是保存在RequestChannel中的请求队列中因此Kafka在构造KafkaRequestHandler实例时必须关联SocketServer组件中的RequestChannel实例也就是说要让I/O线程能够找到请求被保存的地方。
- **apis**这是一个KafkaApis类。如果说KafkaRequestHandler是真正处理请求的那么KafkaApis类就是真正执行请求处理逻辑的地方。在第10节课我会具体讲解KafkaApis的代码。目前你需要知道的是它有个handle方法用于执行请求处理逻辑。
既然KafkaRequestHandler是一个线程类那么除去常规的close、stop、initiateShutdown和awaitShutdown方法最重要的当属run方法实现了如下所示
```
def run(): Unit = {
// 只要该线程尚未关闭,循环运行处理逻辑
while (!stopped) {
val startSelectTime = time.nanoseconds
// 从请求队列中获取下一个待处理的请求
val req = requestChannel.receiveRequest(300)
val endTime = time.nanoseconds
// 统计线程空闲时间
val idleTime = endTime - startSelectTime
// 更新线程空闲百分比指标
aggregateIdleMeter.mark(idleTime / totalHandlerThreads.get)
req match {
// 关闭线程请求
case RequestChannel.ShutdownRequest =&gt;
debug(s&quot;Kafka request handler $id on broker $brokerId received shut down command&quot;)
// 关闭线程
shutdownComplete.countDown()
return
// 普通请求
case request: RequestChannel.Request =&gt;
try {
request.requestDequeueTimeNanos = endTime
trace(s&quot;Kafka request handler $id on broker $brokerId handling request $request&quot;)
// 由KafkaApis.handle方法执行相应处理逻辑
apis.handle(request)
} catch {
// 如果出现严重错误,立即关闭线程
case e: FatalExitError =&gt;
shutdownComplete.countDown()
Exit.exit(e.statusCode)
// 如果是普通异常,记录错误日志
case e: Throwable =&gt; error(&quot;Exception when handling request&quot;, e)
} finally {
// 释放请求对象占用的内存缓冲区资源
request.releaseBuffer()
}
case null =&gt; // 继续
}
}
shutdownComplete.countDown()
}
```
虽然我给一些主要的代码都标记了注释但为了方便你更好地理解我画一张图借助它来展示下KafkaRequestHandler线程的处理逻辑
<img src="https://static001.geekbang.org/resource/image/b5/4d/b5f6d3b4ecea86a3e66a29953034dc4d.jpg" alt="">
我来解释下run方法的主要运行逻辑。它的所有执行逻辑都在while循环之下因此只要标志线程关闭状态的stopped为falserun方法将一直循环执行while下的语句。
第1步是从请求队列中获取下一个待处理的请求同时更新一些相关的统计指标。如果本次循环没取到那么本轮循环结束进入到下一轮。如果是ShutdownRequest请求则说明该Broker发起了关闭操作。
而Broker关闭时会调用KafkaRequestHandler的shutdown方法进而调用initiateShutdown方法以及RequestChannel的sendShutdownRequest方法而后者就是将ShutdownRequest写入到请求队列。
一旦从请求队列中获取到ShutdownRequestrun方法代码会调用shutdownComplete的countDown方法正式完成对KafkaRequestHandler线程的关闭操作。你看看KafkaRequestHandlerPool的shutdown方法代码就能明白这是怎么回事了。
```
def shutdown(): Unit = synchronized {
info(&quot;shutting down&quot;)
for (handler &lt;- runnables)
handler.initiateShutdown() // 调用initiateShutdown方法发起关闭
for (handler &lt;- runnables)
// 调用awaitShutdown方法等待关闭完成
// run方法一旦调用countDown方法这里将解除等待状态
handler.awaitShutdown()
info(&quot;shut down completely&quot;)
}
```
就像代码注释中写的那样一旦run方法执行了countDown方法程序流解除在awaitShutdown方法这里的等待从而完成整个线程的关闭操作。
我们继续说回run方法。如果从请求队列中获取的是普通请求那么首先更新请求移出队列的时间戳然后交由KafkaApis的handle方法执行实际的请求处理逻辑代码。待请求处理完成并被释放缓冲区资源后代码进入到下一轮循环周而复始地执行以上所说的逻辑。
### KafkaRequestHandlerPool
从上面的分析来看KafkaRequestHandler逻辑大体上还是比较简单的。下面我们来看下KafkaRequestHandlerPool线程池的实现。它是管理I/O线程池的实现逻辑也不复杂。它的shutdown方法前面我讲过了这里我们重点学习下**它是如何创建这些线程的,以及创建它们的时机**。
首先看它的定义:
```
// 关键字段说明
// brokerId所属Broker的序号即broker.id值
// requestChannelSocketServer组件下的RequestChannel对象
// apiKafkaApis类实际请求处理逻辑类
// numThreadsI/O线程池初始大小
class KafkaRequestHandlerPool(
val brokerId: Int,
val requestChannel: RequestChannel,
val apis: KafkaApis,
time: Time,
numThreads: Int,
requestHandlerAvgIdleMetricName: String,
logAndThreadNamePrefix : String)
extends Logging with KafkaMetricsGroup {
// I/O线程池大小
private val threadPoolSize: AtomicInteger = new AtomicInteger(numThreads)
// I/O线程池
val runnables = new mutable.ArrayBuffer[KafkaRequestHandler](numThreads)
......
}
```
KafkaRequestHandlerPool对象定义了7个属性其中比较关键的有4个我分别来解释下。
- **brokerId**和KafkaRequestHandler中的一样保存Broker的序号。
- **requestChannel**SocketServer的请求处理通道它下辖的请求队列为所有I/O线程所共享。requestChannel字段也是KafkaRequestHandler类的一个重要属性。
- **apis**KafkaApis实例执行实际的请求处理逻辑。它同时也是KafkaRequestHandler类的一个重要属性。
- **numThreads**线程池中的初始线程数量。它是Broker端参数num.io.threads的值。目前Kafka支持动态修改I/O线程池的大小因此这里的numThreads是初始线程数调整后的I/O线程池的实际大小可以和numThreads不一致。
这里我再详细解释一下numThreads属性和实际线程池中线程数的关系。就像我刚刚说过的I/O线程池的大小是可以修改的。如果你查看KafkaServer.scala中的startup方法你会看到以下这两行代码
```
// KafkaServer.scala
dataPlaneRequestHandlerPool = new KafkaRequestHandlerPool(config.brokerId, socketServer.dataPlaneRequestChannel, dataPlaneRequestProcessor, time, config.numIoThreads, s&quot;${SocketServer.DataPlaneMetricPrefix}RequestHandlerAvgIdlePercent&quot;, SocketServer.DataPlaneThreadPrefix)
controlPlaneRequestHandlerPool = new KafkaRequestHandlerPool(config.brokerId, socketServer.controlPlaneRequestChannelOpt.get, controlPlaneRequestProcessor, time, 1, s&quot;${SocketServer.ControlPlaneMetricPrefix}RequestHandlerAvgIdlePercent&quot;, SocketServer.ControlPlaneThreadPrefix)
```
由代码可知Data plane所属的KafkaRequestHandlerPool线程池的初始数量就是Broker端的参数nums.io.threads即这里的config.numIoThreads值而用于Control plane的线程池的数量则硬编码为1。
因此你可以发现Broker端参数num.io.threads的值控制的是Broker启动时KafkaRequestHandler线程的数量。因此**当你想要在一开始就提升Broker端请求处理能力的时候不妨试着增加这个参数值**。
除了上面那4个属性该类还定义了一个threadPoolSize变量。本质上它就是用AtomicInteger包了一层numThreads罢了。
为什么要这么做呢这是因为目前Kafka支持动态调整KafkaRequestHandlerPool线程池的线程数量但类定义中的numThreads一旦传入就不可变更了因此需要单独创建一个支持更新操作的线程池数量的变量。至于为什么使用AtomicInteger你应该可以想到这是为了保证多线程访问的线程安全性。毕竟这个线程池大小的属性可能被多个线程访问到而AtomicInteger本身提供的原子操作能够有效地确保这种并发访问同时还能提供必要的内存可见性。
既然是管理I/O线程池的类KafkaRequestHandlerPool中最重要的字段当属线程池字段runnables了。就代码而言Kafka选择使用Scala的数组对象类实现I/O线程池。
**createHandler方法**
当线程池初始化时Kafka使用下面这段代码批量创建线程并将它们添加到线程池中
```
for (i &lt;- 0 until numThreads) {
createHandler(i) // 创建numThreads个I/O线程
}
// 创建序号为指定id的I/O线程对象并启动该线程
def createHandler(id: Int): Unit = synchronized {
// 创建KafkaRequestHandler实例并加入到runnables中
runnables += new KafkaRequestHandler(id, brokerId, aggregateIdleMeter, threadPoolSize, requestChannel, apis, time)
// 启动KafkaRequestHandler线程
KafkaThread.daemon(logAndThreadNamePrefix + &quot;-kafka-request-handler-&quot; + id, runnables(id)).start()
}
```
我来解释下这段代码。源码使用for循环批量调用createHandler方法创建多个I/O线程。createHandler方法的主体逻辑分为三步
1. 创建KafkaRequestHandler实例
1. 将创建的线程实例加入到线程池数组;
1. 启动该线程。
**resizeThreadPool方法**
下面我们说说resizeThreadPool方法的代码。这个方法的目的是**把I/O线程池的线程数重设为指定的数值**。代码如下:
```
def resizeThreadPool(newSize: Int): Unit = synchronized {
val currentSize = threadPoolSize.get
info(s&quot;Resizing request handler thread pool size from $currentSize to $newSize&quot;)
if (newSize &gt; currentSize) {
for (i &lt;- currentSize until newSize) {
createHandler(i)
}
} else if (newSize &lt; currentSize) {
for (i &lt;- 1 to (currentSize - newSize)) {
runnables.remove(currentSize - i).stop()
}
}
threadPoolSize.set(newSize)
}
```
该方法首先获取当前线程数量。如果目标数量比当前数量大就利用刚才说到的createHandler方法将线程数补齐到目标值newSize否则的话就将多余的线程从线程池中移除并停止它们。最后把标识线程数量的变量threadPoolSize的值调整为目标值newSize。
至此KafkaRequestHandlerPool类的3个方法shutdown、createHandler和resizeThreadPool我们就学完了。总体而言它就是负责管理I/O线程池的类。
## 全处理流程
有了上面的这些铺垫我们就可以来学习下Kafka请求处理全流程的代码路径了。
我们再来看一下[第7讲](https://time.geekbang.org/column/article/231139)里的这张图。上一次,我主要是想借助它,让你对网络线程池有个整体的了解,今天,我来具体给你讲解下,这张图所展示的完整请求处理逻辑。
<img src="https://static001.geekbang.org/resource/image/52/e8/52c3226ad4736751b4b1ccfcb2a09ee8.jpg" alt="">
图中一共有6步。我分别解释一下同时还会带你去找寻对应的源码。
### 第1步Clients或其他Broker发送请求给Acceptor线程
我在第7节课讲过Acceptor线程实时接收来自外部的发送请求。一旦接收到了之后就会创建对应的Socket通道就像下面这段代码所示
```
// SocketServer.scala中Acceptor的run方法片段
// 读取底层通道上准备就绪I/O操作的数量
val ready = nioSelector.select(500)
// 如果存在准备就绪的I/O事件
if (ready &gt; 0) {
// 获取对应的SelectionKey集合
val keys = nioSelector.selectedKeys()
val iter = keys.iterator()
// 遍历这些SelectionKey
while (iter.hasNext &amp;&amp; isRunning) {
try {
val key = iter.next
iter.remove()
// 测试SelectionKey的底层通道是否能够接受新Socket连接
if (key.isAcceptable) {
// 接受此连接并分配对应的Processor线程
accept(key).foreach { socketChannel =&gt;
var processor: Processor = null
do {
retriesLeft -= 1
processor = synchronized {
currentProcessorIndex = currentProcessorIndex % processors.length
processors(currentProcessorIndex)
}
currentProcessorIndex += 1
// 将新Socket连接加入到Processor线程待处理连接队列
// 等待Processor线程后续处理
} while (!assignNewConnection(socketChannel, processor, retriesLeft == 0))
}
} else {
......
}
......
}
```
可以看到Acceptor线程通过调用accept方法创建对应的SocketChannel然后将该Channel实例传给assignNewConnection方法等待Processor线程将该Socket连接请求放入到它维护的待处理连接队列中。后续Processor线程的run方法会不断地从该队列中取出这些Socket连接请求然后创建对应的Socket连接。
assignNewConnection方法的主要作用是将这个新建的SocketChannel对象存入Processors线程的newConnections队列中。之后Processor线程会不断轮询这个队列中的待处理Channel可以参考第7讲的configureNewConnections方法并向这些Channel注册基于Java NIO的Selector用于真正的请求获取和响应发送I/O操作。
严格来说Acceptor线程处理的这一步并非真正意义上的获取请求仅仅是Acceptor线程为后续Processor线程获取请求铺路而已也就是把需要用到的Socket通道创建出来传给下面的Processor线程使用。
### 第2 &amp; 3步Processor线程处理请求并放入请求队列
一旦Processor线程成功地向SocketChannel注册了SelectorClients端或其他Broker端发送的请求就能通过该SocketChannel被获取到具体的方法是Processor的processCompleteReceives
```
// SocketServer.scala
private def processCompletedReceives(): Unit = {
// 从Selector中提取已接收到的所有请求数据
selector.completedReceives.asScala.foreach { receive =&gt;
try {
// 打开与发送方对应的Socket Channel如果不存在可用的Channel抛出异常
openOrClosingChannel(receive.source) match {
case Some(channel) =&gt;
......
val header = RequestHeader.parse(receive.payload)
if (header.apiKey == ApiKeys.SASL_HANDSHAKE &amp;&amp; channel.maybeBeginServerReauthentication(receive, nowNanosSupplier))
……
else {
val nowNanos = time.nanoseconds()
if (channel.serverAuthenticationSessionExpired(nowNanos)) {
……
} else {
val connectionId = receive.source
val context = new RequestContext(header, connectionId, channel.socketAddress,
channel.principal, listenerName, securityProtocol,
channel.channelMetadataRegistry.clientInformation)
// 根据Channel中获取的Receive对象构建Request对象
val req = new RequestChannel.Request(processor = id, context = context,
startTimeNanos = nowNanos, memoryPool, receive.payload, requestChannel.metrics)
……
// 将该请求放入请求队列
requestChannel.sendRequest(req)
......
}
}
……
}
} catch {
……
}
}
}
```
因为代码很多我进行了精简只保留了最关键的逻辑。该方法会将Selector获取到的所有Receive对象转换成对应的Request对象然后将这些Request实例放置到请求队列中就像上图中第2、3步展示的那样。
所谓的Processor线程处理请求就是指它从底层I/O获取到发送数据将其转换成Request对象实例并最终添加到请求队列的过程。
### 第4步I/O线程处理请求
所谓的I/O线程就是我们开头提到的KafkaRequestHandler线程它的处理逻辑就在KafkaRequestHandler类的run方法中
```
// KafkaRequestHandler.scala
def run(): Unit = {
while (!stopped) {
......
// 从请求队列中获取Request实例
val req = requestChannel.receiveRequest(300)
......
req match {
case RequestChannel.ShutdownRequest =&gt;
......
case request: RequestChannel.Request =&gt;
try {
......
apis.handle(request)
} {
......
}
case null =&gt; // 什么也不做
}
}
......
}
```
KafkaRequestHandler线程循环地从请求队列中获取Request实例然后交由KafkaApis的handle方法执行真正的请求处理逻辑。
### 第5步KafkaRequestHandler线程将Response放入Processor线程的Response队列
这一步的工作由KafkaApis类完成。当然这依然是由KafkaRequestHandler线程来完成的。KafkaApis.scala中有个sendResponse方法将Request的处理结果Response发送出去。本质上它就是调用了RequestChannel的sendResponse方法代码如下
```
def sendResponse(response: RequestChannel.Response): Unit = {
......
// 找到这个Request当初是由哪个Processor线程处理的
val processor = processors.get(response.processor)
if (processor != null) {
// 将Response添加到该Processor线程的Response队列上
processor.enqueueResponse(response)
}
}
```
### 第6步Processor线程发送Response给Request发送方
最后一步是Processor线程取出Response队列中的Response返还给Request发送方。具体代码位于Processor线程的processNewResponses方法中
```
// SocketServer.scala
private def processNewResponses(): Unit = {
var currentResponse: RequestChannel.Response = null
while ({currentResponse = dequeueResponse(); currentResponse != null}) { // 循环获取Response队列中的Response
val channelId = currentResponse.request.context.connectionId
try {
currentResponse match {
case response: NoOpResponse =&gt; // 不需要发送Response
updateRequestMetrics(response)
trace(s&quot;Socket server received empty response to send, registering for read: $response&quot;)
handleChannelMuteEvent(channelId, ChannelMuteEvent.RESPONSE_SENT)
tryUnmuteChannel(channelId)
case response: SendResponse =&gt; // 需要发送Response
sendResponse(response, response.responseSend)
......
}
}
......
}
}
```
从这段代码可知最核心的部分是sendResponse方法来执行Response发送。该方法底层使用Selector实现真正的发送逻辑。至此一个请求被完整处理的流程我就讲完了。
最后我想再补充一点还记得我之前说过有些Response是需要有回调逻辑的吗
实际上在第6步执行完毕之后Processor线程通常还会尝试执行Response中的回调逻辑即Processor类的processCompletedSends方法。不过并非所有Request或Response都指定了回调逻辑。事实上只有很少的Response携带了回调逻辑。比如说FETCH请求在发送Response之后就要求更新下Broker端与消息格式转换操作相关的统计指标。
## 总结
今天我们学习了KafkaRequestHandlerPool线程池及其下辖的KafkaRequestHandler线程该线程就是Kafka社区所称的I/O线程。另外我结合源代码把Kafka的请求处理流程串讲了一遍。我们来回顾下这节课的重点。
- KafkaRequestHandlerI/O线程负责处理Processor线程下发的Request对象。
- KafkaRequestHandlerPool创建和管理一组KafkaRequestHandler线程。
- 请求处理流程总共分为6步。
1. Clients或其他Broker通过Selector机制发起创建连接请求。
1. Processor线程接收请求并将其转换成可处理的Request对象。
1. Processor线程将Request对象放入Request队列。
1. KafkaRequestHandler线程从Request队列中取出待处理请求并进行处理。
1. KafkaRequestHandler线程将Response放回到对应Processor线程的Response队列。
1. Processor线程发送Response给Request发送方。
<img src="https://static001.geekbang.org/resource/image/45/c4/458e65efcab7964911bb6a1755fa89c4.jpg" alt="">
其实今天在谈到Request逻辑执行的时候我卖了个关子——我提到KafkaApis是请求逻辑的真正处理方法。也就是说所有类型的请求处理逻辑都封装在KafkaApis文件下但我并没有深入地去讲它。下节课我会重点和你聊聊这个KafkaApis类。我一直认为该类是查看所有Kafka源码的首要入口类绝对值得我们花一整节课的时间去学习。
## 课后讨论
最后,请你结合今天的内容思考一个问题:你觉得,请求处理流程的哪些部分应用了经典的“生产者-消费者”模式?
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,325 @@
<audio id="audio" title="10 | KafkaApisKafka最重要的源码入口没有之一" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/61/05454b44470855c818a8aa247ebca361.mp3"></audio>
你好我是胡夕。今天我们来收尾Kafka请求处理模块的源码学习。讲到这里关于整个模块我们还有最后一个知识点尚未掌握那就是KafkaApis类。
在上节课中我提到过请求的实际处理逻辑是封装在KafkaApis类中的。你一定很想知道这个类到底是做什么的吧。
实际上我一直认为KafkaApis是Kafka最重要的源码入口。因为每次要查找Kafka某个功能的实现代码时我们几乎总要从这个KafkaApis.scala文件开始找起然后一层一层向下钻取直到定位到实现功能的代码处为止。比如如果你想知道创建Topic的流程你只需要查看KafkaApis的handleCreateTopicsRequest方法如果你想弄懂Consumer提交位移是怎么实现的查询handleOffsetCommitRequest方法就行了。
除此之外,在这一遍遍的钻取过程中,我们还会慢慢地**掌握Kafka实现各种功能的代码路径和源码分布从而建立起对整个Kafka源码工程的完整认识**。
如果这些还不足以吸引你阅读这部分源码,那么,我再给你分享一个真实的案例。
之前在使用Kafka时我发现Producer程序一旦向一个不存在的主题发送消息在创建主题之后Producer端会抛出一个警告
```
Error while fetching metadata with correlation id 3 : {test-topic=LEADER_NOT_AVAILABLE} (org.apache.kafka.clients.NetworkClient)
```
我一直很好奇这里的LEADER_NOT_AVAILABLE异常是在哪里抛出来的。直到有一天我在浏览KafkaApis代码时突然发现了createTopics方法的这两行代码
```
private def createTopic(topic: String,
numPartitions: Int, replicationFactor: Int,
properties: util.Properties = new util.Properties()): MetadataResponseTopic = {
try {
adminZkClient.createTopic(topic, numPartitions, replicationFactor, properties, RackAwareMode.Safe)
......
// 显式封装一个LEADER_NOT_AVAILABLE Response
metadataResponseTopic(Errors.LEADER_NOT_AVAILABLE, topic, isInternal(topic), util.Collections.emptyList())
} catch {
......
}
}
```
这时我才恍然大悟原来Broker端创建完主题后会显式地通知Clients端LEADER_NOT_AVAILABLE异常。Clients端接收到该异常后会主动更新元数据去获取新创建主题的信息。你看如果不是亲自查看源代码我们是无法解释这种现象的。
那么既然KafkaApis这么重要现在我们就来看看这个大名鼎鼎的入口文件吧。我会先给你介绍下它的定义以及最重要的handle方法然后再解释一下其他的重要方法。学完这节课以后你就能掌握从KafkaApis类开始去寻找单个功能具体代码位置的方法了。
事实上,相比于之前更多是向你分享知识的做法,**这节课我分享的是学习知识的方法**。
## KafkaApis类定义
好了, 我们首先来看下KafkaApis类的定义。KafkaApis类定义在源码文件KafkaApis.scala中。该文件位于core工程的server包下是一个将近3000行的巨型文件。好在它实现的逻辑并不复杂绝大部分代码都是用来处理所有Kafka请求类型的因此代码结构整体上显得非常规整。一会儿我们在学习handle方法时你一定会所有体会。
KafkaApis类的定义代码如下
```
class KafkaApis(
val requestChannel: RequestChannel, // 请求通道
val replicaManager: ReplicaManager, // 副本管理器
val adminManager: AdminManager, // 主题、分区、配置等方面的管理器
val groupCoordinator: GroupCoordinator, // 消费者组协调器组件
val txnCoordinator: TransactionCoordinator, // 事务管理器组件
val controller: KafkaController, // 控制器组件
val zkClient: KafkaZkClient, // ZooKeeper客户端程序Kafka依赖于该类实现与ZooKeeper交互
val brokerId: Int, // broker.id参数值
val config: KafkaConfig, // Kafka配置类
val metadataCache: MetadataCache, // 元数据缓存类
val metrics: Metrics,
val authorizer: Option[Authorizer],
val quotas: QuotaManagers, // 配额管理器组件
val fetchManager: FetchManager,
brokerTopicStats: BrokerTopicStats,
val clusterId: String,
time: Time,
val tokenManager: DelegationTokenManager) extends Logging {
type FetchResponseStats = Map[TopicPartition, RecordConversionStats]
this.logIdent = &quot;[KafkaApi-%d] &quot;.format(brokerId)
val adminZkClient = new AdminZkClient(zkClient)
private val alterAclsPurgatory = new DelayedFuturePurgatory(purgatoryName = &quot;AlterAcls&quot;, brokerId = config.brokerId)
......
}
```
我为一些重要的字段添加了注释信息。为了方便你理解,我还画了一张思维导图,罗列出了比较重要的组件:
<img src="https://static001.geekbang.org/resource/image/4f/cc/4fc050472d3c81fa27564297e07d67cc.jpg" alt="">
从这张图可以看出KafkaApis下可谓是大牌云集。放眼整个源码工程KafkaApis关联的“大佬级”组件都是最多的在KafkaApis中你几乎能找到Kafka所有重量级的组件比如负责副本管理的ReplicaManager、维护消费者组的GroupCoordinator以及操作Controller组件的KafkaController等等。
在处理不同类型的RPC请求时KafkaApis会用到不同的组件因此在创建KafkaApis实例时我们必须把可能用到的组件一并传给它这也是它汇聚众多大牌组件于一身的原因。
我说KafkaApis是入口类的另一个原因也在于此。你完全可以打开KafkaApis.scala文件然后根据它的定义一个一个地去研习这些重量级组件的实现原理。等你对这些组件的代码了然于胸了说不定下一个写源码课的人就是你了。
## KafkaApis方法入口
作为Kafka源码的入口类它都定义了哪些方法呢
如果你翻开KafkaApis类的代码你会发现它封装了很多以handle开头的方法。每一个这样的方法都对应于一类请求类型而它们的总方法入口就是handle方法。实际上你完全可以在handle方法间不断跳转去到任意一类请求被处理的实际代码中。下面这段代码就是handle方法的完整实现我们来看一下
```
def handle(request: RequestChannel.Request): Unit = {
try {
trace(s&quot;Handling request:${request.requestDesc(true)} from connection ${request.context.connectionId};&quot; +
s&quot;securityProtocol:${request.context.securityProtocol},principal:${request.context.principal}&quot;)
// 根据请求头部信息中的apiKey字段判断属于哪类请求
// 然后调用响应的handle***方法
// 如果新增RPC协议类型
// 1. 添加新的apiKey标识新请求类型
// 2. 添加新的case分支
// 3. 添加对应的handle***方法
request.header.apiKey match {
case ApiKeys.PRODUCE =&gt; handleProduceRequest(request)
case ApiKeys.FETCH =&gt; handleFetchRequest(request)
case ApiKeys.LIST_OFFSETS =&gt; handleListOffsetRequest(request)
case ApiKeys.METADATA =&gt; handleTopicMetadataRequest(request)
case ApiKeys.LEADER_AND_ISR =&gt; handleLeaderAndIsrRequest(request)
case ApiKeys.STOP_REPLICA =&gt; handleStopReplicaRequest(request)
case ApiKeys.UPDATE_METADATA =&gt; handleUpdateMetadataRequest(request)
case ApiKeys.CONTROLLED_SHUTDOWN =&gt; handleControlledShutdownRequest(request)
case ApiKeys.OFFSET_COMMIT =&gt; handleOffsetCommitRequest(request)
case ApiKeys.OFFSET_FETCH =&gt; handleOffsetFetchRequest(request)
case ApiKeys.FIND_COORDINATOR =&gt; handleFindCoordinatorRequest(request)
case ApiKeys.JOIN_GROUP =&gt; handleJoinGroupRequest(request)
case ApiKeys.HEARTBEAT =&gt; handleHeartbeatRequest(request)
case ApiKeys.LEAVE_GROUP =&gt; handleLeaveGroupRequest(request)
case ApiKeys.SYNC_GROUP =&gt; handleSyncGroupRequest(request)
case ApiKeys.DESCRIBE_GROUPS =&gt; handleDescribeGroupRequest(request)
case ApiKeys.LIST_GROUPS =&gt; handleListGroupsRequest(request)
case ApiKeys.SASL_HANDSHAKE =&gt; handleSaslHandshakeRequest(request)
case ApiKeys.API_VERSIONS =&gt; handleApiVersionsRequest(request)
case ApiKeys.CREATE_TOPICS =&gt; handleCreateTopicsRequest(request)
case ApiKeys.DELETE_TOPICS =&gt; handleDeleteTopicsRequest(request)
case ApiKeys.DELETE_RECORDS =&gt; handleDeleteRecordsRequest(request)
case ApiKeys.INIT_PRODUCER_ID =&gt; handleInitProducerIdRequest(request)
case ApiKeys.OFFSET_FOR_LEADER_EPOCH =&gt; handleOffsetForLeaderEpochRequest(request)
case ApiKeys.ADD_PARTITIONS_TO_TXN =&gt; handleAddPartitionToTxnRequest(request)
case ApiKeys.ADD_OFFSETS_TO_TXN =&gt; handleAddOffsetsToTxnRequest(request)
case ApiKeys.END_TXN =&gt; handleEndTxnRequest(request)
case ApiKeys.WRITE_TXN_MARKERS =&gt; handleWriteTxnMarkersRequest(request)
case ApiKeys.TXN_OFFSET_COMMIT =&gt; handleTxnOffsetCommitRequest(request)
case ApiKeys.DESCRIBE_ACLS =&gt; handleDescribeAcls(request)
case ApiKeys.CREATE_ACLS =&gt; handleCreateAcls(request)
case ApiKeys.DELETE_ACLS =&gt; handleDeleteAcls(request)
case ApiKeys.ALTER_CONFIGS =&gt; handleAlterConfigsRequest(request)
case ApiKeys.DESCRIBE_CONFIGS =&gt; handleDescribeConfigsRequest(request)
case ApiKeys.ALTER_REPLICA_LOG_DIRS =&gt; handleAlterReplicaLogDirsRequest(request)
case ApiKeys.DESCRIBE_LOG_DIRS =&gt; handleDescribeLogDirsRequest(request)
case ApiKeys.SASL_AUTHENTICATE =&gt; handleSaslAuthenticateRequest(request)
case ApiKeys.CREATE_PARTITIONS =&gt; handleCreatePartitionsRequest(request)
case ApiKeys.CREATE_DELEGATION_TOKEN =&gt; handleCreateTokenRequest(request)
case ApiKeys.RENEW_DELEGATION_TOKEN =&gt; handleRenewTokenRequest(request)
case ApiKeys.EXPIRE_DELEGATION_TOKEN =&gt; handleExpireTokenRequest(request)
case ApiKeys.DESCRIBE_DELEGATION_TOKEN =&gt; handleDescribeTokensRequest(request)
case ApiKeys.DELETE_GROUPS =&gt; handleDeleteGroupsRequest(request)
case ApiKeys.ELECT_LEADERS =&gt; handleElectReplicaLeader(request)
case ApiKeys.INCREMENTAL_ALTER_CONFIGS =&gt; handleIncrementalAlterConfigsRequest(request)
case ApiKeys.ALTER_PARTITION_REASSIGNMENTS =&gt; handleAlterPartitionReassignmentsRequest(request)
case ApiKeys.LIST_PARTITION_REASSIGNMENTS =&gt; handleListPartitionReassignmentsRequest(request)
case ApiKeys.OFFSET_DELETE =&gt; handleOffsetDeleteRequest(request)
case ApiKeys.DESCRIBE_CLIENT_QUOTAS =&gt; handleDescribeClientQuotasRequest(request)
case ApiKeys.ALTER_CLIENT_QUOTAS =&gt; handleAlterClientQuotasRequest(request)
}
} catch {
// 如果是严重错误,则抛出异常
case e: FatalExitError =&gt; throw e
// 普通异常的话,记录下错误日志
case e: Throwable =&gt; handleError(request, e)
} finally {
// 记录一下请求本地完成时间即Broker处理完该请求的时间
if (request.apiLocalCompleteTimeNanos &lt; 0)
request.apiLocalCompleteTimeNanos = time.nanoseconds
}
}
```
如果你跟着这门课一直学习的话你应该会发现我很少贴某个类或方法的完整代码因为没必要还会浪费你的时间。但是这个handle方法有点特殊所以我把完整的代码展现给你。
它利用Scala语言中的模式匹配语法完整地列出了对所有请求类型的处理逻辑。通过该方法你能串联出Kafka处理任何请求的源码路径。我强烈推荐你在课下以几个比较重要的请求类型为学习目标从handle方法出发去探寻一下代码是如何为这些请求服务的以加深你对Broker端代码的整体熟练度。这对你后续深入学习源码或解决实际问题非常有帮助。
从上面的代码中,你应该很容易就能找到其中的规律:**这个方法是处理具体请求用的**。处理每类请求的方法名均以handle开头即handle×××Request。比如处理PRODUCE请求的方法叫handleProduceRequest处理FETCH请求的方法叫handleFetchRequest等。
如果你点开ApiKeys你会发现**它实际上是一个枚举类型里面封装了目前Kafka定义所有的RPC协议**。值得一提的是Kafka社区维护了一个官方文档专门记录这些RPC协议包括不同版本所需的Request格式和Response格式。
从这个handle方法中我们也能得到这样的结论每当社区添加新的RPC协议时Broker端大致需要做三件事情。
1. 更新ApiKeys枚举加入新的RPC ApiKey
1. 在KafkaApis中添加对应的handle×××Request方法实现对该RPC请求的处理逻辑
1. 更新KafkaApis的handle方法添加针对RPC协议的case分支。
## 其他重要方法
抛开KafkaApis的定义和handle方法还有几个常用的方法也很重要比如用于发送Response的一组方法以及用于鉴权的方法。特别是前者它是任何一类请求被处理之后都要做的必要步骤。毕竟请求被处理完成还不够Kafka还需要把处理结果发送给请求发送方。
首先就是**sendResponse系列方法**。
为什么说是系列方法呢因为源码中带有sendResponse字眼的方法有7个之多。我分别来介绍一下。
- **sendResponse**RequestChannel.Response最底层的Response发送方法。本质上它调用了SocketServer组件中RequestChannel的sendResponse方法我在前面的课程中讲到过RequestChannel的sendResponse方法会把待发送的Response对象添加到对应Processor线程的Response队列上然后交由Processor线程完成网络间的数据传输。
- **sendResponse**RequestChannel.RequestresponseOpt: Option[AbstractResponse]onComplete: Option[Send =&gt; Unit]该方法接收的实际上是Request而非Response因此它会在内部构造出Response对象之后再调用sendResponse方法。
- **sendNoOpResponseExemptThrottle**发送NoOpResponse类型的Response而不受请求通道上限流throttling的限制。所谓的NoOpResponse是指Processor线程取出该类型的Response后不执行真正的I/O发送操作。
- **sendErrorResponseExemptThrottle**发送携带错误信息的Response而不受限流限制。
- **sendResponseExemptThrottle**发送普通Response而不受限流限制。
- **sendErrorResponseMaybeThrottle**发送携带错误信息的Response但接受限流的约束。
- **sendResponseMaybeThrottle**发送普通Response但接受限流的约束。
这组方法最关键的还是第一个sendResponse方法。大部分类型的请求被处理完成后都会使用这个方法将Response发送出去。至于上面这组方法中的其他方法它们会在内部调用第一个sendResponse方法。当然在调用之前这些方法通常都拥有一些定制化的逻辑。比如sendResponseMaybeThrottle方法就会在执行sendResponse逻辑前先尝试对请求所属的请求通道进行限流操作。因此**我们要着重掌握第一个sendResponse方法是怎么将Response对象发送出去的**。
就像我前面说的,**KafkaApis实际上是把处理完成的Response放回到前端Processor线程的Response队列中而真正将Response返还给Clients或其他Broker的其实是Processor线程而不是执行KafkaApis逻辑的KafkaRequestHandler线程**。
另一个非常重要的方法是authorize方法咱们看看它的代码
```
private[server] def authorize(requestContext: RequestContext,
operation: AclOperation,
resourceType: ResourceType,
resourceName: String,
logIfAllowed: Boolean = true,
logIfDenied: Boolean = true,
refCount: Int = 1): Boolean = {
authorizer.forall { authZ =&gt;
// 获取待鉴权的资源类型
// 常见的资源类型如TOPIC、GROUP、CLUSTER等
val resource = new ResourcePattern(resourceType, resourceName, PatternType.LITERAL)
val actions = Collections.singletonList(new Action(operation, resource, refCount, logIfAllowed, logIfDenied))
// 返回鉴权结果是ALLOWED还是DENIED
authZ.authorize(requestContext, actions).asScala.head == AuthorizationResult.ALLOWED
}
}
```
这个方法是做**授权检验**的。目前Kafka所有的RPC请求都要求发送者无论是Clients还是其他Broker必须具备特定的权限。
接下来我用创建主题的代码来举个例子说明一下authorize方法的实际应用以下是handleCreateTopicsRequest方法的片段
```
// 是否具有CLUSTER资源的CREATE权限
val hasClusterAuthorization = authorize(request, CREATE, CLUSTER, CLUSTER_NAME, logIfDenied = false)
val topics = createTopicsRequest.data.topics.asScala.map(_.name)
// 如果具有CLUSTER CREATE权限则允许主题创建否则还要查看是否具有TOPIC资源的CREATE权限
val authorizedTopics = if (hasClusterAuthorization) topics.toSet else filterAuthorized(request, CREATE, TOPIC, topics.toSeq)
// 是否具有TOPIC资源的DESCRIBE_CONFIGS权限
val authorizedForDescribeConfigs = filterAuthorized(request, DESCRIBE_CONFIGS, TOPIC, topics.toSeq, logIfDenied = false)
.map(name =&gt; name -&gt; results.find(name)).toMap
results.asScala.foreach(topic =&gt; {
if (results.findAll(topic.name).size &gt; 1) {
topic.setErrorCode(Errors.INVALID_REQUEST.code)
topic.setErrorMessage(&quot;Found multiple entries for this topic.&quot;)
} else if (!authorizedTopics.contains(topic.name)) { // 如果不具备CLUSTER资源的CREATE权限或TOPIC资源的CREATE权限认证失败
topic.setErrorCode(Errors.TOPIC_AUTHORIZATION_FAILED.code)
topic.setErrorMessage(&quot;Authorization failed.&quot;)
}
if (!authorizedForDescribeConfigs.contains(topic.name)) { // 如果不具备TOPIC资源的DESCRIBE_CONFIGS权限设置主题配置错误码
topic.setTopicConfigErrorCode(Errors.TOPIC_AUTHORIZATION_FAILED.code)
}
})
......
```
这段代码调用authorize方法来判断Clients方法是否具有创建主题的权限如果没有则显式标记TOPIC_AUTHORIZATION_FAILED告知Clients端。目前Kafka所有的权限控制均发生在KafkaApis中即**所有请求在处理前都需要调用authorize方法做权限校验以保证请求能够被继续执行**。
## KafkaApis请求处理实例解析
在了解了KafkaApis的代码结构之后我拿一段真实的代码来说明一下该类中某个协议处理方法大致的执行流程是什么样的以便让你更清楚地了解请求处理逻辑。
值得注意的是,这里的请求处理逻辑和之前所说的请求处理全流程是有所区别的。今天,我们关注的是**功能层面上请求被处理的逻辑代码**之前的请求处理全流程主要聚焦流程方面的代码即一个请求从被发送到Broker端到Broker端返还Response的代码路径。应该这么说**所有类型请求的被处理流程都是相同的,但是,每类请求却有不同的功能实现逻辑**而这就是KafkaApis类中的各个handle×××Request方法要做的事情。
下面我以handleListGroupsRequest方法为例来介绍一下。顾名思义这是处理ListGroupsRequest请求的方法。这类请求的Response应该返回集群中的消费者组信息。我们来看下它的实现
```
def handleListGroupsRequest(request: RequestChannel.Request): Unit = {
val (error, groups) = groupCoordinator.handleListGroups() // 调用GroupCoordinator的handleListGroups方法拿到所有Group信息
// 如果Clients具备CLUSTER资源的DESCRIBE权限
if (authorize(request, DESCRIBE, CLUSTER, CLUSTER_NAME))
// 直接使用刚才拿到的Group数据封装进Response然后发送
sendResponseMaybeThrottle(request, requestThrottleMs =&gt;
new ListGroupsResponse(new ListGroupsResponseData()
.setErrorCode(error.code)
.setGroups(groups.map { group =&gt; new ListGroupsResponseData.ListedGroup()
.setGroupId(group.groupId)
.setProtocolType(group.protocolType)}.asJava
)
.setThrottleTimeMs(requestThrottleMs)
))
else {
// 找出Clients对哪些Group有GROUP资源的DESCRIBE权限返回这些Group信息
val filteredGroups = groups.filter(group =&gt; authorize(request, DESCRIBE, GROUP, group.groupId))
sendResponseMaybeThrottle(request, requestThrottleMs =&gt;
new ListGroupsResponse(new ListGroupsResponseData()
.setErrorCode(error.code)
.setGroups(filteredGroups.map { group =&gt; new ListGroupsResponseData.ListedGroup()
.setGroupId(group.groupId)
.setProtocolType(group.protocolType)}.asJava
)
.setThrottleTimeMs(requestThrottleMs)
))
}
}
```
我用一张流程图,来说明一下这个执行逻辑:
<img src="https://static001.geekbang.org/resource/image/75/f3/7529b94b80cead7158be5a277e7ff4f3.jpg" alt="">
大体来看handleListGroupsRequest方法的实现逻辑非常简单。通过GroupCoordinator组件获取到所有的消费者组信息之后代码对这些Group进行了权限校验并最终根据校验结果决定给Clients返回哪些可见的消费者组。
## 总结
好了, 我们总结一下KafkaApis类的要点。如前所述我们重点学习了KafkaApis类的定义及其重要方法handle。下面这些关键知识点希望你能掌握。
- KafkaApis是Broker端所有功能的入口同时关联了超多的Kafka组件。它绝对是你学习源码的第一入口。面对庞大的源码工程如果你不知道从何下手那就先从KafkaApis.scala这个文件开始吧。
- handle方法封装了所有RPC请求的具体处理逻辑。每当社区新增RPC协议时增加对应的handle×××Request方法和case分支都是首要的。
- sendResponse系列方法负责发送Response给请求发送方。发送Response的逻辑是将Response对象放置在Processor线程的Response队列中然后交由Processor线程实现网络发送。
- authorize方法是请求处理前权限校验层的主要逻辑实现。你可以查看一下[官方文档](https://docs.confluent.io/current/kafka/authorization.html)了解一下当前都有哪些权限然后对照着具体的方法找出每类RPC协议都要求Clients端具备什么权限。
<img src="https://static001.geekbang.org/resource/image/9e/4c/9ebd3f25518e387a7a60200a8b62114c.jpg" alt="">
至此关于Kafka请求处理模块的内容我们就全部学完了。在这个模块中我们先从RequestChannel入手探讨了Kafka中请求队列的实现原理之后我花了两节课的时间重点介绍了SocketServer组件包括Acceptor线程、Processor线程等子组件的源码以及请求被处理的全流程。今天我们重点研究了KafkaApis类这个顶层的请求功能处理逻辑入口补齐了请求处理的最后一块“拼图”。我希望你能够把这个模块的课程多看几遍认真思考一下这里面的关键实现要点彻底搞明白Kafka网络通信的核心机制。
从下节课开始我们将进入鼎鼎有名的控制器Controller组件的源码学习。我会花5节课的时间带你深入学习Controller的方方面面敬请期待。
## 课后讨论
最后请思考这样一个问题如果一个Consumer要向Broker提交位移它应该具备什么权限你能说出KafkaApis中的哪段代码说明了所需的权限要求吗
欢迎你在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,188 @@
<audio id="audio" title="导读 | 构建Kafka工程和源码阅读环境、Scala语言热身" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f5/8e/f5dd9821615c4c3dd4e62391b7a94a8e.mp3"></audio>
你好,我是胡夕。
从今天开始我们就要正式走入Kafka源码的世界了。既然咱们这个课程是教你阅读Kafka源码的那么你首先就得掌握如何在自己的电脑上搭建Kafka的源码环境甚至是知道怎么对它们进行调试。在这节课我展示了很多实操步骤建议你都跟着操作一遍否则很难会有特别深刻的认识。
话不多说,现在,我们就先来搭建源码环境吧。
## 环境准备
在阅读Kafka源码之前我们要先做一些必要的准备工作。这涉及到一些工具软件的安装比如Java、Gradle、Scala、IDE、Git等等。
如果你是在Linux或Mac系统下搭建环境你需要安装Java、IDE和Git如果你使用的是Windows那么你需要全部安装它们。
咱们这个课程统一使用下面的版本进行源码讲解。
- Oracle Java 8我们使用的是Oracle的JDK及Hotspot JVM。如果你青睐于其他厂商或开源的Java版本比如OpenJDK你可以选择安装不同厂商的JVM版本。
- Gradle 6.3我在这门课里带你阅读的Kafka源码是社区的Trunk分支。Trunk分支目前演进到了2.5版本已经支持Gradle 6.x版本。你最好安装Gradle 6.3或更高版本。
- Scala 2.13社区Trunk分支编译当前支持两个Scala版本分别是2.12和2.13。默认使用2.13进行编译因此我推荐你安装Scala 2.13版本。
- IDEA + Scala插件这门课使用IDEA作为IDE来阅读和配置源码。我对Eclipse充满敬意只是我个人比较习惯使用IDEA。另外你需要为IDEA安装Scala插件这样可以方便你阅读Scala源码。
- Git安装Git主要是为了管理Kafka源码版本。如果你要成为一名社区代码贡献者Git管理工具是必不可少的。
## 构建Kafka工程
等你准备好以上这些之后我们就可以来构建Kafka工程了。
首先我们下载Kafka源代码。方法很简单找一个干净的源码路径然后执行下列命令去下载社区的Trunk代码即可
```
$ git clone https://github.com/apache/kafka.git
```
在漫长的等待之后你的路径上会新增一个名为kafka的子目录它就是Kafka项目的根目录。如果在你的环境中上面这条命令无法执行的话你可以在浏览器中输入[https://codeload.github.com/apache/kafka/zip/trunk](https://codeload.github.com/apache/kafka/zip/trunk)下载源码ZIP包并解压只是这样你就失去了Git管理你要手动链接到远程仓库具体方法可以参考这篇[Git文档](https://help.github.com/articles/fork-a-repo/)。
下载完成后你要进入工程所在的路径下也就是进入到名为kafka的路径下然后执行相应的命令来构建Kafka工程。
如果你是在Mac或Linux平台上搭建环境那么直接运行下列命令构建即可
```
$ ./gradlew build
```
该命令首先下载Gradle Wrapper所需的jar文件然后对Kafka工程进行构建。需要注意的是在执行这条命令时你很可能会遇到下面的这个异常
```
Failed to connect to raw.githubusercontent.com port 443: Connection refused
```
如果碰到了这个异常,你也不用惊慌,你可以去这个[官网链接](https://raw.githubusercontent.com/gradle/gradle/v6.3.0/gradle/wrapper/gradle-wrapper.jar)或者是我提供的[链接](https://pan.baidu.com/s/1tuVHunoTwHfbtoqMvoTNoQ)提取码ntvd直接下载Wrapper所需的Jar包手动把这个Jar文件拷贝到kafka路径下的gradle/wrapper子目录下然后重新执行gradlew build命令去构建工程。
我想提醒你的是官网链接包含的版本号是v6.3.0但是该版本后续可能会变化因此你最好先打开gradlew文件去看一下社区使用的是哪个版本的Gradle。**一旦你发现版本不再是v6.3.0了那就不要再使用我提供的链接了。这个时候你需要直接去官网下载对应版本的Jar包**。
举个例子假设gradlew文件中使用的Gradle版本变更为v6.4.0那么你需要把官网链接URL中的版本号修改为v6.4.0然后去下载这个版本的Wrapper Jar包。
如果你是在Windows平台上构建那你就不能使用Gradle Wrapper了因为Kafka没有提供Windows平台上可运行的Wrapper Bat文件。这个时候你只能使用你自己的环境中自行安装的Gradle。具体命令是
```
kafka&gt; gradle.bat build
```
无论是gradle.bat build命令还是gradlew build命令首次运行时都要花费相当长的时间去下载必要的Jar包你要耐心地等待。
下面我用一张图给你展示下Kafka工程的各个目录以及文件
<img src="https://static001.geekbang.org/resource/image/a2/f7/a2ef664cd8d5494f55919643df1305f7.png" alt="">
这里我再简单介绍一些主要的组件路径。
- **bin目录**保存Kafka工具行脚本我们熟知的kafka-server-start和kafka-console-producer等脚本都存放在这里。
- **clients目录**保存Kafka客户端代码比如生产者和消费者的代码都在该目录下。
- **config目录**保存Kafka的配置文件其中比较重要的配置文件是server.properties。
- **connect目录**保存Connect组件的源代码。我在开篇词里提到过Kafka Connect组件是用来实现Kafka与外部系统之间的实时数据传输的。
- **core目录**保存Broker端代码。Kafka服务器端代码全部保存在该目录下。
- **streams目录**保存Streams组件的源代码。Kafka Streams是实现Kafka实时流处理的组件。
其他的目录要么不太重要,要么和配置相关,这里我就不展开讲了。
除了上面的gradlew build命令之外我再介绍一些常用的构建命令帮助你调试Kafka工程。
我们先看一下测试相关的命令。Kafka源代码分为4大部分Broker端代码、Clients端代码、Connect端代码和Streams端代码。如果你想要测试这4个部分的代码可以分别运行以下4条命令
```
$ ./gradlew core:test
$ ./gradlew clients:test
$ ./gradlew connect:[submodule]:test
$ ./gradlew streams:test
```
你可能注意到了在这4条命令中Connect组件的测试方法不太一样。这是因为Connect工程下细分了多个子模块比如api、runtime等所以你需要显式地指定要测试的子模块名才能进行测试。
如果你要单独对某一个具体的测试用例进行测试比如单独测试Broker端core包的LogTest类可以用下面的命令
```
$ ./gradlew core:test --tests kafka.log.LogTest
```
另外如果你要构建整个Kafka工程并打包出一个可运行的二进制环境就需要运行下面的命令
```
$ ./gradlew clean releaseTarGz
```
成功运行后core、clients和streams目录下就会分别生成对应的二进制发布包它们分别是
- **kafka-2.12-2.5.0-SNAPSHOT.tgz**。它是Kafka的Broker端发布包把该文件解压之后就是标准的Kafka运行环境。该文件位于core路径的/build/distributions目录。
- **kafka-clients-2.5.0-SNAPSHOT.jar**。该Jar包是Clients端代码编译打包之后的二进制发布包。该文件位于clients目录下的/build/libs目录。
- **kafka-streams-2.5.0-SNAPSHOT.jar**。该Jar包是Streams端代码编译打包之后的二进制发布包。该文件位于streams目录下的/build/libs目录。
## 搭建源码阅读环境
刚刚我介绍了如何使用Gradle工具来构建Kafka项目工程现在我来带你看一下如何利用IDEA搭建Kafka源码阅读环境。实际上整个过程非常简单。我们打开IDEA点击“文件”随后点击“打开”选择上一步中的Kafka文件路径即可。
项目工程被导入之后IDEA会对项目进行自动构建等构建完成之后你可以找到core目录源码下的Kafka.scala文件。打开它然后右键点击Kafka你应该就能看到这样的输出结果了
<img src="https://static001.geekbang.org/resource/image/ce/d2/ce0a63e7627c641da471b48a62860ad2.png" alt="">
这就是无参执行Kafka主文件的运行结果。通过这段输出我们能够学会启动Broker所必需的参数即指定server.properties文件的地址。这也是启动Kafka Broker的标准命令。
在开篇词中我也说了这个课程会聚焦于讲解Kafka Broker端源代码。因此在正式学习这部分源码之前我先给你简单介绍一下Broker端源码的组织架构。下图展示了Kafka core包的代码架构
<img src="https://static001.geekbang.org/resource/image/df/b2/dfdd73cc95ecc5390ebeb73c324437b2.png" alt="">
我来给你解释几个比较关键的代码包。
- controller包保存了Kafka控制器Controller代码而控制器组件是Kafka的核心组件后面我们会针对这个包的代码进行详细分析。
- coordinator包保存了**消费者端的GroupCoordinator代码**和**用于事务的TransactionCoordinator代码**。对coordinator包进行分析特别是对消费者端的GroupCoordinator代码进行分析是我们弄明白Broker端协调者组件设计原理的关键。
- log包保存了Kafka最核心的日志结构代码包括日志、日志段、索引文件等后面会有详细介绍。另外该包下还封装了Log Compaction的实现机制是非常重要的源码包。
- network包封装了Kafka服务器端网络层的代码特别是SocketServer.scala这个文件是Kafka实现Reactor模式的具体操作类非常值得一读。
- server包顾名思义它是Kafka的服务器端主代码里面的类非常多很多关键的Kafka组件都存放在这里比如后面要讲到的状态机、Purgatory延时机制等。
在后续的课程中我会挑选Kafka最主要的代码类进行详细分析帮助你深入了解Kafka Broker端重要组件的实现原理。
另外,虽然这门课不会涵盖测试用例的代码分析,但在我看来,**弄懂测试用例是帮助你快速了解Kafka组件的最有效的捷径之一**。如果时间允许的话我建议你多读一读Kafka各个组件下的测试用例它们通常都位于代码包的src/test目录下。拿Kafka日志源码Log来说它对应的LogTest.scala测试文件就写得非常完备里面多达几十个测试用例涵盖了Log的方方面面你一定要读一下。
## Scala 语言热身
因为Broker端的源码完全是基于Scala的所以在开始阅读这部分源码之前我还想花一点时间快速介绍一下 Scala 语言的语法特点。我先拿几个真实的 Kafka 源码片段来帮你热热身。
先来看第一个:
```
def sizeInBytes(segments: Iterable[LogSegment]): Long =
segments.map(_.size.toLong).sum
```
这是一个典型的 Scala 方法,方法名是 sizeInBytes。它接收一组 LogSegment 对象返回一个长整型。LogSegment 对象就是我们后面要谈到的日志段。你在 Kafka 分区目录下看到的每一个.log 文件本质上就是一个 LogSegment。从名字上来看这个方法计算的是这组 LogSegment 的总字节数。具体方法是遍历每个输入 LogSegment调用其 size 方法并将其累加求和之后返回。
再来看一个:
```
val firstOffset: Option[Long] = ......
def numMessages: Long = {
firstOffset match {
case Some(firstOffsetVal) if (firstOffsetVal &gt;= 0 &amp;&amp; lastOffset &gt;= 0) =&gt; (lastOffset - firstOffsetVal + 1)
case _ =&gt; 0
}
}
```
该方法是 LogAppendInfo 对象的一个方法,统计的是 Broker 端一次性批量写入的消息数。这里你需要重点关注 **match****case** 这两个关键字,你可以近似地认为它们等同于 Java 中的 switch但它们的功能要强大得多。该方法统计写入消息数的逻辑是如果 firstOffsetVal 和 lastOffset 值都大于 0则写入消息数等于两者的差值+1如果不存在 firstOffsetVal则无法统计写入消息数简单返回 0 即可。
倘若对你而言弄懂上面这两段代码有些吃力我建议你去快速地学习一下Scala语言。重点学什么呢我建议你重点学习下Scala中对于**集合的遍历语法**,以及**基于match的模式匹配用法**。
另外由于Scala具有的函数式编程风格你至少**要理解Java中Lambda表达式的含义**,这会在很大程度上帮你扫清阅读障碍。
相反地如果上面的代码对你来讲很容易理解那么读懂Broker端80%的源码应该没有什么问题。你可能还会关心剩下的那晦涩难懂的20%源码怎么办呢其实没关系你可以等慢慢精通了Scala语言之后再进行阅读它们不会对你熟练掌握核心源码造成影响的。另外后面涉及到比较难的Scala语法特性时我还会再具体给你解释的所以还是那句话你完全不用担心语言的问题
## 总结
今天是我们开启Kafka源码分析的“热身课”我给出了构建Kafka工程以及搭建Kafka源码阅读环境的具体方法。我建议你对照上面的内容完整地走一遍流程亲身体会一下Kafka工程的构建与源码工程的导入。毕竟这些都是后面阅读具体Kafka代码的前提条件。
最后我想再强调一下,阅读任何一个大型项目的源码都不是一件容易的事情,我希望你在任何时候都不要轻言放弃。很多时候,碰到读不懂的代码你就多读几遍,也许稍后就会有醍醐灌顶的感觉。
## 课后讨论
熟悉Kafka的话你一定听说过kafka-console-producer.sh脚本。我前面提到过该脚本位于工程的bin目录下你能找到它对应的Java类是哪个文件吗这个搜索过程能够给你一些寻找Kafka所需类文件的好思路你不妨去试试看。
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="开篇词 | 阅读源码,逐渐成了职业进阶道路上的“必选项”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/ac/d46dd95cf9595b346f148b113c99a7ac.mp3"></audio>
你好我是胡夕Apache Kafka Committer老虎证券用户增长团队负责人也是《Apache Kafka实战》这本书的作者。
2019年我在极客时间开设了我的第一个专栏《Kafka核心技术与实战》想要帮助Kafka用户掌握Kafka核心设计原理及实战应用技术。时隔一年我又带来了一个源码专栏。在这个专栏中我会带你深入到Kafka核心源码详细分析和讲述那些源码背后的架构思想和编程理念。同时我还会针对一些棘手问题给出源码级的解决思路。
## 为什么要读源码?
谈到源码分析特别是Apache Kafka这类消息引擎的源码你可能会说“我都已经在使用它了也算是也比较熟练了何必还要再花费时间去阅读源码呢
当然了一些非Kafka使用者也会说“我不用Kafka读源码对我有什么用呢
其实,在没有阅读源码之前,我也是这么想的。但是,后来在生产环境上碰到的一件事,彻底改变了我的想法。
我们知道Kafka Broker端有个log.retention.bytes参数官网的描述是它指定了留存日志的最大值。有了这个参数的“帮忙”我们信誓旦旦地向领导保证不会过多占用公司原本就很紧张的物理磁盘资源。但是最终实际占用的磁盘空间却远远超出了这个最大值。
我们查遍了各种资料,却始终找不到问题的根因,当时,我就想,只能读源码碰碰运气了。结果,源码非常清楚地说明了,这个参数能不能起作用和日志段大小息息相关。知道了这一点,问题就迎刃而解了。
这时,我才意识到,很多棘手的问题都要借助源码才能解决。
除此之外,我还发现,在很多互联网公司资深技术岗位的招聘要求上,“读过至少一种开源框架的源码”赫然在列。这也就意味着,**阅读源码正在从“加分项”向“必选项”转变掌握优秀的框架代码实现从NICE-TO-DO变成了MUST-DO**。
那,为什么读源码逐渐成为了必选项?它究竟有什么作用呢?下面我结合我自己的经历,和你说说读源码的几点收获。
**1.可以帮助你更深刻地理解内部设计原理,提升你的系统架构能力和代码功力。**
作为一款优秀的消息引擎Kafka的架构设计有很多为人称道的地方掌握了这些原理将极大地提升我们自身的系统架构能力和代码功力。
当然了即使你不使用Kafka也可以借鉴其优秀的设计理念提升你在其他框架上的系统架构能力。
你可能会问,官网文档也有相应的阐述啊,我单纯阅读文档不就够了吗?
实际上,我一直认为社区官方文档的内容有很大的提升空间,**Kafka有许多很棒的设计理念和特性在文档中并未得到充分的阐述。**
我简单举个例子。Kafka中有个非常重要的概念**当前日志段Active Segment**。Kafka的很多组件比如LogCleaner是区别对待当前日志段和非当前日志段的。但是Kafka官网上几乎完全没有提过它。
所以你看单纯依赖官网文档的话肯定是无法深入了解Kafka的。
**2.可以帮你快速定位问题并制定调优方案,减少解决问题的时间成本。**
很多人认为,阅读源码需要花费很多时间,不值得。这是一个非常大的误区。
实际上,你掌握的源码知识可以很好地指导你日后的实践,帮助你快速地定位问题的原因,迅速找到相应的解决方案。最重要的是,**如果你对源码了然于心,你会很清楚线上环境的潜在问题,提前避“坑”。在解决问题时,阅读源码其实是事半功倍的“捷径”**。
如果用时间成本来考量的话,你可以把阅读源码的时间分摊到后续解决各种问题的时间上,你会发现,这本质上是一件划算的事情。
**3.你还能参加Kafka开源社区成为一名代码贡献者Contributor。**
在社区中你能够和全世界的Kafka源码贡献者协同工作彼此分享交流想想就是一件很有意思的事情。特别是当你的代码被社区采纳之后全世界的Kafka使用者都会使用你写的代码。这简直太让人兴奋了不是吗
总而言之,**阅读源码的好处真的很多,既能精进代码功力,又能锤炼架构技巧,还能高效地解决实际问题,有百利而无一害。**
## 如何用最短的时间掌握最核心的源码?
Kafka代码有50多万行如果我们直接冲下场开始读一定会“丈二和尚摸不着头脑”。
毕竟,面对这么多代码,通读一遍的效率显然并不高。为了避免从入门到放弃,我们要用最高效的方式阅读最核心的源码。
通常来说,阅读大型项目的源码无外乎两种方法。
- **自上而下Top-Down**:从最顶层或最外层的代码一步步深入。通俗地说,就是从 main 函数开始阅读,逐渐向下层层深入,直到抵达最底层代码。这个方法的好处在于,你遍历的是完整的顶层功能路径,这对于你了解各个功能的整体流程极有帮助。
- **自下而上Bottom-Up**:跟自上而下相反,是指先独立地阅读和搞懂每个组件的代码和实现机制,然后不断向上延展,并最终把它们组装起来。该方法不是沿着功能的维度向上溯源的,相反地,它更有助于你掌握底层的基础组件代码。
这两种方法各有千秋不过在学习Kafka源码的过程中我发现将两者结合的方法其实是最高效的即先弄明白最细小单位组件的用途然后再把它们拼接组合起来掌握组件组合之后的功能。
具体怎么做呢首先你要确认最小单位的组件。我主要是看Kafka源码中的包结构package structure比如controller、log、server等这些包基本上就是按照组件来划分的。我给这些组件确定的优先级顺序是“log&gt;network&gt;controller&gt;server&gt;coordinator&gt;……”,毕竟,后面的组件会频繁地调用前面的组件。
等你清楚地了解了单个组件的源码结构,就可以试着切换成自上而下的方法,即从一个大的功能点入手,再逐步深入到各个底层组件的源码。得益于前面的积累,你会对下沉过程中碰到的各层基础代码非常熟悉,这会带给你很大的成就感。比起单纯使用自上而下或自下而上的方法,这套混合方法兼具了两者的优点。
关于如何选择大的功能点我建议你从Kafka的命令行工具开始这种串联学习搞明白这个工具的每一步都是怎么实现的并且在向下钻取的过程中不断复习单个组件的原理同时把这些组件结合在一起。
随着一遍遍地重复这个过程,你会更清楚各个组件间的交互逻辑,成为一个掌握源码的高手!
知道了方法以后我们就可以开始Kafka源码的学习了。在深入细节之前我们先来看下Kafka的源码全景图找到核心的源码。
<img src="https://static001.geekbang.org/resource/image/97/bd/971dee49c13fd501ceecaa9c573e79bd.jpg" alt="">
从功能上讲Kafka源码分为四大模块。
- 服务器端源码实现Kafka架构和各类优秀特性的基础。
- Java客户端源码定义了与Broker端的交互机制以及通用的Broker端组件支撑代码。
- Connect源码用于实现Kafka与外部系统的高性能数据传输。
- Streams源码用于实现实时的流处理功能。
可以看到服务器端源码是理解Kafka底层架构特别是系统运行原理的基础其他三个模块的源码都对它有着强烈的依赖。因此**Kafka最最精华的代码当属服务器端代码无疑**我们学习这部分代码的性价比是最高的。
## 专栏是如何设计的?
那,我们就抓紧开始吧。在这个专栏里,我基于自己对服务器端源码结构的理解,特意为你精选了下面这些源码。
**这些源码全都是极具价值的组件,也是很多实际线上问题的“高发重灾区”**。比如Kafka日志段的代码逻辑就是很多线上异常的“始作俑者”。掌握这些源码能够大大地缩短你定位问题花费的时间。
我把服务器端源码按照功能划分了7个模块每个模块会进一步划开多个子部分详细地给出各个组件级的源码分析。你可以看下这张思维导图的重点介绍。
<img src="https://static001.geekbang.org/resource/image/d0/21/d0b557ff04864adafc4cdc7572cf0a21.jpg" alt="">
### 丰富的流程图+细粒度讲解
在读源码时,我们最常犯两种错误,一种是直接深入最底层的一行行源码之中,陷入细枝末节;另一种是过于粗粒度地学习,学了跟没学没有什么区别。
为了帮助你高效地学习,我摒弃了贪多求全的源码讲解方式,而是采用“流程图+代码注释”相结合的方式,对重点内容进行细粒度讲解,还会结合我的实战经验,给你划重点。
在阅读源码之前,你可以借助图片对各个方法的实现逻辑有个大致的了解。对于重点内容,我会用详细的注释形式帮助你理解。同时,我还绘制了思维导图,帮你总结回顾。
### 真实的案例讲解,解决你的实战问题
很多人虽然也读源码,却不懂源码可以应用到什么场景、解决什么问题。事实上,我在生产环境中碰到的很多问题,都是没办法单纯依赖官方文档或搜索引擎顺利解决的。只有阅读源码,切实掌握了实现原理,才能找到解决方案。
为了帮你学以致用,我会在专栏里给你分享大量的真实案例,既帮助你提前规避陷阱,也帮你积累一些常见问题的解决方案,有一些甚至是不见诸于文档的“武林秘籍”。
### 传递社区的最新发展动向
这是专栏最有意思的一部分。我们学习的Kafka源码每天都在不断地演进着要想玩转Kafka就必须要知道社区未来的更新计划以及重大功能改进。
我会针对一些具体的主题,给你分享最新的动态资讯。我希望展现在你面前的不再是一行行冰冷的代码,而是一个生动活泼的社区形象,让你真正有参与到社区的感觉。不要小看这种感觉,有的时候,它甚至是支撑你走完源码学习之路的最强大动力。
### 课外拓展
除此之外我还会跟你分享一些延伸内容。比如成为Apache Kafka社区的代码贡献者的具体方法、实用的学习资料、经典的面试题讲解等希望你也不要错过这部分的内容。
<img src="https://static001.geekbang.org/resource/image/69/3c/6961bc3841b09586cfayyf97f1fc803c.jpg" alt="">
最后,**我还想再和你说说Scala语言的问题**。毕竟我们将要一起学习的Broker端源码是完全基于Scala的。
不过这部分源码并没有用到Scala多少高大上的语法特性。如果你有Java语言基础就更不用担心语言的问题了因为它们有很多特性非常相似。
即使你不熟悉Scala语言也没关系。你不需要完整、系统地学习这门语言只要能简单了解基本的函数式编程风格以及它的几个关键特性比如集合遍历、模式匹配等就足够了。
当然了为了不影响你理解专栏内涉及的源码我会在“导读”这节课里带你深入了解下Scala语言。同时在专栏里遇到Scala比较难的语言特性时我也会和你具体解释。所以你完全不用担心语言的问题。
好了现在我们就正式开启Apache Kafka源码分析学习之旅吧。正所谓“日拱一卒无有尽功不唐捐终入海。”阅读源码是个“苦差事”希望你别轻易放弃。毕竟**掌握了源码,你就走在了很多人的前面**。
最后我很荣幸能够和你在这里相遇一起学习交流也欢迎你给我留言说说你对Kafka源码分析的看法和疑问。

View File

@@ -0,0 +1,324 @@
<audio id="audio" title="重磅加餐 | 带你快速入门Scala语言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/4f/fdb9ffacc7651e4d2c40d135b7e5644f.mp3"></audio>
你好我是胡夕。最近我在留言区看到一些同学反馈说“Scala语言不太容易理解”于是我决定临时加一节课给你讲一讲Scala语言的基础语法包括变量和函数的定义、元组的写法、函数式编程风格的循环语句的写法、它独有的case类和强大的match模式匹配功能以及Option对象的用法。
学完这节课以后相信你能够在较短的时间里掌握这些实用的Scala语法特别是Kafka源码中用到的Scala语法特性彻底扫清源码阅读路上的编程语言障碍。
## Java函数式编程
就像我在开篇词里面说的你不熟悉Scala语言其实并没有关系但你至少要对Java 8的函数式编程有一定的了解特别是要熟悉Java 8 Stream的用法。
倘若你之前没有怎么接触过Lambda表达式和Java 8 Stream我给你推荐一本好书**《Java 8实战》**。这本书通过大量的实例深入浅出地讲解了Lambda表达式、Stream以及函数式编程方面的内容你可以去读一读。
现在,我就给你分享一个实际的例子,借着它开始我们今天的所有讨论。
TopicPartition是Kafka定义的主题分区类它建模的是Kafka主题的分区对象其关键代码如下
```
public final class TopicPartition implements Serializable {
private final int partition;
private final String topic;
// 其他字段和方法......
}
```
对于任何一个分区而言一个TopicPartition实例最重要的就是**topic和partition字段**,即**Kafka的主题和分区号**。假设给定了一组分区对象List &lt; TopicPartition &gt; 我想要找出分区数大于3且以“test”开头的所有主题列表我应该怎么写这段Java代码呢你可以先思考一下然后再看下面的答案。
我先给出Java 8 Stream风格的答案
```
// 假设分区对象列表变量名是list
Set&lt;String&gt; topics = list.stream()
.filter(tp -&gt; tp.topic().startsWith(&quot;test-&quot;))
.collect(Collectors.groupingBy(TopicPartition::topic, Collectors.counting()))
.entrySet().stream()
.filter(entry -&gt; entry.getValue() &gt; 3)
.map(entry -&gt; entry.getKey()).collect(Collectors.toSet());
```
这是典型的Java 8 Stream代码里面大量使用了诸如filter、map等操作算子,以及Lambda表达式这让代码看上去一气呵成而且具有很好的可读性。
我从第3行开始解释下每一行的作用第3行的filter方法调用实现了筛选以“test”开头主题的功能第4行是运行collect方法同时指定使用groupingBy统计分区数并按照主题进行分组进而生成一个Map对象第5~7行是提取出这个Map对象的所有&lt;K, V&gt;然后再次调用filter方法将分区数大于3的主题提取出来最后是将这些主题做成一个集合返回。
其实,给出这个例子,我只是想说明,**Scala语言的编写风格和Java 8 Stream有很多相似之处**一方面代码中有大量的filter、map甚至是flatMap等操作算子另一方面代码的风格也和Java中的Lambda表达式写法类似。
如果你不信的话我们来看下Kafka中计算消费者Lag的getLag方法代码
```
private def getLag(offset: Option[Long], logEndOffset: Option[Long]): Option[Long] =
offset.filter(_ != -1).flatMap(offset =&gt; logEndOffset.map(_ - offset))
```
你看这里面也有filter和map。是不是和上面的Java代码有异曲同工之妙
如果你现在还看不懂这个方法的代码是什么意思也不用着急接下来我会带着你一步一步来学习。我相信学完了这节课以后你一定能自主搞懂getLag方法的源码含义。getLag代码是非常典型的Kafka源码一旦你熟悉了这种编码风格后面一定可以举一反三一举攻克其他的源码阅读难题。
我们先从Scala语言中的变量Variable开始说起。毕竟不管是学习任何编程语言最基础的就是先搞明白变量是如何定义的。
## 定义变量和函数
Scala有两类变量**val和var**。**val等同于Java中的final变量一旦被初始化就不能再被重新赋值了**。相反地,**var是非final变量可以重复被赋值**。我们看下这段代码:
```
scala&gt; val msg = &quot;hello, world&quot;
msg: String = hello, world
scala&gt; msg = &quot;another string&quot;
&lt;console&gt;:12: error: reassignment to val
msg = &quot;another string&quot;
scala&gt; var a:Long = 1L
a: Long = 1
scala&gt; a = 2
a: Long = 2
```
很直观对吧msg是一个vala是一个var所以msg不允许被重复赋值而a可以。我想提醒你的是**变量后面可以跟“冒号+类型”,以显式标注变量的类型**。比如这段代码第6行的“Long”就是告诉我们变量a是一个Long型。当然如果你不写“Long”也是可以的因为Scala可以通过后面的值“1L”自动判断出a的类型。
不过,很多时候,显式标注上变量类型,可以让代码有更好的可读性和可维护性。
下面我们来看下Scala中的函数如何定义。我以获取两个整数最大值的Max函数为例进行说明代码如下
```
def max(x: Int, y: Int): Int = {
if (x &gt; y) x
else y
}
```
首先def关键字表示这是一个函数。max是函数名括号中的x和y是函数输入参数它们都是Int类型的值。结尾的“Int =”组合表示max函数返回一个整数。
其次max代码使用if语句比较x和y的大小并返回两者中较大的值但是它没有使用所谓的return关键字而是直接写了x或y。**在Scala中函数体具体代码块最后一行的值将被作为函数结果返回**。在这个例子中if分支代码块的最后一行是x因此此路分支返回x。同理else分支返回y。
讲完了max函数我再用Kafka源码中的一个真实函数来帮你进一步地理解Scala函数
```
def deleteIndicesIfExist(
// 这里参数suffix的默认值是&quot;&quot;,即空字符串
// 函数结尾处的Unit类似于Java中的void关键字表示该函数不返回任何结果
baseFile: File, suffix: String = &quot;&quot;): Unit = {
info(s&quot;Deleting index files with suffix $suffix for baseFile $baseFile&quot;)
val offset = offsetFromFile(baseFile)
Files.deleteIfExists(Log.offsetIndexFile(dir, offset, suffix).toPath)
Files.deleteIfExists(Log.timeIndexFile(dir, offset, suffix).toPath)
Files.deleteIfExists(Log.transactionIndexFile(dir, offset, suffix).toPath)
}
```
和上面的max函数相比这个函数有两个额外的语法特性需要你了解。
第一个特性是**参数默认值**这是Java不支持的。这个函数的参数suffix默认值是空字符串因此以下两种调用方式都是合法的
```
deleteIndicesIfExist(baseFile) // OK
deleteIndicesIfExist(baseFile, &quot;.swap&quot;) // OK
```
第二个特性是**该函数的返回值Unit**。Scala的Unit类似于Java的void因此deleteIndicesIfExist函数的返回值是Unit类型表明它仅仅是执行一段逻辑代码不需要返回任何结果。
## 定义元组Tuple
接下来我们来看下Scala中的元组概念。**元组是承载数据的容器,一旦被创建,就不能再被更改了**。元组中的数据可以是不同数据类型的。定义和访问元组的方法很简单,请看下面的代码:
```
scala&gt; val a = (1, 2.3, &quot;hello&quot;, List(1,2,3)) // 定义一个由4个元素构成的元组每个元素允许是不同的类型
a: (Int, Double, String, List[Int]) = (1,2.3,hello,List(1, 2, 3))
scala&gt; a._1 // 访问元组的第一个元素
res0: Int = 1
scala&gt; a._2 // 访问元组的第二个元素
res1: Double = 2.3
scala&gt; a._3 // 访问元组的第三个元素
res2: String = hello
scala&gt; a._4 // 访问元组的第四个元素
res3: List[Int] = List(1, 2, 3)
```
总体上而言元组的用法简单而优雅。Kafka源码中也有很多使用元组的例子比如
```
def checkEnoughReplicasReachOffset(requiredOffset: Long): (Boolean, Errors) = { // 返回(BooleanErrors)类型的元组
......
if (minIsr &lt;= curInSyncReplicaIds.size) {
......
(true, Errors.NONE)
} else
(false, Errors.NOT_ENOUGH_REPLICAS_AFTER_APPEND)
}
```
checkEnoughReplicasReachOffset方法返回一个(Boolean, Errors)类型的元组即元组的第一个元素或字段是Boolean类型第二个元素是Kafka自定义的Errors类型。
该方法会判断某分区ISR中副本的数量是否大于等于所需的最小ISR副本数如果是就返回true, Errors.NONE元组否则返回falseErrors.NOT_ENOUGH_REPLICAS_AFTER_APPEND。目前你不必理会代码中minIsr或curInSyncReplicaIds的含义仅仅掌握Kafka源码中的元组用法就够了。
## 循环写法
下面我们来看下Scala中循环的写法。我们常见的循环有两种写法**命令式编程方式**和**函数式编程方式**。我们熟悉的是第一种比如下面的for循环代码
```
scala&gt; val list = List(1, 2, 3, 4, 5)
list: List[Int] = List(1, 2, 3, 4, 5)
scala&gt; for (element &lt;- list) println(element)
1
2
3
4
5
```
Scala支持的函数式编程风格的循环类似于下面的这种代码
```
scala&gt; list.foreach(e =&gt; println(e))
// 省略输出......
scala&gt; list.foreach(println)
// 省略输出......
```
特别是代码中的第二种写法会让代码写得异常简洁。我用一段真实的Kafka源码再帮你加强下记忆。它取自SocketServer组件中stopProcessingRequests方法主要目的是让Broker停止请求和新入站TCP连接的处理。SocketServer组件是实现Kafka网络通信的重要组件后面我会花3节课的时间专门讨论它。这里咱们先来学习下这段明显具有函数式风格的代码
```
// dataPlaneAcceptors:ConcurrentHashMap&lt;Endpoint, Acceptor&gt;对象
dataPlaneAcceptors.asScala.values.foreach(_.initiateShutdown())
```
这一行代码首先调用asScala方法将Java的ConcurrentHashMap转换成Scala语言中的concurrent.Map对象然后获取它保存的所有Acceptor线程通过foreach循环调用每个Acceptor对象的initiateShutdown方法。如果这个逻辑用命令式编程来实现至少要几行甚至是十几行才能完成。
## case类
在Scala中case类与普通类是类似的只是它具有一些非常重要的不同点。Case类非常适合用来表示不可变数据。同时它最有用的一个特点是case类自动地为所有类字段定义Getter方法这样能省去很多样本代码。我举个例子说明一下。
如果我们要编写一个类表示平面上的一个点Java代码大概长这个样子
```
public final class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
// setter methods......
// getter methods......
}
```
我就不列出完整的Getter和Setter方法了写过Java的你一定知道这些样本代码。但如果用Scala的case类只需要写一行代码就可以了
```
case class Point(x:Int, y: Int) // 默认写法。不能修改x和y
case class Point(var x: Int, var y: Int) // 支持修改x和y
```
Scala会自动地帮你创建出x和y的Getter方法。默认情况下x和y不能被修改如果要支持修改你要采用上面代码中第二行的写法。
## 模式匹配
有了case类的基础接下来我们就可以学习下Scala中强大的模式匹配功能了。
和Java中switch仅仅只能比较数值和字符串相比Scala中的match要强大得多。我先来举个例子
```
def describe(x: Any) = x match {
case 1 =&gt; &quot;one&quot;
case false =&gt; &quot;False&quot;
case &quot;hi&quot; =&gt; &quot;hello, world!&quot;
case Nil =&gt; &quot;the empty list&quot;
case e: IOException =&gt; &quot;this is an IOException&quot;
case s: String if s.length &gt; 10 =&gt; &quot;a long string&quot;
case _ =&gt; &quot;something else&quot;
}
```
这个函数的x是Any类型这相当于Java中的Object类型即所有类的父类。注意倒数第二行的“case _”的写法它是用来兜底的。如果上面的所有case分支都不匹配那就进入到这个分支。另外它还支持一些复杂的表达式比如倒数第三行的case分支表示x是字符串类型而且x的长度超过10的话就进入到这个分支。
要知道Java在JDK 14才刚刚引入这个相同的功能足见Scala语法的强大和便捷。
## Option对象
最后,我再介绍一个小的语法特性或语言特点:**Option对象**。
实际上Java也引入了类似的类Optional。根据我的理解不论是Scala中的Option还是Java中的Optional都是用来帮助我们更好地规避NullPointerException异常的。
Option表示一个容器对象里面可能装了值也可能没有装任何值。由于是容器因此一般都是这样的写法Option[Any]。中括号里面的Any就是上面说到的Any类型它能是任何类型。如果值存在的话就可以使用Some(x)来获取值或给值赋值否则就使用None来表示。我用一段代码帮助你理解
```
scala&gt; val keywords = Map(&quot;scala&quot; -&gt; &quot;option&quot;, &quot;java&quot; -&gt; &quot;optional&quot;) // 创建一个Map对象
keywords: scala.collection.immutable.Map[String,String] = Map(scala -&gt; option, java -&gt; optional)
scala&gt; keywords.get(&quot;java&quot;) // 获取key值为java的value值。由于值存在故返回Some(optional)
res24: Option[String] = Some(optional)
scala&gt; keywords.get(&quot;C&quot;) // 获取key值为C的value值。由于不存在故返回None
res23: Option[String] = None
```
Option对象还经常与模式匹配语法一起使用以实现不同情况下的处理逻辑。比如Option对象有值和没有值时分别执行什么代码。具体写法你可以参考下面这段代码
```
def display(game: Option[String]) = game match {
case Some(s) =&gt; s
case None =&gt; &quot;unknown&quot;
}
scala&gt; display(Some(&quot;Heroes 3&quot;))
res26: String = Heroes 3
scala&gt; display(Some(&quot;StarCraft&quot;))
res27: String = StarCraft
scala&gt; display(None)
res28: String = unknown
```
## 总结
今天我们专门花了些时间快速地学习了一下Scala语言的语法这些语法能够帮助你更快速地上手Kafka源码的学习。现在让我们再来看下这节课刚开始时我提到的getLag方法源码你看看现在是否能够说出它的含义。我再次把它贴出来
```
private def getLag(offset: Option[Long], logEndOffset: Option[Long]): Option[Long] =
offset.filter(_ != -1).flatMap(offset =&gt; logEndOffset.map(_ - offset))
```
现在你应该知道了它是一个函数接收两个类型为Option[Long]的参数同时返回一个Option[Long]的结果。代码逻辑很简单首先判断offset是否有值且不能是-1。这些都是在filter函数中完成的之后调用flatMap方法计算logEndOffset值与offset的差值最后返回这个差值作为Lag。
这节课结束以后,语言问题应该不再是你学习源码的障碍了,接下来,我们就可以继续专心地学习源码了。借着这个机会,我还想跟你多说几句。
很多时候,我们都以为,要有足够强大的毅力才能把源码学习坚持下去,但实际上,毅力是在你读源码的过程中培养起来的。
考虑到源码并不像具体技术本身那样容易掌握我力争用最清晰易懂的方式来讲这门课。所以我希望你每天都能花一点点时间跟着我一起学习我相信到结课的时候你不仅可以搞懂Kafka Broker端源码还能提升自己的毅力。而毅力和执行力的提升可能比技术本身的提升还要弥足珍贵。
另外我还想给你分享一个小技巧想要养成每天阅读源码的习惯你最好把目标拆解得足够小。人的大脑都是有惰性的比起“我每天要读1000行源码”它更愿意接受“每天只读20行”。你可能会说每天读20行这也太少了吧其实不是的。只要你读了20行源码你就一定能再多读一些“20行”这个小目标只是为了促使你愿意开始去做这件事情。而且即使你真的只读了20行那又怎样读20行总好过1行都没有读对吧
当然了,阅读源码经常会遇到一种情况,那就是读不懂某部分的代码。没关系,读不懂的代码,你可以选择先跳过。
如果你是个追求完美的人,那么对于读不懂的代码,我给出几点建议:
1. **多读几遍**。不要小看这个朴素的建议。有的时候,我们的大脑是很任性的,只让它看一遍代码,它可能“傲娇地表示不理解”,但你多给它看几遍,也许就恍然大悟了。
1. **结合各种资料来学习**。比如,社区或网上关于这部分代码的设计文档、源码注释或源码测试用例等。尤其是搞懂测试用例,往往是让我们领悟代码精神最快捷的办法了。
总之,阅读源码是一项长期的工程,不要幻想有捷径或一蹴而就,微小积累会引发巨大改变,我们一起加油。