mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
mod
This commit is contained in:
277
极客时间专栏/Kafka核心源码解读/副本管理模块/21 | AbstractFetcherThread:拉取消息分几步?.md
Normal file
277
极客时间专栏/Kafka核心源码解读/副本管理模块/21 | AbstractFetcherThread:拉取消息分几步?.md
Normal 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值。
|
||||
- brokerTopicStats:Broker端主题的各类监控指标,常见的有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<T extends BaseRecords> {
|
||||
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<Integer> preferredReadReplica;
|
||||
// 该分区对应的已终止事务列表
|
||||
public final List<AbortedTransaction> 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 && !isDelayed
|
||||
// 副本处于ISR的条件:没有lag
|
||||
def isReplicaInSync: Boolean = lag.isDefined && lag.get <= 0
|
||||
// 分区处于截断中状态的条件:副本处于Truncating状态且未被推迟执行
|
||||
def isTruncating: Boolean = state == Truncating && !isDelayed
|
||||
// 分区被推迟获取数据的条件:存在未过期的延迟任务
|
||||
def isDelayed: Boolean =
|
||||
delay.exists(_.getDelay(TimeUnit.MILLISECONDS) > 0)
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段源码中有4个方法,你只要重点了解isReadyForFetch和isTruncating这两个方法即可。因为副本获取线程做的事情就是这两件:日志截断和消息获取。
|
||||
|
||||
至于isReplicaInSync,它被用于副本限流,出镜率不高。而isDelayed,是用于判断是否需要推迟获取对应分区的消息。源码会不断地调整那些不需要推迟的分区的读取顺序,以保证读取的公平性。
|
||||
|
||||
这个公平性,其实就是在partitionStates字段的类型PartitionStates类中实现的。这个类是在clients工程中定义的。它本质上会接收一组要读取的主题分区,然后以轮询的方式依次读取这些分区以确保公平性。
|
||||
|
||||
鉴于咱们这门儿课聚焦于Broker端源码,因此,这里我只是简单和你说下这个类的实现原理。如果你想要深入理解这部分内容,可以翻开clients端工程的源码,自行去探索下这部分的源码。
|
||||
|
||||
```
|
||||
public class PartitionStates<S> {
|
||||
private final LinkedHashMap<TopicPartition, S> map = new LinkedHashMap<>();
|
||||
......
|
||||
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方法的实现原理。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -0,0 +1,471 @@
|
||||
<audio id="audio" title="22 | ReplicaFetcherThread:Follower如何拉取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 <- partitions) {
|
||||
// 获取分区的分区读取状态
|
||||
val partitionState = partitionStates.stateValue(tp)
|
||||
if (partitionState != null) {
|
||||
// 取出高水位值。分区的最大可读取位移值就是高水位值
|
||||
val highWatermark = partitionState.fetchOffset
|
||||
val truncationState = OffsetTruncationState(highWatermark, truncationCompleted = true)
|
||||
|
||||
info(s"Truncating partition $tp to local high watermark $highWatermark")
|
||||
// 执行截断到高水位值
|
||||
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, "maybeFetch")
|
||||
// 如果当前没有可读取的分区,则等待fetchBackOffMs时间等候后续重试
|
||||
if (fetchRequestOpt.isEmpty) {
|
||||
trace(s"There are no active partitions. Back off for $fetchBackOffMs ms before sending a fetch request")
|
||||
partitionMapCond.await(fetchBackOffMs, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
fetchRequestOpt
|
||||
}
|
||||
// 发送FETCH请求给Leader副本,并处理Response
|
||||
fetchRequestOpt.foreach { case ReplicaFetch(sessionPartitions, fetchRequest) =>
|
||||
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"Sending fetch request $fetchRequest")
|
||||
// 给Leader发送FETCH请求
|
||||
responseData = fetchFromLeader(fetchRequest)
|
||||
} catch {
|
||||
......
|
||||
}
|
||||
// 更新请求发送速率指标
|
||||
fetcherStats.requestRate.mark()
|
||||
if (responseData.nonEmpty) {
|
||||
inLock(partitionMapLock) {
|
||||
responseData.foreach { case (topicPartition, partitionData) =>
|
||||
Option(partitionStates.stateValue(topicPartition)).foreach { currentFetchState =>
|
||||
// 获取分区核心信息
|
||||
val fetchPartitionData = sessionPartitions.get(topicPartition)
|
||||
// 处理Response的条件:
|
||||
// 1. 要获取的位移值和之前已保存的下一条待获取位移值相等
|
||||
// 2. 当前分区处于可获取状态
|
||||
if (fetchPartitionData != null && fetchPartitionData.fetchOffset == currentFetchState.fetchOffset && currentFetchState.isReadyForFetch) {
|
||||
// 提取Response中的Leader Epoch值
|
||||
val requestEpoch = if (fetchPartitionData.currentLeaderEpoch.isPresent) Some(fetchPartitionData.currentLeaderEpoch.get().toInt) else None
|
||||
partitionData.error match {
|
||||
// 如果没有错误
|
||||
case Errors.NONE =>
|
||||
try {
|
||||
// 交由子类完成Response的处理
|
||||
val logAppendInfoOpt = processPartitionData(topicPartition, currentFetchState.fetchOffset,
|
||||
partitionData)
|
||||
logAppendInfoOpt.foreach { logAppendInfo =>
|
||||
val validBytes = logAppendInfo.validBytes
|
||||
val nextOffset = if (validBytes > 0) logAppendInfo.lastOffset + 1 else currentFetchState.fetchOffset
|
||||
val lag = Math.max(0L, partitionData.highWatermark - nextOffset)
|
||||
fetcherLagStats.getAndMaybePut(topicPartition).lag = lag
|
||||
if (validBytes > 0 && 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 =>
|
||||
// 调整越界,主要办法是做截断
|
||||
if (handleOutOfRangeError(topicPartition, currentFetchState, requestEpoch))
|
||||
// 如果依然不能成功,加入到出错分区列表
|
||||
partitionsWithError += topicPartition
|
||||
// 如果Leader Epoch值比Leader所在Broker上的Epoch值要新
|
||||
case Errors.UNKNOWN_LEADER_EPOCH =>
|
||||
debug(s"Remote broker has a smaller leader epoch for partition $topicPartition than " +
|
||||
s"this replica's current leader epoch of ${currentFetchState.currentLeaderEpoch}.")
|
||||
// 加入到出错分区列表
|
||||
partitionsWithError += topicPartition
|
||||
// 如果Leader Epoch值比Leader所在Broker上的Epoch值要旧
|
||||
case Errors.FENCED_LEADER_EPOCH =>
|
||||
if (onPartitionFenced(topicPartition, requestEpoch)) partitionsWithError += topicPartition
|
||||
// 如果Leader发生变更
|
||||
case Errors.NOT_LEADER_FOR_PARTITION =>
|
||||
debug(s"Remote broker is not the leader for partition $topicPartition, which could indicate " +
|
||||
"that the partition is being moved")
|
||||
// 加入到出错分区列表
|
||||
partitionsWithError += topicPartition
|
||||
case _ =>
|
||||
error(s"Error for partition $topicPartition at offset ${currentFetchState.fetchOffset}",
|
||||
partitionData.error.exception)
|
||||
// 加入到出错分区列表
|
||||
partitionsWithError += topicPartition
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (partitionsWithError.nonEmpty) {
|
||||
// 处理出错分区列表
|
||||
handlePartitionsWithErrors(partitionsWithError, "processFetchRequest")
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
为了方便你记忆,我先用一张流程图来说明下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"broker-$replicaId-fetcher-$fetcherId", 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类特有的几个字段就可以了。
|
||||
|
||||
- fetcherId:Follower拉取的线程Id,也就是线程的编号。单台Broker上,允许存在多个ReplicaFetcherThread线程。Broker端参数num.replica.fetchers,决定了Kafka到底创建多少个Follower拉取线程。
|
||||
- brokerConfig:KafkaConfig类实例。虽然我们没有正式学习过它的源码,但之前学过的很多组件代码中都有它的身影。它封装了Broker端所有的参数信息。同样地,ReplicaFetcherThread类也是通过它来获取Broker端指定参数的值。
|
||||
- replicaMgr:副本管理器。该线程类通过副本管理器来获取分区对象、副本对象以及它们下面的日志对象。
|
||||
- quota:用做限流。限流属于高阶用法,如果你想深入理解这部分内容的话,可以自行阅读ReplicationQuotaManager类的源码。现在,只要你下次在源码中碰到quota字样的,知道它是用作Follower副本拉取速度控制就行了。
|
||||
- leaderEndpointBlockingSend:这是用于实现同步发送请求的类。所谓的同步发送,是指该线程使用它给指定Broker发送请求,然后线程处于阻塞状态,直到接收到Broker返回的Response。
|
||||
|
||||
除了构造函数中定义的字段之外,ReplicaFetcherThread类还定义了与消息获取息息相关的4个字段。
|
||||
|
||||
- maxWait:Follower发送的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("Offset mismatch for partition %s: fetched offset = %d, log end offset = %d.".format(
|
||||
topicPartition, fetchOffset, log.logEndOffset))
|
||||
if (logTrace)
|
||||
trace("Follower has replica log end offset %d for partition %s. Received %d messages and leader hw %d"
|
||||
.format(log.logEndOffset, topicPartition, records.sizeInBytes, partitionData.highWatermark))
|
||||
// 写入Follower副本本地日志
|
||||
val logAppendInfo = partition.appendRecordsToFollowerOrFutureReplica(records, isFuture = false)
|
||||
if (logTrace)
|
||||
trace("Follower has replica log end offset %d after appending %d bytes of messages for partition %s"
|
||||
.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"Follower set replica high watermark for partition $topicPartition to $followerHighWatermark")
|
||||
// 副本消息拉取限流
|
||||
if (quota.isThrottled(topicPartition))
|
||||
quota.record(records.sizeInBytes)
|
||||
// 更新统计指标值
|
||||
if (partition.isReassigning && 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) =>
|
||||
if (fetchState.isReadyForFetch && !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 =>
|
||||
partitionsWithError += topicPartition
|
||||
}
|
||||
}
|
||||
}
|
||||
val fetchData = builder.build()
|
||||
val fetchRequestOpt = if (fetchData.sessionPartitions.isEmpty && 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 < log.highWatermark)
|
||||
warn(s"Truncating $tp to offset ${offsetTruncationState.offset} below high watermark " +
|
||||
s"${log.highWatermark}")
|
||||
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方法是做什么用的吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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 => 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 => s"$tp:").getOrElse("")
|
||||
val threadName = s"${prefix}ReplicaFetcherThread-$fetcherId-${sourceBroker.id}"
|
||||
// 创建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状态的分区数吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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] => Unit,
|
||||
delayedProduceLock: Option[Lock] = None,
|
||||
recordConversionStatsCallback:
|
||||
Map[TopicPartition, RecordConversionStats] => Unit = _ => ())
|
||||
: 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合法取值是-1,0,1,否则视为非法
|
||||
if (isValidRequiredAcks(requiredAcks)) {
|
||||
val sTime = time.milliseconds
|
||||
// 调用appendToLocalLog方法写入消息集合到本地日志
|
||||
val localProduceResults = appendToLocalLog(
|
||||
internalTopicsAllowed = internalTopicsAllowed,
|
||||
origin, entriesPerPartition, requiredAcks)
|
||||
debug("Produce to local log in %d ms".format(time.milliseconds - sTime))
|
||||
val produceStatus = localProduceResults.map { case (topicPartition, result) =>
|
||||
topicPartition ->
|
||||
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) => k -> 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) => k -> status.responseStatus }
|
||||
// 调用回调逻辑然后返回即可
|
||||
responseCallback(produceResponseStatus)
|
||||
}
|
||||
} else { // 如果requiredAcks值不合法
|
||||
val responseStatus = entriesPerPartition.map { case (topicPartition, _) =>
|
||||
topicPartition -> 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) =>
|
||||
brokerTopicStats.topicStats(topicPartition.topic)
|
||||
.totalProduceRequestRate.mark()
|
||||
brokerTopicStats.allTopicsStats.totalProduceRequestRate.mark()
|
||||
// 如果要写入的主题是内部主题,而internalTopicsAllowed=false,则返回错误
|
||||
if (Topic.isInternal(topicPartition.topic)
|
||||
&& !internalTopicsAllowed) {
|
||||
(topicPartition, LogAppendResult(
|
||||
LogAppendInfo.UnknownLogAppendInfo,
|
||||
Some(new InvalidTopicException(s"Cannot append to internal topic ${topicPartition.topic}"))))
|
||||
} 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 && entriesPerPartition.nonEmpty &&
|
||||
localProduceResults.values.count(_.exception.isDefined) < 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)] => 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 & 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 && 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) =>
|
||||
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 <= 0 || fetchInfos.isEmpty || bytesReadable >= fetchMinBytes || errorReadingData) {
|
||||
// 构建返回结果
|
||||
val fetchPartitionData = logReadResults.map { case (tp, result) =>
|
||||
tp -> FetchPartitionData(result.error, result.highWatermark, result.leaderLogStartOffset, result.info.records,
|
||||
result.lastStableOffset, result.info.abortedTransactions, result.preferredReadReplica, isFromFollower && isAddingReplica(tp, replicaId))
|
||||
}
|
||||
// 调用回调函数
|
||||
responseCallback(fetchPartitionData)
|
||||
} else { // 如果无法立即完成请求
|
||||
val fetchPartitionStatus = new mutable.ArrayBuffer[(TopicPartition, FetchPartitionStatus)]
|
||||
fetchInfos.foreach { case (topicPartition, partitionData) =>
|
||||
logReadResultMap.get(topicPartition).foreach(logReadResult => {
|
||||
val logOffsetMetadata = logReadResult.info.fetchOffsetMetadata
|
||||
fetchPartitionStatus += (topicPartition -> 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, _) => 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参数的具体源码位置吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -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 => 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 < controllerEpoch) {
|
||||
stateChangeLogger.warn(s"Ignoring LeaderAndIsr request from controller $controllerId with " +
|
||||
s"correlation id $correlationId since its controller epoch ${leaderAndIsrRequest.controllerEpoch} is old. " +
|
||||
s"Latest known controller epoch is $controllerEpoch")
|
||||
// 说明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 =>
|
||||
val topicPartition = new TopicPartition(partitionState.topicName, partitionState.partitionIndex)
|
||||
// 从allPartitions中获取对应分区对象
|
||||
val partitionOpt = getPartition(topicPartition) match {
|
||||
// 如果是Offline状态
|
||||
case HostedPartition.Offline =>
|
||||
stateChangeLogger.warn(s"Ignoring LeaderAndIsr request from " +
|
||||
s"controller $controllerId with correlation id $correlationId " +
|
||||
s"epoch $controllerEpoch for partition $topicPartition as the local replica for the " +
|
||||
"partition is in an offline log directory")
|
||||
// 添加对象异常到Response,并设置分区对象变量partitionOpt=None
|
||||
responseMap.put(topicPartition, Errors.KAFKA_STORAGE_ERROR)
|
||||
None
|
||||
// 如果是Online状态,直接赋值partitionOpt即可
|
||||
case HostedPartition.Online(partition) =>
|
||||
Some(partition)
|
||||
// 如果是None状态,则表示没有找到分区对象
|
||||
// 那么创建新的分区对象将,新创建的分区对象加入到allPartitions统一管理
|
||||
// 然后赋值partitionOpt字段
|
||||
case HostedPartition.None =>
|
||||
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) =>
|
||||
partitionState.leader == localBrokerId
|
||||
}
|
||||
// 确定Broker上副本是哪些分区的Follower副本
|
||||
val partitionsToBeFollower = partitionStates.filter { case (k, _) => !partitionsToBeLeader.contains(k) }
|
||||
|
||||
val highWatermarkCheckpoints = new LazyOffsetCheckpoints(this.highWatermarkCheckpoints)
|
||||
val partitionsBecomeLeader = if (partitionsToBeLeader.nonEmpty)
|
||||
// 调用makeLeaders方法为partitionsToBeLeader所有分区
|
||||
// 执行"成为Leader副本"的逻辑
|
||||
makeLeaders(controllerId, controllerEpoch, partitionsToBeLeader, correlationId, responseMap,
|
||||
highWatermarkCheckpoints)
|
||||
else
|
||||
Set.empty[Partition]
|
||||
val partitionsBecomeFollower = if (partitionsToBeFollower.nonEmpty)
|
||||
// 调用makeFollowers方法为令partitionsToBeFollower所有分区
|
||||
// 执行"成为Follower副本"的逻辑
|
||||
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 =>
|
||||
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) =>
|
||||
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支持JBOD(Just 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的方法签名:
|
||||
|
||||
```
|
||||
// controllerId:Controller所在Broker的ID
|
||||
// controllEpoch:Controller Epoch值,可以认为是Controller版本号
|
||||
// partitionStates:LeaderAndIsrRequest请求中携带的分区信息
|
||||
// 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个参数比较关键,我们看下它们的含义。
|
||||
|
||||
- controllerId:Controller所在Broker的ID。该字段只是用于日志输出,无其他实际用途。
|
||||
- controllerEpoch:Controller Epoch值,可以认为是Controller版本号。该字段用于日志输出使用,无其他实际用途。
|
||||
- partitionStates:LeaderAndIsrRequest请求中携带的分区信息,包括每个分区的Leader是谁、ISR都有哪些等数据。
|
||||
|
||||
好了,现在我们继续学习makeLeaders的代码。我把这个方法的关键步骤放在了注释里,并省去了一些日志输出相关的代码。
|
||||
|
||||
```
|
||||
......
|
||||
// 使用Errors.NONE初始化ResponseMap
|
||||
partitionStates.keys.foreach { partition =>
|
||||
......
|
||||
responseMap.put(partition.topicPartition, Errors.NONE)
|
||||
}
|
||||
val partitionsToMakeLeaders = mutable.Set[Partition]()
|
||||
try {
|
||||
// 停止消息拉取
|
||||
replicaFetcherManager.removeFetcherForPartitions(
|
||||
partitionStates.keySet.map(_.topicPartition))
|
||||
stateChangeLogger.info(s"Stopped fetchers as part of LeaderAndIsr request correlationId $correlationId from " +
|
||||
s"controller $controllerId epoch $controllerEpoch as part of the become-leader transition for " +
|
||||
s"${partitionStates.size} partitions")
|
||||
// 更新指定分区的Leader分区信息
|
||||
partitionStates.foreach { case (partition, partitionState) =>
|
||||
try {
|
||||
if (partition.makeLeader(partitionState, highWatermarkCheckpoints))
|
||||
partitionsToMakeLeaders += partition
|
||||
else
|
||||
......
|
||||
} catch {
|
||||
case e: KafkaStorageException =>
|
||||
......
|
||||
// 把KAFKA_SOTRAGE_ERRROR异常封装到Response中
|
||||
responseMap.put(partition.topicPartition, Errors.KAFKA_STORAGE_ERROR)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
case e: Throwable =>
|
||||
......
|
||||
}
|
||||
......
|
||||
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副本。我们还是先看下方法签名:
|
||||
|
||||
```
|
||||
// controllerId:Controller所在Broker的Id
|
||||
// controllerEpoch:Controller 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) =>
|
||||
......
|
||||
// 将所有分区的处理结果的状态初始化为Errors.NONE
|
||||
responseMap.put(partition.topicPartition, Errors.NONE)
|
||||
}
|
||||
val partitionsToMakeFollower: mutable.Set[Partition] = mutable.Set()
|
||||
try {
|
||||
// 遍历partitionStates所有分区
|
||||
partitionStates.foreach { case (partition, partitionState) =>
|
||||
// 拿到分区的Leader Broker ID
|
||||
val newLeaderBrokerId = partitionState.leader
|
||||
try {
|
||||
// 在元数据缓存中找到Leader Broke对象
|
||||
metadataCache.getAliveBrokers.find(_.id == newLeaderBrokerId) match {
|
||||
// 如果Leader确实存在
|
||||
case Some(_) =>
|
||||
// 执行makeFollower方法,将当前Broker配置成该分区的Follower副本
|
||||
if (partition.makeFollower(partitionState, highWatermarkCheckpoints))
|
||||
// 如果配置成功,将该分区加入到结果返回集中
|
||||
partitionsToMakeFollower += partition
|
||||
else // 如果失败,打印错误日志
|
||||
......
|
||||
// 如果Leader不存在
|
||||
case None =>
|
||||
......
|
||||
// 依然创建出分区Follower副本的日志对象
|
||||
partition.createLogIfNotExists(isNew = partitionState.isNew, isFutureReplica = false,
|
||||
highWatermarkCheckpoints)
|
||||
}
|
||||
} catch {
|
||||
case e: KafkaStorageException =>
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这部分代码中,我们可以把它的执行逻辑划分为两大步骤。
|
||||
|
||||
第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 Replicas,AR)和清空ISR;
|
||||
1. 创建日志对象;
|
||||
1. 重设Leader副本的Broker ID。
|
||||
|
||||
接下来,**我们看下makeFollowers方法的第2步,执行其他动作的代码**:
|
||||
|
||||
```
|
||||
// 第二部分:执行其他动作
|
||||
// 移除现有Fetcher线程
|
||||
replicaFetcherManager.removeFetcherForPartitions(
|
||||
partitionsToMakeFollower.map(_.topicPartition))
|
||||
......
|
||||
// 尝试完成延迟请求
|
||||
partitionsToMakeFollower.foreach { partition =>
|
||||
completeDelayedFetchOrProduceRequests(partition.topicPartition)
|
||||
}
|
||||
if (isShuttingDown.get()) {
|
||||
.....
|
||||
} else {
|
||||
// 为需要将当前Broker设置为Follower副本的分区
|
||||
// 确定Leader Broker和起始读取位移值fetchOffset
|
||||
val partitionsToMakeFollowerWithLeaderAndOffset = partitionsToMakeFollower.map { partition =>
|
||||
val leader = metadataCache.getAliveBrokers
|
||||
.find(_.id == partition.leaderReplicaIdOpt.get).get
|
||||
.brokerEndPoint(config.interBrokerListenerName)
|
||||
val fetchOffset = partition.localLogOrException.highWatermark
|
||||
partition.topicPartition -> InitialFetchState(leader,
|
||||
partition.getLeaderEpoch, fetchOffset)
|
||||
}.toMap
|
||||
// 使用上一步确定的Leader Broker和fetchOffset添加新的Fetcher线程
|
||||
replicaFetcherManager.addFetcherForPartitions(
|
||||
partitionsToMakeFollowerWithLeaderAndOffset)
|
||||
}
|
||||
} catch {
|
||||
case e: Throwable =>
|
||||
......
|
||||
throw e
|
||||
}
|
||||
......
|
||||
// 返回需要将当前Broker设置为Follower副本的分区列表
|
||||
partitionsToMakeFollower
|
||||
|
||||
```
|
||||
|
||||
你看,这部分代码的任务比较简单,逻辑也都是线性递进的,很好理解。我带你简单地梳理一下。
|
||||
|
||||
首先,移除现有Fetcher线程。因为Leader可能已经更换了,所以要读取的Broker以及要读取的位移值都可能随之发生变化。
|
||||
|
||||
然后,为需要将当前Broker设置为Follower副本的分区,确定Leader Broker和起始读取位移值fetchOffset。这些信息都已经在LeaderAndIsrRequest中了。
|
||||
|
||||
接下来,使用上一步确定的Leader Broker和fetchOffset添加新的Fetcher线程。
|
||||
|
||||
最后,返回需要将当前Broker设置为Follower副本的分区列表。
|
||||
|
||||
至此,副本管理器管理分区和副本的主要方法实现,我们就都学完啦。可以看出,这些代码实现的大部分,都是围绕着如何处理LeaderAndIsrRequest请求数据展开的。比如,makeLeaders拿到请求数据后,会为分区设置Leader和ISR;makeFollowers拿到数据后,会为分区更换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("isr-expiration", 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("Evaluating ISR list of partitions to see which replicas can be removed from the ISR")
|
||||
allPartitions.keys.foreach { topicPartition =>
|
||||
nonOfflinePartition(topicPartition).foreach(_.maybeShrinkIsr())
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,maybeShrinkIsr方法会遍历该副本管理器上所有分区对象,依次为这些分区中状态为Online的分区,执行Partition类的maybeShrinkIsr方法。这个方法的源码如下:
|
||||
|
||||
```
|
||||
def maybeShrinkIsr(): Unit = {
|
||||
// 判断是否需要执行ISR收缩
|
||||
val needsIsrUpdate = inReadLock(leaderIsrUpdateLock) {
|
||||
needsShrinkIsr()
|
||||
}
|
||||
val leaderHWIncremented = needsIsrUpdate && inWriteLock(leaderIsrUpdateLock) {
|
||||
leaderLogIfLocal match {
|
||||
// 如果是Leader副本
|
||||
case Some(leaderLog) =>
|
||||
// 获取不同步的副本Id列表
|
||||
val outOfSyncReplicaIds = getOutOfSyncReplicas(replicaLagTimeMaxMs)
|
||||
// 如果存在不同步的副本Id列表
|
||||
if (outOfSyncReplicaIds.nonEmpty) {
|
||||
// 计算收缩之后的ISR列表
|
||||
val newInSyncReplicaIds = inSyncReplicaIds -- outOfSyncReplicaIds
|
||||
assert(newInSyncReplicaIds.nonEmpty)
|
||||
info("Shrinking ISR from %s to %s. Leader: (highWatermark: %d, endOffset: %d). Out of sync replicas: %s."
|
||||
.format(inSyncReplicaIds.mkString(","),
|
||||
newInSyncReplicaIds.mkString(","),
|
||||
leaderLog.highWatermark,
|
||||
leaderLog.logEndOffset,
|
||||
outOfSyncReplicaIds.map { replicaId =>
|
||||
s"(brokerId: $replicaId, endOffset: ${getReplicaOrException(replicaId).logEndOffset})"
|
||||
}.mkString(" ")
|
||||
)
|
||||
)
|
||||
// 更新ZooKeeper中分区的ISR数据以及Broker的元数据缓存中的数据
|
||||
shrinkIsr(newInSyncReplicaIds)
|
||||
// 尝试更新Leader副本的高水位值
|
||||
maybeIncrementLeaderHW(leaderLog)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
// 如果不是Leader副本,什么都不做
|
||||
case None => 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("isr-change-propagation", 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 &&
|
||||
(lastIsrChangeMs.get() + ReplicaManager.IsrChangePropagationBlackOut < now ||
|
||||
lastIsrPropagationMs.get() + ReplicaManager.IsrChangePropagationInterval < 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字段。你知道它是在哪里被更新的吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
@@ -0,0 +1,377 @@
|
||||
<audio id="audio" title="26 | MetadataCache:Broker是怎么异步更新元数据缓存的?" 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 =>
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
一旦实例被成功创建,就会被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"[MetadataCache brokerId=$brokerId] "
|
||||
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<Integer> isr; // ISR列表
|
||||
private int zkVersion; // ZooKeeper节点Stat统计信息中的版本号
|
||||
private List<Integer> replicas; // 副本列表
|
||||
private List<Integer> offlineReplicas; // 离线副本列表
|
||||
private List<RawTaggedField> _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) =>
|
||||
partitionsAndStates.keys.map(partitionId => 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 =>
|
||||
// 拿到副本Id列表
|
||||
val replicaIds = partitionInfo.replicas
|
||||
replicaIds.asScala
|
||||
.map(replicaId => replicaId.intValue() -> {
|
||||
// 获取副本所在的Broker Id
|
||||
snapshot.aliveBrokers.get(replicaId.longValue()) match {
|
||||
case Some(broker) =>
|
||||
// 根据Broker Id去获取对应的Broker节点对象
|
||||
broker.getNode(listenerName).getOrElse(Node.noNode())
|
||||
case None => // 如果找不到节点
|
||||
Node.noNode()
|
||||
}}).toMap
|
||||
.filter(pair => pair match {
|
||||
case (_, node) => !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 ID,Value是Broker对象
|
||||
val aliveBrokers = new mutable.LongMap[Broker](metadataSnapshot.aliveBrokers.size)
|
||||
// 保存存活节点对象。Key是Broker ID,Value是监听器->节点对象
|
||||
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 < 0 => None
|
||||
case id => Some(id)
|
||||
}
|
||||
// 遍历UpdateMetadataRequest请求中的所有存活Broker对象
|
||||
updateMetadataRequest.liveBrokers.forEach { broker =>
|
||||
val nodes = new java.util.HashMap[ListenerName, Node]
|
||||
val endPoints = new mutable.ArrayBuffer[EndPoint]
|
||||
// 遍历它的所有EndPoint类型,也就是为Broker配置的监听器
|
||||
broker.endpoints.forEach { ep =>
|
||||
val listenerName = new ListenerName(ep.listener)
|
||||
endPoints += new EndPoint(ep.host, ep.port, listenerName, SecurityProtocol.forId(ep.securityProtocol))
|
||||
// 将<监听器,Broker节点对象>对保存起来
|
||||
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 ID,Value类型是<监听器,节点对象>对。
|
||||
|
||||
然后,该方法从UpdateMetadataRequest中获取Controller所在的Broker ID,并赋值给controllerIdOpt字段。如果集群没有Controller,则赋值该字段为None。
|
||||
|
||||
接着,代码会遍历UpdateMetadataRequest请求中的所有存活Broker对象。取出它配置的所有EndPoint类型,也就是Broker配置的所有监听器。
|
||||
|
||||
最后,代码会遍历它配置的监听器,并将<监听器,Broker节点对象>对保存起来,再将Broker加入到存活Broker对象集合和存活节点对象集合。至此,第一部分代码逻辑完成。
|
||||
|
||||
再来看第二部分的代码。这一部分的主要工作是**确保集群Broker配置了相同的监听器,同时初始化已删除分区数组对象,等待下一部分代码逻辑对它进行操作**。代码如下:
|
||||
|
||||
```
|
||||
// 使用上一部分中的存活Broker节点对象,
|
||||
// 获取当前Broker所有的<监听器,节点>对
|
||||
aliveNodes.get(brokerId).foreach { listenerMap =>
|
||||
val listeners = listenerMap.keySet
|
||||
// 如果发现当前Broker配置的监听器与其他Broker有不同之处,记录错误日志
|
||||
if (!aliveNodes.values.forall(_.keySet == listeners))
|
||||
error(s"Listeners are not identical across brokers: $aliveNodes")
|
||||
}
|
||||
// 构造已删除分区数组,将其作为方法返回结果
|
||||
val deletedPartitions = new mutable.ArrayBuffer[TopicPartition]
|
||||
// UpdateMetadataRequest请求没有携带任何分区信息
|
||||
if (!updateMetadataRequest.partitionStates.iterator.hasNext) {
|
||||
// 构造新的MetadataSnapshot对象,使用之前的分区信息和新的Broker列表信息
|
||||
metadataSnapshot = MetadataSnapshot(metadataSnapshot.partitionStates, controllerIdOpt, aliveBrokers, aliveNodes)
|
||||
// 否则,进入到方法最后一部分
|
||||
} else {
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这部分代码首先使用上一部分中的存活Broker节点对象,获取当前Broker所有的<监听器,节点>对。
|
||||
|
||||
之后,拿到为当前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) =>
|
||||
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 =>
|
||||
val tp = new TopicPartition(state.topicName, state.partitionIndex)
|
||||
// 如果分区处于被删除过程中
|
||||
if (state.leader == LeaderAndIsr.LeaderDuringDelete) {
|
||||
// 将分区从元数据缓存中移除
|
||||
removePartitionInfo(partitionStates, tp.topic, tp.partition)
|
||||
if (traceEnabled)
|
||||
stateChangeLogger.trace(s"Deleted partition $tp from metadata cache in response to UpdateMetadata " +
|
||||
s"request sent by controller $controllerId epoch $controllerEpoch with correlation id $correlationId")
|
||||
// 将分区加入到返回结果数据
|
||||
deletedPartitions += tp
|
||||
} else {
|
||||
// 将分区加入到元数据缓存
|
||||
addOrUpdatePartitionInfo(partitionStates, tp.topic, tp.partition, state)
|
||||
if (traceEnabled)
|
||||
stateChangeLogger.trace(s"Cached leader info $state for partition $tp in response to " +
|
||||
s"UpdateMetadata request sent by controller $controllerId epoch $controllerEpoch with correlation id $correlationId")
|
||||
}
|
||||
}
|
||||
val cachedPartitionsCount = newStates.size - deletedPartitions.size
|
||||
stateChangeLogger.info(s"Add $cachedPartitionsCount partitions and deleted ${deletedPartitions.size} partitions from metadata cache " +
|
||||
s"in response to UpdateMetadata request sent by controller $controllerId epoch $controllerEpoch with correlation id $correlationId")
|
||||
// 使用更新过的分区元数据,和第一部分计算的存活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,你能在源码中找到更新元数据缓存的完整调用路径吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
Reference in New Issue
Block a user