Kafka消费位点管理没你想的那么简单

云计算 Kafka
熟悉RocketMQ的小伙伴都知道RocketMQ已经默认帮我实现好了消息消费失败重试,消费位点自动提交,死信队列等功能,那么kafka是否也是如此呢?

背景

如果你习惯了使用RocketMQ这种自动挡管理消费位点,消息失败重试的方式。你再来使用kafka,会发现kafka这种手动挡的消费位点管理就没那么容易了

熟悉RocketMQ的小伙伴都知道RocketMQ已经默认帮我实现好了消息消费失败重试,消费位点自动提交,死信队列等功能,那么kafka是否也是如此呢?

kafka消费位点管理

kafka消费位点有两种管理方式

  1. 手动提交消费位点
  2. 自动提交消费位点

自动提交消费位点

想要设置自动提交消费位点我们只需要设置两个属性

  1. ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG 自动提交消费位点
  2. ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG 自动提交消费位点的时间间隔

一个简单的消费代码如下

Properties props = new Properties();
    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP_SERVERS);
    props.put(ConsumerConfig.GROUP_ID_CONFIG, GROUP_ID);
    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, IntegerDeserializer.class);
    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
    // 自动提交消费位点的时间间隔
    props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 5000);

    KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
    consumer.subscribe(Collections.singletonList(TOPIC_NAME));

    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(100);
        for (ConsumerRecord<String, String> record : records) {
            try {
                handlerMessage(record);
            } catch (Exception e) {
                log.error("处理消息异常: {}", record, e);
                // 循环继续
            }

        }
    }

自动提交消费位点有几个缺点

  1. 会出现重复消费:比如Consumer每5秒自动提交一次位移,如果在第4秒时,消费了消息,但是还没有提交位移,此时Consumer挂掉了,那么下次Consumer启动时,会从上次提交的位移开始消费,这样就会导致消息重复消费。 当然比如出现Rebalance也是会出现重复消费的情况
  2. 无法精准控制消费位点

手动提交消费位点

手动提交消费位点又分两种

  1. 同步提交(commitSync)
  2. 异步提交(commitAsync)

同步提交(commitSync)

同步提交的方式很简单,就是每次消费完通过调用API consumer.commitSync。

相关的代码如下:

Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP_SERVERS);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, GROUP_ID);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Collections.singletonList(TOPIC_NAME));

        while (true) {
            ConsumerRecords<String, String> records =
                    consumer.poll(Duration.ofSeconds(1));
            // 注意这里消费业务逻辑上消费失败后的消息处理
            handlerMessage(records);
            try {
                // 消费成功后手动提交位点
                consumer.commitSync();
            } catch (CommitFailedException e) {
                // 消费位点提交失败异常处理
                handleError(e); 
            }
        }

同步提交的方式有一个缺点,调用commitSync()时,Consumer会处于阻塞状态,直到broker返回提交成功,严重影响消费性能。

异步提交(commitAsync)

异步提交的方式很简单,就是每次消费完通过调用API consumer.commitAsync。

Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP_SERVERS);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, GROUP_ID);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Collections.singletonList(TOPIC_NAME));

        while (true) {
            ConsumerRecords<String, String> records =
                    consumer.poll(Duration.ofSeconds(1));
            handlerMessage(records); // 处理消息
            consumer.commitAsync((offsets, exception) -> {
                if (exception != null)
                    handleError(exception);
            });
        }

commitAsync主要是提供了异步回调,通过回调来通知消费位点是否提交成功。

异步提交消费位点也有一些缺点,比如消费位点不能重复提交。因为提交位点失败后,重新提交位点可能更晚的消费位点已经提交了,这里提交已经是没有意义的了。

spring-kafka消息消费

可以看到不管是同步提交消费位点还是异步提交消费位点,都有一些问题,想要写出生产可用的消费代码,需要注意的细节非常多。

比如消费失败后的消息如何处理,是停止消费跳出循环,还是说记录消费失败的消息,人工处理等。

这里我们可以简单看看spring-kafka是如何消费消息的。

我们简单看看主流程代码:

图片图片

这里我们忽略源码的一些其他细节。只分析主要的消费流程。

  • invokeOnMessage(cRecord); 处理消息

可以看到invokeOnMessage是被整个try-catch包裹的,这样就保证了消费失败后不会影响整个消费流程。

具体我们先看看消息正常处理的逻辑。

private void invokeOnMessage(final ConsumerRecord<K, V> cRecord) {

   if (cRecord.value() instanceof DeserializationException ex) {
    throw ex;
   }
   if (cRecord.key() instanceof DeserializationException ex) {
    throw ex;
   }
   if (cRecord.value() == null && this.checkNullValueForExceptions) {
    checkDeser(cRecord, SerializationUtils.VALUE_DESERIALIZER_EXCEPTION_HEADER);
   }
   if (cRecord.key() == null && this.checkNullKeyForExceptions) {
    checkDeser(cRecord, SerializationUtils.KEY_DESERIALIZER_EXCEPTION_HEADER);
   }
   doInvokeOnMessage(cRecord);
   if (this.nackSleepDurationMillis < 0 && !this.isManualImmediateAck) {
    ackCurrent(cRecord);
   }
   if (this.isCountAck || this.isTimeOnlyAck) {
    doProcessCommits();
   }
  }

这里主要是一些异常校验,然后就是判断是否可以提交消费位点。如果可以则调用doProcessCommits()进行正常的消费位点提交。

  • doProcessCommits() 消费位点处理

如果消费位点提交失败也会进行一些异常处理。

private void doProcessCommits() {
   if (!this.autoCommit && !this.isRecordAck) {
    try {
     processCommits();
    }
    catch (CommitFailedException cfe) {
     if (this.remainingRecords != null && !this.isBatchListener) {
      ConsumerRecords<K, V> pending = this.remainingRecords;
      this.remainingRecords = null;
      List<ConsumerRecord<?, ?>> records = new ArrayList<>();
      for (ConsumerRecord<K, V> kvConsumerRecord : pending) {
       records.add(kvConsumerRecord);
      }
      this.commonErrorHandler.handleRemaining(cfe, records, this.consumer,
        KafkaMessageListenerContainer.this.thisOrParentContainer);
     }
    }
   }
  }

如果消费位点提交失败则会调用commonErrorHandler进行异常处理。

commonErrorHandler有多个实现类,有一个默认实现DefaultErrorHandler

  • 消息消费失败异常处理

如果消息消费失败,也提供了一个异常处理扩展invokeErrorHandler(cRecord, iterator, e);

里面实际使用的也是DefaultErrorHandler

核心的处理逻辑主要还是在SeekUtils中封装

  • DefaultErrorHandler
public void handleRemaining(Exception thrownException, List<ConsumerRecord<?, ?>> records,
   Consumer<?, ?> consumer, MessageListenerContainer container) {

  SeekUtils.seekOrRecover(thrownException, records, consumer, container, isCommitRecovered(), // NOSONAR
    getFailureTracker(), this.logger, getLogLevel());
 }
  • SeekUtils
public static void seekOrRecover(Exception thrownException, @Nullable List<ConsumerRecord<?, ?>> records,
    Consumer<?, ?> consumer, MessageListenerContainer container, boolean commitRecovered,
    RecoveryStrategy recovery, LogAccessor logger, Level level) {}

可以看到有一个RecoveryStrategy参数,这个是消息消费失败如何恢复,比如我们需要手动增加一个类似死信队列的topic,这里消息消费失败就会自动发送到我们的死信队列

死信队列的topic名字生成规则主要是topicName + -dlt

private static final BiFunction<ConsumerRecord<?, ?>, Exception, TopicPartition>
  DEFAULT_DESTINATION_RESOLVER = (cr, e) -> new TopicPartition(cr.topic() + "-dlt", cr.partition());

总结

可以看到如果我们单纯的使用kafka-client原生的sdk来进行消息消费,是非常容易出现问题的。

我们需要很多细节,比如

  1. 消息消费失败了如何处理,是否需要重试,如果重试还是失败怎么办?丢掉还是手动处理丢到自己创建的死信队列中。
  2. 消费位点提交失败了如何处理。
  3. 消费位点是使用同步提交还是异步提交?或者混合提交?

所以如果spring boot项目还是建议使用spring相关已经封装好的kafka sdk。

非必要尽量不要使用原生的kafka-client sdk。

责任编辑:武晓燕 来源: 小奏技术
相关推荐

2015-04-30 10:12:13

开源云平台OpenStack

2017-08-09 14:49:03

WebHTTPS浏览器

2014-08-25 10:17:54

数据中心管理

2021-03-29 13:00:50

代码替换开发

2020-03-26 10:41:02

API网关大公司

2014-03-14 09:35:56

内存优化软件内存优化

2016-01-07 10:17:48

2015-06-24 10:32:13

讯鸟云计算会展

2021-08-02 15:24:19

Windows 11Windows微软

2014-03-21 15:30:06

产品经理PM能力

2023-12-28 12:07:21

2013-01-15 10:09:43

Windows Ser

2016-07-25 12:58:07

SDN路由故障排查

2014-07-09 09:06:33

SDN自动化

2010-08-04 09:20:31

JavaScript

2009-06-22 14:02:00

2019-05-17 09:33:50

图像识别三维重建文本识别

2020-01-03 08:44:05

TCP网络协议三次握手

2013-02-19 09:21:01

Win 8

2019-07-25 14:52:51

点赞
收藏

51CTO技术栈公众号