欢迎大家关注 github.com/hsfxuebao/j… ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈
书接上文Kafka源码分析3-Producer核心流程分析,本篇文章重点分析元数据更新机制。
在上一篇文章中,已经介绍了 Producer 的发送模型,Producer dosend() 方法中的第一步,就是获取相关的 topic 的 metadata,但在上篇中并没有深入展开,因为这部分的内容比较多,所以本文单独一篇文章进行介绍,本文主要来讲述以下三个问题:
- metadata 内容是什么;
- Producer 更新 metadata 的流程;
- Producer 在什么情况下会去更新 metadata;
Metadata内容
Metadata 类中属性:
/**
* 这个类被 client 线程和后台 sender 所共享,它只保存了所有 topic 的部分数据,当我们请求一个它上面没有的 topic meta 时,
* 它会通过发送 metadata update 来更新 meta 信息,
* 如果 topic meta 过期策略是允许的,那么任何 topic 过期的话都会被从集合中移除,
* 但是 consumer 是不允许 topic 过期的因为它明确地知道它需要管理哪些 topic
*/
public final class Metadata {
private static final Logger log = LoggerFactory.getLogger(Metadata.class);
public static final long TOPIC_EXPIRY_MS = 5 * 60 * 1000;
private static final long TOPIC_EXPIRY_NEEDS_UPDATE = -1L;
//两个更新元数据的请求的最小的时间间隔,默认值是100ms
//目的就是减少网络的压力
private final long refreshBackoffMs;
// 多久自动更新一次元数据,默认值是5分钟更新一次。
private final long metadataExpireMs;
// 集群元数据版本号,元数据更新成功一次,版本号就自增1
private int version;
// 上一次更新元数据的时间戳
private long lastRefreshMs;
/**
* 上一次成功更新元数据的时间戳,如果每次更新都成功,
* lastSuccessfulRefreshMs应该与lastRefreshMs相同,否则lastRefreshMs > lastSuccessfulRefreshMs
*/
private long lastSuccessfulRefreshMs;
// 记录kafka集群的元数据
private Cluster cluster;
// 表示是否强制更新Cluster
private boolean needUpdate;
/* Topics with expiry time */
// 记录当前已知的所有的主题
private final Map<String, Long> topics;
// 监听器集合,用于监听Metadata更新
private final List<Listener> listeners;
// 当接收到 metadata 更新时, ClusterResourceListeners的列表
private final ClusterResourceListeners clusterResourceListeners;
// 是否需要更新全部主题的元数据
private boolean needMetadataForAllTopics;
// 默认为 true, Producer 会定时移除过期的 topic,consumer 则不会移除
private final boolean topicExpiryEnabled;
}
关于 topic 的详细信息(leader 所在节点、replica 所在节点、isr 列表)都是在 Cluster 实例中保存的。
public final class Cluster {
private final boolean isBootstrapConfigured;
// Kafka集群中的broker节点集合,这个参数代表的就是kafka的服务器的信息。
private final List<Node> nodes;
// 未授权的主题集合
private final Set<String> unauthorizedTopics;
// 内置的 topic 列表
private final Set<String> internalTopics;
private final Map<String, List<PartitionInfo>> partitionsByTopic;
// topic对应的partition信息字典,键为topic名称,值为partition信息集合;存放的partition不一定有Leader副本
private final Map<TopicPartition, PartitionInfo> partitionsByTopicPartition;
// topic中可用的partition信息字典,键为topic名称,值为可用的partition信息集合;存放的partition必须是有Leader副本的Partition
private final Map<String, List<PartitionInfo>> availablePartitionsByTopic;
// broker对应的partition信息字典,键为broker的id,值为partition信息集合
private final Map<Integer, List<PartitionInfo>> partitionsByNode;
// broker对应的Node信息字典,键为broker的id,值为表示该节点的Node实例
private final Map<Integer, Node> nodesById;
//kafka集群的id信息(不重要)
private final ClusterResource clusterResource;
}
public class PartitionInfo {
// 主题
private final String topic;
// 分区编号
private final int partition;
// 分区leader副本信息
private final Node leader;
// 全部副本信息
private final Node[] replicas;
// ISR副本信息
private final Node[] inSyncReplicas;
}
Cluster 实例主要保存:
- broker.id 与
node的对应关系;nodesById - topic 与 partition (
PartitionInfo)的对应关系;partitionsByTopicPartition node与 partition (PartitionInfo)的对应关系。partitionsByNode
对集群元数据的客户端缓存,如何根据不同的需求、使用和场景,采用不同的数据结构来进行存放,是我们需要跟kafka客户端的源码设计学习的
Product 的 Metadata更新流程
Producer 在调用 dosend() 方法时,第一步就是通过 waitOnMetadata 方法获取该 topic 的 metadata 信息.
private ClusterAndWaitTime waitOnMetadata(String topic, Integer partition, long maxWaitMs) throws InterruptedException {
// add topic to metadata topic list if it is not there already and reset expiry
// 把当前的topic存入到元数据里面
metadata.add(topic);
//我们使用的是场景驱动的方式,然后我们目前代码执行到的producer端初始化完成。
//我们知道这个cluster里面其实没有元数据,只有我们写代码的时候设置address
Cluster cluster = metadata.fetch();
//根据当前的topic从这个集群的cluster元数据信息里面查看分区的信息。
//因为我们目前是第一次执行这段代码,所以这儿肯定是没有对应的分区的信息的。
Integer partitionsCount = cluster.partitionCountForTopic(topic);
// Return cached metadata if we have it, and if the record's partition is either undefined
// or within the known partition range
//如果在元数据里面获取到了 分区的信息
//我们用场景驱动的方式,我们知道如果是第一次代码进来这儿,代码是不会运行这儿。
if (partitionsCount != null && (partition == null || partition < partitionsCount))
//直接返回cluster元数据信息,拉取元数据花的时间。
return new ClusterAndWaitTime(cluster, 0);
//如果代码执行到这儿,说明,真的需要去服务端拉取元数据。
//记录当前时间
long begin = time.milliseconds();
//剩余多少时间,默认值给的是 最多可以等待的时间。
long remainingWaitMs = maxWaitMs;
//已经花了多少时间。
long elapsed;
// Issue metadata requests until we have metadata for the topic or maxWaitTimeMs is exceeded.
// In case we already have cached metadata for the topic, but the requested partition is greater
// than expected, issue an update request only once. This is necessary in case the metadata
// is stale and the number of partitions for this topic has increased in the meantime.
// 如果没有拉取到相应主题的元数据,将会重复拉取
do {
log.trace("Requesting metadata update for topic {}.", topic);
//1)获取当前元数据的版本
//在Producer管理元数据时候,对于他来说元数据是有版本号的。
//每次成功更新元数据,都会递增这个版本号。
//把needUpdate 标识赋值为true
int version = metadata.requestUpdate();
/**
* TODO 这个步骤重要
* 我们发现这儿去唤醒sender线程。
* 其实是因为,拉取元数据这个操作是有sender线程去完成的。
* 我们知道sender线程肯定就开始进行干活了!! 至于怎么我们后面在继续分析。
*
* 这儿我告诉大家,java的线程的知识,并发的知识,大家一定要掌握。
* 没有掌握好的同学,下去补一补这方面的知识。
*/
sender.wakeup();
try {
//TODO 等待元数据
//同步的等待
//等待这sender线程获取到元数据。
metadata.awaitUpdate(version, remainingWaitMs);
} catch (TimeoutException ex) {
// Rethrow with original maxWaitMs to prevent logging exception with remainingWaitMs
throw new TimeoutException("Failed to update metadata after " + maxWaitMs + " ms.");
}
//尝试获取一下集群的元数据信息。
cluster = metadata.fetch();
//计算一下 拉取元数据已经花了多少时间
elapsed = time.milliseconds() - begin;
// 等待超过最大超时时间,直接抛出异常
if (elapsed >= maxWaitMs)
throw new TimeoutException("Failed to update metadata after " + maxWaitMs + " ms.");
//如果已经获取到了元数据,但是发现topic没有授权
if (cluster.unauthorizedTopics().contains(topic))
throw new TopicAuthorizationException(topic);
//计算出来 还可以用的时间。
remainingWaitMs = maxWaitMs - elapsed;
//尝试获取一下,我们要发送消息的这个topic对应分区的信息。
//如果这个值不为null,说明前面sender线程已经获取到了元数据了。
partitionsCount = cluster.partitionCountForTopic(topic);
} while (partitionsCount == null);
if (partition != null && partition >= partitionsCount) {
throw new KafkaException(
String.format("Invalid partition given with record: %d is not in the range [0...%d).", partition, partitionsCount));
}
// 返回一个对象
return new ClusterAndWaitTime(cluster, elapsed);
}
如果 metadata 中不存在这个 topic 的 metadata,那么就请求更新 metadata,如果 metadata 没有更新的话,方法就一直处在 do ... while 的循环之中,在循环之中,主要做以下操作:
-
metadata.requestUpdate()将 metadata 的needUpdate变量设置为 true(强制更新),并返回当前的版本号(version),通过版本号来判断 metadata 是否完成更新; -
sender.wakeup()唤醒 sender 线程,sender 线程又会去唤醒NetworkClient线程,NetworkClient线程进行一些实际的操作(后面详细介绍); -
metadata.awaitUpdate(version, remainingWaitMs)等待 metadata 的更新。public synchronized void awaitUpdate(final int lastVersion, final long maxWaitMs) throws InterruptedException { if (maxWaitMs < 0) { throw new IllegalArgumentException("Max time to wait for metadata updates should not be < 0 milli seconds"); } long begin = System.currentTimeMillis(); //看剩余可以使用的时间,一开始是最大等待的时间。 long remainingWaitMs = maxWaitMs; //version是元数据的版本号。 //如果当前的这个version小于等于上一次的version。 //说明元数据还没更新。 //因为如果sender线程那儿 更新元数据,如果更新成功了,sender线程肯定回去累加这个version。 while (this.version <= lastVersion) { //如果还有剩余的时间。 if (remainingWaitMs != 0) //让当前线程阻塞等待。 //我们这儿虽然没有去看 sender线程的源码 //但是我们知道,他那儿肯定会做这样的一个操作 //如果更新元数据成功了,会唤醒这个线程。 wait(remainingWaitMs); //如果代码执行到这儿 说明就要么就被唤醒了,要么就到点了。 //计算一下花了多少时间。 long elapsed = System.currentTimeMillis() - begin; // 超时,抛出超时异常 if (elapsed >= maxWaitMs) throw new TimeoutException("Failed to update metadata after " + maxWaitMs + " ms."); //再次计算 可以使用的时间。 remainingWaitMs = maxWaitMs - elapsed; } }
在 Metadata.awaitUpdate() 方法中,线程会阻塞在 while 循环中,直到 metadata 更新成功或者 timeout。
从前面可以看出,此时 Producer 线程会阻塞在两个 while 循环中,直到 metadata 信息更新,那么 metadata 是如何更新的呢?如果有印象的话,前面应该已经介绍过了,主要是通过 sender.wakeup() 来唤醒 sender 线程,间接唤醒 NetworkClient 线程,NetworkClient 线程来负责发送 Metadata 请求,并处理 Server 端的响应。
在 Kafka源码分析3-Producer核心流程分析 中介绍 Producer 发送模型时,在第五步 sender 线程会调用 NetworkClient.poll() 方法进行实际的操作,我们先看sender 中关于元数据更新的代码,后续详细分析sender线程,主要看sender的run方法:
void run(long now) {
/** 获取元数据
* 因为我们是根据场景驱动的方式,目前是我们第一次代码进来,
* 目前还没有获取到元数据
* 所以这个cluster里面是没有元数据
* 如果这儿没有元数据的话,这个方法里面接下来的代码就不用看了
* 是因为接下来的这些代码依赖这个元数据。
* TODO 我们直接看这个方法的最后一行代码
* 就是这行代码去拉取的元数据。
*/
/**
* 我们用场景驱动的方式,现在我们的代码是第二次进来
* 第二次进来的时候,已经有元数据了,所以cluster这儿是有元数据。
* 步骤一:
* 获取元数据
*/
Cluster cluster = metadata.fetch();
// 省略。。
/**
* 步骤八:
* 真正执行网络操作的都是这个NetWorkClient这个组件
* 包括:发送请求,接受响应(处理响应)
*/
// 我们猜这儿可能就是去建立连接。
this.client.poll(pollTimeout, now);
}
NetworkClient 的 poll 方法:
public List<ClientResponse> poll(long timeout, long now) {
/**
* 在这个方法里面有涉及到kafka的网络的方法,但是
* 目前我们还没有给大家讲kafka的网络,所以我们分析的时候
* 暂时不用分析得特别的详细,我们大概知道是如何获取到元数据
* 即可。等我们分析完了kafka的网络以后,我们在回头看这儿的代码
* 的时候,其实代码就比较简单了。
*/
//步骤一:封装了一个要拉取元数据请求
long metadataTimeout = metadataUpdater.maybeUpdate(now);
try {
//步骤二: 发送请求,进行复杂的网络操作
//但是我们目前还没有学习到kafka的网络
//所以这儿大家就只需要知道这儿会发送网络请求。
this.selector.poll(Utils.min(timeout, metadataTimeout, requestTimeoutMs));
} catch (IOException e) {
log.error("Unexpected error during I/O", e);
}
// process completed actions
long updatedNow = this.time.milliseconds();
List<ClientResponse> responses = new ArrayList<>();
//步骤三:处理响应,响应里面就会有我们需要的元数据。
handleAbortedSends(responses);
handleCompletedSends(responses, updatedNow);
/**
* 这个地方是我们在看生产者是如何获取元数据的时候,看的。
* 其实Kafak获取元数据的流程跟我们发送消息的流程是一模一样。
* 获取元数据 -> 判断网络连接是否建立好 -> 建立网络连接
* -> 发送请求(获取元数据的请求) -> 服务端发送回来响应(带了集群的元数据信息)
*/
handleCompletedReceives(responses, updatedNow);
handleDisconnections(responses, updatedNow);
handleConnections();
handleInitiateApiVersionRequests(updatedNow);
//处理超时的请求
handleTimedOutRequests(responses, updatedNow);
// invoke callbacks
for (ClientResponse response : responses) {
try {
//调用的响应的里面的我们之前发送出去的请求的回调函数
//看到了这儿,我们回头再去看一下
//我们当时发送请求的时候,是如何封装这个请求。
//不过虽然目前我们还没看到,但是我们可以大胆猜一下。
//当时封装网络请求的时候,肯定是给他绑定了一个回调函数。
response.onComplete();
} catch (Exception e) {
log.error("Uncaught error in request completion:", e);
}
}
return responses;
}
在这个方法中,主要会以下操作:
metadataUpdater.maybeUpdate(now):判断是否需要更新 Metadata,如果需要更新的话,先与 Broker 建立连接,然后发送更新 metadata 的请求;- 处理 Server 端的一些响应,这里主要讨论的是
handleCompletedReceives(responses, updatedNow)方法,它会处理 Server 端返回的 Metadata 结果。
封装请求
先看一下 metadataUpdater.maybeUpdate() 的具体实现:
public long maybeUpdate(long now) {
// should we update our metadata?
long timeToNextMetadataUpdate = metadata.timeToNextUpdate(now);
long waitForMetadataFetch = this.metadataFetchInProgress ? requestTimeoutMs : 0;
long metadataTimeout = Math.max(timeToNextMetadataUpdate, waitForMetadataFetch);
if (metadataTimeout > 0) {
return metadataTimeout;
}
// Beware that the behavior of this method and the computation of timeouts for poll() are
// highly dependent on the behavior of leastLoadedNode.
Node node = leastLoadedNode(now);
if (node == null) {
log.debug("Give up sending metadata request since no node is available");
return reconnectBackoffMs;
}
//TODO 这个里面会封装请求。
return maybeUpdate(now, node);
}
private long maybeUpdate(long now, Node node) {
String nodeConnectionId = node.idString();
//判断网络连接是否应建立好
//因为我们还没有学习kafka的网络,所以大家就认为这儿的网络是已经建立好了
if (canSendRequest(nodeConnectionId)) {
this.metadataFetchInProgress = true;
MetadataRequest.Builder metadataRequest;
if (metadata.needMetadataForAllTopics())
//封装请求(获取所有topics)的元数据信息的请求。
//但是我们一般获取元数据的时候,只获取自己要发送消息的
//对应的topic的元数据的信息
metadataRequest = MetadataRequest.Builder.allTopics();
else
//我们默认走的这儿的这个方法
//就是拉取我们发送消息的对应的topic的方法
metadataRequest = new MetadataRequest.Builder(new ArrayList<>(metadata.topics()));
log.debug("Sending metadata request {} to node {}", metadataRequest, node.id());
sendInternalMetadataRequest(metadataRequest, nodeConnectionId, now);
return requestTimeoutMs;
}
// 省略。。。
return Long.MAX_VALUE;
}
我们看一下如何封装元数据请求的:
//就是拉取我们发送消息的对应的topic的方法
metadataRequest = new MetadataRequest.Builder(new ArrayList<>(metadata.topics()));
public Builder(List<String> topics) {
super(ApiKeys.METADATA);
this.topics = topics;
}
所以,每次 Producer 请求更新 metadata 时,会有以下几种情况:
- 如果 node 可以发送请求,则直接发送请求;
- 如果该 node 正在建立连接,则直接返回;
- 如果该 node 还没建立连接,则向 broker 初始化链接。
而 KafkaProducer 线程之前是一直阻塞在两个 while 循环中,直到 metadata 更新
- sender 线程第一次调用
poll()方法时,初始化与 node 的连接; - sender 线程第二次调用
poll()方法时,发送Metadata请求; - sender 线程第三次调用
poll()方法时,获取metadataResponse,并更新 metadata。
经过上述 sender 线程三次调用 poll()方法,所请求的 metadata 信息才会得到更新,此时 Producer 线程也不会再阻塞,开始发送消息。
服务端处理元数据请求
也就是我们封装了一个ApiKeys.METADATA的请求,然后在kafka服务端进行处理,我们简单看一下是如何处理的,后面将会解析kafka服务端,KafkaApis 的handle 方法:
def handle(request: RequestChannel.Request) {
try {
trace("Handling request:%s from connection %s;securityProtocol:%s,principal:%s".
format(request.requestDesc(true), request.connectionId, request.securityProtocol, request.session.principal))
ApiKeys.forId(request.requestId) match {
/**
* 因为我们使用的是场景驱动的方式去分析源码,从生产者发送请求过来
* 我们先看这的代码
*/
// todo 处理生产者过来的请求
case ApiKeys.PRODUCE => handleProducerRequest(request)
// todo 这是follower发送过来拉取数据请求(同步数据)
case ApiKeys.FETCH => handleFetchRequest(request)
case ApiKeys.LIST_OFFSETS => handleOffsetRequest(request)
// todo 处理元数据的请求
case ApiKeys.METADATA => handleTopicMetadataRequest(request)
// 省略。。。
}
} catch {
// 省略。。。
} finally
request.apiLocalCompleteTimeMs = time.milliseconds
}
Producer 处理元数据响应
NetworkClient 接收到 Server 端对 Metadata 请求的响应后,更新 Metadata 信息。
private void handleCompletedReceives(List<ClientResponse> responses, long now) {
for (NetworkReceive receive : this.selector.completedReceives()) {
//获取broker id
String source = receive.source();
/**
* kafka 有这样的一个机制:每个连接可以容忍5个发送出去了(参数配置),但是还没接收到响应的请求。
*/
//从数据结构里面移除已经接收到响应的请求。
//把之前存入进去的请求也获取到了
InFlightRequest req = inFlightRequests.completeNext(source);
//解析服务端发送回来的请求(里面有响应的结果数据)
AbstractResponse body = parseResponse(receive.payload(), req.header);
log.trace("Completed receive from node {}, for key {}, received {}", req.destination, req.header.apiKey(), body);
//TODO 如果是关于元数据信息的响应
if (req.isInternalRequest && body instanceof MetadataResponse)
//解析完了以后就把封装成一个一个的clientResponse
//body 存储的是响应的内容
//req 发送出去的那个请求信息
metadataUpdater.handleCompletedMetadataResponse(req.header, now, (MetadataResponse) body);
else if (req.isInternalRequest && body instanceof ApiVersionsResponse)
handleApiVersionsResponse(responses, req, now, (ApiVersionsResponse) body);
else
responses.add(req.completed(body, now));
}
}
public void handleCompletedMetadataResponse(RequestHeader requestHeader, long now, MetadataResponse response) {
this.metadataFetchInProgress = false;
//响应里面会带回来元数据的信息
//获取到了从服务端拉取的集群的元数据信息。
Cluster cluster = response.cluster();
// check if any topics metadata failed to get updated
Map<String, Errors> errors = response.errors();
if (!errors.isEmpty())
log.warn("Error while fetching metadata with correlation id {} : {}", requestHeader.correlationId(), errors);
// don't update the cluster if there are no valid nodes...the topic we want may still be in the process of being
// created which means we will get errors and no nodes until it exists
//如果正常获取到了元数据的信息
if (cluster.nodes().size() > 0) {
// //更新元数据信息。
this.metadata.update(cluster, now);
} else {
log.trace("Ignoring empty metadata response with correlation id {}.", requestHeader.correlationId());
this.metadata.failedUpdate(now);
}
}
Producer Metadata 的更新策略
Metadata 会在下面两种情况下进行更新
- KafkaProducer 第一次发送消息时强制更新,其他时间周期性更新,它会通过 Metadata 的
lastRefreshMs,lastSuccessfulRefreshMs这2个字段来实现; - 强制更新: 调用
Metadata.requestUpdate()将needUpdate置成了 true 来强制更新。
在 NetworkClient 的 poll() 方法调用时,就会去检查这两种更新机制,只要达到其中一种,就行触发更新操作。
Metadata 的强制更新会在以下几种情况下进行:
initConnect方法调用时,初始化连接;poll()方法中对handleDisconnections()方法调用来处理连接断开的情况,这时会触发强制更新;poll()方法中对handleTimedOutRequests()来处理请求超时时;- 发送消息时,如果无法找到 partition 的 leader;
- 处理 Producer 响应(
handleProduceResponse),如果返回关于 Metadata 过期的异常,比如:没有 topic-partition 的相关 meta 或者 client 没有权限获取其 metadata。
强制更新主要是用于处理各种异常情况。
总结
本篇文章主要分析元数据的更新操作,可能分析的不是很全面,还需要花费时间去深刻研究;下篇我们将分析Sender 流程初探。
参考文档:
史上最详细kafka源码注释(kafka-0.10.2.0-src)
kafka技术内幕-图文详解Kafka源码设计与实现