先看两个奇奇怪怪的东西
示例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();
}
- 在上面这段代码中
- 通过比较
ignoreStr和接收到的消息内容,判断消息内容是否与指定的字符串相等。- 如果相等,抛出一个
RuntimeException异常。 - 如果不相等,表示消息内容不为空或与指定字符串不匹配,可以继续处理消息。
- 如果相等,抛出一个
- 最后,通过调用
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();
}
- 在上面这段代码中
- 通过比较
ignoreStr和接收到的消息内容,判断消息内容是否与指定的字符串相等。- 如果相等,打印错误日志后直接
return。 - 如果不相等,正常继续处理消息。
- 如果相等,打印错误日志后直接
- 最后,通过调用
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
💡 为了回答以上两个问题,我们有必要了解消费者的 ack 机制,以及消费者消费消息的流程到底是什么样的这次的结果更加令人大跌眼镜,消费者不仅仅没有像示例一一样进行重试,反而直接就处理了下一个消息,这也意味着,test 这条消息同样已经丢失了
消费者消费消息的流程
消费模式
- 在消息队列中,常见的消费模式有两种:
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发送请求,请求的响应结果会返回对应Coordinator的host、port、节点id
所有Broker在启动时,都会创建和开启相应的 Coordinator 组件。也就是说,所有Broker都有各自的Coordinator组件。那么,Consumer Group如何确定为它服务的Coordinator在哪台Broker上呢?答案就在 Kafka 内部位移主题 __consumer_offsets 身上。
目前,Kafka为某个 Consumer Group 确定 Coordinator 所在的Broker的算法有2个步骤。
- 确定由位移主题的哪个分区来保存该Group数据:partitionId = Math.abs(groupId.hashCode() % offsetsTopicPartitionCount) 。
- 找出该分区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制定分区分配方案
- 一旦一个消费者被选中作为该消费者组的
leader,Coordinator会将要消费的 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向消费者下发分区分配方案- 消费者反序列分区信息并在本地设置分区信息
如下图所示。
至此,消费者初始化完毕。终于,消费者准备拉取该分区存储的消息记录。在拉取消息记录之前,必须要明确从什么位置开始拉取。因此,需要初始化分区偏移量:消费者向
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()做了什么。(源码太长了,这里就不贴了)- 首先,代码调用
sensors.maybeUpdateAssignment(subscriptions)更新最新的分区分配情况。 - 然后,通过调用
prepareFetchRequests()方法准备要发送的抓取请求数据。构建一个fetchRequestMap,其中包含要发送的抓取请求数据和对应的目标节点(Node)。 - 接下来,遍历
fetchRequestMap,构建抓取请求,并使用client.send()方法异步发送请求到目标节点。同时,将目标节点的 ID 添加到nodesWithPendingFetchRequests集合中,表示该节点有待处理的抓取请求。 - 为每个发送的请求添加监听器(
RequestFutureListener),用于处理请求的成功或失败回调。 - 在成功回调中
- 首先将响应转换为
FetchResponse<Records>类型,然后通过目标节点的 ID 获取相应的FetchSessionHandler。如果无法找到FetchSessionHandler,则记录错误并忽略该抓取响应。 - 如果找到了对应的
FetchSessionHandler,则调用其handleResponse()方法处理响应。如果handleResponse()返回false,则表示该响应已被处理过,无需继续处理。 - 对于每个响应的分区数据,将其添加到
completedFetches集合中,并记录相关的指标信息。 - 最后,记录请求的延迟时间,并从
nodesWithPendingFetchRequests集合中移除已处理的目标节点。
- 首先将响应转换为
- 在失败回调中,如果找到了对应的
FetchSessionHandler,则调用其handleError()方法处理运行时异常。
- 首先,代码调用
- 简单来说,其核心逻辑是主要由以下三部分构成
-
通过调用 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;
-
-
按Node依次构建请求节点,并通过 client 的 send 方法将请求异步发送,当收到请求结果后会调用对应的事件监听器。
public void onSuccess(ClientResponse resp); public void onFailure(RuntimeException e);值得注意的是:在Kafka中调用client的send方法并不会真正触发网络请求,而是将请求放到发送缓冲区中,Client的poll方法才会真正触发底层网络请求。
-
当客户端收到服务端请求后会将原始结果放入到
completedFetches中,等待客户端从中解析。(在onSuccess(ClientResponse resp)中)
-
Fetcher 的 fetchedRecords
- 向服务端发送拉取请求异步返回后会将结果返回到一个
completedFetches中,也可以理解为**接收缓存区,**接下来将从缓存区中将结果解析并返回给消费者消费。从接收缓存区中解析数据的具体实现见 Fetcher 的 fetchedRecords 方法。下面简要说明以下这个方法做了什么- 若
nextInLineFetch为null或者已被消费完(isConsumed)(红色框框01)- 则获取队首的
CompletedFetch记录,并判断是否为空。若为空,则跳出循环;否则进行下一步处理(红色框框02)。这里解释一下**nextInLineFetch**nextInLineFetch: 用于跟踪下一个要处理的已完成抓取请求(CompletedFetch)的变量- 该参数的存在主要是因为引入了
maxPollRecords(默认为500),一次拉取的消息条数,一次 Fetch 操作一次每一个分区最多返回50M数据,可能包含的消息条数大于maxPollRecords。
- 则获取队首的
- 从
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次重试
- 在
org.springframework.kafka.listener.KafkaMessageListenerContainer.ListenerConsumer#doInvokeRecordListener中,我们看到,如果业务执行异常,底层会进行异常捕获并使用默认异常处理器SeekToCurrentErrorHandler进行9次重试,9次重试后还是失败,就会调用ommitOffsetsIfNeeded(record)主动提交offset,因此异常消息会在9次重试后丢失。
示例2
这次的结果更加令人大跌眼镜,消费者不仅仅没有像示例一一样进行重试,反而直接就处理了下一个消息,这也意味着,test 这条消息同样已经丢失了
假设消费者本地的 nextFetchOffset = 1555,也就是下一条要消费的 offset 为 1555
此时通过kafka客户端发送一条消息,消息内容为"test",消费者就会拉取到该消息,紧接着修改本地分区 nextFetchOffset = 1555+ 1 = 1556,消费该消息,业务报错,未执行 ack 操作,此时通过kafka客户端再发送一条消息,消息内容为 "test-6" ,由于在拉取 "test" 消息后会将本地分区nextFetchOffset 修改为 1556,那么消费者就可以正常拉取到 "test-6" 这条消息,成功消费,进而执行 ack ,所以 "test" 这条消息就丢了。
- 那么如何避免这样的问题出现呢?详见 《Kafka 位移提交》 (开个坑,后面补上!)
-
感谢所有看到这里的朋友,如果发现了文章的错误的地方或者解释得不清楚的地方,请在评论区指出哦!
-
参考博客