在kafka中,消费者从属于消费者群组,想要知道如何从kafka中读取消息,需要先了解消费者和消费者群组的概念。假设主题T1有四个分区,我们创建一个消费者群组1,群组中有一个消费者;用这个消费者订阅主题T1,则该消费者会收到四个分区中的全部消息。
但是kafka消费者经常会做一个高延迟的操作,比如把数据写到数据库或HDFS,或者使用数据进行比较耗时的计算。在这些情况下,单个消费者无法跟上数据生成的速度,所以我们可以增加群组中的消费者来分担负载。
当增加一个消费者后,主题T1就被群组1中的两个消费者消费;同样的如果群组T1中增加到4个消费者,这是每一个消费者将消费主题T1中的一个分区的消息;
所以说,通过忘群组里面增加消费者是横向伸缩消费能力的主要方式。但当群组中的消费者数量超过主题分区的数量时,那么多出来的消费者就会被闲置。
以上为单个消费者群组中添加消费者,也可以新增一个包含消费者的群组2,这个消费者群组2也会接受主题T1上面的所有消息,并且与消费者群组1之间互不影响。
总的来说,为每一个主题创建一个消费者群组,通过增加或减少消费者数量来达到横向伸缩消费者信息的能力。
分区再平衡
当一个消费者被关闭或者发生崩溃的时候,它就会离开群组,原本由它读取的分区将由群组中的其他消费者来读取。此时的分区所有权从一个消费者转移到另一个消费者,这样的行为被称为再平衡。
再平衡为消费者带来了高可用性和伸缩性。但是在再平衡期间,消费者无法读取消息,会造成整个群组的一小段时间不可用;而且,当分区被重新分配给另一个消费者的时候,消费者当前的读取状态会丢失,还可能去刷新缓存,从而降低了应用程序的速度。
创建kafka消费者
与前面创建kafka生产者一样,kafka消费者也需要创建一个KafkaConsumer对象,并且配置三个必须的属性;这里与kafka生产者属性配置不一样的地方就是多了一个group.id
属性,它指定了消费者所属群组的名字,当然这个不是必须的。
/* 创建一个properties对象,设置一些必要的属性,这里只指定了必要属性 */
Properties props = new Properties();
props.put("bootstrap.servers","node01:9092,node02:9092,node03:9092");
props.put("group.id","CountryCounter");
props.put("key.deserializer","org.apache.common.serialization.StringDeserializer");
props.put("value.deserializer","org.apache.common.serialization.StringDeserializer");
/* 创建kafkaProducer对象 */
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
订阅主题
当创建好消费者后,我们就可以开始订阅主题了。
/* 订阅主题,主题名称为:test */
consumer.subscribe(Collections.singletonList("test"));
如果想要订阅多个主题,那么可以采用正则表达式的方式;当有人创建了新的主题,且主题名称与正则表达式匹配,那么就会立即出发再均衡,消费者就可以读取新添加的主题了。
轮询
消息轮询是消费者API的核心,通过轮询像服务器请求数据。一旦订阅了主题,轮询就会处理所有的细节,包括群组协调,分区再均衡、发送心跳和获取数据。
/* 消息轮询 */
try{
while(true){
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String,String> record : records) {
System.out.printf("topic = %s,partition = %d, key = %s, value = %s, offset = %d,\n",record.topic(), record.partition(), record.key(), record.value(), record.offset());
}
}
}finally {
consumer.close();
}
消费者的配置
除了上述的几个重要配置属性外,大部分的属性是一般不需要进行修改,但有一些与消费者性能和可用性有关的参数需要做一些适当的介绍:
-
fetch.min.bytes
指定了消费者从服务器获取记录的最小字节数。如果可用的数据量小于
fetch.min.bytes
指定的大小,那么它会等到有足够的可用数据时才把它返回给消费者。 -
fetch.max.wait.ms
指定broker的等待时间,默认是500ms。如果过没有足够的数据流入kafka,消费者获取最小数据量的要求就得不到满足,最终导致500ms的延迟。
-
max.partition.fetch.bytes
指定了服务器从每个分区里返回给消费者的最大字节数,默认1MB。
-
session.timeout.ms
指定了消费者在被认为死亡之前可以与服务器断开连接的时间,默认是3s。一般该属性与
heartbeat.interval.ms
同时修改,该值比session.timeout.ms小
。 -
auto.offset.reset
指定了 消费者在读取一个没有偏移量的分区或者偏移量无效的情况下该作何处理,默认值:latest。
latest
:在偏移量无效的情况下,消费者将从最新的记录开始读取数据earliest
:在便宜量无效的情况下,消费者将从起始位置读取分区的记录
-
enable.auto.commit
指定了消费者是否自动提交偏移量,默认值为true。
-
partition.assignment.strategy
选择分区策略,kafka有两种默认的分配策略:
Range
:将主题的若干个连续的分区分配给消费者RoundRobin
:将主题的所有分区逐个分配给消费者
该属性默认值是
org.apache.kafka.clients.consumer.RangeAssignor
,这个类实现Range策略 -
client.id
任意字符串,broker用来标识从客户端发送过来的消息,常用在日志、度量指标和配额里
-
max.poll.records
控制单次调用call()方法能够返回的记录数量
-
receive.buffer.bytes和send.buffer.bytes
socket在读写数据时用到的TCP缓冲区大小。值设为-1,则使用操作系统默认值。
提交和偏移量
偏移量是另一种元数据,他是一个单调递增的整数,在创建消息时,kafka会把它添加到消息里。消费者通过往一个叫作_consumer_offset
的特殊主题发送消息,消息里包含每个分区的偏移量。 如果消费者一直处于运行状态,那么偏移量就没有什么用处。不过,如果有消费者退出或者新分区加入,此时就会触发再均衡。完成再均衡之后,每个消费者可能分配到新的分区,而不是之前处理的那个。为了能够继续之前的工作,消费者需要读取每个分区最后一次提交(把更新分区当前位置的操作叫做提交)的偏移量,然后从偏移量指定的地方继续处理。 因为这个原因,所以如果不能正确提交偏移量,就可能会导致数据丢失或者重复出现消费,比如下面情况:
- 提交的偏移量 < 客户端处理的最后一个消息的偏移量 ,那么处于两个偏移量之间的消息就会被重复消费;
- 提交的偏移量 > 客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失。
KafkaConsumer API提交偏移量的方式主要分为:
- 自动提交
- 手动提交
- 同步提交:commitSync()
- 异步提交:commitAsync()
自动提交
enable.auto.commit
设置为true;那么每过5s,消费者会自动从poll()方法接收到的最大偏移量提交上去。提交时间间隔由auto.commit.interval.ms
控制,默认为5s。
自动提交比较方便,但是并没有为开发者留有余地来避免重复处理消息。例如:我们使用默认的 5s 提交时间间隔,在最近一次提交之后的 3s 发生了再均衡,再均衡之后,消费者从最后一次提交的偏移量位置开始读取消息。这个时候偏移量已经落后了 3s ,所以在这 3s 内到达的消息会被重复处理。
通常情况下, 开发者通过控制偏移量提交时间来消除丢失消息的可能性,并在发生再均衡时减少重复消息的数量。
提交当前偏移量
将enable.auto.commit
设置为false;让应用程序决定何时提交偏移量。使用commitSync()提交由poll()方法返回的最新偏移量,提交成功后马上返回,提交失败则会抛出异常。
同步提交有一个不足之处在于,当broker对提交的请求作出回应之前,应用程序会一直阻塞,这样限制了应用程序的吞吐量;当然可以通过较低提交频率来提高吞吐量,但是这种做法又会增加重复消息的数量。
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecord<String,String> record : records){
System.out.println(record);
}
try{
/* 同步提交 */
consumer.commitSync();
} catch(CommitFailedException e){
log.error("commit failed", e);
}
}
异步提交
异步提交将解决同步提交出现的问题;异步提交只管发送提交请求,无需等待broker的响应。
但是异步提交也有它自己的问题,就是当提交成功或者碰到无法恢复的错误之前,它不会进行重试;之所以不进行重试是因为在它收到服务器响应的时候,可能有一个更大的偏移量已经提交成功,要是发生再均衡,就会出现重复消息;不同于同步提交,比如同步提交偏移量2000和偏移量3000的请求,那么偏移量3000的只有当偏移量2000的响应成功之后才会发出。
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecord<String,String> record : records){
System.out.println(record);
}
try{
/* 异步提交 */
consumer.commitAsync(new OffsetCommitCallback(){
@Override
public void onComplete(Map<TopicPartition,OffsetAndMetadata> offsets, Exception e){
if(e != null){
log.error("Commit failed for offsets {}", offsets, e);
}
}
});
} catch(CommitFailedException e){
log.error("commit failed", e);
}
}
同步和异步组合提交
针对偶尔出现的提交失败,不进行重试问题不大;因为提交失败是临时原因导致的,那么后续的提交总会有成功的;但如果是发生在关闭消费者或者再均衡前的最后一次提交,那么就要确保能够提交成功。这可以使用同步和异步提交组合使用的方式解决。
try{
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecord<String,String> record : records){
System.out.println(record);
}
/* 异步提交 */
consumer.commitAsync();
}
} catch(Exception e){
log.error("Unexpected error", e);
} finally {
try{
/* 同步提交 */
consumer.commitSync();
} finally {
consumer.close();
}
}
提交特定的偏移量
提交偏移量的频率和处理消息批次的频率是一样的,如果想要更频繁的提交偏移量,为避免因在均衡引起的处理整批消息;这种情况呢,消费者API允许在调用commitSync()
和commitAsync()
方法时可以传进去希望提交的分区和偏移量的map。
private Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
int count = 0;
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecord<String,String> record : records){
System.out.println(record);
/* 记录每个主题的每个分区的偏移量 */
currentOffsets.put(new TopicPartition(record.topic(),record.partition()), new OffsetAndMetadata(record.offset() + 1, "no metadata"));
/* 每处理1000条记录就提交一次偏移量 */
if( count % 1000 == 0){
consumer.commitAsync(currentOffsets, null);
}
count++;
}
}
再均衡监听器
消费者在退出或进行分区再均衡之前,会做一些清理工作,比如关闭文件句柄、数据库连接等。在为消费者分配新的分区或移除旧分区时,可以在调用subscribe()方法的时候传进去一个ConsumerRebalanceListener实例就可以了,这个类需要实现两个方法:
-
public void onPartitionsRevoked(Collection<TopicPartition> partitions)
在再均衡开始之前和消费者停止读取消息之后被调用。如果在这里提交偏移量,下一个接管分区的消费者就知道该在哪里开始读取了。
-
public void onPartitionsAssigned(Collection<TopicPartition> partitions)
在重新分配分区之后和消费者开始读取消息之前被调用。
下面的示例演示在失去分区所有权之前通过onPartitionsRevoked()方法来提交偏移量:
private Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
int count = 0;
try{
/* 订阅主题,主题名称为:test,并传进去一个ConsumerRebalanceListener实例 */
consumer.subscribe(Collections.singletonList("test"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
System.out.println("Lost partitions in rebalance. Committing current offsets: " + currentOffsets);
/* 提交已经处理的偏移量 */
consumer.subscribe(currentOffsets);
}
});
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecord<String,String> record : records){
System.out.println(record);
/* 记录每个主题的每个分区的偏移量 */
currentOffsets.put(new TopicPartition(record.topic(),record.partition()), new OffsetAndMetadata(record.offset() + 1, "no metadata"));
/* 每处理1000条记录就提交一次偏移量 */
if( count % 1000 == 0){
consumer.commitAsync(currentOffsets, null);
}
count++;
}
}
} catch( WakeupException e){
//忽略异常,正在关闭消费者
} catch(Exception e){
log.error("Unexpected error", e);
} finally {
try {
consumer.commitSync(currentOffsets);
} finally {
consumer.close();
System.out.println("Closed consumer and we are done");
}
}
##如何退出
kafka提供了consumer.wakeup()方法使得消费者可以优雅的退出循环。同时要记住,consumer.wakeup()
是消费者唯一一个可以从其他线程里安全调用的方法。调用wakeup()方法可以退出poll()并抛出WakeupException
异常,我们无需处理该异常,它只是一种用于跳出循环的一种方式。不过在退出线程之前先调用consumer.close()
方法是很有必要的,它会提交任何还没有提交的东西,并向群组协调器发送消息,告知自己要离开群组。
/*调用 wakeup 优雅的退出*/
final Thread mainThread = Thread.currentThread();
new Thread(() -> {
Scanner sc = new Scanner(System.in);
while (sc.hasNext()) {
if ("exit".equals(sc.next())) {
consumer.wakeup(); /* 调用wakeup()方法退出轮询 */
try {
/*等待主线程完成提交偏移量、关闭消费者等操作*/
mainThread.join();
break;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
try{
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecord<String,String> record : records){
System.out.println(record);
}
}
} catch( WakeupException e){
//忽略异常,正在关闭消费者
} finally {
consumer.close();
System.out.println("Closed consumer and we are done");
}
反序列化器
前面提到,生产者需要用序列化器把对象转换成字节数组再发送给Kafka,那么消费者也需要用反序列化器把从kafka接收到的字节数组转换成java对象。生成消息使用的序列化器与消费消息使用的反序列化器应该是一一对应的。
接下来为前面生产者定义的Customer对象写一个自定义反序列化器:
public class CustomerDeserializer implements Deserializer {
@Override
public void configure(Map configs, boolean isKey) {
// 不做任何配置
}
@Override
public Object deserialize(String topic, byte[] data) {
int id;
int nameSize;
String name;
try{
if (data == null)
return null;
if (data.length < 8)
throw new SerializationException("Size of data received by IntegerDeserializer is shorter than expected");
ByteBuffer buffer = ByteBuffer.wrap(data);
id = buffer.getInt();
nameSize = buffer.getInt();
byte[] nameBytes = new byte[nameSize];
buffer.get(nameBytes);
name = new String(nameBytes, StandardCharsets.UTF_8);
return new Customer(id, name);
} catch (Exception e){
throw new SerializationException("Error when serializing Customer to byte[] " + e);
}
}
@Override
public void close() {
// 不需要关闭任何东西
}
}
独立的消费者
通常情况下,消费者是从属于某个群组,群组中拥有多个消费者以实现kafka的高吞吐和低延迟;但有时候也需要一些更简单的东西。比如,你可能只需要一个消费者从一个主题的所有分区或者某个特定的分区读取数据。这时候就不再需要消费者群组和再均衡了,只需要把主题或者分区分配给消费者,然后开始读取消息并提交偏移量。
下面演示一个消费者是如何为自己分配分区并从分区中读取消息的:
ArrayList<TopicPartition> partitions = new ArrayList<>();
List<PartitionInfo> partitionInfos = consumer.partitionsFor("topic");
if (partitionInfos != null){
for (PartitionInfo partition : partitionInfos) {
partitions.add(new TopicPartition(partition.topic(),partition.partition()));
}
/* 为消费者指定分区 */
consumer.assign(partitions);
while(true){
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String,String> record : records) {
System.out.println(record);
}
consumer.commitSync();
}
}
参考资料
Neha Narkhede, Gwen Shapira ,Todd Palino(著) , 薛命灯 (译) . Kafka 权威指南 . 人民邮电出版社 . 2018.1
https://dy.163.com/article/EVG4T30T0511FQO9.html
https://blog.csdn.net/m0_37809146/article/details/91126212