Fetcher: KafkaConsumer消息消费的管理者

来源:互联网 发布:知乎如何添加好友 编辑:程序博客网 时间:2024/06/12 08:55

我们在客户端使用KafkaConsumer类进行Kafka消息的消费,其实KafkaConsumer是将创建消费请求、接收响应的操作全部交给了Fetcher去处理。我们从KafkaConsumer.poll()方法进入,解析Fetcher的工作流程。
在我们看具体实现以前,不妨来了解一下每一次调用KafkaConsumer.poll(long timeout)方法消费消息的时候,Kafka的基本工作流程:

poll(timeout){    根据poll(timeout)参数,估算剩余时间    while(还有剩余时间)      从Fetcher端拉取消费到的消息      if(消息数量不为空)         创建发送请求         立刻将请求发送      else         return      end   //if ends      计算剩余时间    end  //while ends}

从上述伪代码可以看到,在超时时间到达之前,KafkaConsumer会反复通过调用KafkaConsumer.poll()进行消息的拉取,其实这次消息的获取是上一次请求的返回数据,同时,每一次poll请求,KafkaConsumer都会顺便再一次发送请求以便下一次poll操作能够直接获取返回结果。
看到这里,肯定有人会问,每次poll完成以后都再一次发送请求,那是否会让每一次poll()的执行时间延长?答案是否定的,请求的发送是异步执行的。这个可以通过ConsumerNetworkClient.send()方法看出,读者可自行阅读代码。

    public ConsumerRecords<K, V> poll(long timeout) {        acquire();//确保只有一个唯一线程调用poll方法        try {            if (timeout < 0)                throw new IllegalArgumentException("Timeout must not be negative");            // poll for new data until the timeout expires            long start = time.milliseconds();            long remaining = timeout;            do {                //进行一次消费操作                Map<TopicPartition, List<ConsumerRecord<K, V>>> records = pollOnce(remaining);                if (!records.isEmpty()) {                    fetcher.sendFetches();//在请求到数据以后,顺便发送下一次请求,由于请求是异步发送,因此并不会影响本次消息消费的效率                    client.pollNoWakeup();//发送一个poll请求,并且是立刻返回的,因为timeout=0                    if (this.interceptors == null)                        return new ConsumerRecords<>(records);                    else                        return this.interceptors.onConsume(new ConsumerRecords<>(records));                }                long elapsed = time.milliseconds() - start;//计算剩余可用的时间                remaining = timeout - elapsed;            } while (remaining > 0);            return ConsumerRecords.empty();        } finally {            release();        }    }

在保证超时时间没有到达的前提下,通过调用pollOnce()来进行一次消息的拉取,其实是调用一次Fetcher.fetchedRecords()方法取出已经收到的Kafka消息:

    /**     * 进行一次消费操作,如果这次操作直接在fetcher已经存在,则直接返回这些已经完成的结果,而如果fetcher没有返回任何结果,则会强行进行一次poll操作。     */    private Map<TopicPartition, List<ConsumerRecord<K, V>>> pollOnce(long timeout) {        // TODO: Sub-requests should take into account the poll timeout (KAFKA-1894)        //确认服务端的GroupCoordinator已经获取并且已经能够接受请求        coordinator.ensureCoordinatorReady();        // ensure we have partitions assigned if we expect to        //确认已经完成了分区分配        if (subscriptions.partitionsAutoAssigned())            coordinator.ensurePartitionAssignment();        // fetch positions if we have partitions we're subscribed to that we        // don't know the offset for        if (!subscriptions.hasAllFetchPositions())            updateFetchPositions(this.subscriptions.missingFetchPositions());        long now = time.milliseconds();        // execute delayed tasks (e.g. autocommits and heartbeats) prior to fetching records        //执行heartbeat任务或者自动提交offset任务        client.executeDelayedTasks(now);        // init any new fetches (won't resend pending fetches)        Map<TopicPartition, List<ConsumerRecord<K, V>>> records = fetcher.fetchedRecords();//直接获取已经收到的数据        // if data is available already, e.g. from a previous network client poll() call to commit,        // then just return it immediately        if (!records.isEmpty())            return records;        //如果没有接收到任何一条消息,则真正地发送fetch请求        fetcher.sendFetches();        client.poll(timeout, now);        return fetcher.fetchedRecords();    }

pollOnce()的基本执行逻辑,就是首先确保远程的GroupCoordinator是正常并且已经连接的状态。在这里我需要解释一下Kafka的两种类型的Coordinator:

  • ConsumerCoordinator:客户端角色,每一个客户端的Consumer都会有一个ConsumerCoordinator与之对应,ConsumerCoordinator负责代理这个Consumer与远程的GroupCoordinator进行沟通,比如joinGroup、针对自己在group中的leader或者follower身份进行不同的操作(必须被选举为leader的ConsumerCoordinator会获取整个group的消费者的订阅情况然后进行分区分派,follower身份的ConsumerCoordinator只需要被动接受分派给自己的分区)
  • GroupCoordinator:服务端角色,每一个Group的管理者,用来管理这个Group中所有的ConsumerCoordinator,比如leader的选举。注意,我们必须把group leader选举和分区分派区别开。一个ConsumerCoordinator在进行joinGroup操作的时候,GroupCoordinator会从所有的ConsumerCoordinator选举出来一个Leader,然后Leader进行分区分派,即为Group中的所有ConsumerCoordinator分派分区。即分区分派其实是在客户端进行,而不是服务端进行。

pollOnce()开始时,首先需要确认消费消息以前的所有准备工作已经做完,包括:

  1. 已经确认远程的GroupCoordinator:在初始化状态下,一个Consumer并不清楚自己所在的Group对应的GroupCoordinator会在哪台Kafka Server上,因此会选择一个Kafka Server,发送请求获取GroupCoordinator
  2. 已经完成joinGroup操作:在获取了GroupCoordinator的身份以后,会进行joinGroup操作。GroupCoordinator会从所有的ConsumerCoordinator中选举一个作为这个group的leader,剩余的作为follower。因此需要确认自己已经成功进行了joinGroup操作。
  3. 已经完成了分区分派:在joinGroup操作中被选举为leader的ConsumerCoordinator会负责进行分区分派,即将group中所有topic的每个分区分派给对应的ConsumerCoordinator进行消费,因此需要确认。

通过coordinator.ensureCoordinatorReady();确认GroupCoordinator的身份已经明确并且可以接收请求。如果发现GroupCoordinator还没有准备好,则该方法会一直block直到其处于ready的状态:

  /**     * Block until the coordinator for this group is known and is ready to receive requests.     * 等待直到我们和服务端的GroupCoordinator取得连接     */    public void ensureCoordinatorReady() {        while (coordinatorUnknown()) {//无法获取GroupCoordinator            RequestFuture<Void> future = sendGroupCoordinatorRequest();//发送请求            client.poll(future);//同步等待异步调用的结果            if (future.failed()) {                if (future.isRetriable())                    client.awaitMetadataUpdate();                else                    throw future.exception();            } else if (coordinator != null && client.connectionFailed(coordinator)) {                // we found the coordinator, but the connection has failed, so mark                // it dead and backoff before retrying discovery                coordinatorDead();                time.sleep(retryBackoffMs);//等待一段时间,然后重试            }        }    }

同时,通过 coordinator.ensurePartitionAssignment();确认已经成功加入了group并且分派给自己的分区都是正常的。

当确认了自己与GroupCoordinator的所有状态都正常,在正式获取数据之前,还会对已经到达运行时间的定时任务执行。这种定时任务主要包括两种:

  1. 心跳任务:心跳任务(HeartbeatTask)用来告知GroupCoordinator自己还活着。如果GroupCoordinator长期没有收到心跳,将会认为这个ConsumerCordinator已经退出,从而通过rebalance来将ConsumerCoordinator从group中移除。
  2. offset提交任务:offset提交任务(AutoCommitTask)是当用户设置了consumer的offset提交模式为自动提交以后,用来告知远程的ConsumerCoordinator自己已经消费到的消息位置。每次提交,都会运行AutoCommitTask.run()方法,同时,AutoCommitTask.run()中,会调用AutoCommitTask.reschedule()再次提交一个任务,从而实现这个定时任务的不断提交,即offset的不断提交。

注意,这两种定时任务在Kafka上叫做delayedTask,即可以 容忍适当延迟 的任务。客户端每次执行poll操作,都会检查这些延迟任务的执行时间是否已经到了,如果到了就执行。同时,我们看到,远程的GroupCoordinator是通过心跳来判断ConsumerCoordinator的心跳来判断ConsumerCoordinator是否还活着,而心跳信息只有在poll()被调用的时候发出,因此,如果我们在两次相邻地poll之间的时间超过阈值,GroupCoordinator会认为ConsumerCoordinator已经消失并进行rebalance操作。咋大多数情况下,无论Kafka的代码多么的健壮,一次rebalce都会是一次不稳定因素,是应该竭力避免的行为。因此,我们应该通过合理设置一下两个参数,来竭力避免两次poll相邻时间过长导致的rebalance:

  1. max.poll.records:合理设置每次poll的消息消费数量,如果数量过多,导致一次poll操作返回的消息记录无法在指定时间内完成,则会出发rebalance;
  2. max.poll.interval.ms:尽力保证一次poll的消息能够很快完成,无论我们的业务代码在拿到poll()的结果之后做了什么操作,比如需要存入hdfs、需要存入hive、关系型数据库,都需要对消耗的时间进行预估,保证时间不会太长;

在执行完了中的延迟任务以后,开始调用fetcher.fetchedRecords();获取数据。上面已经说过,这次获取的数据是上一次poll发出的请求所返回的数据,因此是直接从内存中获取的已有数据:

    public Map<TopicPartition, List<ConsumerRecord<K, V>>> fetchedRecords() {        if (this.subscriptions.partitionAssignmentNeeded()) {//是否需要重新进行分区分配            return Collections.emptyMap();//返回空结果        } else {            //保存返回结果,key为TopicPartition,value为这个TopicPartition的所有消费到到数据            Map<TopicPartition, List<ConsumerRecord<K, V>>> drained = new HashMap<>();            int recordsRemaining = maxPollRecords;            //从方法sendFetches可以看到,每一个CompletedFetch的一条数据,是某个TopicPartition的一批数据            Iterator<CompletedFetch> completedFetchesIterator = completedFetches.iterator();//遍历已经返回的结果            while (recordsRemaining > 0) {//计算剩余可以poll的消息量                if (nextInLineRecords == null || nextInLineRecords.isEmpty()) {//第一次进入循环                    if (!completedFetchesIterator.hasNext())                        break;                    CompletedFetch completion = completedFetchesIterator.next();                    completedFetchesIterator.remove();                    //将字节消息转换成ConsumerRecord对象                    nextInLineRecords = parseFetchedData(completion);                } else {                    //将数据从nextInLineRecords中取出,放入到drained中,并且清空nextInLineRecords,更新offset                    recordsRemaining -= append(drained, nextInLineRecords, recordsRemaining);                }            }            return drained;        }    }

fetchedRecords()方法中,通过不停地迭代遍历保存了已完成的消费请求所返回到数据的List<CompletedFetch> completedFetches,从中取出CompletedFetch,但是由于CompletedFetch中保存是返回的原始字节码数据,因此会将字节码翻译为数据对象,依照数据的TopicPartition,存入到Map<TopicPartition, List<ConsumerRecord<K, V>>> drained中。当消息数量已经不小于用户配置的最大消费消息数量,活着当前completedFetches已经没有了数据,则循环退出,返回数据。其中比较重要的方法是private int append(Map<TopicPartition, List<ConsumerRecord<K, V>>> drained,PartitionRecords<K, V> partitionRecords, int maxRecords)方法:

   private int append(Map<TopicPartition, List<ConsumerRecord<K, V>>> drained,                       PartitionRecords<K, V> partitionRecords,                       int maxRecords) {        if (partitionRecords.isEmpty())            return 0;        if (!subscriptions.isAssigned(partitionRecords.partition)) {//判断是否是分配给自己的分区            // this can happen when a rebalance happened before fetched records are returned to the consumer's poll call            log.debug("Not returning fetched records for partition {} since it is no longer assigned", partitionRecords.partition);        } else {//是自己的分区            // note that the consumed position should always be available as long as the partition is still assigned            long position = subscriptions.position(partitionRecords.partition);//当前的分区消费位置            //当且仅当1.这个分区的确是分派给这个consumer 2当前不是pause状态 3.当前存在合法的分区位置,这个分区才会是fetchable            if (!subscriptions.isFetchable(partitionRecords.partition)) {                // this can happen when a partition is paused before fetched records are returned to the consumer's poll call                log.debug("Not returning fetched records for assigned partition {} since it is no longer fetchable", partitionRecords.partition);            } else if (partitionRecords.fetchOffset == position) {//分区位置校验通过                // we are ensured to have at least one record since we already checked for emptiness                List<ConsumerRecord<K, V>> partRecords = partitionRecords.take(maxRecords);                long nextOffset = partRecords.get(partRecords.size() - 1).offset() + 1;//下一个offset是当前收到的最后一条消息的offset+1                log.trace("Returning fetched records at offset {} for assigned partition {} and update " +                        "position to {}", position, partitionRecords.partition, nextOffset);                //将这一批数据保存到map中                List<ConsumerRecord<K, V>> records = drained.get(partitionRecords.partition);                if (records == null) {                    records = partRecords;                    drained.put(partitionRecords.partition, records);                } else {                    records.addAll(partRecords);                }                //更新offset                subscriptions.position(partitionRecords.partition, nextOffset);                return partRecords.size();            } else {                // these records aren't next in line based on the last consumed position, ignore them                // they must be from an obsolete request                log.debug("Ignoring fetched records for {} at offset {} since the current position is {}",                        partitionRecords.partition, partitionRecords.fetchOffset, position);            }        }        partitionRecords.discard();        return 0;    }

这个方法等职责比较关键,核心任务是把返回的一批数据按照TopicPartition归类,存入Map<TopicPartition, List<ConsumerRecord<K, V>>> drained作为最终返回数据,同时,还进行了数据校验:

  • 对于每条数据,校验数据所在的分区是不是分派给自己的分区,因为所有Consumer只有权利消费自己订阅的并且在分区分派时的确分派给了自己的分区;
  • 判断这个分区处于fetchable状态,判断标准是:
    • 这个分区的确是分派给这个consumer;
    • 当前不是pause状态,pause的发生是显式调用KafkaConsuer.pause()方法,用来暂停消费;
    • 当前存在合法的分区位置,所谓合法,即Consumer端记录的上次的消费位置是存在的,而不是空的;
  • 分区位置严格校验:Kafka客户端本地保存了上一次消费的最后一条消息的下一个offset值,因此,在正常情况下,本次请求的一批记录的第一条的offset值,必须等于该值,如果不等于,则忽略数据。

当所有校验通过,则将数据保存在drained中作为最终返回结果,同时,通过subscriptions.position(partitionRecords.partition, nextOffset);更新本地保存的该TopicPartition对应的分区位置为nextOffset:
从上述代码:long nextOffset = partRecords.get(partRecords.size() - 1).offset() + 1;nextoffset是下一条消息的offset值。


在上文中,我们从KafkaConsumer.poll(timeout)方法为入口,分析了消费者如何通过Fetcher进行消息消费的。我们说过,每次消息消费,都是上一次请求对应的返回结果,是从内存中直接获取的请求。因此,现在我们来看看每一次的消费请求是如何发出的。

其实,从poll(timeout)的代码可以看到,每次消费完数据,都会通过Fetcher.sendFetches()顺带发送下一次的消费请求:

    public void sendFetches() {        //调用createFetchRequests创建发送请求,然后逐个请求发送到远程broker        for (Map.Entry<Node, FetchRequest> fetchEntry: createFetchRequests().entrySet()) {            final FetchRequest request = fetchEntry.getValue();//request是对某个节点上的某个TopicPartition的请求数据            //ConsumerNetworkClient.send会将请求放到unsend中            client.send(fetchEntry.getKey(), ApiKeys.FETCH, request)                    .addListener(new RequestFutureListener<ClientResponse>() {                        @Override                        public void onSuccess(ClientResponse resp) {                            FetchResponse response = new FetchResponse(resp.responseBody());                            //获取这一批响应数据中的所有的TopicPartition                            Set<TopicPartition> partitions = new HashSet<>(response.responseData().keySet());                            FetchResponseMetricAggregator metricAggregator = new FetchResponseMetricAggregator(sensors, partitions);                            //对响应数据进行遍历                            for (Map.Entry<TopicPartition, FetchResponse.PartitionData> entry : response.responseData().entrySet()) {                                TopicPartition partition = entry.getKey();                                long fetchOffset = request.fetchData().get(partition).offset;//请求发送的时候这个TopicPartition的offset                              FetchResponse.PartitionData fetchData = entry.getValue();//fetchData中存放了这个TopicPartition所返回的数据                                completedFetches.add(new CompletedFetch(partition, fetchOffset, fetchData, metricAggregator));                            }         sensors.fetchLatency.record(resp.requestLatencyMs());             sensors.fetchThrottleTimeSensor.record(response.getThrottleTime());                        }                        @Override                        public void onFailure(RuntimeException e) {                            log.debug("Fetch failed", e);                        }                    });        }    }

sendFetches()方法通过createFetchRequests()来创建请求,然后,将请求通过ConsumerNetworkClient.send()逐渐发送出去。ApiKeys.FETCH 代表了请求类型为数据请求,即消费请求,除了数据消费请求,还有各种其它请求,都是通过ConsumerNetworkClient.send()发送到远程的,比如:

ApiKeys.PRODUCE 生产消息的请求ApiKeys.METADATA:获取服务器元数据的请求ApiKeys.JOIN_GROUP:加入到group的请求ApiKeys.LEAVE_GROUP:离开group请求ApiKeys.SYNC_GROUP:同步group信息的请求ApiKeys.HEARTBEAT:心跳请求ApiKeys.OFFSET_COMMIT:提交offset的请求ApiKeys.OFFSET_FETCH:获取远程offset的请求

client.send(fetchEntry.getKey(), ApiKeys.FETCH, request)
.addListener()
是通过异步回调的方式来处理返回结果,通过定义一个实现了RequestFutureListener的匿名实现类,实现了收到相应成功或者失败以后的回调:

public interface RequestFutureListener<T> {    void onSuccess(T value);    void onFailure(RuntimeException e);}

当成功收到相应,会将消息经过处理放入到List<CompletedFetch> completedFetches中。上文已经说过,Fetcher.fetchedRecords就是从completedFetches获取消息的。

同时,我们一起来看看Fetcher是如何创建数据消费请求的:

    /**     * Create fetch requests for all nodes for which we have assigned partitions     * that have no existing requests in flight.     * 创建fetch请求,这个请求的key是node,value是一个FetchRequest对象,这个对象封装了对这个节点上的一个或者多个TopicPartition的数据获取请求     */    private Map<Node, FetchRequest> createFetchRequests() {        // create the fetch info        Cluster cluster = metadata.fetch();        //fetchable的key是节点,value是在这个节点上所有TopicPartition的请求信息        Map<Node, Map<TopicPartition, FetchRequest.PartitionData>> fetchable = new HashMap<>();        for (TopicPartition partition : fetchablePartitions()) {//对于每一个partition            Node node = cluster.leaderFor(partition);//查看这个partition的leader节点            if (node == null) {                metadata.requestUpdate();//node是空,则重新更新元数据            } else if (this.client.pendingRequestCount(node) == 0) {//如果这个节点上的pending请求为0,pending既包括in-flight,也包括unsent                // if there is a leader and no in-flight requests, issue a new fetch                Map<TopicPartition, FetchRequest.PartitionData> fetch = fetchable.get(node);                if (fetch == null) {                    fetch = new HashMap<>();                    fetchable.put(node, fetch);                }                long position = this.subscriptions.position(partition);                //将当前的offset信息、请求数据的大小放入request中                fetch.put(partition, new FetchRequest.PartitionData(position, this.fetchSize));//将每个partition的请求保存                log.trace("Added fetch request for partition {} at offset {}", partition, position);            }        }        // create the fetches        Map<Node, FetchRequest> requests = new HashMap<>();        for (Map.Entry<Node, Map<TopicPartition, FetchRequest.PartitionData>> entry : fetchable.entrySet()) {            Node node = entry.getKey();            FetchRequest fetch = new FetchRequest(this.maxWaitMs, this.minBytes, entry.getValue());            requests.put(node, fetch);        }        return requests;    }

createFetchRequests()的执行伪代码:

获取集群元数据获取所有的fetchablePartitionsfor(每一个fetchablePartition){  获取这个partition的leader node  if(无法获取lead node信息)      发送元数据更新请求  else    {       创建对这个节点的数据获取请求,保存在一个Map中    }}请求创建完毕,保存在Map中,返回这个Map

createFetchRequests会获取所谓fetchablePartitions,那么,究竟哪些TopicPartition被认为是fetchable的呢?
我们一起来看 :

    private Set<TopicPartition> fetchablePartitions() {        Set<TopicPartition> fetchable = subscriptions.fetchablePartitions();        //从fetchedRecords()方法中可以看到,nextInLineRecords代表正在进行处理的返回结果        if (nextInLineRecords != null && !nextInLineRecords.isEmpty())            fetchable.remove(nextInLineRecords.partition);        //completedFetches代表已经取回的等待消费的数据        for (CompletedFetch completedFetch : completedFetches)            fetchable.remove(completedFetch.partition);        return fetchable;    }
  • 首先,当然,这个TopicPartition必须是分派给自己的TopicPartition
  • 这个TopicPartition不是处于paused状态
  • 这个TopicPartition有合法的position,即对于这个TopicPartition有合法的消费位置offset的记录
  • 这个TopicPartition在Fetcher对象里面不存在已经取回但是还没被消费的数据

以上就是KafkaConsumer委托Fetcher创建消费请求、获取消费数据的基本流程,其实涉及到比较多的东西,包括通过ConsumerCoordinator代理自己与远程的GroupCoordinator进行沟通,进入和离开Group,分区的分派,通过ConsumerNetworkClient负责底层的网络通信,通过SubscriptionState对象维护本地的TopicPartition的信息,获取到消息以后的校验,通过定时任务进行自动offset提交,通过定时任务进行心跳以报告活性等等。有兴趣的读者可以自行详细阅读代码。我将会有更多的博客来对本过程涉及到的其他方面进行专门的介绍。

虽然Kafka的核心代码在Server端,但是从Consumer或者Producer端进入,基本上可以看到整个消息通信的基本逻辑、设计和业务流程。Consumer端的代码在保证高效、节点网络流量的负载均衡以及客户端和服务端所有状态的一致性、单线程方面做了大量非常好的设计和解决方案,同时,通过ConsumerGroup的概念、Topic订阅的概念、基于Master/Slave设计的Group责任制(一个Group只有一个Consumer会被选举为Group Leader,剩余未Follower)、基于Master/Slave设计的TopicPartition责任制(对于每一个TopicPartition,只有一个Consumer会被选举为Leader,剩余作为Repliation),使得Kafka的消息系统具有非常棒的轻松横向扩展性,分布式环境下也有了很好的数据一致性(所有TopicParition的请求都发往这个TopicParition 的leader),这是我非常喜欢Kafka的一个重要原因。当然,这也对服务端的Leader角色提出了非常高的并发性。后面我们会介绍基于Reactor模式的设计,Kafka Server能够很好处理高并发响应、多任务处理的切换等。

原创粉丝点击