Kafka消费者
文章目录
消费方式
Pull模式,consumer主动从broker中拉取数据【Kafka采取的是这种方式】
- Pull模式的不足之处就是如果Kafka没有数据,消费者可能会陷入循环之中,一直返回空的数据
- 可以通过参数设置,consumer拉取数据为空或者没有达到一定数量的时候进行阻塞
Push模式,Kafka没有采取这种方式,因为这种方式broker决定了消息发送的速率,很难适应所有的消费者的消费的速率
一:Kafka消费者工作流程
1:消费者总体工作流程
- 每一个消费者的offset由消费者提交都系统主题尽心保存
- 每一个分区的数据只能由消费者组中的一个消费者进行消费
- 一个消费者可以消费多个分区的数据
2:消费者组原理
消费者组(Consumer Group),由多个consumer组成,形成一个消费者组的条件就是所有的消费者的groupId是相同的
- 消费者组内的每一个消费者负责消费不同分区的数据,一个分区只能由一个组内的消费者进行消费
- 消费者组之间互不影响,所有的消费者都属于某一个消费者组,也就是消费者组是逻辑上的一个订阅者
- 如果向消费者组中添加了更多的消费者,并且consumer.size > partition.size时,有一部分消费者就会空闲,不会消费任何的消息
- 一个分区只能被一个消费者组中的消费者消费,而不是一个消费者组中的一个消费者只能消费一个分区的消息,一个消费者可能消费多个分区
2.1:消费者组初始化
- coor选一个Leader
- 告诉Leader要消费那个topic
- Leader定制消费方案
- 将消费方案发给coor
- 通知各个consumer
2.2:消息消费流程(重要)
-
创建消费者网络连接客户端
ConsumerNetworkClient
,主要用来跟kafka集群进行交互- 前期做准备工作,首先调用
sendFetches()
方法,抓取数据初始化【Fetch.min.bytes
,fetch.max.wait.ms
,Fetch.max.bytes
】 Fetch.min.bytes
,要抓取数据的大小,默认是1字节fetch.max.wait.ms
:一批数据中最大的等待时间,默认是500msFetch.max.bytes
:每一个批次的最大抓取大小,默认是50M
- 前期做准备工作,首先调用
-
send()
方法发送请求 -
发送完成之后,通过回调方法
complatedFetches(queue)
将发送结果传递给回调 -
消费者每次从回调队列中拉取指定条数的消息【默认的最大拉取条数是500条】
-
反序列化
-
经过拦截器:处理数据的核心位置 + 监控
-
处理数据
2.3:消费者重要参数
参数名称 | 描述 |
---|---|
bootstrap.servers | 向Kafka集群建立初始连接用到的host/port列表。 |
key.deserializer,value.deserializer | 指定接收消息的key和value的反序列化类型。一定要写全类名。 |
group.id(重要) | 标记消费者所属的消费者组。 |
enable.auto.commit(重要) | 默认值为true,消费者会自动周期性地向服务器提交偏移量。 |
auto.commit.interval.ms(重要) | 如果设置了 enable.auto.commit 的值为true,则该值定义了消费者偏移量向Kafka提交的频率,默认5s。 |
auto.offset.reset | 当Kafka中没有初始偏移量或当前偏移量在服务器中不存在,该如何处理? earliest:自动重置偏移量到最早的偏移量。 latest:默认,自动重置偏移量为最新的偏移量。 none:如果消费组原来的(previous)偏移量不存在,则向消费者抛异常。 anything:向消费者抛异常。 |
offsets.topic.num.partitions | __consumer_offsets的分区数,默认是50个分区。 |
heartbeat.interval.ms | Kafka消费者和coordinator之间的心跳时间,默认3s。 该条目的值必须小于session.timeout.ms ,也不应该高于session.timeout.ms的1/3。 |
session.timeout.ms | Kafka消费者和coordinator之间连接超时时间,默认45s。 超过该值,该消费者被移除,消费者组执行再平衡。 |
max.poll.interval.ms | 消费者处理消息的最大时长,默认是5分钟。 超过该值,该消费者被移除,消费者组执行再平衡。 |
fetch.min.bytes(重要) | 默认1个字节。消费者获取服务器端一批消息最小的字节数。 |
fetch.max.wait.ms(重要) | 默认500ms。 如果没有从服务器端获取到一批数据的最小字节数。该时间到,仍然会返回数据。 |
fetch.max.bytes(重要) | 默认Default: 52428800(50 m)。消费者获取服务器端一批消息最大的字节数。 如果服务器端一批次的数据大于该值(50m)仍然可以拉取回来这批数据,因此,这不是一个绝对最大值。 一批次的大小受message.max.bytes (broker config)or max.message.bytes (topic config)影响。 |
max.poll.records | 一次poll拉取数据返回消息的最大条数,默认是500条。 |
二:消费者API(用)
依赖引入部分和生产者部分参见Kafka生产者
1:独立消费者案例(订阅主题)
一个消费者组中只有一个消费者,也就是这一个消费者定义一个主题
在消费者API代码中必须配置消费者组id。
命令行启动消费者不填写消费者组id会被自动填写随机的消费者组id
公共配置工具类
package com.example.kafkademo.util;
import org.apache.kafka.clients.producer.ProducerConfig;
import java.util.Properties;
public class CommonUtils {
/**
* kafka生产者配置配置
* @return 配置内容
*/
public static Properties buildKafkaProperties() {
// 1. 创建kafka生产者配置对象
Properties properties = new Properties();
// 2. 给kafka的配置对象添加信息
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "hadoop102:9092");
// key, value初始化【必须有】
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
// =========> 添加自定义分区器 <============
// properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, "com.example.kafkademo.producer.MyPartitioner")
return properties;
}
}
独立消费者订阅主题示例
package com.example.kafkademo.consumer;
import com.example.kafkademo.util.CommonUtils;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Properties;
/**
* 独立消费者案例(订阅主题)
*/
public class SingleConsumerCustomTopic {
public static void main(String[] args) {
Properties properties = CommonUtils.buildKafkaProperties();
// 配置消费者组(组名任意起名) 必须
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
// 创建消费者对象
try (KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties)) {
// 注册要消费的主题[一个消费者可以消费多个主题]
ArrayList<String> topicList = new ArrayList<>();
topicList.add("first"); // 消费first主题
kafkaConsumer.subscribe(topicList); // 订阅主题
// 拉取数据并打印
while (true) {
// 设置1s消费一批数据
ConsumerRecords<String, String> consumerRecords = kafkaConsumer.poll(Duration.ofSeconds(1L));
// 打印消费到的数据
for (ConsumerRecord<String, String> consumerRecord : consumerRecords) {
System.out.println(consumerRecord);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
2:独立消费者案例(订阅分区)
package com.example.kafkademo.consumer;
import com.example.kafkademo.util.CommonUtils;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.TopicPartition;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
public class SingleConsumerCustomPartitions {
public static void main(String[] args) {
Properties properties = CommonUtils.buildKafkaProperties();
// 配置消费者组
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
try (KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties)) {
// ==========> 消费某个主题的某个分区的数据 <============
List<TopicPartition> topicPartitionList = new ArrayList<>();
// 指定主题和分区 + assign() -> first主题 & 0号分区
topicPartitionList.add(new TopicPartition("first", 0));
// 注意这里是assign()
kafkaConsumer.assign(topicPartitionList);
// 准备进行消费
while (true) {
ConsumerRecords<String, String> consumerRecords = kafkaConsumer.poll(Duration.ofSeconds(1L));
for (ConsumerRecord<String, String> consumerRecord : consumerRecords) {
System.out.println(consumerRecord);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
3:消费者组实例(多消费者实例)
测试同一个主题的分区数据,只能由一个消费者组中的一个消费
复制一份基础消费者的代码,在IDEA中同时启动,即可启动同一个消费者组中的两个消费者
package com.example.kafkademo.consumer;
import com.example.kafkademo.util.CommonUtils;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Properties;
/**
* 再启动一个
*/
public class SingleConsumerCustomTopicSecond {
public static void main(String[] args) {
Properties properties = CommonUtils.buildKafkaProperties();
// 配置消费者组(组名任意起名) 必须
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
// 创建消费者对象
try (KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties)) {
// 注册要消费的主题[一个消费者可以消费多个主题]
ArrayList<String> topicList = new ArrayList<>();
topicList.add("first"); // 消费first主题
kafkaConsumer.subscribe(topicList); // 订阅主题
// 拉取数据并打印
while (true) {
// 设置1s消费一批数据
ConsumerRecords<String, String> consumerRecords = kafkaConsumer.poll(Duration.ofSeconds(1L));
// 打印消费到的数据
for (ConsumerRecord<String, String> consumerRecord : consumerRecords) {
System.out.println(consumerRecord);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
启动代码中的生产者发送消息,在IDEA控制台即可看到两个消费者在消费不同分区的数据
如果只发生到一个分区,可以在发送时增加延迟代码Thread.sleep(2)
;
ConsumerRecord(topic = first, partition = 0, leaderEpoch = 2, offset = 3, CreateTime = 1629169606820, serialized key size = -1, serialized value size = 8, headers = RecordHeaders(headers = [], isReadOnly = false), key = null, value = hello1)
ConsumerRecord(topic = first, partition = 1, leaderEpoch = 3, offset = 2, CreateTime = 1629169609524, serialized key size = -1, serialized value size = 6, headers = RecordHeaders(headers = [], isReadOnly = false), key = null, value = hello2)
ConsumerRecord(topic = first, partition = 2, leaderEpoch = 3, offset = 21, CreateTime = 1629169611884, serialized key size = -1, serialized value size = 6, headers = RecordHeaders(headers = [], isReadOnly = false), key = null, value = hello3)
⚠️ 重新发送到一个全新的主题中,由于默认创建的主题分区数为1,可以看到只能有一个消费者消费到数据
三:分区的分配和再平衡
1:分配分区的前提
首先kafka设定了默认的消费逻辑:一个分区只能被同一个消费组(ConsumerGroup)内的一个消费者消费。
在这个消费逻辑设定下,假设目前某消费组内只有一个消费者C0,订阅了一个topic,这个topic包含6个分区,也就是说这个消费者C0订阅了6个分区,这时候可能会发生下列三种情况:
- 如果这时候消费者组内新增了一个消费者C1,这个时候就需要把之前分配给C0的6个分区拿出来3个分配给C1;
- 如果这时候这个topic多了一些分区,就要按照某种策略,把多出来的分区分配给C0和C1;
- 如果这时候C1消费者挂掉了或者退出了,不在消费者组里了,那所有的分区需要再次分配给C0
参数名称 | 描述 |
---|---|
heartbeat.interval.ms | Kafka消费者和coordinator之间的心跳时间,默认3s。 该条目的值必须小于session.timeout.ms,也不应该高于session.timeout.ms 的1/3。 |
session.timeout.ms | Kafka消费者和coordinator之间连接超时时间,默认45s。 超过该值,该消费者被移除,消费者组执行再平衡。 |
max.poll.interval.ms | 消费者处理消息的最大时长,默认是5分钟。 超过该值,该消费者被移除,消费者组执行再平衡。 |
partition.assignment.strategy | 消费者分区分配策略,默认策略是Range + CooperativeSticky。 Kafka可以同时使用多个分区分配策略。 可以选择的策略包括:Range、RoundRobin、Sticky、CooperativeSticky |
2:Range策略
3:RoundRobin策略
在各个消费者中修改分区策略
// 修改分区分配策略
properties.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
"org.apache.kafka.clients.consumer.RoundRobinAssignor");
4:Sticky策略
粘性分区定义:在执行一次新的分配之前,考虑上一次分配的结果,尽量少的调整分配的变动,可以节省大量的开销。
粘性分区是Kafka从0.11.x版本开始引入这种分配策略
首先会尽量均衡的放置分区到消费者上面,在出现同一消费者组内消费者出现问题的时候,会尽量保持原有分配的分区不变化。
Sticky分配策略的原理比较复杂,它的设计主要实现了两个目的:
- 分区的分配要尽可能的均匀;
- 分区的分配尽可能的与上次分配的保持相同。
如果这两个目的发生了冲突,优先实现第一个目的。
四:offset位移
1:offset的默认维护位置
-
__consumer_offsets主题里面采用key和value的方式存储数据。
-
key是group.id+topic+分区号,value就是当前offset的值。
-
每隔一段时间,kafka内部会对这个topic进行compact,也就是每个group.id+topic+分区号就保留最新数据。
消费offset案例
思想:__consumer_offsets为Kafka中的topic,那就可以通过消费者进行消费
在配置文件config/consumer.properties
中添加配置exclude.internal.topics=false
,默认是true,表示不能消费系统主题。
为了查看该系统主题数据,所以该参数修改为false
采用命令行方式,创建一个新的topic
bin/kafka-topics.sh --bootstrap-server hadoop102:9092
--create --topic kafkarep
--partitions 2
--replication-factor 2
启动生产者往kafkarep生产数据
bin/kafka-console-producer.sh --topic kafkarep --bootstrap-server hadoop102:9092
启动消费者消费kafkarep中的数据
bin/kafka-console-consumer.sh --bootstrap-server hadoop102:9092
--topic kafkarep
--group test
🎉 指定消费者组名称,就可以更好观察数据存储位置(因为key是group.id+topic+分区号)
[hadoop102 kafka]$ bin/kafka-console-consumer.sh
--topic __consumer_offsets
--bootstrap-server hadoop102:9092
--consumer.config config/consumer.properties
--formatter "kafka.coordinator.group.GroupMetadataManager\$OffsetsMessageFormatter"
--from-beginning
[offset,kafkarep,1]::OffsetAndMetadata(offset=7, leaderEpoch=Optional[0], metadata=, commitTimestamp=1622442520203, expireTimestamp=None)
[offset,kafkarep,0]::OffsetAndMetadata(offset=8, leaderEpoch=Optional[0], metadata=, commitTimestamp=1622442520203, expireTimestamp=None)
2:自动提交offset
为了使我们可以能够专注于自己的业务逻辑,Kafka提供了自动自交的offset的功能,自动提交offset的相关参数:
enable.auto.commit
:是否开启自动提交的offset功能,默认是trueauto.commit.interval.ms
:自动提交offset的时间间隔,默认是5s
参数名称 | 描述 |
---|---|
enable.auto.commit | 默认值为true,消费者会自动周期性地向服务器提交偏移量。 |
auto.commit.interval.ms | 如果设置了enable.auto.commit的值为true,则该值定义了消费者偏移量向Kafka提交的频率,默认5s。 |
// 配置消费者组
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
// 是否自动提交offset
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
// 提交offset的时间周期是1000ms, 默认是5s
properties.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 1000);
3:手动提交offset
虽然自动提交offset十分简单便利,但是由于其是基于时间提交的,开发人员很难把握offset提交的时机,因此Kafka还提供了手动提交offset的API
手动提交有两种方式:分别是同步提交和异步提交
- 同步提交(commitSync):必须等待offset提交完毕,再去消费下一批数据
- 异步提交(commitAsync):发送完成提交offset请求之后,就开始消费下一批数据了
二者的共同点是都将会提交最高的偏移量,区别是同步提交会阻塞当前的线程并可以进行重试,异步提交不会阻塞线程同时没有失败重试机制,可能提交失败
同步手动提交(commitSync)
由于同步提交offset有失败重试机制,故更加可靠,但是由于一直等待提交结果,提交的效率比较低
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties)) {
// 订阅要消费的主题
consumer.subscribe(Collections.singletonList("first"));
// 消费消息
while (true) {
ConsumerRecords<String, String> consumerRecords = consumer.poll(Duration.ofSeconds(1L));
for (ConsumerRecord<String, String> consumerRecord : consumerRecords) {
System.out.println(consumerRecord.value());
}
// 同步手动提交
consumer.commitSync();
}
} catch (Exception e) {
e.printStackTrace();
}
异步手动提交
虽然同步提交offset更可靠一些,但是由于其会阻塞当前线程,直到提交成功。因此吞吐量会受到很大的影响。
因此更多的情况下,会选用异步提交offset的方式
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties)) {
// 订阅要消费的主题
consumer.subscribe(Collections.singletonList("first"));
// 消费消息
while (true) {
ConsumerRecords<String, String> consumerRecords = consumer.poll(Duration.ofSeconds(1L));
for (ConsumerRecord<String, String> consumerRecord : consumerRecords) {
System.out.println(consumerRecord.value());
}
// 异步手动提交
consumer.commitAsync();
}
} catch (Exception e) {
e.printStackTrace();
}
4:指定Offset消费
auto.offset.reset = earliest | latest | none
默认是latest
。
当Kafka中没有初始偏移量(消费者组第一次消费)或服务器上不再存在当前偏移量时(例如该数据已被删除),该怎么办?
(1)earliest:自动将偏移量重置为最早的偏移量,–from-beginning。
(2)latest(默认值):自动将偏移量重置为最新偏移量。
(3)none:如果未找到消费者组的先前偏移量,则向消费者抛出异常
(4)任意指定offset位开始消费
核心就是先拿到有那些分区assignment = kafkaConsumer.assignment();
,然后便利这些分区,手动的kafkaConsumer.seek(指定分区,指定offset)
// 订阅要消费的主题
kafkaConsumer.subscribe(Collections.singletonList("first"));
Set<TopicPartition> assignment= new HashSet<>();
while (assignment.size() == 0) {
kafkaConsumer.poll(Duration.ofSeconds(1));
// 获取消费者分区分配信息(有了分区分配信息才能开始消费)
assignment = kafkaConsumer.assignment();
}
// 遍历所有分区,并指定offset从1700的位置开始消费
for (TopicPartition tp: assignment) {
kafkaConsumer.seek(tp, 1700);
}
指定时间消费
在生产环境中,会遇到最近消费的几个小时数据异常,想重新按照时间消费。例如要求按照时间消费前一天的数据,怎么处理
// 订阅要消费的主题
kafkaConsumer.subscribe(Collections.singletonList("first"));
// ==============> begin <==================
Set<TopicPartition> assignment = new HashSet<>();
while (assignment.size() == 0) {
kafkaConsumer.poll(Duration.ofSeconds(1));
// 获取消费者分区分配信息(有了分区分配信息才能开始消费)
assignment = kafkaConsumer.assignment();
}
HashMap<TopicPartition, Long> timestampToSearch = new HashMap<>();
// 封装集合存储,每个分区对应一天前的数据
for (TopicPartition topicPartition : assignment) {
timestampToSearch.put(topicPartition, System.currentTimeMillis() - 24 * 3600 * 1000);
}
// 获取从1天前开始消费的每个分区的offset
Map<TopicPartition, OffsetAndTimestamp> offsets = kafkaConsumer.offsetsForTimes(timestampToSearch);
// 遍历每个分区,对每个分区设置消费时间。
for (TopicPartition topicPartition : assignment) {
OffsetAndTimestamp offsetAndTimestamp = offsets.get(topicPartition);
// 根据时间指定开始消费的位置
if (offsetAndTimestamp != null){
kafkaConsumer.seek(topicPartition, offsetAndTimestamp.offset());
}
}
// ==============> end <==================
5:漏消费和重复消费
重复消费:已经消费了数据,但是offset没提交。自动commit引起的
漏消费:先提交offset后消费,有可能会造成数据的漏消费。
五:生产经验
1:消费者事务
如果想完成Consumer端的精准一次性消费,那么需要Kafka消费端将消费过程和提交offset过程做原子绑定。
此时我们需要将Kafka的offset保存到支持事务的自定义介质(比如MySQL)
2:数据积压(消费者如何提高吞吐量)
- 如果是Kafka的消费能力不足,则可以考虑增加Topic的分区数,并且同时提升消费组的消费者的数量,消费者数 = 分区数
- 如果是下游的数据处理不及时,提高每一个批次拉取的数量。如果批次拉取的数据过少(拉取数据/处理时间 < 生产速度),使得处理的数据小于生产的数据,也会造成数据积压【比如一次最多拉取500条 -> 一次最多拉取1000条】
参数名称 | 描述 |
---|---|
fetch.max.bytes | 默认Default: 52428800(50M)。消费者获取服务器端一批消息最大的字节数。 如果服务器端一批次的数据大于该值(50m)仍然可以拉取回来这批数据,因此,这不是一个绝对最大值。 一批次的大小受message.max.bytes[broker config]或 max.message.bytes[topic config]影响。 |
max.poll.records | 一次poll拉取数据返回消息的最大条数,默认是500条 |