面试官提问:Kafka消费者有几种方式提交位移?

434 阅读8分钟

这个问题问得比较泛泛,如果面试官展开了问还是能问出很多问题,比如同步或异步提交位移。先做业务处理后做提交位移,还是先做提交位移后做业务处理。

首先我给大家介绍一下位移的概念:

位移

位移,反应到kakfa的代码中,就是offset。offset,有人叫偏移量,有人叫位移。为了区分不同的场景我们可以定义:如果讲的是消息在分区中的位置,就用偏移量,如果讲的是消费者端,那就用位移表示。

大家可以看上图,每次消费者拉取消息的时候会首先把要拉取消息的起始位移告诉服务端,上图的拉取起始位移就是3。然后服务端根据起始位移给消费端返回了6个消息。最后,消费端下次拉取的起始位移变为9。这样就保证了下次拉取消息的时候会接着上次消费结束的位移继续消费。这就是位移在消息消费中存在的意义。

有的同学会问,为什么要提交消费位移,位移保存在消费者客户端不好吗?

大家可以想一下,如果消费者突然挂了,重新起来后找不到要继续消费的位移怎么办?同时,如果消费者数量或分区数量发生变化,消费者负责的分区也有可能发生了变化,这样新的消费者需要获取分区的最大消费位移继续消费,否则会造成重复消费或消息丢失。所以消费者处理完拉取消息后,一定要提交消费位移,这样才能保证消息的可靠性。当然,也可以保存在客户端的磁盘中,比如保存在mysql数据库里,但是这样就无形中增加了客户端的复杂度和运维成本。

位移的提交方式

自动提交

消费者默认的提交位移的方式是自动提交位移,同时自动提交也是异步提交。由参数 enable.auto.commit 决定,默认是true,也就是说你不用手动主动提交位移。原理是隔一段时间消费者就会异步提交一次位移。示例代码如下:


KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

consumer.subscribe(Arrays.asList("test1"));

while (true) {

ConsumerRecords<String, String> records = consumer.poll(1000);

for (ConsumerRecord<String, String> record : records)

System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());

}

复制代码

提交的间隔时间消费者客户端也可以设置,对应的参数是 auto.commit.interval.ms,默认是5秒。所以,在默认情况下,消费者每隔5秒钟就会提交一次下一次要拉取的消费位移。这样做的好处是代码会很简单,消费端的开发只要关心拉取消息后的业务处理,不用关心位移提交的问题。但是这样可能造成消息的丢失或消息的重复消费。重复消费很好理解,当消费者处理完消息后自动提交还没进行这时消费者挂了,由于位移没有提交消费者下次起来的时候还会拉到重复的消息。

自动提交属于延迟提交,重复消费很好理解,那么消息的丢失怎么理解呢?我们先看一下下面的图

大家可以看上图,主线程有两个任务,拉取消息和处理消息。当拉取完消息后,主线程会更新这次拉取到哪个位移了,然后开始处理消息。这时因为定时提交位移时间到了,提交位移的线程开始提交位移,提交位移正是主线程更新的拉取的最大位移。不巧,这时消费者挂了,但是消息还没处理完。再重新拉取消息的时候,由于位移已经提交了,主线程再拉取的时候,已经拉取不到上次没处理完的消息了。最终,造成消息丢失。

手动提交

同步手动提交

自动提交的问题本质上是因为提交位移的线程无法判断消息是否已经处理完成。于是,手动提交用来解决自动提交暴露出的问题。手动提交又分为同步提交和异步提交。同步提交实例代码如下:


while (true) {

ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));

process(records);

try {

consumer.commitSync();

} catch (CommitFailedException e) {

e.printStackTrace();

}

}

复制代码

代码很简单,就是把提交位移的过程显示的写到了主线程中,并只有处理完此次位移的提交才能进行下次消息的拉取。这样同样会造成消息的重复消费,因为没提交位移就挂了,挂了重新启动的时候时候会拉取到重复的消息。但是不会出现消息丢失的情况,因为消息的拉取,处理,位移的提交都放到了一个线程里,并且是阻塞的。

同时同步提交的时候可以带参数,代码示例如下:


while (true) {

ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));

for(ConsumerRecord<String,String> record : records){

process(record);

long offset = record.offset();

TopicPartition partition = new TopicPartition(record.topic,record.partition());

try {

consumer.commitSync(Collections.singletonMap(partition,new OffsetAndMetadata(offset+1)));

} catch (CommitFailedException e) {

e.printStackTrace();

}

}

}

复制代码

commitSync()方法可以针对特定的分区提交特定的位移,这样做非常的灵活。比如上面的代码示例,即使会出现重复消费的情况,重复的也不会很严重,因为可以精确到对某个分区一个个位移的提交。每处理完一个位移就提交一个位移。如果出现异常及时处理。比一堆消息处理后再提交会精准很多,但是频繁提交位移会造成效率的下降。在实际使用中除非都消息重复很敏感,其它情况很少这么用。一般是按分区提交。示例代码如下:


while (true) {

ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));

for(TopicPartition partition : records.partitions()){

List<ConsumerRecord<String,String>> partitionRecords = records.records(partition);

process(partitionRecords);

long consumedPartitionOffset = partitionRecords.get(partitionRecords.size() - 1).offset();

consumer.commitSync(Collections.singletonMap(partition,new OffsetAndMetadata(consumedPartitionOffset+1));

}

}

}

复制代码

其实,过程就是先把不同分区的消息集合拉取过来。然后按分区的维度来处理消息和提交位移。这样做会把消息的重复问题集中在一个分区中,同时降低了提交位移的频率。

异步手动提交

异步提交和同步提交相反,步提交的方式在执行的时候主线程不会被阻塞,异步提交可以使消费者的性能得到一定的增强,但是可能在提交消费位移的结果还未返回之前就开始了新一次的拉取操作。

异步提交有三种重载方法如下:


public void commitAsync()

public void commitAsync(OffsetCommitCallback callback)

public void commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets,

OffsetCommitCallback callback)

复制代码

跟同步提交的方法类似,有无参方法,有参方法里可以设置具体提交的分区和位移,同时提供了一个callback对象参数,它提供了一个异步提交的回调方法当位移提交完成后会回调OffsetCommitCallback中的onComplete()方法。示例代码如下:


while (true){

ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));

for (ConsumerRecord<String, String> record : records) {

process(record);

}

consumer.commitAsync(new OffsetCommitCallback(){

@Override

public void onComplete (Map<TopicPartition, OffsetAndMetadata> offsets,

Exception exception) {

if (exception == null) {

System.out.println(offsets);

}else {

log.error("fail to commit offsets {}", offsets, exception);

});

复制代码

利用这个回调方法可以做一些判断,比如确定消息发送成功了才能做后面的业务处理时,这个回调就很管用。

有的同学问,如果消息发送不成功,能不能用回调来做提交位移失败的重试呢?

我的回答是可以用但是不能直接就用,因为大家要考虑这样一个场景,因为提交位移是异步的,第一次提交失败了,这时第二次提交成功了,当你在回调中重试第一次提交后也成功了,这样最大消费位移被前置了,造成重复消费。这时,需要一个递增的参数去保存当前提交的各个分区的最大位移,当提交的位移小于这个值的时候就拒绝重试提交位移。

总结

好,我们再总结一下今天学习的知识。首先,提交位移的目的是保存位移,下次拉取时能找到拉取的位移位置。kafka的消费者分为自动提交位移和手动提交位移。同步提交位移是异步线程的提交,位移的提交不会显示在消费者主线程上,工程师的代码不涉及提交位移。手动提交分两种,同步手动提交和异步手动提交,同步手动提交保证了消息的拉取,处理,位移提交是在一个线程线性完成的,避免了消息的丢失。异步手动提交提升了性能,但是代码稍微繁琐一些,需要实现回调类来处理位移提交后的响应。

如果想详细学习请看 掘金小册《Kafka 源码精讲》 你将获得:

  • 全面学 Kafka 各个组件,系统掌握 Kafka 的原理;
  • 系统学习 Kafak 的轮子组件,如内存缓冲池,基于 NIO 的通信模块;
  • Kafka 高可靠分布式设计;
  • Kafka 底层文件存储设计;