快速入门Kafka消费者

消费者与消费者组

消费者(Consumer)负责订阅Kafka中的主题(Topic),并且从主题上拉取消息。Kafka中还存在消费者组(Consumer Group)的概念。每一个消费者都有一个对应的消费者组。当消息发布到主题后,会被投递给订阅它的消费者组中的一个消费者。

Kafka支持两种消息投递模式:点对点(P2P,Point-to-Point)模式和发布订阅(Pub/Sub)模式.

  • 点对点模式基于队列,消息生产者发送消息到队列,消费者从队列中接受消息。所有消费者都位于同一个消费者组中,每条消息只会被一个消费者所处理。
  • 发布订阅模式在消息一对多广播时采用。所有消费者都属于不同的消费者组,那么所有消息都会广播给所有消费者,每条消息会被所有消费者所处理。

要注意,主题下的某个分区的消息只能分配给一个消费者消费,一条消息也只能给消费者组中一个成员消费者进行消费。关于消费者组,踩过的坑详见:关于Kafka消费者群组的使用与理解–记一次故障引入的及时测试暴露与定位

客户端开发一般流程

一般消费者的消费逻辑包含以下几个步骤:

  1. 配置消费者客户端参数及创建相应的消费者实例;
  2. 订阅主题;
  3. 拉取消息并消费;
  4. 提交消费位移;
  5. 关闭消费者。

示例:

public class KafkaConsumerAnalysis {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupId = "group.demo";
    public static AtomicBoolean isRunning = new AtomicBoolean(true);
    
    //初始化消费者配置
    public static Properties initConfig() {
        Properties pros = new Properties();
        props.put(“bootstrap.servers", brokerList);
        props.put("key.serializer",
                 "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer",
                 "org.apache.kafka.common.serialization.StringSerializer");
        props.put("client.id", "consumer.client.id.demo");
        props.put("group.id", groupId);
        
        return props;
    }
                  
	public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Arrays.asList(topic));
        
        try {
            while(isRunning.get()) {
            ConsumerRecords<String, String>  records = consumer.poll(Duration.ofMills(1000));
                for (ConsumerRecords<String, String> record : records) {
                    //处理消息
                }
        	}
        } catch (Exception e) {
            log.error("exception happends" , e);
        } finally {
            consumer.close();
        }
        
    }    
}

订阅主题与分区

  • 订阅主题

    consumer的subscribe()方法可以用来为消费者订阅主题,可以以集合形式订阅主题,也可以以正则表达式的形式订阅特定模式的主题。subscribe()方法有以下几种重载方法:

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

    注意:一个消费者若先后两次调用subscribe()方法订阅主题,那么最终订阅的主题以最后调用的为准,即主题的多次订阅会以覆盖的形式呈现。

  • 订阅特定分区

    Kafka消费者除了可以订阅主题,也可以通过assign()方法来直接订阅主题下的特定分区,assign()方法签名如下:

    public void assign(Collection<TopicPartition> partition);
    

    泛型TopicPartiton类中只有两个属性:topic和partition,分别代表分区所属的主题和自身分区编号。下列代码实现了消费者只订阅分区中编号为0的分区:

    consumer.assign(Arrays.asList(new TopicPartition("topic-demo", 0)));
    
  • 获取主题元数据信息

    KafkaConsumer还提供了一个获得某一主题下所有分区的方法:partitionsFor()方法,该方法可以查询指定主题的元数据信息,定义如下:

    public List<PartitionInfo> partitionsFor(String topic);
    
  • 取消订阅

    unsubscribe()方法用于取消消费者所订阅的主题或分区,如果将subscribe(Collection)和assign(Collection)方法中的集合参数设置为空,也可以取消订阅,如下三个方法的效果相同:

    consumer.unsubscribe();
    consumer.subscribe(new ArrayList<>());
    consumer.assign(new ArrayList<TopicPartition>());
    

消费消息

Kafka中的消息消费是基于拉模式的,由消费者主动向服务端发起请求来拉取消息。

Kafka中消息消费是一个不断轮询的过程,消费者不断调用poll()方法,返回所订阅的主题(分区)上的一组消息。

poll()方法定义如下:

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

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

在Kafka消费者中,消费方式一般有以下两种:

  1. 按分区消费消息

    ConsumerRecords类提供了records(TopicPartition)方法,用来获取去消息集中指定分区的消息:

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

    以下代码展示了如何获取消息集中的所有分区信息:

    ConsumserRecord<String, String> records = consumer.poll(Duration.ofMills(1000));
    for (TopicPartition tp : records.partition()) {
        for (ConsumerRecord<String, String> record : records.records(tp)) {
            System.out.println(record.partition() + ":" + record.value));
        } 	   
    }
    
    
  2. 按主题消费消息

    public Iterable<ConsumerRecord<K, V>> records(String topic);
    

    ConsumerRecords类并没有提供与partition()类似的topics()方法来获取消息集中所有包含的主题,所以按主题消费消息只能遍历消费者订阅的主题列表来获取消息,代码片段如下:

    List<String> topicList = Arrays.asList(topic1, topic2);
    consumer.subscribe(topicList);
    try {
        while(isRunning.get()) {
            ConsumerRecords<String, String> records = 	consumer.poll(Duration.ofMills(1000));
            for (String topic : topicList) {
                for (ConsumerRecord<String, String> record : records.records(topic)) {
                    System.out.println(record.topic + ":" + record.value());
                }
            }
        }
    } finally {
        consumer.close();
    }
    

位移提交

每条消息在Kafka的分区中都有自己的offset,表征自身在分区中的位置。消费者也有offset的概念,表示消费到分区中某个消息所在的位置。在分区中,offset可以翻译为“偏移量”,在消费者层面,offset可以翻译为“位移”。

关于lastConsumerOffset、committed offset和position之间的关系如下图所示:

position = committed offset = lastConsumedOffset + 1

从用户角度来看,位移提交有两种方式,分别为自动提交手动提交

自动提交

开启Consumer端的参数enable.auto.commit,将其设置为true,则开启了自动提交。

同时派上用场的另一个参数:auto.commit.interval.ms,其默认值为5秒,表明Kafka每5秒自动提交一次位移。

设置上述两个参数的部分代码为:

props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "2000");

开启了自动提交,Kafka会保证在开始调用poll方法时,提交上次poll返回的所有消息。先提交上一批消息的位移,在处理下一批消息,因此自动提交可以保证不出现消费丢失的情况

自动提交消费位移的方式较为简便,避免了复杂的位移提交逻辑。但是自动提交也有较大的缺点,那就是容易造成重复消费的问题。

重复消费的具体场景如下:刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者发生了崩溃或者Kafka发生了重平衡,那么就会从上一次位移提交的地方重新开始消费。减少自动提交的时间间隔可以减小重复消息的窗口大小,但无法完全避免消息的重复消费,并且会导致位移提交更加频繁。

手动提交

与自动提交相比,手动提交实现更加灵活,能够完全把控位移提交的时机和频率。

使用手动提交,需要将enable.auto.commit参数设置为false,然后在应用程序中手动调用相应的API手动进行消费位移的提交。

从Consumer端角度来看,手动提交根据方法返回方式又分为两种,分别为同步提交和异步提交。

同步提交

同步提交对应的API 是KafkaConsumer#commitSync(),方法声明如下:

public void commitSync();

这是一个同步操作,方法被调用后会一直等待,直到位移被成功提交才会返回。同步提交的一般使用方法如下:

while(isRunning.get()) {
    ConsumerRecords<String, String> records = consumer.pool(Duration,ofSeconds(1));
    for (ConsumerRecord<String, String> record : records) {
    	process(record);//处理消息    
    }
    
    try {
        consumer.commitSync();
    } catch (CommitFailedException e) {
        handle(e);//处理提交失败异常
    }
}

手动同步提交在调用commitSync()时,Consumer会处于阻塞状态,直到Broker返回提交结果,阻塞状态才会结束,位移提交时的阻塞情况会影响整个应用程序的TPS。延长提交间隔可以减少阻塞对性能带来的负面影响,但是后果是Consumer的提交频率降低,下次Consumer重启回来后,会有更多消息被重复消费。

异步提交

异步提交的方法声明:

public void commitAsync();

这是一个异步操作,调用该方法后会立即返回,不会阻塞,不会影响Consumer应用的TPS 。由于它是异步的,Kafka提供了回调函数(callback),供实现提交之后的逻辑,如记录日志或处理异常等等。commitAsync的一般使用方法如下:

while(isRunning.get()) {
    ConsumerRecords<String, String> records = consumer.pool(Duration,ofSeconds(1));
    for (ConsumerRecord<String, String> record : records) {
    	process(record);//处理消息    
    }
    
    consumer.commitAsync(offsets, exception) -> {
        if (exception != null) {
            handle(exception);
        }
    });
}

异步提交能否完全替代同步提交呢?不能,异步提交的缺陷在于,出现问题时不会自动重试。因为是异步操作,如果提交失败后自动重试,那么重试时提交的位移可能已经过期。因此重试对于异步提交来说没有意义,故调用commitAsync是不会重试的。

同步+异步组合位移提交

那么,在手动提交时,使用同步提交+异步提交的组合方式,才能达到最理想的效果。二者配合使用可以解决一下问题:

  1. commitSync的重试机制可以规避瞬时性的错误,如网络抖动、Broker端GC等。针对这些瞬时性问题,自动重试通常都会成功;
  2. commitAsync的机制可以避免程序总是处于阻塞状态,从而减少对TPS的影响。

同步+异步提交的具体实现如下:

try {
    while(isRunning.get()) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
        for (ConsumerRecord<String, String> record : records) {
            process(records);// 处理消息
            consumer.commitAsync();// 使用异步提交规避阻塞
        }
    }
} catch (Exception e) {
    handle(e);//处理异常
} finally {
    try {
        consumer.commitSync(); // 最后异常提交使用同步阻塞式提交
    } finally {
        consumer.close();
    }
}

上述代码段同时使用了commitSync()和commitAsync()。对于常规阶段性提交调用commitAsync()避免程序阻塞,而在Consumer关闭前,调用commitSync()方法执行同步阻塞式的位移提交,以确保Consumer关闭前能够保存正确的消费位移数据。

指定分区、消费位移提交

Consumer提供了带参数的同步和异步提交方法,实现按具体消费位移进行提交,方法声明如下:

public void commitSync(Map<TopicPartition, OffsetAndMetadata>);
public void commitAsync(Map<TopicPartition, OffsetAndMetadata>);

方法的参数是一个Map对象,key为TopicPartition,即消息的分区,value为OffsetAndMetadata对象,保存的主要是位移数据。

例如,我们需要每处理100条消息就提交一次位移,以commitAsync为例(commitSync实现方式相同)。相应代码如下:

private Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
int count = 0;
......
while(isRunning.get()) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
        for (ConsumerRecord<String, String> record : records) {
            process(records);// 处理消息
            offsets.put(new TopicPartition(record.topic(), record.partition()), 
                       new OffsetAndMetadata(record.offset() + 1)); //待提交的位移为下一条消息的位移
            if (count % 100 == 0) {
                consumer.commitAsync(offsets, null); //回调处理逻辑为null
            }
            count++;
        }
}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章