KafkaConsumer概念
消费者和消费者群组
假设我们有一个应用程序需要从一个Kafka 主题读取消息并验证这些消息,然后再把它们保存起来。应用程序需要创建一个消费者对象,订阅主题并开始接收消息,然后验证消息井保存结果。过了一阵子,生产者往主题写入消息的速度超过了应用程序验证数据的速度,这个时候该怎么办?如果只使用单个消费者处理消息,应用程序会远跟不上消息生成
的速度。显然,此时很有必要对消费者进行横向伸缩。就像多个生产者可以向相同的主题写入消息一样,我们也可以使用多个消费者从同一个主题读取消息,对消息进行分流。
Kafka 消费者从属于消费者群组。一个群组里的消费者订阅的是同一个主题,每个消费者接收主题一部分分区的消息。
1个消费者,4个分区:
2个消费者,4个分区:
4个消费者,4个分区:
5个消费者,4个分区:
我们可以知道, 消费者数目大于分区数目的时候,有消费者是闲置的,所以最好不要让消费者数目超过分区的数目。
往群组里增加消费者是横向伸缩消费能力的主要方式。Kafka 消费者经常会做一些高延迟的操作,比如把数据写到数据库或HDFS ,或者使用数据进行比较耗时的计算。在这些情况下,单个消费者无法跟上数据生成的速度,所以可以增加更多的消费者,让它们分担负,每个消费者只处理部分分区的消息,这就是横向伸缩的主要手段。
除了通过增加消费者来横向伸缩单个应用程序外,还经常出现多个应用程序从同一个主题读取数据的情况。为每一个需要获取一个或多个主题全部消息的应用程序创建一个消费者群组,然后往群组里添加消费者来伸缩读取能力和处理能力,群组里的每个消费者只处理一部分消息。
两个消费者群组对应一个主题:
消费者群组和分区再均衡
我们已经从上一个小节了解到,群组里的消费者共同读取主题的分区。一个新的悄费者加入群组时,它读取的是原本由其他消费者读取的消息。当一个消费者被关闭或发生崩溃时,它就离开群组,原本由它读取的分区将由群组里的其他消费者来读取。在主题发生变化时, 比如管理员添加了新的分区,会发生分区重分配。
分区的所有权从一个消费者转移到另一个消费者,这样的行为被称为再均衡。不过在正常情况下,我们并不希望发生这样的行为。在再均衡期间,消费者无法读取消息,造成整个群组一小段时间的不可用。另外,当分区被重新分配给另一个消费者时,消费者当前的读取状态会丢失,它有可能还需要去刷新缓存,在它重新恢复状态之前会拖慢应用程序。我们将在本章讨论如何进行安全的再均衡,以及如何避免不必要的再均衡。
如何触发均衡呢?
消费者通过向被指派为群组协调器的broker (不同的群组可以有不同的协调器)发送心跳来维持它们和群组的从属关系以及它们对分区的所有权关系。只要消费者以正常的时间间隔发送心跳,就被认为是活跃的,说明它还在读取分区里的消息。消费者会在轮询消息(为了获取消息)或提交偏移量时发送心跳。如果消费者停止发送心跳的时间足够长,会话就会过期,群组协调器认为它已经死亡,就会触发一次再均衡。
创建Kafka消费者
在读取消息之前,需要先创建一个KafkaConsumer对象。创建KafkaConsumer对象与创建KafkaProducer对象非常相似一一把想要传给消费者的属性放在Properties对象里。
在这里我们需要使用对应的3个必要属性:
bootstrap.servers、key.deserializer、value.deserializer。
不必要的属性是group.id,它指定了KafkaConsumer属于哪个消费群组。
下面演示了如何创建一个KafkaConsumer对象:
Properties properties=new Properties();
properties.put("bootstrap.servers","broker1:9092,broker2:9092");
properties.put("group.id","CountryCounter");
properties.put("key.deserializer","org.apache.kafka.common.serialization.StringDeserializer");
properties.put("value.deserializer","org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String,String> consumer=new KafkaConsumer<String, String>(properties);
订阅主题
subscribe()方法接受一个主题列表作为参数,使用起来很简单。
consumer.subscribe(Collections.singletonList("customerContries"));
如果要订阅所有与test相关的主题,可以如下操作:
consumer.subscribe("test.*");
当然也可以自定义订阅:
List topics = new ArrayList();
topics.add(BaseTopicService.QUERY_BASE_TOPIC);
this.consumer.subscribe(topics);
轮询
消息轮询是消费者API 的核心,通过一个简单的轮询向服务器请求数据。一旦消费者订阅了主题,轮询就会处理所有的细节,包括群组协调、分区再均衡、发送心跳和获取数据,开发者只需要使用一组简单的API 来处理从分区返回的数据。消费者代码的主要部分如下所示:
try {
while (true){
ConsumerRecords<String,String> records=consumer.poll(100);
for (ConsumerRecord<String,String> record: records) {
log.debug("topic=%s,partition=%s,offset=%d,customer=%s,country=%s\n",
record.topic(),record.partition(),record.offset(),
record.key(),record.value());
int updateCount=1;
if (custCountryMap.countainsValue(record.value()))
updateCount=custCountryMap.get(record.value())+1;
custCountryMap.put(record.value(),updateCount);
JSONObject json=new JSONObject(custCountryMap);
System.out.println(json.toString(4));
}
}
}finally {
consumer.close();
}
这是一个无限循环。消费者实际上是一个长期运行的应用程序,它通过持续轮询向Kafka 请求数据。稍后我们会介绍如何退出循环,井关闭消费者。
poll()方法参数是一个超时时间,用于控制poll()方法的阻塞(在消费者的缓冲区里没有可用数据时会发生阻塞),。如果该参数被设为0, poll()会立即返回,否则它会在指定的毫秒数内一直等待broker 返回数据。poll()返回一个记录列表。
我们这里用JSON格式打印结果,实际场景可能是被保存在数据库里。
在退出应用程序之前用close()关闭消费者。
消费者的配置
- fetch. min.bytes
该属性指定了消费者从服务器获取记录的最小字节数(很明显降低负载的作用)。 - fetch.max.wait.ms
这个是最长等待时间,和上面可以同时使用。要么是字节数达到最小值,要么是时间超时。 - session.timeout.ms
该属性指定了消费者在被认为死亡之前可以与服务器断开连接的时间,默认是3s。 - 其他配置以后了解
提交和偏移量
每次调用poll()方法,它总是返回由生产者写入Kafka 但还没有被消费者读取过的记录,我们因此可以追踪到哪些记录是被群组里的哪个消费者读取的。
我们把更新分区当前位置的操作叫作提交。
那么消费者是如何提交偏移量的呢?消费者往一个叫作_consumer_offset 的特殊主题发送消息,消息里包含每个分区的偏移量。如果消费者一直处于运行状态,那么偏移量就没有什么用处。不过,如果悄费者发生崩溃或者有新的消费者加入群组,就会触发再均衡,完成再均衡之后,每个消费者可能分配到新的分区,而不是之前处理的那个。为了能够继续之前的工作,消费者需要读取每个分区最后一次提交的偏移量,然后从偏移量指定的地方继续处理。
对于偏移量。
如果提交的偏移量小于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息就会被重复处理,如图所示。
如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失,如图所示。
所以,处理偏移量的方式对客户端会有很大的影响。KafkaConsumer AP I 提供了很多种方式来提交偏移量。
自动提交
最简单的提交方式是让悄费者自动提交偏移量。如果enable.auto.commit
被设为true,那么每过auto.commit.interval.ms
=5s,消费者会自动把从poll()方法接收到的最大偏移量提交上去。
不过,在使用这种简便的方式之前,需要知道它将会带来怎样的结果。
在最近一次提交之后的3s 发生了再均衡,再均衡之后,消费者从最后一次提交的偏移量位置开始读取消息。所以在这3s 内到达的消息会被重复处理。这种情况是无法避免的。
自动提交虽然方便, 不过并没有为开发者留有余地来避免重复处理消息。
提交当前偏移量
大部分开发者通过控制偏移量提交时间来消除丢失消息的可能性,井在发生再均衡时减少重复消息的数量。消费者API 提供了另一种提交偏移量的方式, 开发者可以在必要的时候提交当前偏移量,而不是基于时间间隔。
首先把enable.auto.commit
设为false,使用commitSync()即可。
while (true){
ConsumerRecords<String,String> records=consumer.poll(100);
for (ConsumerRecord<String,String> record: records) {
log.debug("topic=%s,partition=%s,offset=%d,customer=%s,country=%s\n",
record.topic(),record.partition(),record.offset(),
record.key(),record.value());
}
try {
consumer.commitSync();
}catch (CommitFailedException e){
log.error("commit failed",e);
}
}
只要没有发生不可恢复的错误,commitSync()会一直尝试直至提交成功。如果提交失败, 我们也只能把异常记录到错误日志里。
异步提交
把commitSync改为commitAsync()即可。它也支持回调,在broker 作出响应时会执行回调。回调经常被用于记录提交错误或生成度量指标, 不过如果你要用它来进行重试, 一定要注意提交的顺序。
while (true){
ConsumerRecords<String,String> records=consumer.poll(100);
for (ConsumerRecord<String,String> record: records) {
log.debug("topic=%s,partition=%s,offset=%d,customer=%s,country=%s\n",
record.topic(),record.partition(),record.offset(),
record.key(),record.value());
}
try {
consumer.commitAsync(new OffsetCommitCallback() {
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> map, Exception e) {
if (e!=null){
log.error("commit failed for offsets {}",offsets,e);
}
}
});
}
}
同步异步组合提交
try {
while (true){
ConsumerRecords<String,String> records=consumer.poll(100);
for (ConsumerRecord<String,String> record: records) {
log.debug("topic=%s,partition=%s,offset=%d,customer=%s,country=%s\n",
record.topic(),record.partition(),record.offset(),
record.key(),record.value());
}
try {
consumer.commitAsync();
}catch (Exception e){
log.error("Unexcept Error",e);
}
}
}finally {
try {
consumer.commitSync();
}finally {
consumer.close();
}
}
当然还可以提交特定的偏移量,我们这里不作介绍了
再均衡监听器
rebalanceListener略。
从特定偏移量处开始处理记录
略。
如何退出
在之前讨论轮询时就说过,不需要担心消费者会在一个无限循环里轮询消息,我们会告诉消费者如何优雅地退出循环。
如果确定要退出循环,需要通过另一个线程调用consumer.wakeup()方法。
反序列化器
暂略