深入理解Kafka学习笔记-第三章 消费者

消费者组

Kafka中存在一个消费者组的概念,每个消费者都属于一个消费者组。当消息发布到主题之后,只会被订阅它的消费者组中的其中一个消费者消费。每个分区只能被一个消费者组中的一个消费者所消费。

这里就有一个分配策略的概念:
对一个消费组而言, 一个分区被一个消费者所消费,如果订阅主题有6个分区,一个消费者消费6个分区,两个消费者各消费3个分区,三个消费者个消费两个分区;这样消费者具有横向伸性,可以通过增加或减少消费者数量来提升或降低整体的消费能力。
(默认使用的是RangeAssignor分配策略)

1、其他的分配策略?

消息的两种投递模式

  • 点对点模式:如果所有消费者都隶属于一个消费者组,那么所有的消息都会被均衡地投递给每一消费者,每条消息都只会被一个消费者消费。
  • 发布/订阅模式: 如果每个消费者都属于不同的消费者组,那么所有的消息都会被投递给所有的消费者,每条消息会被所有的消费者消费。

##消费者

具体步骤:

  1. 配置消费者客户端参数及创建相应的消费者实例;
    bootstrap.servers: 格式:host:port,默认为"",不必配置全; group.id: 默认值为"",不配置会报错;client.id: 会生成"consumer-"与数字的拼接。
  2. 订阅主题;
    订阅主题,如果前后订阅了不同的主题,那么以最后一次的为准;
    还有一个带正则参数的方法;
    第二个参数是再均衡监听器;
    还可以直接订阅某些主题的特定分区,通过assign()方法来实现。
    可以通过partitionsFor来查询指定主题的元数据信息

List partitionsFor(String topic)

例子:通过assign方法来获取各个分区的position、committed offset、lastConsumedOffset


    Properties props = initConfig();
    KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
    
    List<PartitionInfo> partitionInfos = consumer.partitionsFor(topic);
    
    partitionInfos.stream().forEach(partitionInfo -> {
     
        int partition = partitionInfo.partition();
        TopicPartition tp = new TopicPartition(topic, partition);
    
        consumer.assign(Arrays.asList(tp));
        long lastConsumedOffset = -1;
        while (true) {
     
            ConsumerRecords<String, String> records = consumer.poll(1000);
            if (records.isEmpty()) {
     
                break;
            }
            List<ConsumerRecord<String, String>> partitionRecords
                    = records.records(tp);
            lastConsumedOffset = partitionRecords
                    .get(partitionRecords.size() - 1).offset();
            consumer.commitSync();//同步提交消费位移
        }
        System.out.println(partition+", comsumed offset is " + lastConsumedOffset);
        OffsetAndMetadata offsetAndMetadata = consumer.committed(tp);
        System.out.println(partition+", commited offset is " + offsetAndMetadata.offset());
        long posititon = consumer.position(tp);
        System.out.println(partition+", the offset of the next record is " + posititon);
    });

结论:position = committed offset = lastConsumedOffset + 1

  1. 拉取消息并消费;
    ConsumerRecords提供了records(TopicPartition tp)方法来获取消息集中指定分区的消息。当然也有主题纬度的重载方法;

单线程;
多线程:

  1. 提交消费位移;

对分区而言,每条消息都有一个唯一的offset,用来表示消息在分区中对应的位置。对于消费者而言,它也有一个offset的概念,表示消费到分区中某个消息所在的位置。
消费位移现在存储在内部主题_consumer_offsets中,消费位移保存的动作成为位移提交。

[外链图片转存失败(img-fiF2a98Z-1562045342121)(leanote://file/getImage?fileId=5d19d9ab5e2ec1579100014f)]

x表示某一次拉取操作中此分区消息的最大偏移量,假设当前者已经消费了x位置的消息,则消费者的消费位移为x,使用lastConsumedOffset来表示。
但是这里要提交的消费位移并不是x,而是x+1,用position表示,它表示下一条需要拉取的消息的位置。
消费者中还有一个committed offset的概念,表示已经提交过的消费位移。
KafkaConsumer提供了两个方法来获取上面的两个值:
position : long position(TopicPartition partition)
committed offset: OffsetAndMetadata committed(TopicPartition partition)

提交时机:消息重复消费,消息丢失
如果拉取一批消息,来不及提交,出现错误,会出现重复消费的情况;
如果拉取一批消息,提前提交了位移,但是还有消息没有消费,后面的消息就丢失了;

自动提交:默认情况下,消费者每隔5秒会将拉取到分区中最大的消费位移进行提交,可能出现重复消费的情况,但如果编程讲消息暂存到队列里,这可能出现消息丢失的情况。

手动提交有两种方法:
commitSync()可以拉取一批消息做相应的处理,也可以使用一个缓冲区,积累到足够的数量再提交(延迟问题),它提交的频率和拉取批次消息、处理批次消息的频率是一样的。
如果想更细粒度的处理可以使用
commitSync(final Map offsets),通过Offsets参数可以提交指定分区的位移。
例子:按分区粒度提交位移:

try {
        while (running.get()) {
            ConsumerRecords records = consumer.poll(1000);
            for (TopicPartition partition : records.partitions()) {
                List> partitionRecords =
                        records.records(partition);
                for (ConsumerRecord record : partitionRecords) {
                    //do some logical processing.
                }
                long lastConsumedOffset = partitionRecords
                        .get(partitionRecords.size() - 1).offset();
                consumer.commitSync(Collections.singletonMap(partition,
                        new OffsetAndMetadata(lastConsumedOffset + 1)));
            }
        }
    } finally {
        consumer.close();
    }

还有异步提交位移的commitAsync()方法,同样提供了带参数的、分区粒度提交的重载方法;并且异步提交提供了一个回调方法,可以对成功和失败的场景进行处理。 异步提交要考虑前一次提交失败,后面一次提交成功的情况,还有消费者退出或者再均衡的情况,这时应该在最后同步提交一次位移,不然出现无必要的重复消费。

指定消费位移

当消费者找不到消费位移时(位移越界也会),消费者会根据消费者客户端参数auto.offset.reset的配置来决定从何处开始消费,默认值为"lastst",表示从分区的末尾开始消费;如果设置未"earliest",表示会从起始处开始消费;如果配置为"none",找不到消费位移时直接报错。

当然也可以细粒度的从特定的位移开始处开始拉取位移,KafkaConsumer提供的seek()方法提供了这个功能
void seek(TopicPartition partition, long offset)
seek方法只能重置消费者分配到的分区的消费位置,而分区分配实在poll方法的调用过程中实现的。也就是在执行seek方法之前需要执行一次poll方法,等到分配到分区之后才能重置消费位置。可以通过KafkaConsumer的assignment方法来判定是否分配到分区。
例子:从消费位移为10的位置开始消费:


    Properties props = initConfig();
    KafkaConsumer consumer = new KafkaConsumer<>(props);
    consumer.subscribe(Arrays.asList(topic));
    long start = System.currentTimeMillis();
    Set assignment = new HashSet<>();
    while (assignment.size() == 0) {
        consumer.poll(Duration.ofMillis(100));
        assignment = consumer.assignment();
    }
    long end = System.currentTimeMillis();
    System.out.println(end - start);
    System.out.println(assignment);
    for (TopicPartition tp : assignment) {
        consumer.seek(tp, 10);
    }
    while (true) {
        ConsumerRecords records =
                consumer.poll(Duration.ofMillis(1000));
        //consume the record.
        for (ConsumerRecord record : records) {
            System.out.println(record.offset() + ":" + record.value());
        }
    }

通过Map endOffsets(Collection partitions)获取分区的末尾位置
Map beginningOffsets(Collection partitions)获取分区的开始位置,不一定是0,日志清理动作会清理旧的数据。

上面的方法不一定有用,还有可以消费指定时间后的消息,可以通过Map offsetsForTimes(Map timestampsToSearch)来追溯到相应的位置。
例子:

    KafkaConsumer consumer = new KafkaConsumer<>(props);
    consumer.subscribe(Arrays.asList(topic));
    Set assignment = new HashSet<>();
    while (assignment.size() == 0) {
        consumer.poll(Duration.ofMillis(100));
        assignment = consumer.assignment();
    }
    System.out.println(assignment);

    Map timestampToSearch = new HashedMap();
    for (TopicPartition tp : assignment) {
        timestampToSearch.put(tp, System.currentTimeMillis() - 1*24*3600*1000);
    }
    Map offsets = consumer.offsetsForTimes(timestampToSearch);
    System.out.println(offsets);

    for (TopicPartition topicPartition : assignment) {
        OffsetAndTimestamp offsetAndTimestamp = offsets.get(topicPartition);
        if (offsetAndTimestamp != null) {
            consumer.seek(topicPartition, offsetAndTimestamp.offset());
        }
    }

    while (true) {
        ConsumerRecords records =
                consumer.poll(Duration.ofMillis(1000));
        //consume the record.
        for (ConsumerRecord record : records) {
            System.out.println("topic = " + record.topic()
                    + ", partition = " + record.partition()
                    + ", offset = " + record.offset());
            System.out.println("key = " + record.key()
                    + ", value = " + record.value());
        }
    }

通过seek方法结合再均衡监听器可以将消费位移保存到外部存储介质中,提供更加精细的消费能力。

  1. 关闭消费者实例。

pause()和resume()方法分别用来实现暂停某些分区在拉取操作时返回数据给客户端和恢复拉取操作。
注意事项:

再均衡

public interface ConsumerRebalanceListener {
    /**
    *再均衡之间消费者停止;拉取消息之后调用,可以用来同步提交位移
    */
    void onPartitionsRevoked(Collection partitions);
    /**
    *再重新分配分区之后,消费者拉取消息之前调用
    */
    void onPartitionsAssigned(Collection partitions);
}

例子: 再均衡监听器的使用


    Map currentOffsets = new HashMap<>();
    consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
        @Override
        public void onPartitionsRevoked(Collection partitions) {
            consumer.commitSync(currentOffsets);
        }

        @Override
        public void onPartitionsAssigned(Collection partitions) {
            //do nothing.
        }
    });

    try {
        while (isRunning.get()) {
            ConsumerRecords records =
                    consumer.poll(Duration.ofMillis(100));
            for (ConsumerRecord record : records) {
                //process the record.
                currentOffsets.put(
                        new TopicPartition(record.topic(), record.partition()),
                        new OffsetAndMetadata(record.offset() + 1));
            }
            consumer.commitAsync(currentOffsets, null);
        }
    } finally {
        consumer.close();
    }

消费者拦截器

public interface ConsumerInterceptor extends Configurable {
    /**
     * 再poll方法返回之前调用,对消息进行相应的定制化处理,比如过滤消息等;异常不会往上抛
     */
    public ConsumerRecords onConsume(ConsumerRecords records);

    /**
     * 在提交消费位移之后调用,可以用来记录跟踪提交的位移信息,比如同步提交的时候
     */
    public void onCommit(Map offsets);

    public void close();
}

消费者线程

每一个主要的方法调用之前都会调用acquireAndEnsureOpen()方法,其调用acquire()方法

    private void acquire() {
        long threadId = Thread.currentThread().getId();
        if (threadId != currentThread.get() && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId))
            throw new ConcurrentModificationException("KafkaConsumer is not safe for multi-threaded access");
        refcount.incrementAndGet();
    }

可以看到每一个KafkaConsumer不支持多线程。
如何多线程的消费消息呢?
1、线程封闭,每一个线程实例化一个KafkaConsumer。每一个消费线程消费一个或多个分区,所有线程隶属于一个消费者组,一般线程的个数不大于分区的个数即可。
2、将处理消息的任务交给线程异步处理,这里要考虑消费位移提交的问题,可能造成消息的丢失。

###重要参数

1、fetch.min.bytes
一次拉取的最小数据量,该值得大小是吞吐量和延迟的折中;默认1B.一般可以增大该值,提升吞吐量。

2、fetch.max.wait.ms
拉取的最长等待时间,如果一直没有消息可拉取,超过这个时间会返回,配合上面的参数使用;默认值为500ms.

3、max.poll.records
配置一次拉取的最大消息数,默认为500.如果消息比较小,且处理的比较快可以增大该值。

4、max.poll.interval.ms
消费者组管理消费者时,拉取消息最长等待空间时间,如果超过该间隔,没有发起poll操作,消费者组会认为该消费者已离开,进行再均衡操作。

如果单线程拉取消息,且处理的时间比较长?
(表现异常rebalance,而且平均间隔2到3分钟就会rebalance一次)

1、增大max.poll.interval.ms值
2、减小max.poll.records值
3、没处理一条消息,提交一条消息

你可能感兴趣的:(消息中间件,Kafka)