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,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你能在源码中找到更新元数据缓存的完整调用路径吗
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。