Kafka Java客户端里的消费者
生产消费流程
基础概念
消费者群组
多个消费者可以组成消费者群组,一个群组里的消费者订阅的都是同一个主题,每个消费者接收主题某个分区的消息。
往消费者群组里增加消费者是进行横向伸缩能力的主要方式。
但是,一个群组里消费者数量超过了主题的分区数量,多出来的消费者是没有用处的。
如果是多个应用程序,需要从同一个主题中读取数据,只要保证每个应用程序有自己的消费者群组就行了。
订阅
创建消费者后,使用 subscribe 方法订阅主题,这个方法接受一个主题列表为参数,也可以接受一个正则表达式为参数;正则表达式同样也匹配多个主题。如果新创建了新主题,并且主题名字和正则表达式匹配,那么会立即触发一次再均衡,消费者就可以读取新添加的主题。
consumer.subscribe(Collections.singletonList("hello-topic"));
拉取
为了不断的获取消息,我们要在循环中不断的进行轮询,也就是不停调用 poll 方法。
poll 方法的参数为超时时间,控制 poll 方法的阻塞时间,它会让消费者在指定的毫秒数内一直等待 broker 返回数据。poll 方法将会返回一个记录(消息)列表,每一条记录都包含了记录所属的主题信息,记录所在分区信息,记录在分区里的偏移量,以及记录的键值对。
while(true){
//TODO 拉取(新版本)
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for(ConsumerRecord<String, String> record:records){
System.out.println(String.format("topic:%s,分区:%d,偏移量:%d," + "key:%s,value:%s",record.topic(),record.partition(),
record.offset(),record.key(),record.value()));
}
//自动提交
}
poll 方法不仅仅只是获取数据,在新消费者第一次调用时,它会负责查找群组,加入群组,接受分配的分区。
提交和偏移量
当我们调用 poll 方法的时候,broker 返回的是生产者写入 kafka ,即将被读取的记录,消费者可以使用 Kafka 来追踪消息在分区里的位置,我们称之为偏移量。消费者更新自己读取到哪个消息的操作,我们称之为提交(默认是自动提交)。
消费者是如何提交偏移量的呢?消费者会往一个叫做 __consumer_offsets 的特殊主题发送一个清息,里面会包括每个分区的偏移量。
核心概念
多线程安全问题
Kafkaconsumer 的实现不是线程安全的,所以我们在多线程的环境下,使用 Kafkaconsumer 的实例要小心,应该每个消费数据的线程拥有自己的 Kafkaconsumer 实例。
点击 这里 查看示例。
群组协调
消费者要加入群组时,会向群组协调器发送一个 JoinGroup 请求,第一个加入群主的消费者成为群主。
群主会获得群组的成员列表,并负责给每一个消费者分配分区。分配完毕后,群主把分配情况发送给群组协调器,协调器再把这些信息发送给所有的消费者,每个消费者只能看到自己的分配信息,只有群主知道群组里所有消费者的分配信息。群组协调的工作会在消费者发生变化(新加入或者掉线),主题中分区发生了变化(增加)时发生。
分区再均衡
当消费者群组里的消费者发生变化,或者主题里的分区发生了变化,都会导致再均衡现象的发生。
本质上是消费者的分区所有权的变化。
再均衡对 Kafka很 重要,这是消费者群组带来高可用性和伸缩性的关键所在。不过一般情况下,尽量减少再均衡,因为再均衡期间,消费者是无法读取消息的,会造成整个群组一小段时间的不可用。
消费者通过向称为群组协调器的 broker(不同的群组有不同的协调器)发送心跳来维持它和群组的从属关系以及对分区的所有权关系。如果消费者长时间不发送心跳,群组协调器认为它已经死亡,就会触发一次再均衡。
心跳由单独的线程负责,相关的控制参数为max.pollinterval.ms。
消费安全
一般情况下,我们调用poll方法的时候,broker 返回的是生产者写入 Kafka 同时 kafka 的消费者提交偏移量,这样可以确保消费者消息消费不丢失也不重复,所以一般情况下Kafka提供的原生的消费者是安全的,但是事情会这么完美吗?
消费者提交偏移量中的问题
消费者会往一个叫做 __consumer_offsets 的特殊主题发送一个消息,里面会包括每个分区的偏移量。
发生了再均衡之后,消费者可能会被分配新的分区,为了能够继续工作,消费者需要读取每个分区最后一次提交的偏移量,然后从指定的地方,继续做处理。
- 如果提交的偏移量小于消费者实际处理的最后一个消息的偏移量,处于两个偏移量之间的消息会被重复处理
- 如果提交的偏移量大于消费者实际处理的最后一个消息的偏移量,处于两个偏移量之间的消息将会丢失
所以,处理偏移量的方式对客户端会有很大的影响。KafkaconsumerAPl 提供了很多种方式来提交偏移量。
自动提交
最简单的提交方式是让消费者自动提交偏移量。如果 enable.auto.comnit 被设为 true(默认),消费者会自动把从 poll 方法接收到的最大偏移量提交上去。提交时间间隔由 auto.commit.interval.ms 控制,默认值是5s。
自动提交是在 while(true) 里进行的,消费者每次在 while(true) 里会检查是否该提交偏移量了。
在调用 close 方法之前也会进行自动提交。
假设我们仍然使用默认的5s提交时间间隔,在最近一次提交之后的3s发生了再均衡,再均衡之后,消费者从最后一次提交的偏移量位置开始读取消息。这个时候偏移量已经落后了3s,所以在这3s内到达的消息会被程序重复处理。
自动提交虽然方便,但是很明显是一种基于时间提交的方式,并没有为我们留有余地来避免重复处理消息,在处理异常或提前退出循环要格外小心。
手动提交
同步
我们通过控制偏移量提交时间来消除丢失消息的可能性,并在发生再均衡时减少重复消息的数量。消费者APl提供了另一种提交偏移量的方式,开发者可以在必要的时候提交当前偏移量,而不是基于时间间隔。
把 auto.commit.offset 设为false,自行决定何时提交偏移量。使用 commitSync 提交偏移量最简单也最可靠。提交成功后马上返回,如果提交失败就抛出异常。
public class CommitSync {
public static void main(String[] args) {
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"192.168.100.14:9092");
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,"commitSync");
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,false);
KafkaConsumer<String,String> consumer
= new KafkaConsumer(properties);
try {
consumer.subscribe(Collections.singletonList(
"commit-topic"));
while(true){
ConsumerRecords<String, String> records
= consumer.poll(Duration.ofMillis(500));
//TODO do our work
}
//TODO 开始事务
//TODO 读业务写数据库
//TODO 偏移量写入数据库
//提交
consumer.commitSync();
}
} finally {
consumer.close();
}
}
}
异步
同步提交时,在broker对提交请求作出回应之前,应用程序会一直阻塞。我们可以使用异步提交 API,我们只管发送提交请求,无需等待broker的响应。
try {
consumer.subscribe(Collections.singletonList(
"commit-topic"));
while(true){
ConsumerRecords<String, String> records
= consumer.poll(Duration.ofMillis(500));
for(ConsumerRecord<String, String> record:records){
//do our work
}
consumer.commitAsync();
/*允许执行回调*/
// consumer.commitAsync(new OffsetCommitCallback() {
// public void onComplete(
// Map<TopicPartition, OffsetAndMetadata> offsets,
// Exception exception) {
// if(exception!=null){
// System.out.print("Commmit failed for offsets ");
// System.out.println(offsets);
// exception.printStackTrace();
// }
// }
// });
}
} finally {
consumer.close();
}
在成功提交或碰到无法恢复的错误之前,commitSync 会一直重试,但是 commitAsync 不会。它之所以不进行重试,是因为在它收到服务器响应的时候,可能有一个更大的偏移量已经提交成功。
假设我们发出一个请求用于提交偏移量2000,这个时候发生了短暂的通信问题,服务器收不到请求,自然也不会作出任何响应。与此同时,我们处理了另外一批消息,并成功提交了偏移量3000。如果 commitAsync 重新尝试提交偏移量2000,它有可能在偏移量3000之后提交成功。这个时候如果发生再均衡,就会出现重复消费。因为新接管此分区的消费者会2001开始。
commitAsync 也支持回调,在broker作出响应时会执行回调。回调经常被用于记录提交错误或生成度量指标。
同步 + 异步
因为同步提交一定会成功、异步可能会失败,所以一般的场景是同步和异步一起来做。
一般情况下,针对偶尔出现的提交失败,不进行重试不会有太大问题,因为如果提交失败是因为临时问题导致的,那么后续的提交总会有成功的。但如果这是发生在关闭消费者或再均衡前的最后一次提交,就要确保能够提交成功。
try {
consumer.subscribe(Collections.singletonList(
"commit-topic"));
while(true){
ConsumerRecords<String, String> records
= consumer.poll(500);
for(ConsumerRecord<String, String> record:records){
//do our work
}
consumer.commitAsync();
}
} catch (CommitFailedException e) {
System.out.println("Commit failed:");
e.printStackTrace();
} finally {
try {
consumer.commitSync();
} finally {
consumer.close();
}
}
特定
在我们前面的提交中,提交偏移量的频率与处理消息批次的频率是一样的。但如果想要更频繁地提交该怎么办?
如果poll方法返回一大批数据,为了避免因再均衡引起的重复处理整批消息,想要在批次中间提交偏移量该怎么办?
这种情况无法通过调用commitsync或commitAsync来实现,因为它们只会提交最后一个偏移量,即处理完整批数据。
public class CommitSpecial {
public static void main(String[] args) {
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"192.168.100.14:9092");
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,"CommitSpecial");
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,false);
KafkaConsumer<String,String> consumer
= new KafkaConsumer(properties);
Map<TopicPartition, OffsetAndMetadata> currOffsets
= new HashMap();
int count = 0;
try {
consumer.subscribe(Collections.singletonList(
"commit-topic"));
while(true){
ConsumerRecords<String, String> records
= consumer.poll(Duration.ofMillis(500));
for(ConsumerRecord<String, String> record:records){
//do our work
currOffsets.put(new TopicPartition(record.topic(),record.partition()),
new OffsetAndMetadata(record.offset()+1,"no meta"));
if(count%11==0){
consumer.commitAsync(currOffsets,null);
}
count++;
}
}
} finally {
try {
consumer.commitSync();
} finally {
consumer.close();
}
}
}
}
分区再均衡
再均衡监听器
消费者在进行分区再均衡前后,会做一些清理工作。比如,提交偏移量、关闭文件句柄、数据库连接等。
在为消费者分配新分区或移除旧分区时,可以通过消费者 API 执行一些应用程序代码,在调用 subscribe 方法时传进去一个ConsumerRebalancelistener 实例就可以了。
ConsumerRebalancelistener有两个需要实现的方法。
- public void onPartitionsRevoked(Collection< TopicPartition> partitions)方法会在再均衡开始之前和消费者停止读取消息之后被调用。如果在这里提交偏移量,下一个接管分区的消费者就知道该从哪里开始读取了
- public void onPartitionsAssigned(Collection< TopicPartition> partitions)方法会在重新分配分区之后和消费者开始读取消息之前被调用。
点击这里查看示例
默认的监听器是不做事的:
从特定偏移量处开始记录
到目前为止,我们知道了如何使用 poll 方法从各个分区的最新偏移量处开始处理消息。不过,有时候我们也需要从特定的偏移量处开始读取消息。
如果想从分区的起始位置开始读取消息,或者直接跳到分区的末尾开始读取消息,可以使seekToBeginning(Collection< TopicPartition > tp)和 seekToEnd(Collection< TopicPartition > tp)这两个方法。
如果记录是保存在数据库里而偏移量是提交到Kafka上,那么就无法实现原子操作。不过,如果在同一个事务里把记录和偏移量都写到数据库里会怎样呢?那么我们就会知道记录和偏移量要么都成功提交,要么都没有,然后重新处理记录。
现在的问题是:如果偏移量是保存在数据库里而不是Kafka里,那么消费者在得到新分区时怎么知道该从哪里开始读取?这个时候可以使用seek方法。
在消费者启动或分配到新分区时,可以使用 seek 方法查找保存在数据库里的偏移量。我们可以使用使用 ConsumerRebalancelistener 和 seek 方法确保我们是从数据库里保存的偏移量所指定的位置开始处理消息的。
这一点在上一节的示例中有体现。
优雅退出
如果确定要退出循环,需要通过另一个线程调用consumer.wakeup 方法。如果循环运行在主线程里,可以在 ShutdownHook 里调用该方法。要记住,consumer.wakeup 是消费者唯一一个可以从其他线程里安全调用的方法。调用 consumer.wakeup 可以退出poll ,并抛出 WakeupException 异常。我们不需要处理Wakeup Exception,因为它只是用于跳出循环的一种方式。不过,在退出线程之前调用 consumer.close 是很有必要的,它会提交任何还没有提交的东西,并向群组协调器发送消息,告知自己要离开群组,接下来就会触发再均衡,而不需要等待会话超时。
参考:King——笔记-Kafka