Kafka源码分析4-元数据更新机制

2,352 阅读13分钟

欢迎大家关注 github.com/hsfxuebao/j… ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

书接上文Kafka源码分析3-Producer核心流程分析,本篇文章重点分析元数据更新机制。

在上一篇文章中,已经介绍了 Producer 的发送模型,Producer dosend() 方法中的第一步,就是获取相关的 topic 的 metadata,但在上篇中并没有深入展开,因为这部分的内容比较多,所以本文单独一篇文章进行介绍,本文主要来讲述以下三个问题:

  1. metadata 内容是什么;
  2. Producer 更新 metadata 的流程;
  3. 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 实例主要保存:

  1. broker.id 与 node 的对应关系;nodesById
  2. topic 与 partition (PartitionInfo)的对应关系; partitionsByTopicPartition
  3. 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 的循环之中,在循环之中,主要做以下操作:

  1. metadata.requestUpdate() 将 metadata 的 needUpdate 变量设置为 true(强制更新),并返回当前的版本号(version),通过版本号来判断 metadata 是否完成更新;

  2. sender.wakeup() 唤醒 sender 线程,sender 线程又会去唤醒 NetworkClient 线程,NetworkClient 线程进行一些实际的操作(后面详细介绍);

  3. 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线程,主要看senderrun方法:

void run(long now) {

    /** 获取元数据
     *    因为我们是根据场景驱动的方式,目前是我们第一次代码进来,
     *    目前还没有获取到元数据
     *    所以这个cluster里面是没有元数据
     *    如果这儿没有元数据的话,这个方法里面接下来的代码就不用看了
     *    是因为接下来的这些代码依赖这个元数据。
     *    TODO 我们直接看这个方法的最后一行代码
     *    就是这行代码去拉取的元数据。
     */
    /**
     * 我们用场景驱动的方式,现在我们的代码是第二次进来
     * 第二次进来的时候,已经有元数据了,所以cluster这儿是有元数据。
     * 步骤一:
     *      获取元数据
     */
    Cluster cluster = metadata.fetch();
    // 省略。。   

    /**
     * 步骤八:
     * 真正执行网络操作的都是这个NetWorkClient这个组件
     * 包括:发送请求,接受响应(处理响应)
     */
    // 我们猜这儿可能就是去建立连接。
    this.client.poll(pollTimeout, now);
}

NetworkClientpoll 方法:

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 时,会有以下几种情况:

  1. 如果 node 可以发送请求,则直接发送请求;
  2. 如果该 node 正在建立连接,则直接返回;
  3. 如果该 node 还没建立连接,则向 broker 初始化链接。

而 KafkaProducer 线程之前是一直阻塞在两个 while 循环中,直到 metadata 更新

  1. sender 线程第一次调用 poll() 方法时,初始化与 node 的连接;
  2. sender 线程第二次调用 poll() 方法时,发送 Metadata 请求;
  3. sender 线程第三次调用 poll() 方法时,获取 metadataResponse,并更新 metadata。

经过上述 sender 线程三次调用 poll()方法,所请求的 metadata 信息才会得到更新,此时 Producer 线程也不会再阻塞,开始发送消息。

服务端处理元数据请求

也就是我们封装了一个ApiKeys.METADATA的请求,然后在kafka服务端进行处理,我们简单看一下是如何处理的,后面将会解析kafka服务端,KafkaApishandle 方法:

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 会在下面两种情况下进行更新

  1. KafkaProducer 第一次发送消息时强制更新,其他时间周期性更新,它会通过 Metadata 的 lastRefreshMs, lastSuccessfulRefreshMs 这2个字段来实现;
  2. 强制更新: 调用 Metadata.requestUpdate()needUpdate 置成了 true 来强制更新。

在 NetworkClient 的 poll() 方法调用时,就会去检查这两种更新机制,只要达到其中一种,就行触发更新操作。

Metadata 的强制更新会在以下几种情况下进行:

  1. initConnect 方法调用时,初始化连接;
  2. poll() 方法中对 handleDisconnections() 方法调用来处理连接断开的情况,这时会触发强制更新;
  3. poll() 方法中对 handleTimedOutRequests() 来处理请求超时时;
  4. 发送消息时,如果无法找到 partition 的 leader;
  5. 处理 Producer 响应(handleProduceResponse),如果返回关于 Metadata 过期的异常,比如:没有 topic-partition 的相关 meta 或者 client 没有权限获取其 metadata。

强制更新主要是用于处理各种异常情况。

总结

本篇文章主要分析元数据的更新操作,可能分析的不是很全面,还需要花费时间去深刻研究;下篇我们将分析Sender 流程初探。

参考文档:

史上最详细kafka源码注释(kafka-0.10.2.0-src)

kafka技术内幕-图文详解Kafka源码设计与实现

Kafka 源码分析系列