Kafka原理剖析之「位点提交」

一、背景

Kafka的位点提交一直是Consumer端非常重要的一部分,业务上我们经常遇到的消息丢失、消息重复也与其息息相关。位点提交说简单也简单,说复杂也确实复杂,没有人能用一段简短的话将其说清楚,最近团队生产环境便遇到一个小概率的报错

"Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets. The coordinator is not available."

此错误一出,Consumer的流量直接跌0,且无法自愈,虽然客户端重启后可自动恢复,但影响及损失还是非常巨大的,当然最后定位就是「位点提交」一手炮制的,是开源的一个重大bug,受此影响的版本跨越2.6.x ~ 3.1.x :

https://issues.apache.org/jira/browse/KAFKA-13840

借此bug正好来梳理一下Kafka有关位点提交的知识点

二、概述

关于位点提交(commit offset)大家最直观的感受便是自动或手动提交,但是仔细一想,还是有很多细节问题,例如:

  • 手动的同步提交与异步提交有什么区别?
  • 使用自动提交模式时,提交动作是同步的还是异步的?
  • 消费模式使用assign或subscribe,在提交位点时有区别吗?
  • 同步提交与异步提交能否混合使用?
  • 手动提交与自动提交能否混合使用?

其实这些问题都是万变不离其宗,我们把各个特征总结一下,这些问题自然也就迎刃而解

三、为什么要提交位点?

在开始介绍各类位点提交的策略之前,我们先抛出一个灵魂拷问:"为什么一定要提交位点?"。 Consumer会周期性的从Broker拉取消息,每次拉取消息的时候,顺便提交位点不可以吗?为什么一定要让用户感知提交位点,还提供了各种各样的策略?

其实回答这个问题,我们理解以下2个配置就够了

  • fetch.max.bytes and max.partition.fetch.bytes
  • 均作用于Broker端。首先需要明确的是,Consumer的一次拉取经常是针对多个partition的,因此max.partition.fetch.bytes控制的一个partition拉取消息的最大值,而fetch.max.bytes控制的则是本次请求整体的上限
  • max.poll.records
  • 作用于Consumer端。而此参数控制的就是一次 poll 方法最多返回的消息条数,因此并不是每次调用 poll 方法都会发起一次网络请求的

因此也就导致了发起网络的频次跟用户处理业务数据的频次是不一样的

简单总结一下,单次网络请求拉取的数据量可能是很大的,需要客户端通过多次调用poll()方法来消化,如果按照网络请求的频次来提交位点的话,那这个提交频次未免太粗了 ,Consumer一旦发生重启,将会导致大量的消息重复

其次按照网络请求的频次来提交位点的话,程序将变得不够灵活,业务端对于消息的处理会有自己的理解,将提交位点的发起动作放在Consumer,设计更有弹性

四、Consumer网络模型简介

4.1、单线程的Consumer

在开始介绍Consumer端的网络模型之前,我们先看下Producer的

可见Producer是线程安全的,Producer内部维护了一个并发的缓存队列,所有的数据都会先写入队列,然后由Sender线程负责将其发送至网络

而Consumer则不同,我们罗列一下Consumer的特点

  • 单线程(业务处理与网络共享一个线程)
  • 非线程安全

不过这里说的单线程不够严谨,在0.10.1版本以后:

  • Subscribe模式下,Consumer将心跳逻辑放在了一个独立线程中,如果消息处理逻辑不能在 max.poll.interval.ms 内完成,则consumer将停止发送心跳,然后发送LeaveGroup请求主动离组,从而引发coordinator开启新一轮rebalance
  • Assign模式下,则只有一个Main线程

用户处理业务逻辑的时间可能会很长,因此心跳线程的引入主要是为了解决心跳问题,其非常轻量,因此我们泛泛的讲,Consumer其实就是单线程的,包括提交位点,那一个单线程的客户端是如何保证高效的吞吐,又是如何与用户处理数据的逻辑解耦呢?其实这是个很有意思,也很有深度的问题,但不是本文的重点,后续我们再展开详聊

因此我们知道,所有提交位点的动作均是交由Consumer Main线程来提交的,但是单线程并不意味着阻塞,不要忘记,我们底层依赖的是JDK的NIO,因此网络发送、接受部分均是异步执行的

4.2、网络模型

既然Consumer是单线程,而NIO是异步的,那么Consumer如何处理这些网络请求呢?Producer比较好理解,有一个专门负责交互的Sender线程,而单线程的Consumer如何处理呢

其实Consumer所有的网络发送动作,均放在main线程中,而在Consumer内部,为每个建联的Broker都维护了一个unsent列表,这个列表中存放了待发送的请求,每次业务端程序执行consumer.poll()方法时,会先后触发2次网络发送的操作:

  1. 尝试将所有Broker待发送区的数据发送出去
  2. 处理网络接收到的请求
  3. 尝试将所有Broker待发送区的数据发送出去(again)

回到我们位点提交的case中,如果某个Broker积攒了大量的未发送请求,那提交位点的请求岂不是要等待很久才能发出去?是的,如果unsent列表中有很多请求确实会这样,不过正常情况下,同一个Broker中不会积攒大量请求,如果一次从Broker中拉取的消息还没有被消费完,是不会向该Broker再次发送请求的,因此业务poll()的频率是要远高于网络发送频率的,而单次poll时,又会触发2次trySend,因此可保证不存在unsent列表的数据过多而发不出的情况

BTW:Consumer的网络三件套:NetworkClient、Selector、KafkaChannel与Producer是完全一样的。关于Consumer的核心组件,盗用一张网上的图

有了上面的基础,我们再来讨论位点提交的方式,就会变得非常清晰明朗了

五、手动-异步提交

执行异步提交的代码通常是这样写的

while (true) {
    // 拉取消息
    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
    // 如果拉取的消息不为空
    if (!records.isEmpty()) {
        // 执行常规业务处理逻辑
        doBusiness(records);
        // 异步提交位点
        kafkaConsumer.commitAsync();
    }
}

kafkaConsumer.commitAsync() 负责拼接提交位点的request,然后将请求放在对应Broker的unsent列表中,程序将返回,待到下一次业务执行poll(),或合适的时机,会将此请求发出去,并不阻塞main线程

而对于提交位点的结果,如果指定了回调函数,如下:

kafkaConsumer.commitAsync(new OffsetCommitCallback() {
    @Override
    public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
    }
});

可以对异常进行处理,也可以拿到实时提交的位点

而对于没有指定回调函数的case,Consumer会提供一个默认的回调函数org.apache.kafka.clients.consumer.internals.ConsumerCoordinator.DefaultOffsetCommitCallback,在发生异常时,输出error日志

六、手动-同步提交

而对于同步提交

kafkaConsumer.commitSync();

首先需要明确的是,同步提交是会阻塞 Consumer的Main线程的,手动提交会首先将提交请求放在对应Broker的unsent列表的尾部,继而不断地触发调用,将网络待发送区的数据发送出去,同时不间断接收网络请求,直到收到本次提交的响应;不过同步提交也有超时时间,默认为60s,如果超时,将会抛出TimeoutException异常

同步提交是低效的,会影响Consumer整体的消费吞吐,而有些相对严苛的业务场景,同步提交又是必不可少的,读者根据自己的业务case来决定使用哪种策略

七、自动提交

与手动提交相对的,便是自动提交,首先明确一点,自动提交的模式,是异步提交

自动提交并不是 启动一个全新的线程去提交位点,也不是严格按照固定时间间隔去提交。自动提交与手动提交一样,也是由Consumer Main线程触发的

由于位点提交、处理业务逻辑、网络收发、元数据更新等,都共享了Consumer的Main线程,因此并不能保证提交位点的时间间隔严格控制在auto.commit.interval.ms(默认5000,即5s)内,因此真实提交位点的时间间隔只会大于等于auto.commit.interval.ms

总结一下自动提交的特点:

  • 异步提交
  • 提交操作由Consumer的Main线程发起
  • 配置 auto.commit.interval.ms 只能保证提交的最小间隔,真实提交时间间隔通常大于此配置

至此,我们尝试回答一下刚开始提出的问题

  • 手动的同步提交与异步提交有什么区别?
  • 同步提交会阻塞Consumer的Main线程,相对而言,异步提交性能更高
  • 使用自动提交模式时,提交动作是同步的还是异步的?
  • 异步的
  • 消费模式使用assign或subscribe,在提交位点时有区别吗?
  • subscribe模式会有心跳线程,心跳线程维护了与Coordinator的建联
  • 同步提交与异步提交能否混合使用?
  • 可以,通常在大部分场景使用异步提交,而在需要明确拿到已提交位点的case下使用同步提交
  • 手动提交与自动提交能否混合使用?
  • 可以,不过语义上会有很多冲突,不建议混合使用

八、开源Bug

回到文章刚开始提到的异常报错

"Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets. The coordinator is not available."

这个bug并不是在所有case下都会存在

  • Subscribe
  • 自动提交 -- 正常运行
  • 手动-异步提交 -- 正常运行
  • 手动-同步提交 -- 正常运行
  • Assign
  • 自动提交 -- Bug
  • 手动-异步提交 -- Bug
  • 手动-同步提交 -- 正常运行

为什么会出现如何奇怪的情况呢?其实跟一下源码便会有结论

8.1、Subscribe

在Subscribe模式下,Consumer与Coordinator的交互是通过线程org.apache.kafka.clients.consumer.internals.AbstractCoordinator.HeartbeatThread进行的。当程序发现Coordinator找不到时,便会发起寻找Coordinator的网络请求,方法如下

// org.apache.kafka.clients.consumer.internals.AbstractCoordinator#lookupCoordinator
protected synchronized RequestFuture<Void> lookupCoordinator() {
    if (findCoordinatorFuture == null) {
        // find a node to ask about the coordinator
        Node node = this.client.leastLoadedNode();
        if (node == null) {
            log.debug("No broker available to send FindCoordinator request");
            return RequestFuture.noBrokersAvailable();
        } else {
            findCoordinatorFuture = sendFindCoordinatorRequest(node);
        }
    }
    return findCoordinatorFuture;
}

而其中涉及一个findCoordinatorFuture的成员变量,必须要满足findCoordinatorFuture == null,才会真正发起网络请求,因此在方法执行完,需要将其置空,如下方法

// org.apache.kafka.clients.consumer.internals.AbstractCoordinator#clearFindCoordinatorFuture
private synchronized void clearFindCoordinatorFuture() {
    findCoordinatorFuture = null;
}

说白了,也就是每次调用,都需要lookupCoordinator()与clearFindCoordinatorFuture()成对儿出现;当然心跳线程也是这样做的

if (coordinatorUnknown()) {
    if (findCoordinatorFuture != null) {
        // clear the future so that after the backoff, if the hb still sees coordinator unknown in
        // the next iteration it will try to re-discover the coordinator in case the main thread cannot
        // 清理辅助变量findCoordinatorFuture
        clearFindCoordinatorFuture();

        // backoff properly
        AbstractCoordinator.this.wait(rebalanceConfig.retryBackoffMs);
    } else {
        // 寻找Coordinator
        lookupCoordinator();
    }
}

因此在Subscribe模式下,无论何种提交方式,都是没有Bug的

8.2、Assign

因为自动提交也是异步提交,因此我们只聚焦在同步提交与异步提交。其实同步提交与异步提交,它们构建入参、处理响应等均是调用的同一个方法,唯一不同的是发起调用处的逻辑。我们先看下同步提交的逻辑

// org.apache.kafka.clients.consumer.internals.AbstractCoordinator#ensureCoordinatorReady
protected synchronized boolean ensureCoordinatorReady(final Timer timer) {
    if (!coordinatorUnknown())
        return true;

    do {
        if (fatalFindCoordinatorException != null) {
            final RuntimeException fatalException = fatalFindCoordinatorException;
            fatalFindCoordinatorException = null;
            throw fatalException;
        }
        final RequestFuture<Void> future = lookupCoordinator();

        // some other business
        // .......

        clearFindCoordinatorFuture();
        if (fatalException != null)
            throw fatalException;
    } while (coordinatorUnknown() && timer.notExpired());

    return !coordinatorUnknown();
}

没有问题,lookupCoordinator()与clearFindCoordinatorFuture()又成对儿出现

而异步提交呢?

// org.apache.kafka.clients.consumer.internals.ConsumerCoordinator#commitOffsetsAsync
public void commitOffsetsAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, final OffsetCommitCallback callback) {
    invokeCompletedOffsetCommitCallbacks();

    if (!coordinatorUnknown()) {
        doCommitOffsetsAsync(offsets, callback);
    } else {
        // we don't know the current coordinator, so try to find it and then send the commit
        // or fail (we don't want recursive retries which can cause offset commits to arrive
        // out of order). Note that there may be multiple offset commits chained to the same
        // coordinator lookup request. This is fine because the listeners will be invoked in
        // the same order that they were added. Note also that AbstractCoordinator prevents
        // multiple concurrent coordinator lookup requests.
        pendingAsyncCommits.incrementAndGet();
        lookupCoordinator().addListener(new RequestFutureListener<Void>() {
            @Override
            public void onSuccess(Void value) {
                // do something
            }

            @Override
            public void onFailure(RuntimeException e) {
                // do something
            }
        });
    }

    // ensure the commit has a chance to be transmitted (without blocking on its completion).
    // Note that commits are treated as heartbeats by the coordinator, so there is no need to
    // explicitly allow heartbeats through delayed task execution.
    client.pollNoWakeup();
}

非常遗憾,只有lookupCoordinator(),却没有clearFindCoordinatorFuture(),导致成员变量一直得不到重置,也就无法正常发起寻找Coordinator的请求,其实如果修复的话,也非常简单,只需要在RequestFutureListener的回调结果中显式调用clearFindCoordinatorFuture()即可

这个bug隐藏的很深,只靠单测,感觉还是很难发现的,bug已经在3.2.1版本修复。虽然我们生产环境是2.8.2的Broker,但是还是可以直接通过升级Consumer版本来解决,即便client版本高于了server端。这个当然得益于Kafka灵活的版本策略,还是要为其点个赞的

参考文档