Kafka 消费者消费消息的流程

2,496 阅读15分钟

先看两个奇奇怪怪的东西

示例1

@KafkaListener(topics = "test", containerFactory = "kafkaListenerContainerFactory")
public void listen01(ConsumerRecord<String, String> consumerRecord, Acknowledgment ack) {
    log.info("receive message topic:{}, partition:{}, offset:{}, message:{}", consumerRecord.topic(),
            consumerRecord.partition(), consumerRecord.offset(), consumerRecord.value());
    String ignoreStr = "test";
    if (ignoreStr.equals(consumerRecord.value())) {
        throw new RuntimeException("message content is empty");
    }
    ack.acknowledge();
}
  • 在上面这段代码中
    1. 通过比较 ignoreStr 和接收到的消息内容,判断消息内容是否与指定的字符串相等。
      • 如果相等,抛出一个 RuntimeException 异常。
      • 如果不相等,表示消息内容不为空或与指定字符串不匹配,可以继续处理消息。
    2. 最后,通过调用 ack.acknowledge() 来手动确认消息
  • 接着,生产者向 Kafka 发送 10 条数据,如下
for (int i = 0; i < 10; i++) {
    ProducerRecord<String, String> record;
    if (i == 5) {
        record = new ProducerRecord<>("test", "test", "test");
    } else {
        record = new ProducerRecord<>("test", "test", "test-" + i);
    }
    producer.send(record);
}
  • 这时,在消费端会发生什么事情呢
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1551, message:test-1
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1552, message:test-2
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1553, message:test-3
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1554, message:test-4
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1556, message:test-6
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1557, message:test-7
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1558, message:test-8
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1559, message:test-9

可以看到,在 test 这条消息没有被 ack 的前提下,消费端竟然只执行了 10 次之后,就丢失了,并没有像预想的一样卡在 test 这条消息不动,也就是说 test 这条消息在没有被 ack 的情况下丢失了

示例2

public void listen02(ConsumerRecord<String, String> consumerRecord, Acknowledgment ack) {
        log.info("receive message topic:{}, partition:{}, offset:{}, message:{}", consumerRecord.topic(),
                consumerRecord.partition(), consumerRecord.offset(), consumerRecord.value());
        String ignoreStr = "test";
        if (ignoreStr.equals(consumerRecord.value())) {
            log.error("message content is empty");
						return;
        }
        ack.acknowledge();
    }
  • 在上面这段代码中
    1. 通过比较 ignoreStr 和接收到的消息内容,判断消息内容是否与指定的字符串相等。
      • 如果相等,打印错误日志后直接 return
      • 如果不相等,正常继续处理消息。
    2. 最后,通过调用 ack.acknowledge() 来手动确认消息的处理完成
  • 与示例一一样,向 Kafka 中写入 10 条数据。
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1551, message:test-1
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1552, message:test-2
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1553, message:test-3
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1554, message:test-4
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1555, message:test
ERROR com.warrior.kafka.service.ConsumerService - message content is empty
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1556, message:test-6
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1557, message:test-7
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1558, message:test-8
INFO  com.warrior.kafka.service.ConsumerService - receive message topic:test, partition:0, offset:1559, message:test-9

这次的结果更加令人大跌眼镜,消费者不仅仅没有像示例一一样进行重试,反而直接就处理了下一个消息,这也意味着,test 这条消息同样已经丢失了

💡 为了回答以上两个问题,我们有必要了解消费者的 ack 机制,以及消费者消费消息的流程到底是什么样的

消费者消费消息的流程

消费模式

  • 在消息队列中,常见的消费模式有两种:
    • poll(拉):消费者主动向服务端拉取消息。
    • push(推):服务端主动推送消息给消费者。
  • 由于推模式很难考虑到每个客户端不同的消费速率,因此kafka采用的是 poll 的模式
  • 为了避免服务端没有消息,造成消费端一直空轮询。Kafka 做了改进,如果没消息服务端就会暂时保持该请求,在一段时间内有消息再回应给客户端。
💡 了解了这一点之后,我们来看一下一个**消费者的初始化过程**是什么样子的

消费者初始化过程

找到 Coordinator

  • Kafka中,Coordinator(协调器)是指一种特殊的角色,Kafka 集群会给每一个消费者组分配一个 Group Coordinator,负责管理和协调消费者组中的消费者,分配分区给消费者,并跟踪消费者的偏移量(offset)。它还处理消费者加入(Join)和离开(Leave)消费者组的请求,并在消费者组中的消费者发生变化时重新平衡分区。
  • 因此,消费者组初始化的首要任务就是寻找自己所在组对应的 Coordinator
  • org.apache.kafka.clients.consumer.internals.ConsumerCoordinator#poll(org.apache.kafka.common.utils.Timer, boolean) 方法中会向kafka中的一个broker发送请求,请求的响应结果会返回对应Coordinatorhostport节点id

所有Broker在启动时,都会创建和开启相应的 Coordinator 组件。也就是说,所有Broker都有各自的Coordinator组件。那么,Consumer Group如何确定为它服务的Coordinator在哪台Broker上呢?答案就在 Kafka 内部位移主题 __consumer_offsets 身上。

目前,Kafka为某个 Consumer Group 确定 Coordinator 所在的Broker的算法有2个步骤。

  1. 确定由位移主题的哪个分区来保存该Group数据:partitionId = Math.abs(groupId.hashCode() % offsetsTopicPartitionCount) 。
  2. 找出该分区Leader副本所在的Broker,该 Broker 即为对应的 Coordinator 。

也即,Kafka会计算该 Group 的 group.id 参数的哈希值。比如你有个 Group 的 group.id 设置成了 “test-group”,那么它的 hashCode 值就应该是 627841412 。其次,Kafka会计算__consumer_offsets 的分区数,通常是50个分区,之后将刚才那个哈希值对分区数进行取模加求绝对值计算,即 abs(627841412 % 50) = 12。此时,我们就知道了位移主题的分区12负责保存这个Group的数据。有了分区号,算法的第2步就变得很简单了,我们只需要找出位移主题分区12的Leader副本在哪个Broker上就可以了。这个Broker,就是我们要找的Coordinator。

加入消费者组

  • 在寻找到Coordinator后,消费者会向对应的Coordinator发送加入消费者组请求
  • org.apache.kafka.clients.consumer.internals.ConsumerCoordinator#poll(org.apache.kafka.common.utils.Timer, boolean)中的ensureActiveGroup,该方法会向Coordinator发送加入消费者组请求

Coordinator响应加入消费者请求

  • Coordinator在收到消费者加入消费者组请求后,会从同一个消费者组中选择一个消费者作为leader,其余消费者作为flower

消费者leader制定分区分配方案

  • 一旦一个消费者被选中作为该消费者组的 leaderCoordinator会将要消费的 Topic 、Partition 情况发送给 leader 消费者
  • leader 消费者负责制定该分组分区分配方案,在org.apache.kafka.clients.consumer.internals.AbstractCoordinator#onJoinLeader 中的performAssignment,先寻找对应的分区分配器,根据分配器来给消费者组中的消费者分配分区,指定完分区分配方案之后,Leader 消费者将该方案发送给 Coordinator

Coordinator 将分区分配方案下发到其他消费者

  • Coordinator在收到消费者leader制定的分区分配方案后,会将该方案通知到各个消费者,告诉每个消费者应该消费哪些分区

消费者设置分区信息

  • 消费者反序列化分区信息
  • 将分区信息设置在SubscriptionState对象的assignment属性中,后续消费者拉取消息的时候会用到

总结

总结一下,消费者初始化流程如下

  • 寻找后续交互的Coordinator
  • 消费者请求加入消费者组,Coordinator从消费者中选择一个作为leader
  • leader消费者制定分区分配方案
  • Leader 将分区分配方案同步给Coordinator
  • Coordinator向消费者下发分区分配方案
  • 消费者反序列分区信息并在本地设置分区信息

如下图所示。

Untitled.png

至此,消费者初始化完毕。终于,消费者准备拉取该分区存储的消息记录。在拉取消息记录之前,必须要明确从什么位置开始拉取。因此,需要初始化分区偏移量:消费者向Coordinator询问对应的offset,得到偏移量后进行本地初始化。接下来就是要从Broker服务端将数据拉取下来,提交给消费端进行消费,对应流程中的pollForFetches方法。

消息拉取详解

概述

Kafka 拉取消息的核心设计理念是:

  • KafkaConsumer 在调用poll方法时,如果本地缓存区中(completedFeches) 存在未消费的消息,则直接从本地缓存区中拉取消息
  • 否则会调用client#send方法进行异步多线程并行发送拉取请求
    • 发往不同的 broker 节点的请求是并发执行,执行完毕后再将结果放入到poll方法所在线程中的缓存区,实现多个线程的协同

消费端拉取流程详解

  • org.apache.kafka.clients.consumer.KafkaConsumer#pollForFetches 说起
private Map<TopicPartition, List<ConsumerRecord<K, V>>> pollForFetches(Timer timer) {
				// 等待获取消费者记录的超时时间。它会根据以下条件计算得出:
				// 如果 coordinator 为空,表示消费者未加入消费者组,
				// 此时使用传入的计时器 timer 的剩余时间作为超时时间。
				// 如果 coordinator 不为空,表示消费者已经加入消费者组,
        // 此时需要计算下一次轮询的时间和传入的计时器剩余时间,选择较小的值作为超时时间。
        long pollTimeout = coordinator == null ? timer.remainingMs() :
                Math.min(coordinator.timeToNextPoll(timer.currentTimeMs()), timer.remainingMs());

        // 调用 fetcher.fetchedRecords() 方法获取已经从 Kafka 集群获取到的消费者记录,
        // 并将其保存在 records 变量中。如果已经有记录可用,则直接返回 records。
        final Map<TopicPartition, List<ConsumerRecord<K, V>>> records = fetcher.fetchedRecords();
        if (!records.isEmpty()) {
            return records;
        }

        // 没有可用的记录,将调用 fetcher.sendFetches() 方法发送新的获取请求
        fetcher.sendFetches();

        // We do not want to be stuck blocking in poll if we are missing some positions
        // since the offset lookup may be backing off after a failure

        // NOTE: the use of cachedSubscriptionHashAllFetchPositions means we MUST call
        // updateAssignmentMetadataIfNeeded before this method.
        if (!cachedSubscriptionHashAllFetchPositions && pollTimeout > retryBackoffMs) {
            pollTimeout = retryBackoffMs;
        }

        log.trace("Polling for fetches with timeout {}", pollTimeout);

        Timer pollTimer = time.timer(pollTimeout);
				// 在指定的超时时间内等待可用的消费者记录到达。
				// 它会不断轮询检查是否有新的记录可用,直到达到超时时间或有新的记录可用为止
        client.poll(pollTimer, () -> {
            // since a fetch might be completed by the background thread, we need this poll condition
            // to ensure that we do not block unnecessarily in poll()
            return !fetcher.hasAvailableFetches();
        });
        timer.update(pollTimer.currentTimeMs());
				// 返回从 Kafka 集群获取到的消费者记录
        return fetcher.fetchedRecords();
    }
  • pollForFetches() 是消息拉取的实现入口,其核心步骤如下
    • 等待获取消费者记录的超时时间
    • 拉取缓存区中解析已异步拉取的消息。
    • 没有可用的记录,将调用 fetcher.sendFetches() 向Broker发送拉取请求,该请求是一个异步请求
    • 轮询,尝试从缓存区中解析已拉起的消息。
    • 返回从 Kafka 集群获取到的消费者记录

Fetcher sendFetches()

  • 前面我们说到,缓冲区中没有可用的记录,将调用 fetcher.sendFetches() 向Broker发送拉取请求,该请求是一个异步请求,下面我们来看一下 sendFeatches() 做了什么。(源码太长了,这里就不贴了)
    1. 首先,代码调用 sensors.maybeUpdateAssignment(subscriptions) 更新最新的分区分配情况。
    2. 然后,通过调用 prepareFetchRequests() 方法准备要发送的抓取请求数据。构建一个 fetchRequestMap,其中包含要发送的抓取请求数据和对应的目标节点(Node)。
    3. 接下来,遍历 fetchRequestMap ,构建抓取请求,并使用 client.send() 方法异步发送请求到目标节点。同时,将目标节点的 ID 添加到 nodesWithPendingFetchRequests 集合中,表示该节点有待处理的抓取请求。
    4. 为每个发送的请求添加监听器(RequestFutureListener),用于处理请求的成功或失败回调。
    5. 在成功回调中
      1. 首先将响应转换为 FetchResponse<Records> 类型,然后通过目标节点的 ID 获取相应的 FetchSessionHandler。如果无法找到 FetchSessionHandler,则记录错误并忽略该抓取响应。
      2. 如果找到了对应的 FetchSessionHandler,则调用其 handleResponse() 方法处理响应。如果 handleResponse() 返回 false,则表示该响应已被处理过,无需继续处理。
      3. 对于每个响应的分区数据,将其添加到 completedFetches 集合中,并记录相关的指标信息。
      4. 最后,记录请求的延迟时间,并从 nodesWithPendingFetchRequests 集合中移除已处理的目标节点。
    6. 在失败回调中,如果找到了对应的 FetchSessionHandler,则调用其 handleError() 方法处理运行时异常。
  • 简单来说,其核心逻辑是主要由以下三部分构成
    1. 通过调用 preparefetchRequest,构建请求对象,请求对象为一个 map,其 key 和 value 如下

      • 键(Key):Node 对象,表示目标节点

      • 值(Values): FetchSessionHandler.FetchRequestData 对象,FetchRequestData 的结构如下:

        /**
         * The partitions to send in the fetch request.
         * 键是要发送的主题分区,值是该分区的抓取数据信息 
         */
        private final Map<TopicPartition, PartitionData> toSend;
        
        /**
         * The partitions to send in the request's "forget" list.
         * 要在请求中标记为 "forget" 的主题分区(当消费者无需再继续获取某个分区的消息时,可以将该分区标记为 "forget"。)
         */
        private final List<TopicPartition> toForget;
        
        /**
         * All of the partitions which exist in the fetch request session.
         * 
         */
        private final Map<TopicPartition, PartitionData> sessionPartitions;
        
        /**
         * The metadata to use in this fetch request.
         * 示在这个抓取请求中要使用的元数据。包含了与请求相关的元数据信息,如消费者组、分配策略等。
         */
        private final FetchMetadata metadata;
        
    2. 按Node依次构建请求节点,并通过 client 的 send 方法将请求异步发送,当收到请求结果后会调用对应的事件监听器。

      public void onSuccess(ClientResponse resp);
      public void onFailure(RuntimeException e);
      

      值得注意的是:在Kafka中调用client的send方法并不会真正触发网络请求,而是将请求放到发送缓冲区中,Client的poll方法才会真正触发底层网络请求。

    3. 当客户端收到服务端请求后会将原始结果放入到 completedFetches 中,等待客户端从中解析。(在 onSuccess(ClientResponse resp) 中)

FetcherfetchedRecords

Untitled 1.png

  • 向服务端发送拉取请求异步返回后会将结果返回到一个completedFetches 中,也可以理解为**接收缓存区,**接下来将从缓存区中将结果解析并返回给消费者消费。从接收缓存区中解析数据的具体实现见 Fetcher 的 fetchedRecords 方法。下面简要说明以下这个方法做了什么
    1. nextInLineFetch null 或者已被消费完isConsumed)(红色框框01)
      1. 则获取队首的 CompletedFetch 记录,并判断是否为空。若为空,则跳出循环;否则进行下一步处理(红色框框02)。这里解释一下**nextInLineFetch**
        1. nextInLineFetch : 用于跟踪下一个要处理的已完成抓取请求(CompletedFetch)的变量
        2. 该参数的存在主要是因为引入了 maxPollRecords (默认为500),一次拉取的消息条数,一次 Fetch 操作一次每一个分区最多返回50M数据,可能包含的消息条数大于maxPollRecords。
    2. nextInLineFetch 中解析数据(红色框框03)

nextInLineFetch 中解析数据

一路追下去,我们终于在 org.apache.kafka.clients.consumer.internals.Fetcher.CompletedFetch#fetchRecords() 发现了我们熟悉的身影ConsumerRecord<K, V>,也就是消费者方法参数中的ConsumerRecord<String, String> consumerRecord (下面不是完整代码,保留了核心逻辑)

private List<ConsumerRecord<K, V>> fetchRecords(int maxRecords) {
	List<ConsumerRecord<K, V>> records = new ArrayList<>();
	    for (int i = 0; i < maxRecords; i++) {
	        if (cachedRecordException == null) {
	            corruptLastRecord = true;
	            lastRecord = nextFetchedRecord();
	            corruptLastRecord = false;
	        }
	        if (lastRecord == null)
	            break;
					// 解析消息记录,放入集合中
	        records.add(parseRecord(partition, currentBatch, lastRecord));
	        recordsRead++;
	        bytesRead += lastRecord.sizeInBytes();
					// nextFetchOffset = 最后一条消息记录offset + 1
	        nextFetchOffset = lastRecord.offset() + 1;
	        cachedRecordException = null;
	    }
	return records;
}
  • 在源码中可以看到每次拉取完消息后,消费者会将分区本地 nextFetchOffset 设置为最后一条消息对应 offset + 1,这一点对回答文章开头的问题至关重要,我们将在下一个 Part 详细解释

消费消息

在拉取到消息后,对消息记录进行消费,源码如下,

private void doInvokeWithRecords(final ConsumerRecords<K, V> records) {
			Iterator<ConsumerRecord<K, V>> iterator = records.iterator();
			while (iterator.hasNext()) {
				if (this.stopImmediate && !isRunning()) {
					break;
				}
				final ConsumerRecord<K, V> record = checkEarlyIntercept(iterator.next());
				if (record == null) {
					continue;
				}
				this.logger.trace(() -> "Processing " + ListenerUtils.recordToString(record));
				doInvokeRecordListener(record, iterator);
				if (this.nackSleep >= 0) {
					handleNack(records, record);
					break;
				}
			}
		}
  • 其实就是对消息记录迭代遍历,然后使用doInvokeRecordListener(record, iterator);调用对应被@KafkaListener注解标注的方法

至此,消费者拉取消息、消费消息的流程梳理完毕!

回到最初的问题

现在,让我们来解答一下文章开头的两个问题

示例1

test 这条消息没有被 ack 的前提下,为什么在不捕获异常的情况下会对消息进行重试,消费端竟然只重试了 9 次之后,就丢失了,并没有像预想的一样卡在 test 这条消息不动,也就是说 test 这条消息在没有被 ack 的情况下丢失了

  • org.springframework.kafka.listener.KafkaMessageListenerContainer.ListenerConsumer我们看到在没有定义异常处理器的情况下会默认使用SeekToCurrentErrorHandler异常处理器
  • SeekToCurrentErrorHandler异常处理器的注释中我们看到该异常处理器会进行无时间间隔的9次重试

Untitled 2.png

  • org.springframework.kafka.listener.KafkaMessageListenerContainer.ListenerConsumer#doInvokeRecordListener 中,我们看到,如果业务执行异常,底层会进行异常捕获并使用默认异常处理器SeekToCurrentErrorHandler进行9次重试,9次重试后还是失败,就会调用ommitOffsetsIfNeeded(record)主动提交offset,因此异常消息会在9次重试后丢失。

Untitled 3.png

示例2

这次的结果更加令人大跌眼镜,消费者不仅仅没有像示例一一样进行重试,反而直接就处理了下一个消息,这也意味着,test 这条消息同样已经丢失了

假设消费者本地的 nextFetchOffset = 1555,也就是下一条要消费的 offset 为 1555

此时通过kafka客户端发送一条消息,消息内容为"test",消费者就会拉取到该消息,紧接着修改本地分区 nextFetchOffset = 1555+ 1 = 1556,消费该消息,业务报错,未执行 ack 操作,此时通过kafka客户端再发送一条消息,消息内容为 "test-6" ,由于在拉取 "test" 消息后会将本地分区nextFetchOffset 修改为 1556,那么消费者就可以正常拉取到 "test-6" 这条消息,成功消费,进而执行 ack ,所以 "test" 这条消息就丢了。