图解 Kafka 生产者元数据拉取管理全流程(下)

justtouch
发布于 2022-8-9 16:56
浏览
0收藏

02.3 初探 cluster
github 源码地址如下:

https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/common/Cluster.java

https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/common/Node.java

https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/common/PartitionInfo.java

https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/common/TopicPartition.java

public final class Cluster {
    private final boolean isBootstrapConfigured;
    // kafka集群中的broker节点
    private final List<Node> nodes;
    // 未授权的主题集合
    private final Set<String> unauthorizedTopics;
    // 无效的主题集合
    private final Set<String> invalidTopics;
    // 内部主题集合
    private final Set<String> internalTopics;
    // controller 节点
    private final Node controller;
    // topic对应的 partition 信息字典,存放的 partition 不一定有 Leader 副本, 键为topic,值为 partition 信息集合。
    private final Map<TopicPartition, PartitionInfo> partitionsByTopicPartition;
    // 键为topic,值为可用 partition 信息集合,存放的 partition 一定有 Leader 副本
    private final Map<String, List<PartitionInfo>> partitionsByTopic;
    // 键为broker的id,值为partition 信息集合
    private final Map<String, List<PartitionInfo>> availablePartitionsByTopic;
    // 键为broker的id,值为表示该节点的node实例
    private final Map<Integer, List<PartitionInfo>> partitionsByNode;
    private final Map<Integer, Node> nodesById;
    private final ClusterResource clusterResource;

Cluster类基本保存了所有的kafka集群相关的信息。

 

Node broker节点的相关的信息

 public class Node {
    // broker 节点id
    private final int id;
    // broker 节点id的字符串
    private final String idString;
    // broker 节点的地址用于socket连接
    private final String host;
    // 端口
    private final int port;
    // broker 节点的机架
    private final String rack;
    // 初始化节点属性
    public Node(int id, String host, int port, String rack) {
        this.id = id;
        this.idString = Integer.toString(id);
        this.host = host;
        this.port = port;
        this.rack = rack;
    }
 }

 

PartitionInfo 分区信息

public class PartitionInfo {
    // 主题
    private final String topic;
    // 分区编号id
    private final int partition;
    // 分区 Leader 副本信息,唯一进行通信的节点
    private final Node leader;
    // 全部副本信息
    private final Node[] replicas;
    // ISR 副本信息, follower角色
    private final Node[] inSyncReplicas;
    // 离线副本信息
    private final Node[] offlineReplicas;
    // 初始化分区信息
    public PartitionInfo(String topic,
                         int partition,
                         Node leader,
                         Node[] replicas,
                         Node[] inSyncReplicas,
                         Node[] offlineReplicas) {
        this.topic = topic;
        this.partition = partition;
        this.leader = leader;
        this.replicas = replicas;
        this.inSyncReplicas = inSyncReplicas;
        this.offlineReplicas = offlineReplicas;
    }
 }

TopicPartition
每个topic和每个分区组成的唯一索引,代表一个分区标识。

public final class TopicPartition implements Serializable {
    // hash值,用来hashCode方法缓存
    private int hash = 0;
    // 分区编号
    private final int partition;
    // 主题名称
    private final String topic;
    // 初始化主题分区索引
    public TopicPartition(String topic, int partition) {
        this.partition = partition;
        this.topic = topic;
    }
    // 计算hash值
    public int hashCode() {
        if (hash != 0)
            return hash;
        final int prime = 31;
        int result = 1;
        result = prime * result + partition;
        result = prime * result + Objects.hashCode(topic);
        this.hash = result;
        return result;
    }
 }

综上映射关系可以看出,kafka 是以「分区」为最小管理单元,然后分区中的 Leader 负责交互。「Cluster」代表整个kafka集群的实体类,「MetaData」的角色相当于「Cluster」的在客户端的一个维护者。

 

02.4 主线程加载元数据
我们先来看加载元数据大体过程。

图解 Kafka 生产者元数据拉取管理全流程(下)-鸿蒙开发者社区
接下来分析详细源码,首先客户端可以直接调用「 producer.send() 」进行发送,底层调用 doSend() 方法。

// 向 topic 异步地发送数据,当发送确认后唤起回调函数
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
   ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record);
   // 调用 doSend 发送,支持回调函数
   return doSend(interceptedRecord, callback);
}

private Future<RecordMetadata> doSend(ProducerRecord<K, V> record, Callback callback) {
        TopicPartition tp = null;
        try {
            // 假如 sender 线程为空或者不运行,报错
            throwIfProducerClosed();
            // 当前时间【毫秒】 
            long nowMs = time.milliseconds();
            ClusterAndWaitTime clusterAndWaitTime;
            try {
                // 等待元数据更新
                clusterAndWaitTime = waitOnMetadata(record.topic(), record.partition(), nowMs, maxBlockTimeMs);
            } catch (KafkaException e) {
                if (metadata.isClosed())
                    throw new KafkaException("Producer closed while send in progress", e);
                throw e;
            }
            nowMs += clusterAndWaitTime.waitedOnMetadataMs;
            long remainingWaitMs = Math.max(0, maxBlockTimeMs - clusterAndWaitTime.waitedOnMetadataMs);
            Cluster cluster = clusterAndWaitTime.cluster;
            ....
            // 将消息记录追加到消息缓冲区
            RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey,
                    serializedValue, headers, interceptCallback, remainingWaitMs, true, nowMs);
                    
            // 当批次满了或者新批次被创建了,开始唤醒sender线程数据发送
            if (result.batchIsFull || result.newBatchCreated) {
                this.sender.wakeup();
            }
            return result.future;
        } catch (ApiException e) {
            ....
            return new FutureFailure(e);
        } catch (InterruptedException e) {
            ....
            throw new InterruptException(e);
        } catch (KafkaException e) {
            ....
            throw e;
        } catch (Exception e) {
            ....
            throw e;
        }
 }

从上述发送的源码中,可以看出来主线程在发送消息前需要先获取元数据,这样才能知道消息要发送到哪些节点。

中间通过调用 waitOnMetadata() 获取元数据的相关信息,为后续发送消息提供支持,接下来我们重点分析下 waitOnMetadata() 这个方法。

private ClusterAndWaitTime waitOnMetadata(String topic, Integer partition, long nowMs, long maxWaitMs) throws InterruptedException {
        // 1. 从元数据缓存中获取元数据
        Cluster cluster = metadata.fetch();
        // 2. 判断该主题是否是无效的主题
        if (cluster.invalidTopics().contains(topic))
            throw new InvalidTopicException(topic);
        // 3. 将该主题放入元数据主题列表中
        metadata.add(topic, nowMs);
        // 4. 从元数据缓存中获取主题对应的分区数
        Integer partitionsCount = cluster.partitionCountForTopic(topic);
        // 5. 满足这些条件后就不用拉取元数据,直接从元数据缓存中返回
        if (partitionsCount != null && (partition == null || partition < partitionsCount))
            // 返回元数据信息
            return new ClusterAndWaitTime(cluster, 0);

        long remainingWaitMs = maxWaitMs;
        long elapsed = 0;
        // 6. 不停的轮询唤醒 sender 线程更新元数据
        do {
            // 7. 将主题 topic及过期时间添加到元数据主题列表中
            metadata.add(topic, nowMs + elapsed);
            // 8. 标记元数据更新标识,获取元数据版本号
            int version = metadata.requestUpdateForTopic(topic);
            // 9. 唤醒 sender 子线程
            sender.wakeup();
            try {
                // 10. 阻塞线程等待元数据更新成功
                metadata.awaitUpdate(version, remainingWaitMs);
            } catch (TimeoutException ex) {
                ...
            }
            // 11. 到这里,应该是当前cluster更新成功了,再次获取元数据
            cluster = metadata.fetch();
            // 12. 计算等待更新完成元数据消耗时间
            elapsed = time.milliseconds() - nowMs;
            // 13. 如果超时,抛超时异常
            if (elapsed >= maxWaitMs) {
               ...
            }
            metadata.maybeThrowExceptionForTopic(topic);
            remainingWaitMs = maxWaitMs - elapsed;
            // 14. 获取元数据分区数
            partitionsCount = cluster.partitionCountForTopic(topic);
        } while (partitionsCount == null || (partition != null && partition >= partitionsCount));
        // 15. 返回元数据以及消耗时间
        return new ClusterAndWaitTime(cluster, elapsed);
 }

该方法用来获取元数据以及元数据消耗时间,我们具体分析下主流程的逻辑:

1. 从元数据缓存中获取元数据,如果metadata不存在当前topic的元数据,会触发一次强制刷新,metaData中的needUpdate置为true。
2. 判断该主题是否是无效的主题, 如果是抛异常。
3. 将该主题放入元数据的主题列表中,通过 Sender 线程定时更新这些主题的元数据。
4. 从元数据缓存中获取主题对应的分区数。
5. 判断元数据缓存是否能满足需要,就是说要能够找到要发送消息的主题分区,条件是:partitionsCount != null && (partition == null || partition < partitionsCount)  即「主题对应的分区数不能为空且发送的分区ID要小于主题的分区数」。
6. 不停的轮询唤醒 sender 线程更新元数据,这里有两个条件要满足其一才可以。
●  partitionsCount == null 「从元数据缓存中获取主题对应的分区数为空」,这里要发送的主题连Leader分区都没有,可能主题分区根本不存在也可能没拉取到最新分区信息,如果真的不存在就没必要继续直接抛异常。
●  partition != null && partition >= partitionsCount「分区不为空且要发送的分区ID大于主题分区数」,这里说明了主题分区的数量增加了,需要重新拉取下获取最新分区信息。
7. 将主题 topic及过期时间添加到元数据主题列表中
8. 标记元数据更新标识,并获取当前元数据版本号,提醒 Sender 线程更新元数据。
9. 唤醒 sender 子线程进行元数据更新以及消息发送。
10. 阻塞主线程并等待Sender线程更新元数据成功。
11. 待 「Sender 子线程更新元数据成功」或者「阻塞超时解除阻塞」,再次获取元数据。
12. 计算等待更新完成元数据消耗时间。
13. 如果超时,抛超时异常。maxWaitMs 最大1分钟。
14. 获取元数据分区数。
15. 返回元数据以及消耗时间。

聪明的读者可能发现在生产者中获取元数据都是基于topic的,主要原因就是对于生产者来说,没必要拉取全部的元数据,只拉取自己需要的主题元数据就可以了。

至此,元数据加载已经分析完了, 接下来我们看下 Sender 子线程是如何拉取元数据的。

03 Sender 线程拉取元数据
由于 Sender 线程的深度分析不是本文的重点,这里先简单的给大家带一带,后续会有专门篇章去分析。

github 源码地址如下:

https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/clients/producer/internals/Sender.java

https://github.com/apache/kafka/blob/2.7/clients/src/main/java/org/apache/kafka/clients/NetworkClient.java

我们来看看 Sender 线程拉取元数据的大体过程。

图解 Kafka 生产者元数据拉取管理全流程(下)-鸿蒙开发者社区图解 Kafka 生产者元数据拉取管理全流程(下)-鸿蒙开发者社区
接下来我们详细分析具体的源码。

03.1 Sender 周期执行
通过源码可以得知 Sender 是一个 Runnable 对象,那整个 Sender 线程执行的核心逻辑就在 run() 方法中,run() 方法中的第一段代码就是循环调用 runOnce() 方法:

 public class Sender implements Runnable {
     public void run() {
        // running 字段用来标识当前 Sender 线程是否正常执行
        while (running) {
            try {
                // 如果正常运行,则执行运行周期
                runOnce();
            } catch (Exception e) {
                log.error("Uncaught error in kafka producer I/O thread: ", e);
            }
        }
    }
    
    void runOnce() {
        .....  // 此处省略事务消息相关的处理逻辑
        long currentTimeMs = time.milliseconds();
        // 创建发送到 kafka 集群的请求
        long pollTimeout = sendProducerData(currentTimeMs);
        // 真正执行网络IO的地方,会将请求发送出去,并处理收到的响应
        client.poll(pollTimeout, currentTimeMs);
    }
 }

上述源码中的 runOnce() 方法是 Sender 线程一个执行的周期,在这个周期中会进行一次批量的请求发送,并进行一次响应的处理。

接下来我们看里面2个涉及到元数据的重要方法:

03.1.1 sendProducerData()

 private long sendProducerData(long now) {
        // 从元数据缓存中获取元数据
        Cluster cluster = metadata.fetch();
        // 通过元数据cluster获取要发送的节点 Leader 分区信息
        RecordAccumulator.ReadyCheckResult result = this.accumulator.ready(cluster, now);
        // 如果主题的 Leader 分区对应的节点不存在就强制更新元数据
        if (!result.unknownLeaderTopics.isEmpty()) {
            for (String topic : result.unknownLeaderTopics)
                // 将主题加入元数据主题列表
                this.metadata.add(topic, now);
            // 强制标记元数据更新标识
            this.metadata.requestUpdate();
        }
      ..... // 忽略能发送的节点数据
  }

该方法用来进行 Sender 线程创建请求的核心,这里只分析下元数据涉及的部分:

1. 从元数据缓存中获取元数据。
2. 通过元数据cluster获取要发送的节点 Leader 分区信息。
3. 如果主题的 Leader 分区对应的节点不存在就强制更新元数据
●  循环无 Leader 分区的主题,将主题加入元数据主题列表。
●  强制标记元数据更新标识。
●  对于无 Leader 分区的主题,可能分区正在选主中,也可能 Leader 分区所在节点 Crash,所以要强制更新元数据保证元数据一致性。

03.1.2 client.poll()

public class NetworkClient implements KafkaClient {
   public List<ClientResponse> poll(long timeout, long now) {
        ....
        // 尝试更新元数据
        long metadataTimeout = metadataUpdater.maybeUpdate(now);
        try {
            // 执行IO操作
            this.selector.poll(Utils.min(timeout, metadataTimeout, defaultRequestTimeoutMs));
        } catch (IOException e) {
            log.error("Unexpected error during I/O", e);
        }
        ....
        List<ClientResponse> responses = new ArrayList<>();
        // 处理更新命令的返回
        handleCompletedReceives(responses, updatedNow);
        return responses;
   }
}

该方法用来发送网络IO请求,在请求之前先执行尝试更新元数据的请求。

03.2 元数据更新触发时机
03.2.1 metadataUpdater.maybeUpdate()

 public class NetworkClient implements KafkaClient {
    private NetworkClient(MetadataUpdater metadataUpdater,
                          Metadata metadata,
                          Selectable selector,
                          String clientId,
                          int maxInFlightRequestsPerConnection,
                          long reconnectBackoffMs,
                          long reconnectBackoffMax,
                          int socketSendBuffer,
                          int socketReceiveBuffer,
                          int defaultRequestTimeoutMs,
                          long connectionSetupTimeoutMs,
                          long connectionSetupTimeoutMaxMs,
                          ClientDnsLookup clientDnsLookup,
                          Time time,
                          boolean discoverBrokerVersions,
                          ApiVersions apiVersions,
                          Sensor throttleTimeSensor,
                          LogContext logContext) {
       
        if (metadataUpdater == null) {
            // 通过这里可以看出 metadataUpdate 实例化对象
            this.metadataUpdater = new DefaultMetadataUpdater(metadata);
        } else {
            this.metadataUpdater = metadataUpdater;
        }
        ...
    } 
 }

从初始化函数中可以得出元数据更新组件是调用 NetworkClient 内部类即 DefaultMetadataUpdater,前面已多次提到更新集群元数据的场景,而这些更新操作都是在标记集群元数据是否需要更新,而真正执行更新的操作是这里。接下来我们看下这个类的 maybeUpdate() 方法。

03.2.2 public maybeUpdate()

 public long maybeUpdate(long now) {
      // 计算下次要更新元数据的时间,其中会检测needUpdate的值、退避时间、是否长时间未更新
      long timeToNextMetadataUpdate = metadata.timeToNextUpdate(now);
      // 检测是否已经发送了元数据的请求,如果一个元数据的请求,还未从服务端返回,那么时间设置为 waitForMetadataFetch(默认30s)
      long waitForMetadataFetch = hasFetchInProgress() ? defaultRequestTimeoutMs : 0;
      // 计算元数据超时时间
      long metadataTimeout = Math.max(timeToNextMetadataUpdate, waitForMetadataFetch);
      if (metadataTimeout > 0) {
          return metadataTimeout;
      }
      // 表示需要立即更新,取最空闲的节点node
      Node node = leastLoadedNode(now);
      // 发送更新元数据的请求     
      return maybeUpdate(now, node);
  }

从这段源码中可以看出上来先计算下次要更新元数据的时间,我们看下这个计算过程。

public class Metadata implements Closeable {
  // 计算下次更新元数据的时间
  public synchronized long timeToNextUpdate(long nowMs) {
      long timeToExpire = updateRequested() ? 0 : Math.max(this.lastSuccessfulRefreshMs + this.metadataExpireMs - nowMs, 0);
      return Math.max(timeToExpire, timeToAllowUpdate(nowMs));
  }
  
  // 判断这2个标识是否为true
  public synchronized boolean updateRequested() {
      return this.needFullUpdate || this.needPartialUpdate;
  }
  // 计算允许更新元数据的时机
  public synchronized long timeToAllowUpdate(long nowMs) {
      return Math.max(this.lastRefreshMs + this.refreshBackoffMs - nowMs, 0);
  }
}

1. 元数据是否过期 timeToExpire,计算方式:
●  首先会通过 updateRequested() 方法检查 Metadata 中的 「needFullUpdate」「needPartialUpdate」,如果这两个标识位为 true,表示 Metadata 需要立即更新,
●  否则计算上次更新成功的时间距离当前时间是否已经超过了指定的元数据过期时间阈值metadataExpireMs「默认5分钟」。
2. 允许更新的时间点 timeToAllowUpdate,计算方式:
●  上次更新时间 + 退避时间 - 当前时间的间隔 「要求上次更新时间与当前时间的间隔不能大于退避时间,如果大于则需要等待」。
3. 最后计算这俩值的最大值作为下次更新元数据的时间。

分析完元数据更新时机,最后调用 maybeUpdate(now, node) 发送更新元数据的请求。

03.2.3 private maybeUpdate()

private long maybeUpdate(long now, Node node) {
      String nodeConnectionId = node.idString();
      // 判断当前node的状态是否可以发送Request请求
      if (canSendRequest(nodeConnectionId, now)) {
          // 构建元数据请求
          Metadata.MetadataRequestAndVersion requestAndVersion = metadata.newMetadataRequestAndVersion(now);
          MetadataRequest.Builder metadataRequest = requestAndVersion.requestBuilder;
          // 向 nodeConnectionId 发送元数据请求
          sendInternalMetadataRequest(metadataRequest, nodeConnectionId, now);
          inProgress = new InProgressData(requestAndVersion.requestVersion, requestAndVersion.isPartialUpdate);
          return defaultRequestTimeoutMs;
      }
      // 判断Node是否正在连接
      if (isAnyNodeConnecting()) {
          return reconnectBackoffMs;
      }
      // 如果存在可用的Node,则尝试初始化连接
      if (connectionStates.canConnect(nodeConnectionId, now)) {
          // 初始化与node的连接
          initiateConnect(node, now);
          return reconnectBackoffMs;
      }
      // 阻塞等待有新的节点可用
      return Long.MAX_VALUE;
  }

该方法用来发送更新元数据的请求,具体的实现逻辑如下:

1. canSendRequest() 判断当前node的状态是否可以发送Request请求, 如果可以发送则构建元数据请求,调用 sendInternalMetadataRequest() 向 nodeConnectionId 发送元数据请求。
2. isAnyNodeConnecting() 如果该 node 正在建立连接,则直接返回重新连接超时时间,等待更新成功。
3. connectionStates.canConnect() 如果存在可用的Node,则尝试初始化连接,返回重新连接超时时间,等待更新成功。
4. 阻塞等待有新的节点可用。

从上述源码可以看出:更新流程一直在重试,直到元数据更新成功为止。

1. Sender 子线程第一次调用 poll() 方法时,尝试初始化与 node 的连接。
2. Sender 子线程第二次调用 poll() 方法时,发送 Metadata 请求。
3. Sender 子线程会阻塞等待一定时间,当有响应返回时则获取 metadataResponse,并更新 metadata。

如果元数据更新成功以后,KafkaProducer 主线程就不会被阻塞,当 NetworkClient 接收到服务端对 Metadata 请求的响应后,就会更新 Metadata 信息, 即 poll() 方法后续的操作。

03.2.4 handleCompletedReceives()

  private void handleCompletedReceives(List<ClientResponse> responses, long now) {
    for (NetworkReceive receive : this.selector.completedReceives()) {
        ....
        // 如果是MetadataResponse类的响应,由metadataUpdater来处理
        if (req.isInternalRequest && response instanceof MetadataResponse)
          // 处理成功返回的响应信息
          metadataUpdater.handleSuccessfulResponse(req.header, now, (MetadataResponse) response);
        ....
   }
 }

该方法用来判断成功接收服务端返回的响应,根据不同的响应返回做不同的操作,这里只看下跟元数据更新有关的逻辑。

03.2.5 handleSuccessfulResponse()

 public void handleSuccessfulResponse(RequestHeader requestHeader, long now, MetadataResponse response) {
     ....
    // Check if any topic's 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);
    // 判断broker信息是否为空,如果为空表示没有获得元数据
    if (response.brokers().isEmpty()) {
        // 更新失败
        this.metadata.failedUpdate(now);
    } else {
        // 如果成功 则开始更新元数据
        this.metadata.update(inProgress.requestVersion, response, inProgress.isPartialUpdate, now);
    }
    inProgress = null;
 }
 
 // 元数据更新失败
 public synchronized void failedUpdate(long now) {
     // 最后一次更新元数据的时间为当前时间
     this.lastRefreshMs = now;
 }

该方法用来对成功返回信息进行处理,主要是对元数据信息的更新。

1. 查看 response 返回信息的 error。
2. 判断broker信息是否为空
●  如果为空表示没有获得元数据即更新失败了,然后调用 failedUpdate(now)方法记录 lastRefreshMs 为当前时间,不允许立即更新元数据。
●  如果不为空表示获取元数据成功,则调用 MetaData.update() 更新元数据。

至此,Sender 子线程拉取并更新元数据分析完毕。

最后通过一张图来描述整个元数据拉取的全过程:

图解 Kafka 生产者元数据拉取管理全流程(下)-鸿蒙开发者社区
05 总结
这里,我们一起来总结一下这篇文章的重点。

1、通过「场景驱动」的方式从元数据的使用场景出发,抛出主线程加载元数据和子线程拉取元数据的过程是怎样的?

2、带你梳理了「主线程如何加载元数据源码全貌」,包括 ProducerMetadata 类、Metadata 元数据基类、cluster类的几个重要方法源码分析,最后分析主线程加载元数据过程。

3、又带你梳理了「Sender 线程拉取元数据」,包括 Sender 周期执行、元数据更新触发时机的几个重要方法源码分析。

3、最后通过一张元数据流程图来勾勒出元数据拉取和更新的全貌。

下一篇我们来深度剖析「Kafka NIO实现」,大家期待,我们下期见。

本文转载自公众号捉虫大师

分类
标签
已于2022-8-9 16:56:54修改
收藏
回复
举报
回复
    相关推荐