三、Kafka消费者

目录

  • 1. 消费者与消费者组
  • 2. 客户端开发
    • 2.1 配置必要的参数
    • 2.2 订阅主题与分区
    • 2.3 反序列化
    • 2.4 消息消费
    • 2.5 位移提交
    • 2.6 控制或关闭消费
    • 2.7 指定位移消费
    • 2.8 再均衡
    • 2.9 消费者拦截器
    • 2.10 多线程实现
    • 2.11 重要的消费者参数

1. 消费者与消费者组

消费者(Consumer)负责订阅Kafka主题,并从订阅的主题上拉取消息。与其它消息中间件不同的是:Kafka消费理念中还有一层消费者组(Consumer Group)的概念,一个消费者组包含多个消费者,消息发布到主题后,会投递给每个消费者组中的其中一个消费者。

三、Kafka消费者_第1张图片
如上图,一个主题有四个分区,P0、P1、P2、P3。两个消费者组A、B都订阅了这个主题,消费者组A中有四个消费者C1、C2、C3、C4,消费者组B中有两个消费者C5、C6。Kafka默认规则,最后的分配是消费者A组中非每一个消费者消费一个分区,B消费两个分区。两个消费者组之间互不影响,每一个分区只能被一个消费者组中的一个消费者消费。

如果消费者组中的消费者数量大于分区数,则会出现有消费者分配不到分区,如下图。

三、Kafka消费者_第2张图片
对于消息中间件而言,一般有两种消息投递模式,点对点(P2P,Point-to-Point)模式和发布/订阅模式(Pub/Sub)。点对点模式是基于队列,生产者发送消息到队列,消费者从队列中接受消息。发布订阅则是以主题为消息的媒介,生产者将消息发送到某个主题,消费者订阅这个主题拉取消息,发布/订阅在消息的一对多广播时采用。Kafka同时支持两种消息投递模式。

  • 如果所有的消费者都隶属于同一个消费组,那么消息会均衡的投递给每一个消费者,即每条消息只会有一个消费者处理,相当于点对点。
  • 如果所有的消费者都隶属于不同的消费者组,所有的消息会被广播给所有的消费者,即每条消息会被所有的消费者处理,相当于发布/订阅。

2. 客户端开发

一个正常的消费逻辑需要具备一下几个步骤
(1) 配置消费者客户端参数和创建消费者实例。
(2) 订阅主题
(3) 拉取消息并消费
(4) 提交消费位移
(5) 关闭消费者实例

import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

public class ConsumerTest {

    public static void main(String[] args) {
        String server = "127.0.0.1:9092";
        String topic = "demo";
        Properties properties = new Properties();
        properties.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, server);
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "group1");

        Consumer<String, String> consumer = new KafkaConsumer<>(properties);
        consumer.subscribe(Collections.singletonList(topic));

        try {
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));
                int count = records.count();
                System.out.println("count:" + count);
                for (ConsumerRecord<String, String> record : records) {
                    System.out.printf("topic = %s, partition = %d, offset = %d, value = %s\n",
                            record.topic(), record.partition(), record.offset(), record.value());
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }

    }

}

2.1 配置必要的参数

  • bootstrap.servers:kafka连接地址
  • group.id:指定消费者组的名称,默认为""
  • key.deserializer、value.deserializer:用来指定key和value的反序列化器的类名。消息是byte[]类型的,所以需要反序列化操作才能还原消息。

这里的话还可以设置一个client.id参数表示客户端的id,默认为"“,不不设置的话自动生成,内容形式为"consumer-1”,“consumer-2”。

消费者相关的参数在ConsumerConfig里面去找。

2.2 订阅主题与分区

在消费者创建好之后,就需要订阅相关主题了。一个消费者可以订阅一个或者多个主题,这里的示例使用subscribe()去订阅了一个主题,看下这个方法的重载。

void subscribe(Collection<String> topics);
void subscribe(Collection<String> topics, ConsumerRebalanceListener callback);
void subscribe(Pattern pattern, ConsumerRebalanceListener callback);
void subscribe(Pattern pattern);  

订阅主题集合比较简单,这里就不多解释了。如果一个消费者订阅了两次,那么以最后一次订阅的为准。下面的代码最终订阅的是topic2

consumer.subscribe(Collections.singletonList(topic1));
consumer.subscribe(Collections.singletonList(topic2));

subscribe(Pattern)订阅则是使用正则匹配主题,之后的过程中,有人创建了主题且符合正则表达式,那么这个主题也会被消费者消费。

subscribe()重载还有一个参数类型四是ConsumerRebalanceListener,这个是用来设置响应的再均衡监听器的,后面再讲。

subscribe()可以用来订阅某些主题,kafka提供了assign()方法直接订阅主题的某个分区。

void assign(Collection<TopicPartition> partitions);

TopicPartition = 主题 + 分区,问题来了,主题知道,怎么指定分区呢?KafkaConsumer提供了partitionsFor()方法来获取主题元数据信息。

List<PartitionInfo> partitionsFor(String topic);

稍加修改则可以订阅所有分区。

List<PartitionInfo> partitionInfoList = consumer.partitionsFor(topic);       
List<TopicPartition> topicPartitionList = partitionInfoList.stream()         
        .map(info -> new TopicPartition(info.topic(), info.partition()))     
        .collect(Collectors.toList());                                       
consumer.assign(topicPartitionList);                                         

既然有订阅,自然有取消订阅了,下面三种方式效果相同。订阅一个空集合或者取消订阅。

consumer.unsubscribe();                             
consumer.assign(Collections.emptyList());           
consumer.subscribe(Collections.emptyList());        

如果没有订阅任何主题或分区,拉取消息的时候自然就会抛出异常了。

三种订阅方式分别代表了三种不同的订阅状态,集合subscribe(Collection)、正则subscribe(Pattern)、指定分区assign(Collection)分别表示AUTO_TOPICS、AUTO_PATTERN和USED_ASSIGNED(如果没有订阅,状态为NONE)。这三种状态时互斥的,在一个消费者中只能使用其中一种。

subscribe()具有消费者再均衡的功能,在多个消费者的情况下可以根据分区策略动态分配消费者与分区的关系。消费者组内消费者减少时,分区分配关系自动调整,实现消费者的负载均衡和自动故障转移。assign()则不具备自动均衡的功能。

2.3 反序列化

序列化是将对象转成byte[],反序列则是将byte[]还原成对象,kafka提供的org.apache.kafka.common.serialization.Deserializer

void configure(Map<String, ?> configs, boolean isKey);
T deserialize(String topic, byte[] data);
void close();

configure()和close()都是空实现,而deserialize()是将byte转成对应的对象。

注意,非必要情况下不建议使用自定义的序列化器和反序列化器的,这样会增加消费者和生产者之间的耦合度,系统升级的时候也很容易出错,需要需要考虑序列化和反序列化的兼容性问题。如果kafka提供的序列化器和反序列化器无法满足要求时,建议使用Avro、JSON、Thrift、ProtoBuf等通用的序列化工具,尽可能的前后兼容。

2.4 消息消费

Kafka中的消息消费是基于拉模式的。消息消费一般有两种模式:推模式和拉模式,推模式是服务端主动推送消息给消费者,拉模式则是消费者主动向服务端拉取消息。

Kafka消费消息是一个不断轮询的过程,也就是消费者需要重复调用poll()方法,poll()方法返回的是订阅主题的一组消息,没有可消费的消息则返回空集合。

ConsumerRecords<K, V> poll(Duration timeout);

poll()方法有一个参数用来控制阻塞时间,在消费者缓冲区没有可用数据时会发生阻塞。

注意poll()方法返回的是ConsumerRecordsConsumerRecords包含若干条ConsumerRecord,需要遍历得到每一条消息。

ConsumerRecords提供了一个records(TopicPartition)的方法获取指定分区的消息。

List<ConsumerRecord<K, V>> records(TopicPartition partition);

poll()看起来只是简单拉取了一下消息而已,但是内部逻辑缺不是这么简单,涉及消费位移、消费者协调器、组协调器、消费者的选举、分区分配的分发、再均衡的逻辑、心跳等内容,后面在慢慢介绍。

2.5 位移提交

相对分区来说,每条消息都有一个唯一的offset,用来表示消息在分区中的位置。对于消费者而言,也有一个offset的概念,用来表示消费到分区的某个位置,称为“位移”或者消费“位移”。

调用poll()返回的就是还没有被消费的消息集,要做到这一点,就需要记录上一次消费时的位移。并且这个位移需要持久化保存,否则消费者重启后就无法知晓以前的消费位移了。再考虑一种情况,新的消费者加入时,需要再均衡动作,同一分区再均衡后可能分配给新的消费者,如果不持久化保存这个消费位移,那么这个新的消费者也无法知晓之前的消费位移。

在旧的客户端中,消费位移存储在zookeeper中。新的客户端,消费唯一储存在Kafka内部主题__consumer_offsets中。消费位移持久化的动作称为提交,消费者在消费消息后需要执行消费位移的提交。

三、Kafka消费者_第3张图片
x表示当前消费到的位置,需要提交的消费位移是x+1而不是x,对应图中的position,它表示下一条需要拉取的消息位置。

KafkaConsumer提供position()committed()方法分别获取position和committed offset。

long position(TopicPartition partition);
Map<TopicPartition, OffsetAndMetadata> committed(Set<TopicPartition> partitions);

代码稍微修改一下,看控制台输出

TopicPartition topicPartition = new TopicPartition(topic, 0);                                                     
// 直接订阅某个分区                                                                                                       
consumer.assign(Collections.singleton(topicPartition));                                                           
                                                                                                                  
long lastConsumedOffset = -1;                                                                                     
while (true) {                                                                                                    
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));                               
    if (records.isEmpty()) {                                                                                      
        // 演示,直接跳出循环                                                                                              
        break;                                                                                                    
    }                                                                                                             
    List<ConsumerRecord<String, String>> recordList = records.records(topicPartition);                            
    // 消费位移                                                                                                       
    lastConsumedOffset = recordList.get(recordList.size() - 1).offset();                                          
    recordList.forEach(record -> System.out.printf("topic = %s, value = %s\n", record.topic(), record.value()));  
                                                                                                                  
    // 同步提交消费位移                                                                                                   
    consumer.commitSync();                                                                                       
}                                                                                                                 
                                                                                                                  
System.out.printf("lastConsumedOffset: %d\n", lastConsumedOffset);                                                
long position = consumer.position(topicPartition);                                                                
System.out.printf("position: %d\n", position);                                                                    
Map<TopicPartition, OffsetAndMetadata> map = consumer.committed(Collections.singleton(topicPartition));           
map.forEach((tp, meta) -> System.out.printf("committed offset: %d\n", meta.offset()));                            

注意:这里isEmpty()判断为空时跳出循环,这里只是演示。控制台输出结果如下,从输出可以证明,提交位移提交的是x+1,也就是下一条消息的offset。

lastConsumedOffset: 46
position: 47
committed offset: 47

对于消费位移的提交时机也很有讲究,可能会出现重复消费或者消息丢失的情况。以下图为例,当前poll()拉取的消息集为[x+2, x+7],其中x+5表示正在处理的消息位置,x+8表示将要提交的消费位移。如果拉取完消息后就提交了位移,当消费x+5时遇到了异常,故障恢复后,重新拉取的消息是从x+8开始的,x+5到x+7之间的消费未被消费,因此消息就丢失了。

如果消费位移提交是在所有消息消费完之后执行的,当消费到x+5时异常,故障恢复后,重新拉取消息从x+2开始的,x+2到x+4之间的消息又被重新消费了一遍,因此发生了重复消费的现象。

三、Kafka消费者_第4张图片
在Kafka中默认消费位移提交的方式是自动的,enable.auto.commit,默认true,这个自动提交不是每消费一条消息就提交一次,而是周期性提交。auto.commit.interval.ms,默认5秒。

默认方式,自动提交,消费者每隔5秒拉取到每个分区的最大消息位移进行提交。自动提交的动作在poll()方法里面完成的,在每次向服务端发起拉取请求之前会检查是否可以提交位移,如果可以,就提交上一次轮循的位移。

在Kafka消费编程中位移提交是一大难点,自动提交方式非常简洁,但是也会存在重复消费和消息丢失的问题。假设刚提交完消费位移,然后拉取下一批消息,在下一次自动提交位移前消费者崩溃了,那么又需要从上一次位移提交的地方开始消费,这里就发生了重复消费的问题。有人说我们可以减小提交的时间间隔,但是这样并不能避免重复消费,而且使得位移提交更加频繁。

消息丢失又是在什么情况下会发生呢?参考下图。线程A拉取消息并存入本次缓存,如BlockingQueue,线程B从缓存里面读取消息并处理。假设目标是第y+1次拉取,第m次提交位移,x+6已经提交,但是线程B在处理x+3,此时线程B异常,待其恢复后从m处也就是x+6位置拉取消息,x+3到x+6之间的消息没有处理,这样就发生了消息丢失。

三、Kafka消费者_第5张图片
自动提交正常情况下不会发生重复消费或者消息丢失的情况,但是也会存在异常情况,自动提交无法做到精准的位移管理。Kafka还提供了手动位移提交的方式,前提是将enable.auto.commited设置为false

properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false)

手动提交有同步提交和异步提交,对应KafkaConsumercommitSync()commitAsync()。这里代码就不演示了。

对于无参commitSync()来说,提交消费位移的频率和拉取批次消息、处理批次消息的频率是一样的,如果追求更细粒度、更精准的提交,就需要用到另一个方法。

void commitSync(Map<TopicPartition, OffsetAndMetadata> offsets);

比如每消费一条消息就提交一次位移, 就可以使用这种方式,代码如下

while (true) {                                                                                             
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));                        
                                                                                                           
    records.forEach(record -> {                                                                            
        long offset = record.offset();                                                                     
        consumer.commitSync(Collections.singletonMap(topicPartition, new OffsetAndMetadata(offset + 1)));  
    });                                                                                                    
                                                                                                           
}                                                                                                          

由于commitSync()是同步执行的,会消耗一定的性能,这种方式将性能拉低到了一个最低点。更多的时候是按照分区粒度提交位移,代码如下

while (true) {                                                                                                                                           
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));                                                                      
    for (TopicPartition topicPartition : records.partitions()) {                                                                                         
        List<ConsumerRecord<String, String>> recordList = records.records(topicPartition);                                                               
        if (recordList.isEmpty()) {                                                                                                                      
            continue;                                                                                                                                    
        }                                                                                                                                                
        // 消息处理                                                                                                                                          
        recordList.forEach(record -> System.out.printf("topic = %s, partition = %d, value = %s\n", record.topic(), record.partition(), record.value())); 
        long lastConsumedOffset = recordList.get(recordList.size() - 1).offset();                                                                        
        consumer.commitSync(Collections.singletonMap(topicPartition, new OffsetAndMetadata(lastConsumedOffset + 1)));                                    
    }                                                                                                                                                    
}                                                                                                                                                        

异步提交的时候不需要阻塞消费者线程,使得消费者性能有一定的增强。

void commitAsync();
void commitAsync(OffsetCommitCallback callback);
void commitAsync(Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback);

下面代码演示了OffsetCommitCallback回调

while (true) {                                                                                                                                 
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));                                                            
    // 处理消息                                                                                                                                    
    records.forEach(record -> System.out.printf("topic = %s, value = %s\n", record.topic(), record.value()));                                  
    consumer.commitAsync((offsets, exception) -> {                                                                                             
        if (exception == null) {                                                                                                               
            System.out.println(offsets);                                                                                                       
        } else {                                                                                                                               
            exception.printStackTrace();                                                                                                       
        }                                                                                                                                      
    });                                                                                                                                        
}                                                                                                                                              

异步提交的时候也会有失败的情况,怎么处理呢?通常想到的方法就是重试,问题也在这里。如果某次提交的消费位移是x,失败了,下次提交的位移是x+y,成功了。如果这里重试的话,消费位移又变成了x。此时发生异常会出现重复消费的问题。

这里的话可以设置一个递增的需要来维护异步提交的顺序,每次位移提交就增加需要对应的值。重试的时候,检查位移和需要值大小,如果前者小于后者,说明有更大的位移已经提交了,不再需要本次重试。如果两者相同,则说明可以重试提交。

一般情况下,位移提交失败的情况很少发生,不重试也没关系,后面的提交也会有成功的。

2.6 控制或关闭消费

KafkaConsumer提供了pause()resume()方法实现某些分区的暂定拉取和恢复。

void pause(Collection<TopicPartition> partitions);
void resume(Collection<TopicPartition> partitions);

KafkaConsumer还提供了paused()方法返回所以被暂定的主题分区。

Set<TopicPartition> paused();

关于如何优雅的退出poll()循环,上面的代码用的是while(true),这里可以使用AtomicBoolean -> set(false)或者调用consumer.wakeup()wakeup()是唯一一个KafkaConsumer可以安全调用的方法,KafkaConsumer是非线程安全的。别忘了finally里面close()回收资源。

AtomicBoolean isRunning = new AtomicBoolean(true); 

try {                                                                                  
    while (isRunning.get()) {                                                          
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));
        // 消息处理                                                                        
        System.out.println(records);                                                   
    }                                                                                  
} catch (WakeupException e) {                                                          
    // ignore                                                                          
} catch (Exception e) {                                                                
    e.printStackTrace();                                                               
} finally {                                                                            
    // 这里可能还提交消费位移                                                                     
    consumer.close();                                                                  
}                                                                                      

2.7 指定位移消费

有种情况,消费者无法找到消费位移。订阅新主题是没有可以查找到的消费位移的。或者消费位移因过期被删除也是查找不到消费位移的。这个时候就需要根据客户端参数auto.offset.reset决定从哪儿开始消费,这个参数的默认值为lasted,表示从下一条要写入的位置开始消费;earliest表示从头开始消费。

三、Kafka消费者_第6张图片
auto.offset.reset参数还有一个值none,表示查找不到消费位移的时候,抛出NoOffsetForPartitionException异常。

有些时候,我们需要从指定位移处开始拉取消息,KafkaConsumer的seek()方法正好提供了这个功能。

void seek(TopicPartition partition, long offset);
void seek(TopicPartition partition, OffsetAndMetadata offsetAndMetadata);

seek()使用方法如下

Duration duration = Duration.ofSeconds(3);    
// ①                         
consumer.poll(duration);     
// ②                                          
Set<TopicPartition> topicPartitionSet = consumer.assignment();         
for (TopicPartition topicPartition : topicPartitionSet) { 
	// ③ 设置每个分区从10开始             
    consumer.seek(topicPartition, 10);                                 
}                                                                      
while (true) {                                                         
    ConsumerRecords<String, String> records = consumer.poll(duration); 
    System.out.println(records);                                       
}                                                                      

①中的poll()参数设置为0的话,方法立刻返回,poll()内部分区操作的逻辑来不及实施,②返回的就是一个空列表,③自然就没有作用了。这里可以稍微改动一下,就是使用assignment()方法判断是否分配到了对应分区。

Duration duration = Duration.ofMillis(100);                           
Set<TopicPartition> topicPartitionSet = Collections.emptySet();       
while (topicPartitionSet.isEmpty()) {                                 
    consumer.poll(duration);                                          
    topicPartitionSet = consumer.assignment();                        
}                                                                     
for (TopicPartition topicPartition : topicPartitionSet) {             
    consumer.seek(topicPartition, 10);                                
}                                                                     
while (true) {                                                        
    ConsumerRecords<String, String> records = consumer.poll(duration);
    System.out.println(records);                                      
}                                                                     

如果未分配到分区调用seek(),会抛出IllegalStateException异常。

如果消费者启动的时候能找到消费位移,除非发生位移越界,否则auto.offset.reset的参数不会生效,此时想指定从头或者从尾开始消费,就需要使用seek()方法了。

Duration duration = Duration.ofMillis(100);                               
Set<TopicPartition> topicPartitionSet = Collections.emptySet();           
while (topicPartitionSet.isEmpty()) {                                     
    consumer.poll(duration);                                              
    topicPartitionSet = consumer.assignment();                            
}                                                                         
Map<TopicPartition, Long> map = consumer.endOffsets(topicPartitionSet);   
map.forEach(consumer::seek);                                              

注意这里的endOffsets()返回的是将要写入最新消息的位置。

Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions);
Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions, Duration timeout);

endOffsets()的等待时间由客户端参数request.timeout.ms设置,默认是30秒。endOffsets()对应的是beginningOffsets(),一个分区的起始位置是0,但是并不代表时刻都是0,因为日志清理的动作会清除就的数据,所以分区的起始位置会自然而然的增加。

Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions);
Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions, Duration timeout);

KafkaConsumer还提供了seekToBeginning()seekToEnd()实现从头或者从尾开始消费。

void seekToBeginning(Collection<TopicPartition> partitions);
void seekToEnd(Collection<TopicPartition> partitions);

有些时候我们不知道特定的消费位置,但是知道一个时间点,比如消费昨天晚上八点以后的数据。KafkaConsumer提供了offsetsForTimes()来实现这种场景。

Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch);
Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch, Duration timeout);

kafka消费位移储存在一个内部主题中,而seek()可以突破这一限制,使得消费位移可以保存在任意介质中,如:数据库、文件系统等。

2.8 再均衡

再均衡指分区所有权从一个消费这转移到另一个消费者的行为,消费组具备高可用和弹性伸缩的特性,我们可以方便又安全的添加或者移除消费者。但是在均衡期间,消费组内的消费者无法读取消息的,换句话说,在均衡发生的这一小段时间内,消费组变得不可用。另外,当一个分区分配给另一个消费者,消费者当前状态也会丢失。比如消费一部分消息但是没来得及提交消费位移发生了再均衡,新的消费者又重新消费了以便原消费者以消费的消息,发生了消费重复。一般情况下,尽量避免不必要的再均衡。

再均衡器可以设定均衡动作前后一些准备或者收尾工作。ConsumerRebalanceListener接口

(1) void onPartitionsRevoked(Collection partitions);
再均衡开始之前和消费者停止读取消息之后被调用。可以通过这个方法提交消费位移,避免一些不必要的重复消费现象的发生。
(2) void onPartitionsAssigned(Collection partitions);
重新分配分区后和消费者开始读取消费消息之前被调用。

Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>(16);                                                                                               
consumer.subscribe(Collections.singletonList(topic), new ConsumerRebalanceListener() {                                                                                   
    @Override                                                                                                                                                            
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {                                                                                             
        consumer.commitSync(currentOffsets);                                                                                                                             
        currentOffsets.clear();                                                                                                                                          
    }                                                                                                                                                                    
                                                                                                                                                                         
    @Override                                                                                                                                                            
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {                                                                                            
        // do nothing                                                                                                                                                    
    }                                                                                                                                                                    
});                                                                                                                                                                      
                                                                                                                                                                         
while (true) {                                                                                                                                                           
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));                                                                                      
    records.forEach(record -> currentOffsets.put(new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset() + 1)));                   
    consumer.commitAsync(currentOffsets, null);                                                                                                                          
}                                                                                                                                                                        

代码中使用了一个currentOffsets局部变量保存消费位移,正常消费完成后异步提交消费位移,再均衡发生之前通过再均衡器监听同步提交消费位移,避免不必要的重复消费。

我们还能将消费位移保存在外部,并且听过seek()来优化。

三、Kafka消费者_第7张图片

2.9 消费者拦截器

消费者拦截器主要在消费到消息或者提交消费位移的时候进行一些定制化操作。

消费者拦截器需要实现org.apache.kafka.clients.consumer.ConsumerInterceptor接口。

ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);
void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);
void close();

KafkaConsumer在poll()之前调用拦截器的onConsume()对消息进行定制化操作。如果onConsume()方法抛出异常,会被捕获并记录到日志中,不会再向上传递。

在消费位移提交完成后调用onCommit(),使用这个方法可以跟踪提交的位移信息。

在自定义消费者拦截器后,可以通过配置interceptor.classes来指定拦截器,这里也有拦截器链的概念,多个用,隔开。如果拦截器链中的某个拦截器执行失败,那么下一个拦截器会接着上一个执行成功的拦截器继续执行。

2.10 多线程实现

KafkaProducer是线程安全的,而KafkaConsumer是非线程安全的。KafkaConsumer中定义了一个acquire()方法,用来检测当前是否只有一个线程在操作,若有其它线程在操作抛出ConcurrentModificationException异常。

KafkaConsumer每个公共方法执行前都会先调用acquire()方法,wakeup()例外。

acquire()和通常说的锁不同,它不会造成阻塞等待,我们可以将其理解为一个轻量级锁,它通过线程技术标记的方式检测线程是否发生了并发操作,以此保证只有一个线程在操作。acquire()release()方法成对出现,表示加锁和解锁。

acquire()release()是私有方法,因此实际应用中不需要显示调用。

KafkaConsumer是非线程安全的不代表我们只能以单线程的方式执行。生产者发送消息速度大于消费者处理消息速度,那么会有越来越多的消息得不到及时消费,造成一定的延迟。kafka的消息保留机制,有些消息在消费之前被清理了,从而造成消息的丢失。我们可以通过多线程消费的方式提高整体的消费能力。

第一种,每一个线程实例化一个KafkaConsumer对象。一个消费线程消费一个或者多个分区,所有消费线程属于同一个消费组,但是当消费线程数大于分区数,会有消费线程因分配不到分区一直处于空闲状态。

三、Kafka消费者_第8张图片

第二种,通过assign()seek()等方法实现。不过这种方式对于消费位移提交和顺序控制处理非常复杂,不推荐使用。

第三种,将消息模块改成多线程的实现方式,一个消费者,poll()还是单线程,但是使用多线程去处理拉取的消息。

三、Kafka消费者_第9张图片
第三种实现方式相比第一种方式而言,除了横向扩展能力,还可以减少TCP连接对系统资源的消耗,缺点是消息顺序性处理较难。这里也会出现消息丢失的问题。

再提供一种解决思路,总体上是基于滑动窗口实现的。

三、Kafka消费者_第10张图片
如上图所示,一个方格代表一个批次的消息,一个滑动窗口包含若干方格,startOffset是滑动窗口的起始位置,endOffset是滑动窗口的末尾位置。每当startOffset指向的方格被消费完成,就可以提交这部分的消费位移,与此同时,窗口滑动一格,删除原来startOffset所值方格中对应的消息,并且拉取新消息进入窗口。滑动窗口大小固定,消息缓冲大小也固定了,这部分内存开销可控。 一个方格对应一个消费线程,对于窗口大小固定的情况,方格越小并行度越高,对于方格大小固定的情况,窗口越大并行度越高。

如果一个方格内消息无法被标记为消费完成,那么会造成startOffset悬停。为了使窗口能继续滑动,需要设定一个阈值,当startOffset悬停一段时间后对这部分消息本地重试消费,失败就转入重试队列,如果还不奏效就转入死信队列。真实应用无法消费的情况极少,一般都是业务代码处理逻辑引起的。如果需要保证消息的可靠,可以将无法处理的消息写入磁盘或者保存在其他地方。

2.11 重要的消费者参数

  • fetch.min.byte
    配置Consumer在一起拉取请求能从kafka拉取的最小数据量,默认是1B。Kafka在收到消费者拉取请求时,如果数据量小于这个配置,就需要等待,知道满足这个大小。调大这个参数可以提供一定的吞吐量,不过会造成额外的延迟。

  • fetch.max.bytes
    配置Consumer在一起拉取请求能从kafka拉取的最大数据量,默认50MB。比如这个参数小于任何一条消息,那么会不会造成无法消费呢?答案是不会无法消费,该参数设定的不是绝对最大值,如果第一个非空分区中拉取的第一条消息大于该值,该消息仍能返回,保证消费者继续工作。

  • fetch.max.waite.ms
    消息小于fetch.min.bytefetch.min.byte时最大阻塞时间,默认500ms。

  • max.partition.fetch.bytes
    每个分区返回给Consumer的最大数据量,默认1MB。这个参数跟fetch.max.bytes相似,前者用来限制一次拉取每个分区的消息大小,后者用来限制一次拉取整体消息大小。

  • max.poll.record
    一次拉取最大消息条数,默认500条。

  • connections.max.idel.ms
    多久之后关闭限制的连接,默认9分钟

  • exclude.internal.topics
    Kafka有两个内部主题:__consumer_offsets和__transaction_state。这个参数用来指定这两个内部主题是否向消费者公开,默认true。true只能用subscribe(Collection)的方式订阅主题,false则没有限制。

  • receive.buffer.bytes
    Scoket接受消息缓冲区(SO_RECBUF)的大小,默认64KB。-1则使用系统默认值。

  • send.buffer.bytes
    Scoket发送消息缓冲区(SO_SEDBUF)大小,默认128KB,-1则使用系统默认值。

  • request.timeout.ms
    消费者等待请求响应的最长时间,默认30000ms

  • metadata.max.age.ms
    元数据过期时间,默认值5分钟。元数据在这个时间内没有进行更新,会被强制更新。

  • reconnect.backoff.ms
    尝试重新连接主机之前的等待时间(也称为避退时间),默认50ms,避免频繁的连接主机。

  • retry.backoff.ms
    尝试重新发送失败的请求到指定的主题分区等待时间,默认值100ms

  • isolation.level
    消费者事务隔离级别。字符串类型,有效值为read_uncommittedread_committed,表示消费者所消费到的位置,如果设置为read_committed,那么消费者就会忽略事务未提交的消息,即只能消费到LSO (LastStableOffset)的位置,默认情况下为read_uncommitted,即可以消费到HW(High Watermark)处的位置。

部分参数

参数名 默认值 解释
bootstrap.servers “” kafka地址
key.deserializer key的反序列化类
value.deserializer value的反序列化类
group.id “” 消费组名称
client.id “” 消费者客户端id
heartbeat.interval.ms 3000 当使用Kafka的分组管理功能时,心跳到消费者协调器之间的预计时间。心跳用于确保消费者的会话保持活动状态,当有新消费者加入或离开组时方便重新平衡。该值必须比 session.timeout.ms小,通常不高于1/3。它可以调整得更低,以控制正常重新平衡的预期时间。
session.timeout.ms 10000 检测消费者是否超时的时间
max.poll.interval.ms 300000 当通过消费组管理消费者时,该配置指定拉取消息线程最长空闲时间,若超过这个时间间隔还没有发起poll操作,则消费组认为该消费者已离开了消费组,将进行再均衡操作。
auto.offset.reset latest 参数值为字符串类型有效值为earliest1atestnone,配置为其余值会报出异常。
enable.auto.commit true 是否自动提交位移
auto.commit.interval.ms 5000 自动提交位移的时间间隔
partition.assignment.strategy org.apache.kafka.clients.consumer.RangeAssignor 消费者的分区分配策略
interceptor.class “” 消费者拦截器

你可能感兴趣的:(kafka,kafka,java,分布式)