对于kafka 中 的分区而言,它的每条消息都有唯一的offset,用来表示在分区中对应的位置。对于消费者而言,它也有一个offset的概念,消费者使用offset来表示消费到分区中某个消息所在的位置。这里所介绍的是消费者的位移,即第二种情况。
在每次调用poll方法的时候,返回的是还没有消费过的消息集,要做到这一点,就需要记录上一次消费时候的消费位移,并且这个位移必须是做持久化的保存,而不是单单保存在内存中,否则消费者重启后就无法获取之前的消费位移。还有新的情况是,如果新增一个消费者,那么分区再均衡的时候,新的消费者无法获取消费位移。
旧版的客户端中,消费位移的信息存储再zookeeper中,而新版的客户端,消费位移则存储在kafka内部的主题_consumer_offsets中。我将消费位移做持久化操作的动作称为提交,消费者在消费完消息之后需要执行消费位移的提交。
消费者位移
kafkaConsumer类中提供了position(TopicPartition)和committed(TopicPartition)两个方法来来获取上面所说的lastConsumerOffset和position。在消费者中还有一个committed offset的概念,我们来测试下这个三个值得关系:
public static void testCommittedOffset(String topic,String brokerList){
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,"group.demo1");
//开启手动提交消费位移
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,false);
//实例化consumer
JSONDeserializer<User> userJSONDeserializer = new JSONDeserializer<>();
userJSONDeserializer.setClazz(User.class);
KafkaConsumer<String,User> consumer = new KafkaConsumer<>(properties, new StringDeserializer(),userJSONDeserializer);
long lastConsumerOffset = -1L;
long position = -1L;
long committedOffset = -1L;
//获取topic主题对应 所有分区信息
List<PartitionInfo> partitionInfos = consumer.partitionsFor(topic);
PartitionInfo partitionInfo = partitionInfos.get(0);
TopicPartition topicPartition = new TopicPartition(partitionInfo.topic(), partitionInfo.partition());
try{
consumer.assign(Collections.singleton(topicPartition));
while(isRunning.get()){
ConsumerRecords<String, User> records = consumer.poll(Duration.ofMillis(1000));
Iterator<ConsumerRecord<String, User>> iterator = records.iterator();
while(iterator.hasNext()){
ConsumerRecord<String, User> record = iterator.next();
System.out.printf(" key = %s, value = %s,offset = %d,%n", record.key(), record.value(), record.offset());
lastConsumerOffset = record.offset();
}
//这里我们只演示拉取一次消息
break;
}
//同步提交位移信息
consumer.commitSync();
OffsetAndMetadata committed = consumer.committed(topicPartition);
committedOffset = committed.offset();
//获取当前分区位置
position = consumer.position(topicPartition);
System.out.printf("lastConsumerOffset=%d,position=%d,committed offset = %d" ,lastConsumerOffset,position, committedOffset);
}catch (Exception e){
e.printStackTrace();
}finally {
consumer.close();
}
}
运行该方法后,我们看到打印得消息如下:
lastConsumerOffset=6499,position=6500,committed offset = 6500
说明position = conmmitted offset == lastConsumerOffset + 1
在提交了消费位移后成立。
在kafka中默认消费位置的提交方式是自动提交的,这个由消费者客户端参数enable.auto.commit配置,默认值为true。当然默认的提交也不是每消费一条消息就提交一次,而是定期提交,这个定期的周期由客户端的参数auto.commit.interval.ms
来决定的。
/**
* <code>enable.auto.commit</code>
*/
public static final String ENABLE_AUTO_COMMIT_CONFIG = "enable.auto.commit";
private static final String ENABLE_AUTO_COMMIT_DOC = "If true the consumer's offset will be periodically committed in the background.";
/**
* <code>auto.commit.interval.ms</code>
*/
public static final String AUTO_COMMIT_INTERVAL_MS_CONFIG = "auto.commit.interval.ms";
private static final String AUTO_COMMIT_INTERVAL_MS_DOC = "The frequency in milliseconds that the consumer offsets are auto-committed to Kafka if <code>enable.auto.commit</code> is set to <code>true</code>.";
在kafka消费的编程逻辑中位移提交是一大难点。自动提交的方式简单快捷。但可能会引发重复消费和消息丢失的问题。
重复消费:当消费者拉取到消息后,在下一次位移提交前,消费者崩溃。重启后会再次拉取上一次消费的消息。再均衡的情况也会引起重复消费。
消息丢失:如果线程A负责从kafka拉取消息存入到BlockingQueue中,然后由其他的线程进行相应的逻辑处理,再BlockingQueue中还有消息的时候,消费者崩溃,那么会引起消息的丢失。
指定位移消费
上面我们已经谈论如何进行消费位移的提交,正式有了消费位移的持久化,才能使消费者再关闭、崩溃或者遇到再均衡的时候,可以让接替的消费者能够根据存储的消费位移继续进行消费。
当一个消费组建立时候,它根本就没有可以查找的消费位移。kafka中每当消费者找不到所记录的消费位移时,就会根据消费者客户端参数auto.offset.reset的配置来决定从何处开始消费,这个参数的值默认为"latest";表示消费从分区末尾开始进行消费。如下图分区中已经写入了6条消息,此时启动一个新的消费组来进行消费:
- 1、默认时从消息开始写入的位置读取信息
- 2、如果设置
auto.offset.reset=earliest
将会从分区开始的位置进行消息的读取 - 3、如果设置为NONE,将会抛NoOffsetForPartitionException异常
如果我们需要更加细粒度来决定从特定的位移处开始拉取消息,KafkaConsumer的seek()方法正好提供了这个功能,可以让我们往前消费。
第一个方法中,topicPartition表示分区,而offset参数用来指定从分区的哪个位置开始消费。
那下面我们实验下这个方法:
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,"group.demo1");
//实例化consumer
JSONDeserializer<User> userJSONDeserializer = new JSONDeserializer<>();
userJSONDeserializer.setClazz(User.class);
KafkaConsumer<String,User> consumer = new KafkaConsumer<>(properties, new StringDeserializer(),userJSONDeserializer);
//获取topic主题对应 所有分区信息
List<PartitionInfo> partitionInfos = consumer.partitionsFor(topic);
PartitionInfo partitionInfo = partitionInfos.get(0);
TopicPartition topicPartition = new TopicPartition(partitionInfo.topic(), partitionInfo.partition());
try{
consumer.subscribe(Collections.singleton(topic));
consumer.seek(topicPartition,10);
while(isRunning.get()){
ConsumerRecords<String, User> records = consumer.poll(Duration.ofMillis(1000));
Iterator<ConsumerRecord<String, User>> iterator = records.iterator();
while(iterator.hasNext()){
ConsumerRecord<String, User> record = iterator.next();
System.out.printf(" key = %s, value = %s,offset = %d,%n", record.key(), record.value(), record.offset());
}
//这里我们只演示拉取一次消息
break;
}
这里我们订阅主题后,直接从第一topicPartition的位移消费调整为10。如果运行上面的代码,将会抛出如下异常:
java.lang.IllegalStateException: No current assignment for partition topic-demo3-0
at org.apache.kafka.clients.consumer.internals.SubscriptionState.assignedState(SubscriptionState.java:323)
at org.apache.kafka.clients.consumer.internals.SubscriptionState.seekUnvalidated(SubscriptionState.java:340)
at org.apache.kafka.clients.consumer.KafkaConsumer.seek(KafkaConsumer.java:1550)
这个时当前还未分配到分区。我们时在poll()方法中分配分区的,所以需要将代码进行如下调整。
public static void testSeek(String topic,String brokerList){
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,"group.demo1");
//实例化consumer
JSONDeserializer<User> userJSONDeserializer = new JSONDeserializer<>();
userJSONDeserializer.setClazz(User.class);
KafkaConsumer<String,User> consumer = new KafkaConsumer<>(properties, new StringDeserializer(),userJSONDeserializer);
Set<TopicPartition> assignment = new HashSet<>();
try{
consumer.subscribe(Collections.singleton(topic));
//如果已经分配到分区,那么assignment.size()
while(assignment.size() == 0 ){
consumer.poll(Duration.ofMillis(100));
assignment = consumer.assignment();
}
for(TopicPartition p:assignment){
consumer.seek(p,10);
}
while(isRunning.get()){
ConsumerRecords<String, User> records = consumer.poll(Duration.ofMillis(1000));
Iterator<ConsumerRecord<String, User>> iterator = records.iterator();
while(iterator.hasNext()){
ConsumerRecord<String, User> record = iterator.next();
System.out.printf(" key = %s, value = %s,offset = %d,%n", record.key(), record.value(), record.offset());
}
}
}catch (Exception e){
e.printStackTrace();
}finally {
consumer.close();
}
}
我们可以通过org.apache.kafka.clients.consumer.KafkaConsumer#endOffsets(java.util.Collection<org.apache.kafka.common.TopicPartition>)方法来获取分区尾部消息位置。
通过offsetsForTimes()方法来查询一个分区的在某个具体时间点的分区位置
只要获取到消息的位置,我们就可以通过seek()方法来进行消费。