一、背景

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灵活的版本策略,还是要为其点个赞的

参考文档

Kafka原理剖析之「位点提交」的更多相关文章

  1. kafka(三)原理剖析

    一.生产者消息分区机制原理剖析 在使用Kafka 生产和消费消息的时候,肯定是希望能够将数据均匀地分配到所有服务器上.比如很多公司使用 Kafka 收集应用服务器的日志数据,这种数据都是很多的,特别是 ...

  2. Spark剖析-宽依赖与窄依赖、基于yarn的两种提交模式、sparkcontext原理剖析

    Spark剖析-宽依赖与窄依赖.基于yarn的两种提交模式.sparkcontext原理剖析 一.宽依赖与窄依赖 二.基于yarn的两种提交模式深度剖析 2.1 Standalne-client 2. ...

  3. Java阻塞队列中的异类,SynchronousQueue底层实现原理剖析

    上篇文章谈到BlockingQueue的使用场景,并重点分析了ArrayBlockingQueue的实现原理,了解到ArrayBlockingQueue底层是基于数组实现的阻塞队列. 但是Blocki ...

  4. 开源 serverless 产品原理剖析 - Kubeless

    背景 Serverless 架构的出现让开发者不用过多地考虑传统的服务器采购.硬件运维.网络拓扑.资源扩容等问题,可以将更多的精力放在业务的拓展和创新上. 随着 serverless 概念的深入人心, ...

  5. 别再误解MySQL和「幻读」了

    The so-called phantom problem occurs within a transaction when the same query produces different set ...

  6. 【vuejs面试题】务必熟知的vuejs面试题「务必收藏」

    如果能帮到你,点个赞吧,务必熟知的vuejs面试题「务必收藏」 vuejs 基础必备 1.active-class 是哪个组件的属性?嵌套路由怎么定义 (1).active-class 是 vue-r ...

  7. 「建议心心」要就来15道多线程面试题一次爽到底(1.1w字用心整理)

    . 本文是给**「建议收藏」200MB大厂面试文档,整理总结2020年最强面试题库「CoreJava篇」**写的答案,所有相关文章已经收录在码云仓库:https://gitee.com/bingqil ...

  8. Java程序员必会Synchronized底层原理剖析

    synchronized作为Java程序员最常用同步工具,很多人却对它的用法和实现原理一知半解,以至于还有不少人认为synchronized是重量级锁,性能较差,尽量少用. 但不可否认的是synchr ...

  9. 「C语言」Windows+EclipseCDT下的C语言开发环境准备

    之前写过一篇 「C语言」在Windows平台搭建C语言开发环境的多种方式 ,讨论了如何在Windows下用DEV C++.EclipseCDT.VisualStudio.Sublime Test.Cl ...

  10. 微服务架构之「 API网关 」

    在微服务架构的系列文章中,前面已经通过文章<架构设计之「服务注册 」>介绍过了服务注册的原理和应用,今天这篇文章我们来聊一聊「 API网关 」. 「 API网关 」是任何微服务架构的重要组 ...

随机推荐

  1. 【Azure 应用服务】在App Service for Windows中实现反向代理

    问题描述 如何在App Service for Windows(.NET Stack)中,如何实现反向代理呢? 正向代理:客户端想要访问一个服务器,但是它可能无法直接访问这台服务器,这时候这可找一台可 ...

  2. C++11的类型转换

    //C类型转换 /* C语言:显式和隐式类型转换 隐式类型转化:编译器在编译阶段自动进行,能转就转,不能转就编译失败 显式类型转化:需要用户自己处理. 隐式类型:界定:相关类型,相近类型,意义相似的类 ...

  3. Java自定义注解校验枚举值类型参数

    项目开发中会经常使用到各种枚举值,枚举值一般都是固定的,不会随意改变其中的值. 比如性别分为男女,确定之后一般都不会轻易改变,这时候使用枚举值就非常地方便.很多 时候,在页面中传入的参数就是枚举值中的 ...

  4. [VueJsDev] 基础知识 - 常见编码集

    [VueJsDev] 目录列表 https://www.cnblogs.com/pengchenggang/p/17037320.html 常用编码集 ::: details 目录 目录 常用编码集 ...

  5. FastWiki v0.1.0发布!新增超多功能

    FastWiki 发布 v0.1.0 https://github.com/239573049/fast-wiki/releases/tag/v0.1.0 更新日志 兼容OpenAI接口格式 删除Bl ...

  6. day01-项目介绍和功能实现

    项目练习01 1.项目介绍 这是一个简单的项目练习,用于掌握新学习的SpringBoot技术. 项目操作界面 ● 技术栈 Vue3+ElementPlus+Axios+MyBatisPlus+Spri ...

  7. linux shell 字体颜色设置

    使用 echo -e "\033[0;32;40m" 可以将字体设置成绿色. 这里必须使用echo 的选项 "-e",因为后面需要用到转义序列. 转义序列就是一 ...

  8. [503. 下一个更大元素 II] 单调栈

    import java.util.ArrayDeque; import java.util.Deque; class Solution { public static void main(String ...

  9. uniapp中引入Leaflet

    1. 引言 uniapp中自带有map组件,并且自带的map组件有常见的显示地图.绘制点线面的功能 但是,它存在以下问题: 收费,自带的map组件使用的是高德.腾讯的地图,无论使用什么样的功能,即使只 ...

  10. 记录--Vue3 封装 ECharts 通用组件

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 按需导入的配置文件 配置文件这里就不再赘述,内容都是一样的,主打一个随用随取,按需导入. import * as echarts from ...