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 的模式,其他模式则不用。

相关推荐
摇滚侠1 小时前
Spring Boot 3零基础教程,Spring Intializer,笔记05
spring boot·笔记·spring
hhhjjjj1 小时前
kafka4使用记录
kafka
兮动人2 小时前
Spring Bean耗时分析工具
java·后端·spring·bean耗时分析工具
MESSIR222 小时前
Spring IOC(控制反转)中常用注解
java·spring
华洛2 小时前
公开一个AI产品的商业逻辑与设计方案——AI带来的涂色卡自由
前端·后端·产品
追逐时光者2 小时前
C#/.NET/.NET Core技术前沿周刊 | 第 57 期(2025年10.1-10.12)
后端·.net
间彧3 小时前
Spring Bean生命周期中init-method详解与项目实战
后端
间彧3 小时前
InitializingBean详解与项目实战应用
后端
间彧3 小时前
@PostConstruct详解与项目实战应用
后端
jiajixi3 小时前
Go 异步编程
开发语言·后端·golang