极客时间Kafka - 09 Kafka Java Consumer 多线程开发实例

news2025/1/18 3:48:37

文章目录

      • 1. Kafka Java Consumer 设计原理
      • 2. 多线程方案
      • 3. 代码实现
      • 4. 问题思考

目前,计算机的硬件条件已经大大改善,即使是在普通的笔记本电脑上,多核都已经是标配了,更不用说专业的服务器了。如果跑在强劲服务器机器上的应用程序依然是单线程架构,那实在是有点暴殄天物了。不过,Kafka Java Consumer 就是单线程的设计,你是不是感到很惊讶。所以,探究它的多线程消费方案,就显得非常必要了。

1. Kafka Java Consumer 设计原理

在开始探究之前,我先简单阐述下 Kafka Java Consumer 为什么采用单线程的设计。了解了这一点,对我们后面制定多线程方案大有裨益。

谈到 Java Consumer API,最重要的当属它的入口类 KafkaConsumer 了。我们说 KafkaConsumer 是单线程的设计,严格来说这是不准确的。因为,从 Kafka 0.10.1.0 版本开始,KafkaConsumer 就变为了双线程的设计,即用户主线程和心跳线程

所谓用户主线程,就是你启动 Consumer 应用程序 main 方法的那个线程,而新引入的心跳线程(Heartbeat Thread)只负责定期给对应的 Broker 机器发送心跳请求,以标识消费者应用的存活性(liveness)。引入这个心跳线程还有一个目的,那就是期望它能将心跳频率与主线程调用 KafkaConsumer.poll 方法的频率分开,从而解耦真实的消息处理逻辑与消费者组成员存活性管理。

不过,虽然有心跳线程,但实际的消息获取逻辑依然是在用户主线程中完成的。因此,在消费消息的这个层面上,我们依然可以安全地认为 KafkaConsumer 是单线程的设计。

其实,在社区推出 Java Consumer API 之前,Kafka 中存在着一组统称为 Scala Consumer 的 API。这组 API,或者说这个Consumer,也被称为老版本 Consumer,目前在新版的 Kafka 代码中已经被完全移除了。

我之所以重提旧事,是想告诉你,老版本 Consumer 是多线程的架构,每个 Consumer 实例在内部为所有订阅的主题分区创建对应的消息获取线程,也称 Fetcher 线程。老版本 Consumer 同时也是阻塞式的(blocking),Consumer 实例启动后,内部会创建很多阻塞式的消息获取迭代器。但在很多场景下,Consumer 端是有非阻塞需求的,比如在流处理应用中执行过滤(filter)、连接(join)、分组(group by)等操作时就不能是阻塞式的。基于这个原因,社区为新版本 Consumer 设计了单线程 + 轮询的机制。这种设计能够较好地实现非阻塞式的消息获取。

除此之外,单线程的设计能够简化 Consumer 端的设计。Consumer 获取到消息后,处理消息的逻辑是否采用多线程,完全由你决定。这样,你就拥有了把消息处理的多线程管理策略从 Consumer 端代码中剥离的权利。

另外,不论使用哪种编程语言,单线程的设计都比较容易实现。相反,并不是所有的编程语言都能够很好地支持多线程。从这一点上来说,单线程设计的 Consumer 更容易移植到其他语言上。毕竟,Kafka 社区想要打造上下游生态的话,肯定是希望出现越来越多的客户端的。

2. 多线程方案

了解了单线程的设计原理之后,我们来具体分析一下 KafkaConsumer 这个类的使用方法,以及如何推演出对应的多线程方案。

首先,我们要明确的是,KafkaConsumer 类不是线程安全的 (thread-safe)。所有的网络 I/O 处理都是发生在用户主线程中,因此,你在使用过程中必须要确保线程安全。简单来说,就是你不能在多个线程中共享同一个 KafkaConsumer 实例,否则程序会抛出 ConcurrentModificationException 异常。

当然了,这也不是绝对的。KafkaConsumer 中有个方法是例外的,它就是wakeup(),你可以在其他线程中安全地调用**KafkaConsumer.wakeup()**来唤醒 Consumer。

鉴于 KafkaConsumer 不是线程安全的事实,我们能够制定两套多线程方案。

消费者程序启动多个线程,每个线程维护专属的 KafkaConsumer 实例,负责完整的消息获取、消息处理流程。如下图所示:

在这里插入图片描述

消费者程序使用单或多线程获取消息,同时创建多个消费线程执行消息处理逻辑。获取消息的线程可以是一个,也可以是多个,每个线程维护专属的 KafkaConsumer 实例,处理消息则交由特定的线程池来做,从而实现消息获取与消息处理的真正解耦。具体架构如下图所示:

在这里插入图片描述

总体来说,这两种方案都会创建多个线程,这些线程都会参与到消息的消费过程中,但各自的思路是不一样的。

我们来打个比方。比如一个完整的消费者应用程序要做的事情是 1、2、3、4、5,那么方案 1 的思路是粗粒度化的工作划分,也就是说方案 1 会创建多个线程,每个线程完整地执行 1、2、3、4、5,以实现并行处理的目标,它不会进一步分割具体的子任务;而方案 2 则更细粒度化,它会将 1、2 分割出来,用单线程(也可以是多线程)来做,对于 3、4、5,则用另外的多个线程来做。

这两种方案孰优孰劣呢?应该说是各有千秋。我总结了一下这两种方案的优缺点,我们先来看看下面这张表格。

在这里插入图片描述

我们先看方案 1,它的优势有 3 点:

① 实现起来简单,因为它比较符合目前我们使用 Consumer API 的习惯。我们在写代码的时候,使用多个线程并在每个线程中创建专属的 KafkaConsumer 实例就可以了。

② 多个线程之间彼此没有任何交互,省去了很多保障线程安全方面的开销。

③ 由于每个线程使用专属的 KafkaConsumer 实例来执行消息获取和消息处理逻辑,因此,Kafka 主题中的每个分区都能保证只被一个线程处理,这样就很容易实现分区内的消息消费顺序。这对在乎事件先后顺序的应用场景来说,是非常重要的优势。

说完了方案 1 的优势,我们来看看这个方案的不足之处:

① 每个线程都维护自己的 KafkaConsumer 实例,必然会占用更多的系统资源,比如内存、TCP 连接等。在资源紧张的系统环境中,方案 1 的这个劣势会表现得更加明显。

② 这个方案能使用的线程数受限于 Consumer 订阅主题的总分区数。我们知道,在一个消费者组中,每个订阅分区都只能被组内的一个消费者实例所消费。假设一个消费者组订阅了 100 个分区,那么方案 1 最多只能扩展到 100 个线程,多余的线程无法分配到任何分区,只会白白消耗系统资源。当然了,这种扩展性方面的局限可以被多机架构所缓解。除了在一台机器上启用 100 个线程消费数据,我们也可以选择在 100 台机器上分别创建 1 个线程,效果是一样的。因此,如果你的机器资源很丰富,这个劣势就不足为虑了。

③ 每个线程完整地执行消息获取和消息处理逻辑。一旦消息处理逻辑很重,造成消息处理速度慢,就很容易出现不必要的 Rebalance,从而引发整个消费者组的消费停滞。这个劣势你一定要注意。

下面我们来说说方案 2。

与方案 1 的粗粒度不同,方案 2 将任务切分成了消息获取消息处理两个部分,分别由不同的线程处理它们。比起方案 1,方案 2 的最大优势就在于它的高伸缩性,就是说我们可以独立地调节消息获取的线程数,以及消息处理的线程数,而不必考虑两者之间是否相互影响。如果你的消费获取速度慢,那么增加消费获取的线程数即可;如果是消息的处理速度慢,那么增加 Worker 线程池线程数即可。

不过,这种架构也有它的缺陷:

① 它的实现难度要比方案 1 大得多,毕竟它有两组线程,你需要分别管理它们。

② 因为该方案将消息获取和消息处理分开了,也就是说获取某条消息的线程不是处理该消息的线程,因此无法保证分区内的消费顺序。举个例子,比如在某个分区中,消息 1 在消息 2 之前被保存,那么 Consumer 获取消息的顺序必然是消息 1 在前,消息 2 在后,但是,后面的 Worker 线程却有可能先处理消息 2,再处理消息 1,这就破坏了消息在分区中的顺序。还是那句话,如果你在意 Kafka 中消息的先后顺序,方案 2 的这个劣势是致命的。

③ 方案 2 引入了多组线程,使得整个消息消费链路被拉长,最终导致正确位移提交会变得异常困难,结果就是可能会出现消息的重复消费。如果你在意这一点,那么我不推荐你使用方案 2。

3. 代码实现

方案 1 的主体代码:

public class KafkaConsumerRunner implements Runnable {
    private final AtomicBoolean closed = new AtomicBoolean(false);
    private final KafkaConsumer consumer;

    public void run() {
        try {
            consumer.subscribe(Arrays.asList("topic"));
            while (!closed.get()) {
                // 执行消息获取逻辑
                ConsumerRecords records = consumer.poll(Duration.ofMillis(10000));
                // 执行消息处理逻辑
            }
        } catch (WakeupException e) {
            // Ignore exception if closing
            if (!closed.get())
                throw e;
        } finally {
            consumer.close();
        }
    }

    // Shutdown hook which can be called from a separate thread
    public void shutdown() {
        closed.set(true);
        consumer.wakeup();
    }
}

这段代码创建了一个 Runnable 类,表示执行消费获取和消费处理的逻辑。每个 KafkaConsumerRunner 类都会创建一个专属的 KafkaConsumer 实例。在实际应用中,你可以创建多个 KafkaConsumerRunner 实例,并依次执行启动它们,以实现方案 1 的多线程架构。

对于方案 2 来说,核心的代码是这样的:

public class KafkaConsumerRunner  {
    private final KafkaConsumer<String, String> consumer;
    private ExecutorService executors;
    //...
    
    private int workerNum = ...;
    executors = new ThreadPoolExecutor(workerNum, workerNum, 0L, TimeUnit.MILLISECONDS,new ArrayBlockingQueue<>(1000),new ThreadPoolExecutor.CallerRunsPolicy());
    
    //...
     while (true)  {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
        for (final ConsumerRecord record : records) {
            executors.submit(new Worker(record));
        }
    }
    //...
}

当 Consumer 的 poll 方法返回消息后,由专门的线程池来负责处理具体的消息。调用 poll 方法的主线程不负责消息处理逻辑,这样就实现了方案 2 的多线程架构。

4. 问题思考

今天我们讨论的都是多线程的方案,可能有人会说,何必这么麻烦,我直接启动多个 Consumer 进程不就得了?那么,请你比较一下多线程方案和多进程方案,想一想它们各自的优劣之处。

方案2我觉得还有个问题就是如果是自动提交,那就会容易出现消息丢失,因为异步消费消息,如果worker线程有异常,主线程捕获不到异常,就会造成消息丢失,这个是不是还需要做补偿机制;如果是手动提交,那offer set也会有可能丢失,消息重复消费,消息重复还好处理,做幂等就行。

方案2最核心的如何commit没有说,难道只能启用自动提交吗?我觉得可以用Cyclicbarrier来实现线程池执行完毕后,由consumer来commit,不用countdownlatch因为它只能记录一次,而cb可以反复用,或者用forkjoin方式,总之要等待多线程都处理完才能commit,风险就是某个消息处理太慢回导致整体都不能commit,而触发rebalance以及重复消费,而重复消费我用布隆过滤器来解决

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/85123.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

JSP ssh科研管理系统myeclipse开发mysql数据库MVC模式java编程计算机网页设计

一、源码特点 JSP ssh科研管理系统是一套完善的web设计系统&#xff08;系统采用ssh框架进行设计开发&#xff09;&#xff0c;对理解JSP java编程开发语言有帮助&#xff0c;系统具有完整的源代码和数据库&#xff0c;系统主要采用B/S模式开发。开发环境为TOMCAT7.0,Myec…

Core Scheduling

Core Scheduling要解决什么问题&#xff1f; core scheduling是v5.14中新增的功能&#xff0c;下图是内核数据结构为该功能所添加的字段。 为什么有core scheduling呢&#xff1f;因为当开启超线程(HyperThreading)时&#xff0c;一个物理核就变成了两个逻辑核&#xff0c;但&…

postgres 源码解析43 元组的插入流程详解 heap_insert

本文讲解postgres中元组的插入流程&#xff0c;深入了解其实现原理。同时此过程涉及元组xmin/xmax与标识位的设置细节&#xff0c;与事务的可见性部分密切相关相关&#xff0c;借此复习一下。 heappage结构 执行流程框架图 heap_prepare_insert 该函数执行内容较为简单&#…

课设项目之——教学辅助系统(学生考试监考系统)

在考试场中为学生监考十分枯燥&#xff0c;因此&#xff0c;建立一个可靠的作弊检测系统来识别学生是否存在作弊行为。 使用一个名为 Yolo3 的训练模型和一个名为 coco 的数据集&#xff0c;我们测试了考场中学生的书籍和手机&#xff0c;并将他们标记为作弊者。 使用haarcasc…

如何将dxf或dwg等CAD文件与卫星影像地图叠加进行绘图设计?

引言&#xff1a; 在测绘、电力、水利、规划或道路设计等GIS相关行业中&#xff0c;通常会用AutoCAD进行矢量地图数据的绘制&#xff0c;而这些地图数据通常又是建立在投影平面坐标的基础上进行绘制的。 为了确保地图数据的准确性与精度的要求&#xff0c;这些地图数据经常会…

将一个乱序数组变为有序数组的最少交换次数

给定一个包含1-n的数列&#xff0c;通过交换任意两个元素给数列重新排序。求最少需要多少次交换&#xff0c;能把数组排成按1-n递增的顺序 总之就是将这个位置应该出现的元素和这个位置现在的元素交换位置 代码实现&#xff1a; 核心&#xff1a;记住一点&#xff0c;hashmap用…

【debug】时序预测的结果都是一个趋势

时序预测的结果都是一个趋势现象原因solutionother solutions现象 预测的是一个序列。 在测试集中随机取20个来看&#xff0c;所有的预测序列都是一个趋势&#xff0c;但是大小有所区别。 举例图片 原因 目前来看是数据的问题&#xff0c;应该是样本不均衡&#xff0c;某一…

简单个人网页制作 个人介绍网页模板 静态HTML留言表单页面网站模板 大学生个人主页网页

&#x1f389;精彩专栏推荐&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb; ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 &#x1f482; 作者主页: 【主页——&#x1f680;获取更多优质源码】 &#x1f393; web前端期末大作业…

[ Linux ] 一篇带你理解Linux下线程概念

目录 1.Linux线程的概念 1.1什么是线程 1.1.1如何验证一个进程内有多个线程&#xff1f; 1.2线程的优点 1.3线程的缺点 1.4 线程异常 1.5 线程用途 2.Linux进程与线程 2.1进程和线程 2.2 进程和线程的关系 2.3如何看待之前学习的单进程&#xff1f; 1.Linux线程的概…

迪杰斯特拉算法求图的最短路径(java)

迪杰斯特拉算法 图的最短路径的解法 单源最短路径 从一个点开始&#xff0c;可以找到其中任意一个点的最短路径。 多源最短路径 从任何一个点开始&#xff0c;可以找到其中任何一个点的最短路径。 解题过程 给定一个带权有向图G(G, V), 另外&#xff0c;还给定 V 中的一…

力扣(LeetCode)1832. 判断句子是否为全字母句(C++)

哈希集合1 哈希集合记录 262626 个字母是否出现&#xff0c;一次遍历字符串&#xff0c;维护哈希集合&#xff0c;同时维护答案。遍历完成&#xff0c;仅当答案等于 262626 &#xff0c;句子是全字母句。 class Solution { public:bool checkIfPangram(string sentence) {boo…

轻松提高性能和并发度,springboot简单几步集成缓存

目录 1、缘由 2、技术介绍 2.1、技术调研 2.2、spring支持的cache 2.3、cache的核心注解 2.3.1 EnableCaching 2.3.2 Cacheable 2.3.3 CachePut 2.3.4 CacheEvict 2.4 cache的架构 2.5 cachemanager的实现类 3、搞个例子 3.1 为什么使用redis 作为缓存 3.2 代码走起…

【虚幻引擎】UE4/UE5数字孪生与前端Web页面匹配

一、数字孪生 数字孪生是一种多维动态的数字映射&#xff0c;可大幅提高效能。数字孪生是充分利用物理模型、传感器更新、运行历史等数据&#xff0c;集成多学科、多物理量、多尺度、多概率的仿真过程&#xff0c;在虚拟空间中完成对现实体的复制和映射&#xff0c;从而反映物理…

MySQL常用窗口函数

1、窗口函数概念 窗口的概念非常重要&#xff0c;它可以理解为记录集合&#xff0c;窗口函数也就是在满足某种条件的记录集合上执行的特殊函数对于每条记录都要在此窗口内执行函数&#xff0c;有的函数随着记录不同&#xff0c;窗口大小都是固定的&#xff0c;这种属于静态窗口…

c语言:枚举类型—enum

枚举类型一.常见形式二.枚举和宏定义三.枚举的意义四.插个小知识一.常见形式 这里举一个例子&#xff0c;我想要枚举颜色 注意一下细节&#xff0c;所有成员间用逗号隔开&#xff0c;最后一个成员后不加标点符号 这里看上去和定义结构体和联合体的样式一样&#xff0c;但其实前…

minio安装部署和minIO-Client的使用

minio安装部署和minIO-Client的使用 一、服务器安装minio 1.进行下载 下载地址&#xff1a; GNU/Linux https://dl.min.io/server/minio/release/linux-amd64/minio2.新建minio安装目录&#xff0c;执行如下命令 mkdir -p /home/minio/data把二进制文件上传到安装目录后&a…

【PAT甲级 - C++题解】1128 N Queens Puzzle

✍个人博客&#xff1a;https://blog.csdn.net/Newin2020?spm1011.2415.3001.5343 &#x1f4da;专栏地址&#xff1a;PAT题解集合 &#x1f4dd;原题地址&#xff1a; 题目详情 - 1128 N Queens Puzzle (pintia.cn) &#x1f511;中文翻译&#xff1a;皇后问题 &#x1f4e3;…

第9章 无线网络和移动网络

目录 9.1 无线局域网 WLAN 9.1.1 无线局域网的组成 1. 无线局域网 WLAN (Wireless Local Area Network) 2. IEEE 802.11 3. 移动自组网络 9.1.2 802.11 局域网的物理层 9.1.3 802.11 局域网的 MAC 层协议 1. CSMA/CA 协议 2. 时间间隔 DIFS 的重要性 3. MAC两个子层…

acwing基础课——Floyd

由数据范围反推算法复杂度以及算法内容 - AcWing 常用代码模板3——搜索与图论 - AcWing 基本思想&#xff1a; floyd算法的原理是基于动态规划的基础上实现的&#xff0c;因为是稠密图我们通过邻接矩阵来存储&#xff0c;我们将各点距离初始化为正无穷(该点到自己的距离为0)&…

软件测试基础理论体系学习8-什么是验收测试?验收测试的内容是什么?过程是什么?有什么测试策略?

8-什么是验收测试&#xff1f;验收测试的内容是什么&#xff1f;过程是什么&#xff1f;有什么测试策略&#xff1f;1 验收测试的主要内容1.1 简介和说明1.2 验收测试的目的1.3 验收测试的任务1.4 验收测试主要内容1.4.1 验收测试标准1.4.2 配置复审1.4.3 α、β测试2 验收测试…