12.指定位移消费

在上一节讲述了如何进行消费位移提交,正是有了消费位移的持久化,才使消费者在关闭、奔溃或者在遇到再均衡的时候,可以让接替的消费者能够根据之前存储的消费位移继续消费。但是,如果当一个新的消费组建立的时候,根本没有可以查找的消费位移。或者消费组内的一个新消费者订阅了一个新的主题,它也没有可以查找的消费位移。(同一个分区的消息,对同一个消费组来说只能消费一次。所以当新的消费组建立或者消费者订阅了新的消费组,也就代表了该消费组在这个分区中没有位移信息。)当_consumer_offsets 主题中有关这个消费组的位移信息过期而被删除后,它也没有可以查找的消费位移。
在这里插入图片描述
在Kafka中,当消费者找不到所记录的消费位移时,就会根据消费者客户端参数 auto.offset.reset 的配置来决定从何处开始消费,如果值是“earliest” 那么从起始处开始,如果值是“latest”那么从末尾开始,默认值是“latest”(9是下一条要写入消息的位置)。除了查找不到消费位移,位移越界也会触发 auto.offset.reset 参数的执行。
在默认配置下,用一个新的消费组来消费主题的时候,客户端会报出重置位移的提示信息:
在这里插入图片描述
auto.offset.reset 参数还有一个可配置的值是“none”,该配置意味着出现查不到的消费位移的时候,既不会从起始出开始,也不会从末尾处开始,而是会报出 NoOffsetForPartitionException 异常:

org.apache.kafka.clients.consumer.NoOffsetForPartitionException: Undefined offset with no reset policy for partitions: [topic-demo-3, topic-demo-0, topic-demo-2, topic-demo-1].

如果能够找到消费位移,那么配置“none”不会报出任何异常。如果配置的不是以上三个值那么会抛出 ConfigException 异常:

org.apache.kafka.common.config.ConfigException: Invalid value any for configuration auto.offset.reset: String must be one of: latest, earliest, none.

拉取消息是根据poll()方法中的逻辑来处理的,对于开发者而言不知道具体的逻辑,无法精确地掌控其消费的起始位置。提供的 auto.offset.reset 参数也只能在找不到消费位移或者位移越界的时候从起始位置或者末尾位置开始消费。但是有时候我们需要更细粒度的掌控,可以让我们从特定的位移处开始拉取消息,而KafkaConsumer 中的 seek() 方法正好提供了这个功能,可以追前消费或回溯消费。seek() 方法的具体定义如下:

public void seek(TopicPartition partition, long offset)

partition 表示分区,offset 用来指定从分区的那个位置开始消费。 seek()方法只能重置消费者分配到的分区的消费位置,而分区的分配是在 poll() 方法的调用过程中实现的。(比如说主题A有两个分区,消费组A1有两个消费者,为了消费能力的提升,所以 Kafka 会有一个分配策略这样的话每个消费者会分到一个分区)也就是说在调用seek()方法之前需要调用一次poll()方法,等分配到分区后才可以重置消费位置。示例:

//代码清单12-1 seek方法的使用示例
    private static final Logger logger = LoggerFactory.getLogger(KafkaConsumerAsync.class);

    public static final String brokerList = "172.16.15.89:9092";
    public static final String topic = "bbbbbb";
    public static final String groupId = "group8";
    public static final AtomicBoolean isRunning = new AtomicBoolean(true);

    public static Properties ininConfig(){
        Properties properties=new Properties();
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
        properties.put(ConsumerConfig.GROUP_ID_CONFIG,groupId);
        return properties;
    }


    public static void main(String[] args) {
        Properties properties=ininConfig();
        KafkaConsumer<String,String> consumer=new KafkaConsumer<String, String>(properties);

        //订阅主题
        consumer.subscribe(Arrays.asList(topic));

        //调用poll方法
        consumer.poll(Duration.ofMillis(1000));

        //获取消费者所分配到的分区
        for (TopicPartition tp:consumer.assignment()){
            //设置从位移为66的开始 因为只有一个分区,并且该主题上节添加了100条数据 那么此次应该是66条开始
            consumer.seek(tp,66);
        }

        while (isRunning.get()){
            ConsumerRecords<String,String> records=consumer.poll(Duration.ofMillis(1000));

            for(ConsumerRecord<String,String> record:records){
                System.out.println(record.value());
            }

        }
        consumer.close();
    }

在这里插入图片描述
上面代码中 //调用poll方法 这一步,如果将poll()方法的参数设置为0,此方法就会立即返回,那么poll()方法内部进行分区分配的逻辑就会来不及实施。也就是说,消费者没有分配到任何分区,那么 assignment() 方法就是一个空列表,seek() 方法也就不会被循环调用。那么poll()方法的timeout参数设置为多少合适呢?太短会使分配分区的动作失败,太长又可能造成不必要的等待。我们可以通过 KafkaConsumer 的 assignment() 方法来判定是否分配到了相应的分区,示例:

//代码清单12-2 seek()方法的另一种使用示例
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

consumer.subscribe(Arrays.asList(topic));

Set<TopicPartition> assignment = new HashSet<>();

//如果集合为空 那么继续调用poll()方法 取分区
while (assignment.size() == 0) {//如果不为0,则说明已经成功分配到了分区
    consumer.poll(Duration.ofMillis(100));
    assignment = consumer.assignment();
}

for (TopicPartition tp : assignment) {
    consumer.seek(tp, 10);
}

while (true) {
    ConsumerRecords<String, String> records =
            consumer.poll(Duration.ofMillis(1000));
    //consume the record.
}

如果对未分配到的分区执行 seek() 方法,那么就会报出 IllegalStateException 异常:

consumer.subscribe(Arrays.asList(topic));
consumer.seek(new TopicPartition(topic,0),10);
从开始或从末尾消费

如果消费组内的消费者在启动的时候能够找到消费位移,那么除非是位移越界,否则 auto.offset.reset 参数并不会生效,如果此时想从开头或者末尾开始消费,就需要 seek() 方法的帮助示例:

//用来获取指定分区的末尾的消费位置  注意:这里获取的是将要写入最新消息的位置
Map<TopicPartition, Long> offsets = consumer.endOffsets(assignment);    ①
for (TopicPartition tp : assignment) {
    consumer.seek(tp, offsets.get(tp));							        ②
}

consumer.endOffsets() 方法定义如下,其中 partitions 参数表示分区的集合,而 timeout 参数用来设置等待获取的超时时间。 如果没有指定 timeout 参数,那么由客户端参数 request.timeout.ms 来设置,默认值30000。

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

与 endOffsets() 方法对应的是 beginningOffsets() 方法,一个分区的起始位置起初是0,但是并不代表每时每刻都是0,因为日志清理的动作会清理旧的数据,所以分区的起始位置会自然而然增加。beginningOffsets() 方法的具体定义如下:

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

beginningOffsets() 方法和 endOffsets() 方法参数一样,一个是从分区开头消费一个是从分区末尾消费。其实 KafkaConsumer中直接提供了 seekToBeginning() 方法和 seekToEnd() 方法来实现这两个功能,具体定义如下:

public void seekToBeginning(Collection<TopicPartition> partitions)
public void seekToEnd(Collection<TopicPartition> partitions)
根据时间消费

有时候并不知道特定的消费位置,却知道一个相关的时间点,比如想要消费昨天8点后的消息,此时可以通过 KafkaConsumer 中的 offsetsForTimes() 方法来实现。offsetsForTimes() 方法的参数 timestampsToSearch 是一个Map类型,key为待查询的分区,value为待查询的时间戳。该方法会返回时间戳(消息的时间戳)大于等于 待查询时间(map中的value) 的第一条消息 对应的位置和时间戳,对应于 OffsetAndTimestamp 中的 offset 和 timestamp 字段。

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

下面示例演示了 offsetsForTimes() 和 seek() 之间的使用方法,首先通过 offsetForTimes() 方法获取一天之前的位置,然后使用 seek() 方法追溯到相应的位置开始消费:


        Properties properties=ininConfig();
        KafkaConsumer<String,String> consumer=new KafkaConsumer<String, String>(properties);

        //订阅主题
        consumer.subscribe(Arrays.asList(topic));

        //调用poll方法
        consumer.poll(Duration.ofMillis(1000));

        Map<TopicPartition, Long> timestampToSearch = new HashMap<>();
        //循环消费者分配到的分区
        for (TopicPartition tp1:consumer.assignment()){
        
            //设置 key为分区 value为前一天
            timestampToSearch.put(tp1,System.currentTimeMillis()-1*24*3600*1000);
        }

        //调用 offsetsForTimes方法 返回
        Map<TopicPartition, OffsetAndTimestamp> offsets=consumer.offsetsForTimes(timestampToSearch);

        //循环消费者得到的分区
        for (TopicPartition tp:consumer.assignment()){
        
            //根据key 获取该分区的value
            OffsetAndTimestamp offsetAndTimestamp=offsets.get(tp);
            
            if (offsetAndTimestamp!=null) {
                //使用seek方法设置消费位移
                consumer.seek(tp, offsetAndTimestamp.offset());
            }
        }


        while (isRunning.get()){
            ConsumerRecords<String,String> records=consumer.poll(Duration.ofMillis(1000));

            for(ConsumerRecord<String,String> record:records){
                System.out.println(record.value());
            }

        }
        consumer.close();
    
位移越界

位移越界是指知道消费位置却无法在实际的分区中查找到,比如想要从上图中位置是10处拉取消息就会发生位移越界 注意拉取位置是9的并没有越界。假设消息有100条那么位移为99,此时使用 seek() 方法指定位移120 会发生越界,那么会根据 auto.offset.reset 参数的默认值来将拉取位移重置为100,此时也可以知道分区中最大消息的offset为99。
在这里插入图片描述
上一节提及了 Kafka 中的消费位移是存储在一个内部主题中的,而本节的 seek() 方法可以突破这一个限制:消费位移可以保存在任意的存储介质中,例如数据库、文件系统等。以数据库为例,我们将消费位移保存在一个表中,在下次消费的时候可以读取存储在数据库表中的消费位移并通过 seek() 方法指向这个具体的位置:

//代码清单12-4 消费位移保存在DB中
consumer.subscribe(Arrays.asList(topic));
//省略poll()方法及assignment的逻辑
for(TopicPartition tp: assignment){
    long offset = getOffsetFromDB(tp);//从DB中读取消费位移
    consumer.seek(tp, offset);
}
while(true){
	//拉取消息
    ConsumerRecords<String, String> records =  consumer.poll(Duration.ofMillis(1000));
    
    //循环消息集中的分区
    for (TopicPartition partition : records.partitions()) {
    	
    	//获取分区中的消息
        List<ConsumerRecord<String, String>> partitionRecords =  records.records(partition);
        //处理消息
        for (ConsumerRecord<String, String> record : partitionRecords) {
            //process the record.
        }
        
        //消费完分区的消息后 获取该分区消息集最后一条的位移
        long lastConsumedOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
         //将下一次拉取的消息的位置存储在DB中
        storeOffsetToDB(partition, lastConsumedOffset+1);
    }
}

seek() 方法为我们提供了从特定位置读取消息的能力,我们可以通过这个方法来向前跳过若干消息,也可以通过这个方法来向后回溯若干消息,这样为消息的消费提供了很大的灵活性。seek() 方法也为我们提供了将消费位移保存在外部存储介质中的能力,还可以配合再均衡监听器来提供更加精准的消费能力。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值