图解 Kafka 生产者元数据拉取管理全流程(上)
大家好,我是 华仔, 又跟大家见面了。
在上一篇中,正式开启了「Kafka的源码之旅」,主要讲述了 KafkaProducer 初始化时用到的核心组件以及消息发送的核心流程,带你梳理生产者初始化整体的源码分析脉络,并通过「场景驱动」的方式带大家一点点的对 Kafka 源码进行深度剖析,一起掌握 Kafka 源码核心架构设计思想。
今天这篇我们就来聊聊生产者是会如何拉取和管理元数据的,带你梳理生产者元数据管理整体的源码分析脉络。
认真读完这篇文章,我相信你会对 Kafka 生产获取和管理元数据源码有更加深刻的理解。
这篇文章干货很多,希望你可以耐心读完。
01 总的概述
消息想从 Producer 端发送到 Broker 端,必须要先知道 Topic 在 Broker 的分布情况,才能判断消息该发往哪些节点,比如:「Topic 对应的 Leader 分区有哪些」、「Leader分区分布在哪些 Broker 节点」、「Topic 分区动态变更」等等,所以及时获取元数据对生产者正常工作是非常有必要的。
元数据获取涉及的底层组件比较多,主要分为:「KafkaProducer 主线程加载元数据」、「Sender 子线程拉取元数据」。
接下来我们逐一分析元数据在生产者端是如何被加载和拉取、更新的。为了方便大家理解,所有的源码只保留骨干。
02 主线程如何加载元数据
首先我们来看下 KafkaProducer 主线程是如何加载元数据的。
在上一篇中《图解Kafka生产者初始化核心流程》我们分析知道集群元数据的初始化是在 KafkaProducer 主线程的构造函数中来完成的,我们来看一下相关源码:
// 初始化 Kafka 集群元数据,元数据会保存到客户端中,并与服务端元数据保持一致
if (metadata != null) {
this.metadata = metadata;
} else {
// 初始化集群元数据
this.metadata = new ProducerMetadata(retryBackoffMs,
// 元数据过期时间:默认5分钟
config.getLong(ProducerConfig.METADATA_MAX_AGE_CONFIG),
// topic最大空闲时间,如果在规定时间没有被访问,将从缓存删除,下次访问时强制获取元数据
config.getLong(ProducerConfig.METADATA_MAX_IDLE_CONFIG),
logContext,
clusterResourceListeners,
Time.SYSTEM);
// 启动metadata的引导程序
this.metadata.bootstrap(addresses);
}
从上述源代码我们可以看出在 KafkaProducer 的构造方法中,如果metadata为空就会初始化集群元数据类「ProducerMetadata」,然后通过调用 「this.metadata.bootstrap」这个方法来启动引导程序,这时 metaData 对象里并没有具体的元数据信息,因为客户端还没发送元数据更新的请求,后面会通过唤醒 Sender 线程进行发送请求获取元数据的。
这里的 this.meta 其实就是 Kafka 内存中的一个对象,底层会做一层缓存,因此并不会一直请求 Kafka Broker 端进行获取。
我先给大家总结下元数据初始化及启动的调用关系图,口说无凭,我们来扒开源码瞅一瞅,这样更真实。
通过上述的调用关系图,我们可以看出:
1. ProducerMetadata 类是 MetaData 的子类。
2. Metadata 类是元数据基类,封装了元数据的具体信息、版本控制、更新标识、响应解析等。
3. 元数据的信息其实最终是保存在元数据缓存即 MetadataCache 中,而 它最核心的是 Cluster , 保存了元数据基础信息。
接下来会挨个类展开来进行讲解。
02.1 初探 ProducerMetadata
在主线程中初始化了 ProducerMetadata 类的对象,我们先来看看这个类都做了哪些事情。
github 源码地址如下:
https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/clients/producer/internals/ProducerMetadata.java
public class ProducerMetadata extends Metadata {
// 主题元数据过期时间,如果在这个时间段内未被访问,它就会从缓存中删除。
private final long metadataIdleMs;
/* Topics with expiry time */
// map集合,生产者的元数据主题集合,里面保存着
// 主题和主题过期时间的对应关系,即 topic, nowMs + metadataIdleMs,
// 过期了的主题会被踢出去。
private final Map<String, Long> topics = new HashMap<>();
// 新的主题集合, set集合,即第一次要发送的主题
private final Set<String> newTopics = new HashSet<>();
private final Logger log;
private final Time time;
public ProducerMetadata(long refreshBackoffMs,
long metadataExpireMs,
long metadataIdleMs,
LogContext logContext,
ClusterResourceListeners clusterResourceListeners,
Time time) {
//调用父类 Metadata 的构造函数
super(refreshBackoffMs, metadataExpireMs, logContext, clusterResourceListeners);
....
}
}
我们可以看出这里只是调用了父类的构造函数进行类属性的初始化,接下来我们深度分析下 ProducerMetadata 类中的几个比较重要的方法。
02.1.1 add()
public synchronized void add(String topic, long nowMs) {
// 判断对象是否为空
Objects.requireNonNull(topic, "topic cannot be null");
if (topics.put(topic, nowMs + metadataIdleMs) == null) {
// 添加主题到新主题集合中
newTopics.add(topic);
// 更新元数据标记 属于Metadata类方法,后面小节分析
requestUpdateForNewTopics();
}
}
该方法主要用来向元数据主题集合 topics 中添加主题,主要用在「KafkaProducer 主线程」以及「Sender 子线程」中,我们来看下是如何添加的,具体逻辑如下:
1. 往元数据主题集合 topics 中添加主题和对应的过期时间(当时时间+过期时间段「默认值:5分钟」)。
2. 如果元数据主题集合中不存在该主题时,说明是第一次就把该主题添加到新主题集合中。
3. 标记要更新新主题元数据的属性字段「lastRefreshMs」 、「needPartialUpdate」 、「requestVersion」,以便后续唤醒 Sender 线程去服务端拉取新主题的元数据。
此时主题被添加到元数据主题主题集合中,但是如果集合里面数据过期了该怎么办?接下来我们看另外一个方法是如何判断的。
02.1.2 retainTopic()
public synchronized boolean retainTopic(String topic, boolean isInternal, long nowMs) {
// 获取该主题的过期时间
Long expireMs = topics.get(topic);
// 如果为空表示该主题不在元数据主题集合中
if (expireMs == null) {
return false;
// 判断该主题是否在新集合中
} else if (newTopics.contains(topic)) {
return true;
// 判断是否超过了过期时间
} else if (expireMs <= nowMs) {
log.debug("Removing unused topic {} from the metadata list, expiryMs {} now {}", topic, expireMs, nowMs);
// 超过后直接从元数据主题集合中删除该主题
topics.remove(topic);
return false;
} else {
return true;
}
}
该方法用来判断元数据中是否该保留该主题,会在 handleMetadataResponse 即处理元数据响应结果的时候进行调用,我们来看下它是如何判断的。
1. 先判断元数据主题集合中是否存在该主题,如果不存在直接返回false。
2. 然后判断该主题是否在新主题集合中,如果存在直接返回true。
3. 再判断该主题是否超过了过期时间,如果超过了,就从元数据主题集合中删除该主题,再请求元数据的时候就不用带上该主题,可以有效的减少网络传输数据大小。
02.1.3 requestUpdateForTopic()
public synchronized int requestUpdateForTopic(String topic) {
// 如果新主题集合中存在该主题
if (newTopics.contains(topic)) {
// 针对新主题集合标记部分更新,并返回版本
return requestUpdateForNewTopics();
} else {
// 全量更新,并返回版本
return requestUpdate();
}
}
该方法用来判断是全量更新元数据还是部分更新元数据,逻辑相对比较简单,主要用在 KafkaProducer 主线程元数据同步等待时调用,后续小节会详细分析。
02.1.4 update()
public synchronized void update(int requestVersion, MetadataResponse response, boolean isPartialUpdate, long nowMs) {
// 调用父类的update方法
super.update(requestVersion, response, isPartialUpdate, nowMs);
// 如果新主题集合不为空,则遍历响应元数据找出已经获取元数据的主题,并从新主题集合中删除
if (!newTopics.isEmpty()) {
for (MetadataResponse.TopicMetadata metadata : response.topicMetadata()) {
newTopics.remove(metadata.topic());
}
}
// 唤醒等待元数据更新完成的线程
notifyAll();
}
该方法用来更新生产端元数据的,具体逻辑如下:
1. 先调用父类的 update() 方法。
2. 然后判断新主题集合是否不为空,如果不为空则遍历响应元数据找出已经获取元数据的主题,并从新主题集合中删除。
3. 最后调用 notifyAll() 来唤醒等待元数据更新完成的线程。
从上述 update()方法 中可以看出最后调用 notifyAll() 来唤醒阻塞的线程, 那么它是如何唤醒的呢,这就是接下来要分析的方法。
02.1.5 awaitUpdate()
public synchronized void awaitUpdate(final int lastVersion, final long timeoutMs) throws InterruptedException {
long currentTimeMs = time.milliseconds();
long deadlineMs = currentTimeMs + timeoutMs < 0 ? Long.MAX_VALUE : currentTimeMs + timeoutMs;
// 通过调用 time.waitObject 来实现线程阻塞
time.waitObject(this, () -> {
// Throw fatal exceptions, if there are any. Recoverable topic errors will be handled by the caller.
maybeThrowFatalException();
return updateVersion() > lastVersion || isClosed();
}, deadlineMs);
if (isClosed())
throw new KafkaException("Requested metadata update after close");
}
该方法用来实现线程阻塞的功能,用在主线程中如果发现主题对应的元数据不存在时,阻塞并等待 Sender 线程将元数据更新完成。
重点是调用了 time.waitObject() 方法来实现阻塞功能,它的实现还是有一些技巧的,它的底层通过调用 SystemTime 包里面的 waitObject() 实现的,源码如下:
public void waitObject(Object obj, Supplier<Boolean> condition, long deadlineMs) throws InterruptedException {
synchronized (obj) {
while (true) {
// 判断条件是否满足即元数据是否更新成功,成功直接返回
if (condition.get())
return;
long currentTimeMs = milliseconds();
// 超时抛出异常
if (currentTimeMs >= deadlineMs)
throw new TimeoutException("Condition not satisfied before deadline");
// 调用 wait 阻塞线程
obj.wait(deadlineMs - currentTimeMs);
}
}
}
1. 通过一个循环来判断条件是否满足,即元数据是否更新成功了,如果成功则跳出循环,释放锁。
2. 获取当前时间,判断是否超时,如果超时后会抛出超时的异常。
3. 如果未超时就调用 wait 方法来阻塞线程,直到满足过期时间的条件,解除阻塞。
如果你的项目中也需要类似的功能要实现一个锁,可以参考这里的代码,实现非常巧妙。
接下来我们来重点分析下元数据基类。
02.2 初探 Metadata
首先它是一个线程安全的类,因此Metadata通过synchronized修饰几乎所有方法来保证线程安全,里面封装了元数据基本信息以及元数据的相关操作,主要被用在生产端的 「KafkaProducer 主线程」、「Sender 子线程」中,对于生产者来说只需要获取自己发送的主题集合的元数据,这样可以有效降低网络传输的数据量。因此,它内部只维护部分主题的元数据。
github 源码地址如下:
https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/clients/Metadata.java
https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/clients/MetadataCache.java
我们先来看下这个类的重要字段:
1. refreshBackoffMs:请求元数据失败后重试间隔时间,默认值:100ms。
2. metadataExpireMs:元数据过期时间,默认值:5分钟,时间一到会再次发送获取元数据的请求。
3. updateVersion:元数据版本号,每次请求服务端+1,保存在本地内存中。
4. requestVersion:元数据加入到新主题集合的版本号,每次+1。
5. astRefreshMs:最后一次更新元数据的时间。
6. lastSuccessfulRefreshMs:最后一次成功更新全部主题元数据的时间。
7. fatalException:失败异常。
8. invalidTopics:无效的主题集合。
9. unauthorizedTopics:无权限的主题集合。
10. MetadataCache:元数据缓存,客户端真正存储元数据的对象。
11. needFullUpdate:是否全部主题更新,对生产者来说,全部主题是指最近发送的主题集合。
12. needPartialUpdate:是否部分主题更新,对生产者来说,部分主题是指新发送的主题集合。
介绍完字段后,我们来深度分析下几个元数据用到的重要方法。
02.2.1 bootstrap()
public synchronized void bootstrap(List<InetSocketAddress> addresses) {
// 是否全部主题更新为true
this.needFullUpdate = true;
// 版本更新为1
this.updateVersion += 1;
// 初始化元数据缓存
this.cache = MetadataCache.bootstrap(addresses);
}
该方法用来引导启动程序的,即在第一次使用前进行初始化的工作。
1. 由于此时生产者刚启动,本地缓存中的元数据是空的,因此先将 needFullUpdate 置为 true,即需要全部主题进行更新。
2. 在初始化时将 updateVersion 置为 0,此时将版本更新+1。
3. 调用元数据缓存类 MetadataCache.bootstrap() 初始化元数据缓存。
我们来看下元数据缓存的启动程序都做了哪些事情。
02.2.2 MetadataCache.bootstrap()
static MetadataCache bootstrap(List<InetSocketAddress> addresses) {
...
// 因还未获取元数据,此时元数据缓存都是空集合
return new MetadataCache(null, nodes, Collections.emptyList(),
Collections.emptySet(), Collections.emptySet(), Collections.emptySet(),
null, Cluster.bootstrap(addresses));
}
此时获取元数据被启动了,但是还未获取元首,所以元数据缓存都是空的集合,接下来我们来分析下元数据是如何被更新以及如何解析响应的,这里涉及到以下几个方法。
02.2.3 requestUpdate()
/**
* Request an update of the current cluster metadata info, return the current updateVersion before the update
*/
public synchronized int requestUpdate() {
// 全部主题更新设置为true
this.needFullUpdate = true;
// 返回元数据版本号
return this.updateVersion;
}
该方法用来设置全部主题更新的标记,上来就先将 needFullUpdate 置为 true,即要全部更新主题,然后返回更新版本。 主要在「ProducerMetadata」、「sender 子线程」、「NetworkClient线程」中使用。
02.2.4 requestUpdateForNewTopics()
public synchronized int requestUpdateForNewTopics() {
// 重写上次刷新的时间戳以允许立即更新。
this.lastRefreshMs = 0;
// 部分主题更新设置为true
this.needPartialUpdate = true;
// 元数据加入到新主题集合的版本号 + 1
this.requestVersion++;
// 返回元数据版本号
return this.updateVersion;
}
该方法用来设置新集合主题更新的标记,这里并未真正发送更新元数据的请求,只是设置标识位的值,Kafka必须确保在第一次拉消息前元数据是可用的,即必须更新一次元数据。
1. 将 lastRefreshMs 设置为0,即重写上次刷新的时间戳以允许立即更新。
2. 将需要更新元数据的标志位 needPartialUpdate 设置 true。
3. 将元数据加入到新主题集合的版本号 requestVersion +1 。
4. 返回元数据版本号。
02.2.3 fetch()
/**
* Get the current cluster info without blocking
*/
public synchronized Cluster fetch() {
// 返回元数据缓存
return cache.cluster();
}
该方法用来获取集群元数据的,默认从元数据缓存中返回元数据。
02.2.4 update()
public synchronized void update(int requestVersion, MetadataResponse response, boolean isPartialUpdate, long nowMs) {
.....
// 是否是部分主题更新标记
this.needPartialUpdate = requestVersion < this.requestVersion;
// 最后一次更新元数据的时间为当前时间
this.lastRefreshMs = nowMs;
// 元数据版本号 +1
this.updateVersion += 1;
// 判断非部分更新即全部主题更新
if (!isPartialUpdate) {
// 全部主题更新标记为否
this.needFullUpdate = false;
// 最后一次成功更新全部主题元数据的时间更新为当前时间
this.lastSuccessfulRefreshMs = nowMs;
}
String previousClusterId = cache.clusterResource().clusterId();
// 解析元数据响应结果
this.cache = handleMetadataResponse(response, isPartialUpdate, nowMs);
....
}
该方法主要做了几件事情,具体如下:
1. 设置是否是部分主题更新 needPartialUpdate,根据参数:元数据加入到新主题集合的版本号 「requestVersion」与「元数据中的requestVersion」对比。
2. 设置最后一次更新元数据的时间为当前时间。
3. 设置元数据版本号 +1 。
4. 判断是否全部主题更新,如果是则说明更新全部主题的响应已经收到了,此时将 needFullUpdate 标记为否,lastSuccessfulRefreshMs 更新为当前时间。
5. 解析元数据响应结果,并设置缓存。
最后调用了 handleMetadataResponse 这个重要方法来解析元数据响应,接下来重点分析下它做了些什么。
02.2.5 handleMetadataResponse()
private MetadataCache handleMetadataResponse(MetadataResponse metadataResponse, boolean isPartialUpdate, long nowMs) {
// All encountered topics.
Set<String> topics = new HashSet<>();
// 初始化相关主题集合
Set<String> internalTopics = new HashSet<>();
Set<String> unauthorizedTopics = new HashSet<>();
Set<String> invalidTopics = new HashSet<>();
List<MetadataResponse.PartitionMetadata> partitions = new ArrayList<>();
// 遍历主题的元数据响应
for (MetadataResponse.TopicMetadata metadata : metadataResponse.topicMetadata()) {
// 将该主题添加到元数据主题集合中
topics.add(metadata.topic());
// 判断是否保留主题元数据
if (!retainTopic(metadata.topic(), metadata.isInternal(), nowMs))
continue;
// 判断是否是内部主题。
if (metadata.isInternal())
internalTopics.add(metadata.topic());
// 判断是否元数据响应error为空
if (metadata.error() == Errors.NONE) {
// 遍历分区信息
for (MetadataResponse.PartitionMetadata partitionMetadata : metadata.partitionMetadata()) {
....
// 判断分区元数据是否有无效异常
if (partitionMetadata.error.exception() instanceof InvalidMetadataException) {
....
// 标记全部主题更新
requestUpdate();
}
}
} else { // 如果元数据响应有错误
// 判断是否是无效元数据异常
if (metadata.error().exception() instanceof InvalidMetadataException) {
....
// 标记全部主题更新
requestUpdate();
}
// 判断是否无效主题错误
if (metadata.error() == Errors.INVALID_TOPIC_EXCEPTION)
// 将主题添加到无效主题集合中
invalidTopics.add(metadata.topic());
// 判断是否无权限主题错误
else if (metadata.error() == Errors.TOPIC_AUTHORIZATION_FAILED)
// 将主题添加到无权限主题集合中
unauthorizedTopics.add(metadata.topic());
}
}
Map<Integer, Node> nodes = metadataResponse.brokersById();
// 判断是否部分主题更新
if (isPartialUpdate)
// 如果是则与现在的元数据缓存合并在一起
return this.cache.mergeWith(metadataResponse.clusterId(), nodes, partitions,
unauthorizedTopics, invalidTopics, internalTopics, metadataResponse.controller(),
(topic, isInternal) -> !topics.contains(topic) && retainTopic(topic, isInternal, nowMs));
else
// 如果是全部主题更新的话,就重新初始化元数据缓存
return new MetadataCache(metadataResponse.clusterId(), nodes, partitions,
unauthorizedTopics, invalidTopics, internalTopics, metadataResponse.controller());
}
该方法用来解析元数据响应,我们具体分析下主流程的逻辑:
1. 首先初始化相关集合:「内部主题集合」、「无效主题集合」、「无权限主题集合」。
2. 遍历主题的元数据响应。
3. 将该主题添加到元数据主题集合中。
4. 判断是否保留主题元数据,如果过期可能就没必要保留了。
5. 判断是否是内部主题,如果是内部主题就添加到内部主题集合。
6. 判断是否元数据响应error为空的话
● 如果为空就开始遍历主题下的分区信息,更新本地元数据缓存。
● 判断分区元数据是否有无效异常,如果有则要打出相应的日志,
● 做好需要全部主题更新元数据的标记,后续会提醒 Sender 线程去更新元数据。
7. 否则如果元数据响应error不为空的话
● 先判断是否是无效元数据异常,如果有则做好需要全部主题更新元数据的标记,后续会提醒 Sender 线程去更新元数据。
● 判断是否无效主题错误,如果是将主题添加到无效主题集合中。
● 判断是否无权限主题错误,如果是将主题添加到无权限主题集合中。
8. 判断是否部分主题更新响应
● 如果是则与现在的元数据缓存合并在一起。
● 否则就重新初始化元数据缓存。
至此,元数据相关类的重要方法已经分析完毕,剩余没分析到的方法待到后续场景中进行分析,接下来我们看下主线程是如何加载元数据的。