Spring-Kafka 消息提交机制

前言

我们在使用 kafka 过程中,经常遇到 重复消费、业务处理异常导致的消息丢失 ...

在 Kafka 中,offset 记录了我们目前消费的位置,重复消费、消息丢失等场景出现,本质与 offset 处理方式有关。

本文将深入讲解 spring 整合 kafka 时,如何提交 offset ,及其应用场景。

原理

提交方式

大方向上分为两种,自动提交和手动提交

自动提交

消费者在消费消息后,自动将消息的偏移量 offset 提交给 Kafka。这种方式的优点是简单易用,但缺点是可能会导致消息丢失或重复消费。

配置自动提交参数:

ini 复制代码
spring.kafka.consumer.enable-auto-commit=true 
spring.kafka.consumer.auto-commit-interval=1000 # 自动提交的时间间隔,单位为毫秒

手动提交

消费者在消费消息后,手动将消息的偏移量 offset 提交给 Kafka。这种方式的优点是可以更精细地控制消息的提交,避免消息丢失或重复消费,但缺点是需要更多的代码来管理提交逻辑。

配置手动提交参数:

ini 复制代码
spring.kafka.consumer.enable-auto-commit=false

当然,我们在使用 spring-kafka 的时候,不一定完全需要手动提交,spring 已经将手动提交做了更细分的划分,我们在使用时简单配置即可,在一定程度上减少开发。

Spring ACK 策略:

  1. RECORD:每处理一条消息就提交一次偏移量。适用于对消息处理要求较高的场景。
  2. BATCH:处理一批消息后提交一次偏移量。适用于批量处理的场景,可以提高性能。
  3. TIME:每隔一定时间提交一次偏移量。适用于需要定期提交的场景。
  4. COUNT:每处理一定数量的消息后提交一次偏移量。适用于需要按消息数量提交的场景。
  5. COUNT_TIME:结合时间和数量的策略,满足任一条件即提交偏移量。适用于需要更灵活的提交策略的场景。
  6. MANUAL:手动提交偏移量,但不会立即提交。适用于需要手动控制提交时机的场景。
  7. MANUAL_IMMEDIATE:手动提交偏移量,并且立即提交。适用于需要立即提交偏移量的场景。

spring ack 策略何时使用?

  • 当 enable-auto-commit=true 时,Kafka 会自动提交偏移量,Spring Kafka 的 ACK 策略将不起作用。
  • 当 enable-auto-commit=false 时,Kafka 不会自动提交偏移量,Spring Kafka 的 ACK 策略将生效。

源码实现

源码参考版本:spring-kafka-2.2.9-RELEASE

消息拉取&消费

Spring kafka 消息处理核心类文件:KafkaMessageListenerContainer,spring 为每一个消费端创建一个线程,每一个消费者的核心处理逻辑从 run 方法开始:

java 复制代码
        public void run() {
            this.consumerThread = Thread.currentThread();
            if (this.genericListener instanceof ConsumerSeekAware) {
                ((ConsumerSeekAware) this.genericListener).registerSeekCallback(this);
            }
            if (this.transactionManager != null) {
                ProducerFactoryUtils.setConsumerGroupId(this.consumerGroupId);
            }
            this.count = 0;
            this.last = System.currentTimeMillis();
            initAsignedPartitions();
            while (isRunning()) {
                try {
                    pollAndInvoke();
                }
                catch (@SuppressWarnings(UNUSED) WakeupException e) {
                    // Ignore, we're stopping or applying immediate foreign acks
                }
                catch (NoOffsetForPartitionException nofpe) {
                    this.fatalError = true;
                    ListenerConsumer.this.logger.error("No offset and no reset policy", nofpe);
                    break;
                }
                catch (Exception e) {
                    handleConsumerException(e);
                }
                catch (Error e) { // NOSONAR - rethrown
                    Runnable runnable = KafkaMessageListenerContainer.this.emergencyStop;
                    if (runnable != null) {
                        runnable.run();
                    }
                    this.logger.error("Stopping container due to an Error", e);
                    wrapUp();
                    throw e;
                }
            }
            wrapUp();
        }

真正的处理逻辑在于 pollAndInvoke:

java 复制代码
        protected void pollAndInvoke() {
            // 非自动提交,也不是 RECROD 提交
            if (!this.autoCommit && !this.isRecordAck) {
                // 从这里依照策略进行提交
                processCommits();
            }
            processSeeks();
            checkPaused();
            this.polling.set(true);
            ConsumerRecords<K, V> records = this.consumer.poll(this.pollTimeout);
            if (!this.polling.compareAndSet(true, false)) {
                if (records.count() > 0 && this.logger.isDebugEnabled()) {
                    this.logger.debug("Discarding polled records, container stopped: " + records.count());
                }
                return;
            }
            this.lastPoll = System.currentTimeMillis();
            checkResumed();
            debugRecords(records);
            if (records != null && records.count() > 0) {
                if (this.containerProperties.getIdleEventInterval() != null) {
                    this.lastReceive = System.currentTimeMillis();
                }
                
                // 业务处理入口
                invokeListener(records);
            }
            else {
                checkIdle();
            }
        }

我们展开 offset 位移提交逻辑分析:

java 复制代码
     private void processCommits() {
            this.count += this.acks.size();
            handleAcks();
            AckMode ackMode = this.containerProperties.getAckMode();
            if (!this.isManualImmediateAck) {
                if (!this.isManualAck) {
                    updatePendingOffsets();
                }
                boolean countExceeded = this.isCountAck && this.count >= this.containerProperties.getAckCount();
                if ((!this.isTimeOnlyAck && !this.isCountAck) || countExceeded) {
                    if (this.logger.isDebugEnabled() && isCountAck) {
                        this.logger.debug("Committing in " + ackMode.name() + " because count "
                                + this.count
                                + " exceeds configured limit of " + this.containerProperties.getAckCount());
                    }
                    commitIfNecessary();
                    this.count = 0;
                }
                else {
                    timedAcks(ackMode);
                }
            }
        }
        
        private void handleAcks() {
            ConsumerRecord<K, V> record = this.acks.poll();
            while (record != null) {
                if (this.logger.isTraceEnabled()) {
                    this.logger.trace("Ack: " + record);
                }
                processAck(record);
                record = this.acks.poll();
            }
        }        

可以看到,这里将处理除 MANUAL_IMMEDIATE 之外的几种 ACK 方式,因为手动并立即提交模式,会在处理消息之后立即提交 offset,处理逻辑在 invokeListener 中。

processCommits() 逻辑会处理 批量数量时间 相关的几种模式提交 offset逻辑。

需要注意的是,该方法是在 pollAndInvoke 中调用,而 pollAndInvoke 则每一次拉取一批消息,也就是说这几种 ACK 处理模式是每一批次消息都处理完之后,下一批次开始之前,进行尝试提交 offset。

遇到异常时

有时候业务逻辑会抛出异常,此时消息 offset 提交是怎样的?

展开 invokeListener 业务处理层:

java 复制代码
         private RuntimeException doInvokeRecordListener(final ConsumerRecord<K, V> record,
                @SuppressWarnings(RAW_TYPES) Producer producer,
                Iterator<ConsumerRecord<K, V>> iterator) {

            try {
                invokeOnMessage(record, producer);
            }
            catch (RuntimeException e) {
                // 判断遇到异常时,是否将此消息进行 ack
                if (this.containerProperties.isAckOnError() && !this.autoCommit && producer == null) {
                    ackCurrent(record);
                }
                if (this.errorHandler == null) {
                    throw e;
                }
                try {
                    invokeErrorHandler(record, producer, iterator, e);
                }
                catch (RuntimeException ee) {
                    this.logger.error("Error handler threw an exception", ee);
                    return ee;
                }
                catch (Error er) { //NOSONAR
                    this.logger.error("Error handler threw an error", er);
                    throw er;
                }
            }
            return null;
        }
        
        public boolean isAckOnError() {
            return this.ackOnError &&
                !(AckMode.MANUAL_IMMEDIATE.equals(this.ackMode) || AckMode.MANUAL.equals(this.ackMode));
    }

由此可见:

  1. MANUAL、MANUAL_IMMEDIATE 模式,遇到异常是不会提交 ack。消息可以再次消费。
  2. 其他几种模式,还需要根据配置 ackOnError 决定,当前源码版本中,ackOnError 默认 true,也就是会自动提交 ack。换句话说当你业务抛出异常时,消息没有被正常处理,而 offset 已经被提交了,该消息不会再次进行消费。

值得注意的是,MANUAL、MANUAL_IMMEDIATE 等方式,你需要小心 ack 逻辑,避免异常消息无法被 ack,导致不断重试,成为死信队列。

另外,MANUAL、MANUAL_IMMEDIATE 也是你在业务逻辑中,需要手动调用 ack 的模式,其他模式则不用。

相关推荐
SomeB1oody1 小时前
【Rust自学】6.1. 定义枚举
开发语言·后端·rust
SomeB1oody1 小时前
【Rust自学】5.3. struct的方法(Method)
开发语言·后端·rust
啦啦右一3 小时前
Spring Boot | (一)Spring开发环境构建
spring boot·后端·spring
森屿Serien3 小时前
Spring Boot常用注解
java·spring boot·后端
盛派网络小助手5 小时前
微信 SDK 更新 Sample,NCF 文档和模板更新,更多更新日志,欢迎解锁
开发语言·人工智能·后端·架构·c#
∝请叫*我简单先生5 小时前
java如何使用poi-tl在word模板里渲染多张图片
java·后端·poi-tl
荆州克莱6 小时前
mysql中局部变量_MySQL中变量的总结
spring boot·spring·spring cloud·css3·技术
zquwei6 小时前
SpringCloudGateway+Nacos注册与转发Netty+WebSocket
java·网络·分布式·后端·websocket·网络协议·spring
dessler6 小时前
Docker-run命令详细讲解
linux·运维·后端·docker
火烧屁屁啦6 小时前
【JavaEE进阶】初始Spring Web MVC
java·spring·java-ee