目录
- [1.Kafka 入门](#1.Kafka 入门)
-
- 1.1.概述
-
- [1.1.1.初识 Kafka](#1.1.1.初识 Kafka)
- 1.1.2.消息队列
- 1.1.3.生产者-消费者模式
- 1.1.4.消息中间件对比
- 1.1.5.ZooKeeper
- 1.2.快速上手
-
- 1.2.1.环境安装
-
- [1.2.1.1.安装 Java8(略)](#1.2.1.1.安装 Java8(略))
- [1.2.1.2.安装 Kafka](#1.2.1.2.安装 Kafka)
- [1.2.1.3.启动 ZooKeeper](#1.2.1.3.启动 ZooKeeper)
- [1.2.1.4.启动 Kafka](#1.2.1.4.启动 Kafka)
- 1.2.2.消息主题
- 1.2.3.生产与消费数据
-
- 1.2.3.1.命令行操作
- [1.2.3.2.Java API 操作](#1.2.3.2.Java API 操作)
- 1.2.3.3.客户端工具操作------kafkatool
- 1.2.6.总结
- [2.Kafka 基础](#2.Kafka 基础)
-
- 2.1.集群部署
-
- 2.1.1.解压文件
- [2.1.2.安装 ZooKeeper](#2.1.2.安装 ZooKeeper)
- [2.1.3.安装 Kafka](#2.1.3.安装 Kafka)
- 2.1.4.封装启动脚本
- 2.1.5.测试
- 2.2.集群启动
-
- 2.2.1.相关概念
- [2.2.2.启动 ZooKeeper](#2.2.2.启动 ZooKeeper)
-
- [2.2.2.1.Controller 节点选举](#2.2.2.1.Controller 节点选举)
- [2.2.2.2.使用工具 prettyzoo 查看 Zookeeper 节点](#2.2.2.2.使用工具 prettyzoo 查看 Zookeeper 节点)
- [2.2.3.启动 Kafka](#2.2.3.启动 Kafka)
-
- [2.2.3.1.初始化 ZooKeeper](#2.2.3.1.初始化 ZooKeeper)
- 2.2.3.2.初始化服务
-
- 2.2.3.2.1.启动任务调度器
- 2.2.3.2.2.创建数据管理器
- 2.2.3.2.3.创建远程数据管理器
- 2.2.3.2.4.创建副本管理器
- [2.2.3.2.5.创建 ZK 元数据缓存](#2.2.3.2.5.创建 ZK 元数据缓存)
- [2.2.3.2.6.创建 Broker 通信对象](#2.2.3.2.6.创建 Broker 通信对象)
- 2.2.3.2.7.创建网络通信对象
- [2.2.3.2.8.注册 Broker 节点](#2.2.3.2.8.注册 Broker 节点)
- 2.2.3.3.启动控制器
- 2.3.创建主题
-
- 2.3.1.相关概念
-
- 2.3.1.1.主题:Topic
- 2.3.1.2.分区:Partition
- 2.3.1.3.副本:Replication
- [2.3.1.4.副本类型:Leader & Follower](#2.3.1.4.副本类型:Leader & Follower)
- 2.3.1.5.日志:Log
- 2.3.2.创建主题
-
- [2.3.2.1.客户端 API](#2.3.2.1.客户端 API)
- 2.3.5.1.创建主题流程
-
- 2.3.5.1.1.命令行提交创建指令
- [2.3.5.1.2.Controller 接收创建主题请求](#2.3.5.1.2.Controller 接收创建主题请求)
- 2.3.5.1.3.创建主题
- 2.4.生产数据
-
- 2.4.1.生产消息的基本步骤
- 2.4.2.生产消息的基本代码
- 2.4.3.发送消息
- 2.4.4.消息分区
- 2.4.5.消息可靠性
-
- [2.4.5.1.ACK = 0](#2.4.5.1.ACK = 0)
- [2.4.5.2.ACK = 1](#2.4.5.2.ACK = 1)
- [2.4.5.3.ACK = -1 或者 all(默认)](#2.4.5.3.ACK = -1 或者 all(默认))
- [2.4.6 消息去重 & 有序](#2.4.6 消息去重 & 有序)
- 2.4.6.1.数据重试
- 2.5.存储消息
-
- 2.5.1.存储组件
- 2.5.2.数据存储
-
- [2.5.2.1.ACKS 校验](#2.5.2.1.ACKS 校验)
- 2.5.2.2.内部主题校验
- [2.5.2.3.ACKS 应答及副本数量关系校验](#2.5.2.3.ACKS 应答及副本数量关系校验)
- 2.5.2.4.日志文件滚动判断
- 2.5.2.5.请求数据重复性校验
- 2.5.2.6.请求数据序列号校验
- 2.5.2.7.数据存储
- 2.5.3.存储文件格式
- 2.5.4.数据刷写
- 2.5.5.副本同步
- 2.5.6.数据一致性
-
- 2.5.6.1.数据一致性
- [2.5.6.2.HW 在副本之间的传递](#2.5.6.2.HW 在副本之间的传递)
- [2.5.6.3.ISR (In-Sync Replicas) 变化和传播](#2.5.6.3.ISR (In-Sync Replicas) 变化和传播)
- 2.6.消费消息
-
- 2.6.1.消费消息的基本步骤
- 2.6.2.消费消息的基本代码
- 2.6.3.消费消息的基本原理
-
- 2.6.3.1.消费者组
-
- [2.6.3.1.1.消费数据的方式:push & pull](#2.6.3.1.1.消费数据的方式:push & pull)
- [2.6.3.1.2.消费者组 Consumer Group](#2.6.3.1.2.消费者组 Consumer Group)
- [2.6.3.2.调度(协调)器 Coordinator](#2.6.3.2.调度(协调)器 Coordinator)
- [2.6.3.3.消费者分配策略 Assignor](#2.6.3.3.消费者分配策略 Assignor)
- [2.6.3.4.偏移量 Offset](#2.6.3.4.偏移量 Offset)
- 2.6.3.5.消费者事务
- 2.6.3.6.偏移量的保存
- 2.6.3.7.消费数据
本文笔记整理自视频 https://www.bilibili.com/video/BV1Gp421m7UN,相关资料可在该视频评论区领取。
1.Kafka 入门
1.1.概述
1.1.1.初识 Kafka
(1)Kafka 是一个由 Scala 和 Java 语言开发的,经典高吞吐量的分布式消息发布和订阅系统,也是大数据技术领域中用作数据交换的核心组件之一。以高吞吐,低延迟,高伸缩,高可靠性,高并发,且社区活跃度高等特性,从而备受广大技术组织的喜爱。
(2)2010年,Linkedin 公司为了解决消息传输过程中由各种缺陷导致的阻塞、服务无法访问等问题,主导开发了一款分布式消息日志传输系统。主导开发的首席架构师 Jay Kreps 因为喜欢写出《变形记》的西方表现主义文学先驱小说家 Jay Kafka,所以给这个消息系统起了一个很酷,却和软件系统特性无关的名称 Kafka。
(3)因为备受技术组织的喜爱,2011年,Kafka 软件被捐献给 Apache 基金会,并于 7 月被纳入 Apache 软件基金会孵化器项目进行孵化。2012 年 10 月,Kafka 从孵化器项目中毕业,转成 Apache 的顶级项目。由独立的消息日志传输系统转型为开源分布式事件流处理平台系统,被数千家公司用于高性能数据管道、流分析、数据集成和关键任务应用程序。
(4)官网地址:https://kafka.apache.org/

1.1.2.消息队列
(1)Kafka 软件最初的设计就是专门用于数据传输的消息系统,类似功能的软件有 RabbitMQ、ActiveMQ、RocketMQ 等。这些软件名称中的 MQ 是英文单词 Message Queue 的简称,也就是所谓的消息队列的意思。这些软件的核心功能是传输数据,而 Java 中如果想要实现数据传输功能,那么这个软件一般需要遵循 Java 消息服务技术规范 JMS (Java Message Service)。前面提到的 ActiveMQ 软件就完全遵循了 JMS 技术规范,而 RabbitMQ 是遵循了类似JMS规范并兼容 JMS 规范的跨平台的 AMQP (Advanced Message Queuing Protocol) 规范。除了上面描述的 JMS,AMQP 外,还有一种用于物联网小型设备之间传输消息的 MQTT 通讯协议。
(2)Kafka 拥有作为一个消息系统应该具备的功能,但是却有着独特的设计。可以这样说,Kafka 借鉴了 JMS 规范的思想,但是却并没有完全遵循 JMS 规范。这也恰恰是软件名称为 Kafka,而不是 KafkaMQ 的原因。
(3)由上可知,无论学习哪一种消息传输系统,JMS 规范都是大家应该首先了解的。所以这里就对 JMS 规范做一个简单的介绍:
- JMS 是 Java 平台的消息中间件通用规范,定义了主要用于消息中间件的标准接口。如果不是很理解这个概念,可以简单地将 JMS 类比为Java 和数据库之间的 JDBC 规范。Java 应用程序根据 JDBC 规范种的接口访问关系型数据库,而每个关系型数据库厂商可以根据 JDBC 接口来实现具体的访问规则。JMS 定义的就是系统和系统之间传输消息的接口。
- 为了实现系统和系统之间的数据传输,JMS 规范中定义很多用于通信的组件:
JMS Provider:JMS 消息提供者。其实就是实现 JMS 接口和规范的消息中间件,也就是我们提供消息服务的软件系统,比如 RabbitMQ、ActiveMQ、Kafka。JMS Message:JMS 消息。这里的消息指的就是数据。一般采用Java数据模型进行封装,其中包含消息头,消息属性和消息主体内容。JMS Producer:JMS 消息生产者。所谓的生产者,就是生产数据的客户端应用程序,这些应用通过 JMS 接口发送 JMS 消息。JMS Consumer:JMS 消息消费者。所谓的消费者,就是从消息提供者(JMS Provider)中获取数据的客户端应用程序,这些应用通过 JMS 接口接收 JMS 消息。
- JMS 支持两种消息发送和接收模型:一种是 P2P (Peer-to-Peer) 点对点模型 ,另外一种是发布/订阅 (Publish/Subscribe) 模型 :
- P2P 模型:P2P 模型是基于队列的,消息生产者将数据发送到消息队列中,消息消费者从消息队列中接收消息。因为队列的存在,消息的异步传输成为可能。P2P 模型的规定就是每一个消息数据,只有一个消费者,当发送者发送消息以后,不管接收者有没有运行都不影响消息发布到队列中。接收者在成功接收消息后会向发送者发送接收成功的消息。
- 发布/订阅模型 :所谓得发布订阅模型就是事先将传输的数据进行分类,我们管这个数据的分类称之为主题 (Topic)。也就是说,生产者发送消息时,会根据主题进行发送。比如咱们的消息中有一个分类是 NBA,那么生产者在生产消息时,就可以将 NBA 篮球消息数据发送到 NBA 主题中,这样,对 NBA 消息主题感兴趣的消费者就可以申请订阅 NBA 主题,然后从该主题中获取消息。这样,也就是说一个消息,是允许被多个消费者同时消费的 。这里生产者发送消息,我们称之为发布消息,而消费者从主题中获取消息,我们就称之为订阅消息。Kafka 采用就是这种模型。
1.1.3.生产者-消费者模式
(1)生产者-消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通信,而通过阻塞队列来进行通信,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个消息缓冲区,平衡了生产者和消费者的处理能力。在数据传输过程中,起到了一个削弱峰值的作用,也就是我们经常说到的削峰。

(2)上图中的缓冲区就是用来给生产者和消费者解耦的。在单点环境中,我们一般会采用阻塞式队列实现这个缓冲区。而在分布式环境中,一般会采用第三方软件实现缓冲区,这个第三方软件我们一般称之为中间件。纵观大多数应用场景,解耦合最常用的方式就是增加中间件。
(3)遵循 JMS 规范的消息传输软件 (RabbitMQ、ActiveMQ、Kafka、RocketMQ),我们一般就称之为消息中间件。使用软件的目的本质上也就是为了降低消息生产者和消费者之间的耦合性。提升消息的传输效率。
1.1.4.消息中间件对比
| 特性 | ActiveMQ | RabbitMQ | RocketMQ | Kafka |
|---|---|---|---|---|
| 单机吞吐量 | 万级,比 RocketMQ、Kafka 低一个数量级 | 万级,比 RocketMQ、Kafka 低一个数量级 | 10 万级,支持高吞吐 | 10 万级,支持高吞吐 |
| Topic 数量对吞吐量的影响 | Topic 可以达到几百/几千量级 | Topic 可以达到几百量级,如果更多的话,吞吐量会大幅度下降 | ||
| 时效性 | ms级 | 微秒级别,延迟最低 | ms级 | ms级 |
| 可用性 | 高,基于主从架构实现高可用 | 高,基于主从架构实现高可用 | 非常高,分布式架构 | 非常高,分布式架构 |
| 消息可靠性 | 有较低的概率丢失数据 | 基本不丢 | 经过参数优化配置,可以做到 0 丢失 | 经过参数优化配置,可以做到 0 丢失 |
| 功能支持 | MQ 领域的功能极其完备 | 并发能力强,性能极好,延时很低 | MQ 功能较为完善,分布式,扩展性好 | 功能较为简单,支持简单的 MQ 功能,在大数据领域被广泛使用 |
| 其他 | 很早的软件,社区不是很活跃 | 开源,稳定,社区活跃度高 | 阿里开发,社区活跃度不高 | 开源,高吞吐量,社区活跃度极高 |
通过上面各种消息中间件的对比,大概可以了解,在大数据场景中我们主要采用 Kafka 作为消息中间件,而在 JaveEE 开发中我们主要采用ActiveMQ、RabbitMQ、RocketMQ 作为消息中间件。如果将 JavaEE 和大数据在项目中进行融合的话,那么 Kafka 其实是一个不错的选择。
1.1.5.ZooKeeper
(1)ZooKeeper 是一个开放源码的分布式应用程序协调服务软件。在当前的 Web 软件开发中,多节点分布式的架构设计已经成为必然,那么如何保证架构中不同的节点所运行的环境,系统配置是相同的,就是一个非常重要的话题。一般情况下,我们会采用独立的第三方软件保存分布式系统中的全局环境信息以及系统配置信息,这样系统中的每一个节点在运行时就可以从第三方软件中获取一致的数据。也就是说通过这个第三方软件来协调分布式各个节点之间的环境以及配置信息。Kafka 软件是一个分布式事件流处理平台系统,底层采用分布式的架构设计,就是说,也存在多个服务节点,多个节点之间 Kafka 就是采用 ZooKeeper 来实现协调调度的。
(2)ZooKeeper 的核心作用:
- ZooKeeper 的数据存储结构可以简单地理解为一个 Tree 结构,而 Tree 结构上的每一个节点可以用于存储数据,所以一般情况下,我们可以将分布式系统的元数据(环境信息以及系统配置信息)保存在 ZooKeeper 节点中。
- ZooKeeper 创建数据节点时,会根据业务场景创建临时节点或永久(持久)节点。永久节点就是无论客户端是否连接上 ZooKeeper 都一直存在的节点,而临时节点指的是客户端连接时创建,断开连接后删除的节点。同时,ZooKeeper 也提供了 Watch(监控)机制用于监控节点的变化,然后通知对应的客户端进行相应的变化。Kafka 软件中就内置了 ZooKeeper 的客户端,用于进行 ZooKeeper 的连接和通信。
(3)其实,Kafka 作为一个独立的分布式消息传输系统,还需要第三方软件进行节点间的协调调度,不能实现自我管理,无形中就导致 Kafka 和其他软件之间形成了耦合性,制约了 Kafka 软件的发展,所以从Kafka 2.8.X版本开始,Kafka 就尝试增加了 Raft 算法实现节点间的协调管理,来代替 ZooKeeper。不过 Kafka 官方不推荐此方式应用在生产环境中,计划在 Kafka 4.X 版本中完全移除 ZooKeeper,让我们拭目以待。
1.2.快速上手
1.2.1.环境安装
作为开源分布式事件流处理平台,Kafka 分布式软件环境的安装相对比较复杂,不利于 Kafka 软件的入门学习和练习。所以我们这里先搭建相对比较简单的 Windows 单机环境,让初学者快速掌握软件的基本原理和用法,后面的课程中,我们再深入学习 Kafka 软件在生产环境中的安装和使用。
1.2.1.1.安装 Java8(略)
(1)当前 Java 软件开发中,主流的版本就是 Java 8,而 Kafka 3.X 官方建议 Java 版本更新至 Java11,但是 Java8 依然可用。未来 Kafka 4.X版本会完全弃用 Java8,不过,咱们当前学习的 Kafka 版本为 3.6.1 版本,所以使用 Java 8 即可,无需升级。
(2)Kafka 的绝大数代码都是 Scala 语言编写的,而 Scala 语言本身就是基于 Java 语言开发的,并且由于 Kafka 内置了 Scala 语言包,所以 Kafka 是可以直接运行在 JVM 上的,无需安装其他软件。你能看到这个课件,相信你肯定已经安装 Java8 了,基本的环境变量也应该配置好了,所以此处安装过程省略。
1.2.1.2.安装 Kafka
(1)下载软件安装包:kafka_2.12-3.6.1.tgz,下载地址:https://kafka.apache.org/downloads
- 这里的 3.6.1,是 Kafka 软件的版本。截至到 2023 年 12 月 24 日,Kafka 最新版本为 3.6.1。
- 2.12 是对应的 Scala 开发语言版本。Scala2.12 和 Java 8 是兼容的,所以可以直接使用。
- tgz 是一种 Linux 系统中常见的压缩文件格式,类似与 Windows 系统的 zip 和 rar 格式。所以 Windows 环境中可以直接使用压缩工具进行解压缩。
(2)为了访问方便,可以将解压后的文件目录改为kafka, 更改后的文件目录结构如下:
- bin:Linux 系统下可执行脚本文件
- bin/windows:Windows 系统下可执行脚本文件
- config:配置文件
- libs:依赖类库
- licenses:许可信息
- site-docs:文档
- logs:服务日志
1.2.1.3.启动 ZooKeeper
当前版本 Kafka 软件内部依然依赖 ZooKeeper 进行多节点协调调度,所以启动 Kafka 软件之前,需要先启动 ZooKeeper 软件。不过因为 Kafka 软件本身内置了 ZooKeeper 软件,所以无需额外安装 ZooKeeper 软件,直接调用脚本命令启动即可。具体操作步骤如下:
(1)进入 Kafka 解压缩文件夹的 config 目录,修改 zookeeper.properties 配置文件
powershell
# the directory where the snapshot is stored.
# 修改 dataDir 配置,用于设置 ZooKeeper 数据存储位置,该路径如果不存在会自动创建
dataDir=E:/kafka_2.12-3.6.1/data/zk
(2)打开 DOS 窗口,进入 E:/kafka_2.12-3.6.1/bin/windows 目录,因为本章节演示的是 Windows 环境下 Kafka 软件的安装和使用,所以启动 ZooKeeper 软件的指令为 Windows 环境下的 bat 批处理文件。调用启动指令时,需要传递配置文件的路径
powershell
# 因为当前目录为 Windows,所以需要通过相对路径找到 zookeeper 的配置文件
zookeeper-server-start.bat ../../config/zookeeper.properties
出
现如下界面,ZooKeeper 启动成功:

(3)为了操作方便,也可以在 Kafka 解压缩后的目录中,创建脚本文件 zk.cmd
powershell
call bin/windows/zookeeper-server-start.bat config/zookeeper.properties
1.2.1.4.启动 Kafka
(1)进入 Kafka 解压缩文件夹的 config 目录,修改 server.properties 配置文件:
powershell
# Listener name, hostname and port the broker will advertise to clients.
# If not set, it uses the value for "listeners".
# 客户端访问 Kafka 服务器时,默认连接的服务为本机的端口 9092,如果想要改变,可以修改如下配置
# 此处我们不做任何改变,默认即可
#advertised.listeners=PLAINTEXT://your.host.name:9092
# A comma separated list of directories under which to store log files
# 配置Kafka数据的存放位置,如果文件目录不存在,会自动生成。
log.dirs=E:/kafka_2.12-3.6.1/data/kafka
(2)打开 DOS 窗口,进入 E:/kafka_2.12-3.6.1/bin/windows 目录,调用启动指令,传递配置文件的路径
powershell
# 因为当前目录为 windows,所以需要通过相对路径找到 kafka 的配置文件
kafka-server-start.bat ../../config/server.properties
(3)出现如下界面,Kafka启动成功:

(4)为了操作方便,也可以在kafka解压缩后的目录中,创建脚本文件 kfk.cmd:
powershell
#调用启动命令,且同时指定配置文件。
call bin/windows/kafka-server-start.bat config/server.properties
(5)DOS 窗口中,输入 jps 指令,查看当前启动的软件进程:

这里名称为 QuorumPeerMain 的就是 ZooKeeper 软件进程,名称为 Kafka 的就是 Kafka 系统进程。此时,说明 Kafka 已经可以正常使用了。
启动顺序:ZooKeeper -> Kafka
关闭顺序:Kafka -> ZooKeeper
1.2.2.消息主题
(1)在消息发布/订阅 (Publish/Subscribe) 模型中,为了可以让消费者对感兴趣的消息进行消费,而不是对所有的数据进行消费,包括那些不感兴趣的消息,所以定义了主题 (Topic) 的概念,也就是说将不同的消息进行分类,分成不同的主题 (Topic),然后消息生产者在生成消息时,就会向指定的主题 (Topic) 中发送。而消息消费者也可以订阅自己感兴趣的主题 (Topic) 并从中获取消息。
(2)有很多种方式都可以操作Kafka消息中的主题 (Topic):命令行、第三方工具、Java API、自动创建。而对于初学者来讲,掌握基本的命令行操作是必要的。所以接下来,我们采用命令行进行操作。
1.2.2.1.创建主题
(1)按照上述教程依次启动 ZooKeeper、Kafka 服务进程。
(2)打开 DOS 窗口,进入 E:/kafka_2.12-3.6.1/bin/windows 目录:

(3)DOS 窗口输入指令,创建主题
powershell
# Kafka 是通过 kafka-topics.bat 指令文件进行消息主题操作的。其中包含了对主题的查询,创建,删除等功能。
# 调用指令创建主题时,需要传递多个参数,而且参数的前缀为两个横线。因为参数比较多,为了演示方便,这里我们只说明必须传递的参数,其他参数后面课程中会进行讲解
# --bootstrap-server: 把当前的DOS窗口当成Kafka的客户端,那么进行操作前,就需要连接服务器,这里的参数就表示服务器的连接方式,
# 因为我们在本机启动 Kafka 服务进程,且Kafka默认端口为9092,所以此处,后面接的参数值为localhost:9092,用空格隔开
# --create: 表示对主题的创建操作,是个操作参数,后面无需增加参数值
# --topic: 主题的名称,后面接的参数值一般就是见名知意的字符串名称,类似于 java 中的字符串类型标识符名称,当然也可以使用数字,只不过最后还是当成数字字符串使用。
# 指令
kafka-topics.bat --bootstrap-server localhost:9092 --create --topic test


(4)DOS 窗口输入指令,查看指定主题信息:
powershell
# --bootstrap-server : 把当前的 DOS 窗口当成 Kafka 的客户端,那么进行操作前,就需要连接服务器,这里的参数就表示服务器的连接方式,因为我们在本机启动 Kafka 服务进程,且 Kafka 默认端口为 9092,所以此处,后面接的参数值为 localhost:9092,用空格隔开
# --describe : 查看主题的详细信息
# --topic : 查询的主题名称
# 指令
kafka-topics.bat --bootstrap-server localhost:9092 --describe --topic test

1.2.2.2.查询主题
(1)DOS 窗口输入指令,查看所有主题
powershell
# Kafka 是通过 kafka-topics.bat 文件进行消息主题操作的。其中包含了对主题的查询,创建,删除等功能。
# --bootstrap-server: 把当前的 DOS 窗口当成 Kafka 的客户端,那么进行操作前,就需要连接服务器,这里的参数就表示服务器的连接方式,
# 因为我们在本机启动 Kafka 服务进程,且 Kafka 默认端口为 9092,所以此处,后面接的参数值为 localhost:9092,用空格隔开
# --list: 表示对所有主题的查询操作,是个操作参数,后面无需增加参数值
# 指令
kafka-topics.bat --bootstrap-server localhost:9092 --list

(2)DOS窗口输入指令,查看指定主题信息
powershell
# --bootstrap-server: 把当前的 DOS 窗口当成 Kafka 的客户端,那么进行操作前,就需要连接服务器,这里的参数就表示服务器的连接方式,
# 因为我们在本机启动 Kafka 服务进程,且 Kafka 默认端口为 9092,所以此处,后面接的参数值为 localhost:9092,用空格隔开
# --describe: 查看主题的详细信息
# --topic: 查询的主题名称
# 指令
kafka-topics.bat --bootstrap-server localhost:9092 --describe --topic test

1.2.2.3.修改主题
创建主题后,可能需要对某些参数进行修改,那么就需要使用指令进行操作。DOS 窗口输入指令,修改指定主题的参数
powershell
# Kafka 是通过 kafka-topics.bat 文件进行消息主题操作的。其中包含了对主题的查询,创建,删除等功能。
# --bootstrap-server: 把当前的 DOS 窗口当成 Kafka 的客户端,那么进行操作前,就需要连接服务器,这里的参数就表示服务器的连接方式,
# 因为我们在本机启动 Kafka 服务进程,且 Kafka 默认端口为 9092,所以此处,后面接的参数值为 localhost:9092,用空格隔开
# --alter: 表示对所有主题的查询操作,是个操作参数,后面无需增加参数值
# --topic: 修改的主题名称
# --partitions: 修改的配置参数:分区数量
# 指令
kafka-topics.bat --bootstrap-server localhost:9092 --topic test --alter --partitions 2

1.2.2.4.删除主题
如果主题创建后不需要了,或创建的主题有问题,那么我们可以通过相应的指令删除主题。DOS 窗口输入指令,删除指定名称的主题:
powershell
# Kafka 是通过 kafka-topics.bat 文件进行消息主题操作的。其中包含了对主题的查询,创建,删除等功能。
# --bootstrap-server: 把当前的 DOS 窗口当成 Kafka 的客户端,那么进行操作前,就需要连接服务器,这里的参数就表示服务器的连接方式,
# 因为我们在本机启动 Kafka 服务进程,且 Kafka 默认端口为 9092,所以此处,后面接的参数值为 localhost:9092,用空格隔开
# --delete: 表示对主题的删除操作,是个操作参数,后面无需增加参数值。默认情况下,删除操作是逻辑删除,也就是说数据存储的文件依然存在,但是通过指令查询不出来。如果想要直接删除,需要在 server.properties 文件中设置参数 delete.topic.enable=true
# --topic: 删除的主题名称
# 指令
kafka-topics.bat --bootstrap-server localhost:9092 --topic test --delete
注意:Windows 系统中由于权限或进程锁定的问题,删除 topic 会导致 Kafka 服务节点异常关闭。请在后续的 Linux 系统下演示此操作,或者直接删除 data 目录下的文件,重启即可。
1.2.3.生产与消费数据
消息主题创建好了,就可以通过 Kafka 客户端向 Kafka 服务器的主题中发送消息了。Kafka 生产者客户端并不是一个独立的软件系统,而是一套 API 接口,只要通过接口能连接 Kafka 并发送数据的组件我们都可以称之为 Kafka 生产者。下面我们就演示几种不同的方式。
1.2.3.1.命令行操作
打开 DOS 窗口,进入 E:/kafka_2.12-3.6.1/bin/windows 目录,DOS 窗口输入指令,进入生产者控制台
powershell
# Kafka 是通过 kafka-console-producer.bat 文件进行消息生产者操作的。
# 调用指令时,需要传递多个参数,而且参数的前缀为两个横线,因为参数比较多。为了演示方便,这里我们只说明必须传递的参数,其他参数后面课程中会进行讲解
# --bootstrap-server: 把当前的 DOS 窗口当成 Kafka 的客户端,那么进行操作前,就需要连接服务器,这里的参数就表示服务器的连接方式,
# 因为我们在本机启动 Kafka 服务进程,且 Kafka 默认端口为 9092,所以此处,后面接的参数值为 localhost:9092,用空格隔开。早期版本的Kafka 也可以通过 --broker-list 参数进行连接,当前版本已经不推荐使用了。
# --topic: 主题的名称,后面接的参数值就是之前已经创建好的主题名称。
# 指令
kafka-console-producer.bat --bootstrap-server localhost:9092 --topic test

注意:这里的数据需要回车后,才能真正将数据发送到 Kafka 服务器。
打开另一个 DOS 窗口,进入 E:/kafka_2.12-3.6.1/bin/windows 目录,DOS 窗口输入指令,进入消费者者控制台,输入以下指令,消费数据:
powershell
kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic test

注意:如果指定的主题中没有数据时,控制台不会展示消费到的数据。
1.2.3.2.Java API 操作
一般情况下,我们可以通过 Java 程序来生产和消费数据,所以接下来,我们就演示一下 IDEA 中使用 Kafka Java API 来生产和消费数据。
(1)创建 Kafka 项目:

(2)修改 pom.xml 文件,增加 Maven 依赖:
xml
<dependencies>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.6.1</version>
</dependency>
</dependencies>
(3)创建 com.atguigu.kafka.test.KafkaProducerTest 类,并添加 mian 方法,增加生产者代码:
java
package com.atguigu.kafka.test;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;
public class KafkaProducerTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
StringSerializer.class.getName());
configMap.put(
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
StringSerializer.class.getName());
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
producer.send(record);
}
//关闭生产者连接
producer.close();
}
}
(4)创建 com.atguigu.kafka.test.KafkaConsumerTest 类,并添加 mian 方法,增加消费者代码:
java
package com.atguigu.kafka.test;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class KafkaConsumerTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<String, Object>();
//配置属性:Kafka 集群地址
configMap.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性: Kafka 传输的数据为KV对,所以需要对获取的数据分别进行反序列化
configMap.put(
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
configMap.put(
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
//配置属性: 消费者组
configMap.put("group.id", "atguigu");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(configMap);
//消费者订阅指定主题的数据
consumer.subscribe(Collections.singletonList("test"));
while (true) {
//每隔 100 毫秒,抓取一次数据
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
//打印抓取的数据
for (ConsumerRecord<String, String> record : records) {
System.out.println(record);
}
}
}
}
先启动消费者,再启动生产者:


1.2.3.3.客户端工具操作------kafkatool
(1)有的时候,使用命令行进行操作还是有一些麻烦,并且操作起来也不是很直观,所以我们一般会采用一些小工具进行快速访问。这里我们介绍一个 kafkatool 工具软件。软件的安装过程比较简单,根据提示默认安装即可。

(2)安装好以后,我们打开工具

(3)连接成功后的页面如下所示:

(4)添加主题

(5)生产数据:



(5)增加成功后,点击绿色箭头按钮进行查询,工具会显示当前数据

1.2.6.总结
(1)本章作为 Kafka 软件的入门章节,介绍了一些消息传输系统中的基本概念以及单机版 Windows 系统中 Kafka 软件的基本操作。如果仅从操作上,感觉 Kafka 和数据库的功能还是有点像的。比如:
- 数据库可以创建表保存数据,Kafka 可以创建主题保存消息。
- Java 客户端程序可以通过 JDBC 访问数据库:保存数据、修改数据、查询数据,kafka可以通过生产者客户端生产数据,通过消费者客户端消费数据。
(2)从这几点来看,确实有相像的地方,但其实两者的本质并不一样:
- 数据库的本质是为了更好的组织和管理数据,所以关注点是如何设计更好的数据模型用于保存数据,保证核心的业务数据不丢失,这样才能准确地对数据进行操作。
- Kafka 的本质是为了高效地传输数据。所以软件的侧重点是如何优化传输的过程,让数据更快,更安全地进行系统间的传输。
(3)通过以上的介绍,你会发现,两者的区别还是很大的,不能混为一谈。接下来的章节我们会给大家详细讲解 Kafka 在分布式环境中是如何高效地传输数据的。

2.Kafka 基础
Kafka 借鉴了 JMS 规范的思想,但是却并没有完全遵循 JMS 规范,因此从设计原理上,Kafka 的内部也会有很多用于数据传输的组件对象,这些组件对象之间会形成关联,组合在一起实现高效的数据传输。所以接下来,我们就按照数据流转的过程详细讲一讲 Kafka 中的基础概念以及核心组件。
2.1.集群部署
生产环境都是采用 Linux 系统搭建服务器集群,但是我们的重点是在于学习 Kafka 的基础概念和核心组件,所以这里我们搭建一个简单易用的 Windows 集群方便大家的学习和练习。Linux 集群的搭建会在后续给大家进行讲解。
2.1.1.解压文件
将 Kafka 安装包 kafka-3.6.1-src.tgz 解压缩到目录 E:\cluster 下,并复制出 3 份改名如下图所示:

2.1.2.安装 ZooKeeper
对 kafka-zookeeper,修改 config/zookeeper.properties 文件:
powershell
# 此处注意,如果文件目录不存在,会自动创建
dataDir=E:/cluster/kafka-zookeeper/data
2.1.3.安装 Kafka
对 kafka-node-1,修改 config/server.properties 文件:
powershell
# Kafka 节点数字标识,集群内具有唯一性
broker.id=1
# 监听器 9091 为本地端口,如果冲突,请重新指定
listeners=PLAINTEXT://:9091
# 数据文件路径,如果不存在,会自动创建
log.dirs=E:/cluster/kafka-node-1/data
将 kafka-node-1 文件夹复制两份,改名为 kafka-node-2,kafka-node-3,分别修改 kafka-node-2、kafka-node-3 文件夹中的配置文件 server.properties:
- 将文件内容中的 broker.id=1 分别改为 broker.id=2、broker.id=3
- 将文件内容中的 9091 分别改为9092、9093(如果端口冲突,请重新设置)
- 将文件内容中的 kafka-node-1 分别改为kafka-node-2、kafka-node-3
2.1.4.封装启动脚本
因为 Kafka 启动前,必须先启动 ZooKeeper,并且 Kafka 集群中有多个节点需要启动,所以启动过程比较繁琐,这里我们将启动的指令进行封装。
(1)在 kafka-zookeeper 文件夹下创建 zk.cmd 批处理文件
powershell
call bin/windows/zookeeper-server-start.bat config/zookeeper.properties
(2)在 kafka-node-1,kafka-node-2,kafka-node-3 文件夹下分别创建 kfk.cmd 批处理文件
powershell
call bin/windows/kafka-server-start.bat config/server.properties
(3)在 cluster 文件夹下创建 cluster.cmd 批处理文件,用于启动 Kafka 集群
powershell
cd kafka-zookeeper
start zk.cmd
ping 127.0.0.1 -n 10 >nul
cd ../kafka-node-1
start kfk.cmd
cd ../kafka-node-2
start kfk.cmd
cd ../kafka-node-3
start kfk.cmd
(4)在 cluster 文件夹下创建 cluster-clear.cmd 批处理文件,用于清理和重置 Kafka 数据
powershell
cd kafka-zookeeper
rd /s /q data
cd ../kafka-node-1
rd /s /q data
cd ../kafka-node-2
rd /s /q data
cd ../kafka-node-3
rd /s /q data
(5)双击执行 cluster.cmd 文件,启动 Kafka 集群。集群启动命令后,会打开多个黑窗口,每一个窗口都是一个 Kafka 服务,请不要关闭,一旦关闭,对应的 Kafka 服务就停止了。如果启动过程报错,主要是因为 zookeeper 和 kafka 的同步问题,请先执行 cluster-clear.cmd 文件,再执行 cluster.cmd 文件即可。
2.1.5.测试
(1)启动成功之后,可以用 kafkatool 进行连接查看,具体如下图所示:

(2)添加主题,其中 Partition Count 表示主题分区数量,Replica Count 表示副本数量(一般设置的值不能超过 Broker 的数量,否则会有超过 2 个以及以上的副本在同一个 Broker 中,没有实际意义)。


2.2.集群启动
2.2.1.相关概念
2.2.1.1.代理:Broker
使用 Kafka 前,我们都会启动 Kafka 服务进程,这里的 Kafka 服务进程我们一般会称之为 Kafka Broker 或 Kafka Server。因为 Kafka 是分布式消息系统,所以在实际的生产环境中,是需要多个服务进程形成集群提供消息服务的。所以每一个服务节点都是一个 broker,而且在 Kafka 集群中,为了区分不同的服务节点,每一个 broker 都应该有一个不重复的全局 ID,称之为 broker.id,这个 ID 可以在 Kafka 软件的配置文件 server.properties 中进行配置。
powershell
# The id of the broker. This must be set to a unique integer for each broker
# 集群ID
broker.id=1
上述 Kafka 集群中每一个节点都有自己的 ID,整数且唯一。
2.2.1.2.控制器:Controller
(1)Kafka 是分布式消息传输系统,所以存在多个 Broker 服务节点,但是它的软件架构采用的是分布式系统中比较常见的主从 (Master - Slave) 架构,也就是说需要从多个 Broker 中找到一个用于管理整个 Kafka 集群的 Master 节点,这个节点,我们就称之为 Controller。它是 Apache Kafka 的核心组件非常重要。它的主要作用是在 Apache Zookeeper 的帮助下管理和协调控制整个 Kafka 集群。

(2)如果在运行过程中,Controller 节点出现了故障,那么Kafka 会依托于 ZooKeeper 软件选举其他的节点作为新的 Controller,让 Kafka 集群实现高可用。

(3)Kafka 集群中 Controller 的基本功能:
- Broker 管理,监听 /brokers/ids 节点相关的变化:
- Broker 数量增加或减少的变化
- Broker 对应的数据变化
- Topic 管理
- 新增:监听 /brokers/topics 节点相关的变化
- 修改:监听 /brokers/topics 节点相关的变化
- 删除:监听 /admin/delete_topics 节点相关的变化
- Partation 管理
- 监听 /admin/reassign_partitions 节点相关的变化
- 监听 /isr_change_notification 节点相关的变化
- 监听 /preferred_replica_election 节点相关的变化
- 数据服务
- 启动分区状态机和副本状态机
2.2.2.启动 ZooKeeper

2.2.2.1.Controller 节点选举
(1)Kafka 集群中含有多个服务节点,而分布式系统中经典的主从 (Master - Slave) 架构就要求从多个服务节点中找一个节点作为集群管理Master,Kafka 集群中的这个 Master,我们称之为集群控制器 Controller。

如果此时 Controller 节点出现故障,它就不能再管理集群功能,那么其他的 Slave 节点该如何是好呢?

如果从剩余的 2 个 Slave 节点中选一个节点出来作为新的集群控制器是不是一个不错的方案,我们将这个选择的过程称之为:选举 (elect)。方案是不错,但是问题就在于选哪一个 Slave 节点呢?不同的软件实现类似的选举功能都会有一些选举算法,而 Kafka 是依赖于 ZooKeeper 实现 Broker 节点选举功能。

(2)ZooKeeper 如何实现 Kafka 的节点选举呢?这就要说到我们用到 ZooKeeper 的 3 个功能:
- 一个是在 ZooKeeper 软件中创建节点 ZNode,创建一个 ZNode 时,我们会设定这个节点是持久化创建,还是临时创建。所谓的持久化创建,就是 ZNode 一旦创建后会一直存在,而临时创建,是根据当前的客户端连接创建的临时节点 ZNode,一旦客户端连接断开,那么这个临时节点 ZNode 也会被自动删除,所以这样的节点称之为临时节点。
- ZooKeeper 节点是不允许有重复的,所以多个客户端创建同一个节点,只能有一个创建成功。
- 另外一个是客户端可以在 ZooKeeper 的节点上增加监听器,用于监听节点的状态变化,一旦监听的节点状态发生变化,那么监听器就会触发响应,实现特定监听功能。
(3)有了上面的三个知识点,我们这里就介绍一下 Kafka 是如何利用 ZooKeeper 实现 Controller 节点的选举的:
- 第一次启动 Kafka 集群时,会同时启动多个 Broker 节点,每一个 Broker 节点就会连接 ZooKeeper,并尝试创建一个临时节点
/controller - 因为 ZooKeeper 中一个节点不允许重复创建,所以多个 Broker 节点,最终只能有一个 Broker 节点可以创建成功,那么这个创建成功的 Broker 节点就会自动作为 Kafka 集群控制器节点,用于管理整个 Kafka 集群。
- 没有选举成功的其他 Slave 节点会创建 ZNode 监听器,用于监听
/controller节点的状态变化。 - 一旦 Controller 节点出现故障或挂掉了,那么对应的 ZooKeeper 客户端连接就会中断。ZooKeeper 中的
/controller节点就会自动被删除,而其他的那些 Slave 节点因为增加了监听器,所以当监听到/controller节点被删除后,就会马上向 ZooKeeper 发出创建/controller节点的请求,一旦创建成功,那么该 Broker 就变成了新的 Controller 节点了。
2.2.2.2.使用工具 prettyzoo 查看 Zookeeper 节点
使用工具 prettyzoo 来连接 Zookeeper 来观察其节点情况:
- 单机版的 Kafka:


- 集群版的 Kafka:

2.2.3.启动 Kafka
ZooKeeper 已经启动好了,那我们现在可以启动多个 Kafka Broker 节点构建 Kafka 集群了。构建的过程中,每一个 Broker 节点就是一个 Java 进程,而在这个进程中,有很多需要提前准备好,并进行初始化的内部组件对象。
2.2.3.1.初始化 ZooKeeper
Kafka Broker 启动时,首先会先创建 ZooKeeper 客户端 (KafkaZkClient),用于和 ZooKeeper 进行交互。客户端对象创建完成后,会通过该客户端对象向 ZooKeeper 发送创建 ZNode 的请求,注意,这里创建的 ZNode 都是持久化 ZNode。

| 节点 | 类型 | 说明 |
|---|---|---|
| /admin/delete_topics | 持久化节点 | 配置需要删除的 topic,因为删除过程中,可能 broker 下线,或执行失败,那么就需要在 broker 重新上线后,根据当前节点继续删除操作,一旦 topic 所有的分区数据全部删除,那么当前节点的数据才会进行清理 |
| /brokers/ids | 持久化节点 | 服务节点 ID 标识,只要 broker 启动,那么就会在当前节点中增加子节点,brokerID 不能重复 |
| /brokers/topics | 持久化节点 | 服务节点中的主题详细信息,包括分区、副本 |
| /brokers/seqid | 持久化节点 | seqid 主要用于自动生产 brokerId |
| /config/changes | 持久化节点 | kafka 的元数据发生变化时,会向该节点下创建子节点。并写入对应信息 |
| /config/clients | 持久化节点 | 客户端配置,默认为空 |
| /config/brokers | 持久化节点 | 服务节点相关配置,默认为空 |
| /config/ips | 持久化节点 | IP 配置,默认为空 |
| /config/topics | 持久化节点 | 主题配置,默认为空 |
| /config/users | 持久化节点 | 用户配置,默认为空 |
| /consumers | 持久化节点 | 消费者节点,用于记录消费者相关信息 |
| /isr_change_notification | 持久化节点 | ISR 列表发生变更时候的通知,在 Kafka 当中由于存在 ISR 列表变更的情况发生,为了保证 ISR 列表更新的及时性,定义了isr_change_notification这个节点,主要用于通知 Controller 来及时将ISR列表进行变更。 |
| /latest_producer_id_block | 持久化节点 | 保存 PID 块,主要用于能够保证生产者的任意写入请求都能够得到响应。 |
| /log_dir_event_notification | 持久化节点 | 主要用于保存当 broker 当中某些数据路径出现异常时候,例如磁盘损坏,文件读写失败等异常时候,向 ZooKeeper 当中增加一个通知序号,Controller 节点监听到这个节点的变化之后,就会做出对应的处理操作 |
| /cluster/id | 持久化节点 | 主要用于保存 Kafka 集群的唯一 id 信息,每个 Kafka 集群都会给分配要给唯一 id,以及对应的版本号 |
2.2.3.2.初始化服务
Kafka Broker 中有很多的服务对象,用于实现内部管理和外部通信操作。

2.2.3.2.1.启动任务调度器
每一个 Broker 在启动时都会创建内部调度器 (KafkaScheduler) 并启动,用于完成节点内部的工作任务。底层就是 Java 中的定时任务线程 池ScheduledThreadPoolExecutor。
2.2.3.2.2.创建数据管理器
每一个 Broker 在启动时都会创建数据管理器 (LogManager),用于接收到消息后,完成后续的数据创建,查询,清理等处理。
2.2.3.2.3.创建远程数据管理器
每一个 Broker 在启动时都会创建远程数据管理器 (RemoteLogManager),用于和其他 Broker 节点进行数据状态同步。
2.2.3.2.4.创建副本管理器
每一个 Broker 在启动时都会创建副本管理器 (ReplicaManager),用于对主题的副本进行处理。
2.2.3.2.5.创建 ZK 元数据缓存
每一个 Broker 在启动时会将 ZK 的关于 Kafka 的元数据进行缓存,创建元数据对象 (ZkMetadataCache)。
2.2.3.2.6.创建 Broker 通信对象
每一个 Broker 在启动时会创建 Broker 之间的通道管理器对象 (BrokerToControllerChannelManager),用于管理 Broker 和 Controller 之间的通信。
2.2.3.2.7.创建网络通信对象
每一个 Broker 在启动时会创建自己的网络通信对象 (SocketServer),用于和其他 Broker 之间的进行通信,其中包含了 Java 用于 NIO 通信的 Channel、Selector 对象。

2.2.3.2.8.注册 Broker 节点
Broker 启动时,会通过 ZK 客户端对象向 ZK 注册当前的 Broker 节点 ID,注册后创捷的 ZK 节点为临时节点。如果当前 Broker 的 ZK 客户端断开和 ZK 的连接,注册的节点会被删除。
2.2.3.3.启动控制器
控制器 (KafkaController) 是每一个 Broker 启动时都会创建的核心对象,用于和 ZK 之间建立连接并申请自己为整个 Kafka 集群的 Master 管理者。如果申请成功,那么会完成管理者的初始化操作,并建立和其他 Broker 之间的数据通道接收各种事件,进行封装后交给事件管理器,并定义了 process 方法,用于真正处理各类事件。

2.2.3.3.1.初始化通道管理器
创建通道管理器 (ControllerChannelManager),该管理器维护了 Controller 和集群所有 Broker 节点之间的网络连接,并向 Broker 发送控制类请求及接收响应。
2.2.3.3.2.初始化事件管理器
创建事件管理器 (ControllerEventManager) 维护了 Controller 和集群所有 Broker 节点之间的网络连接,并向 Broker 发送控制类请求及接收响应。
2.2.3.3.3.初始化状态管理器
创建状态管理器 (ControllerChangeHandler) 可以监听 /controller 节点的操作,一旦节点创建 (ControllerChange) ,删除 (Reelect),数据发生变化 (ControllerChange),那么监听后执行相应的处理。
2.2.3.3.4.启动控制器
控制器对象启动后,会向事件管理器发送 Startup 事件,事件处理线程接收到事件后会通过 ZK 客户端向 ZK 申请 /controller 节点,申请成功后,执行当前节点成为 Controller 的一些列操作。主要是注册各类 ZooKeeper 监听器、删除日志路径变更和 ISR 副本变更通知事件、启动 Controller 通道管理器,以及启动副本状态机和分区状态机。
2.3.创建主题
主题 (Topic) 是 Kafka 中消息的逻辑分类,但是这个分类不应该是固定的,而是应该由外部的业务场景进行定义(注意:Kafka 中其实是有两个固定的,用于记录消费者偏移量和事务处理的主题),所以 Kafka 提供了相应的指令和客户端进行主题操作。
2.3.1.相关概念
2.3.1.1.主题:Topic
(1)Kafka 是分布式消息传输系统,采用的数据传输方式为发布-订阅模式 ,也就是说由消息的生产者发布消息,消费者订阅消息后获取数据。为了对消费者订阅的消息进行区分,所以对消息在逻辑上进行了分类,这个分类我们称之为主题 (Topic)。消息的生产者必须将消息数据发送到某一个主题,而消费者必须从某一个主题中获取消息,并且消费者可以同时消费一个或多个主题的数据。Kafka 集群中可以存放多个主题的消息数据。
(2)为了防止主题的名称和监控指标的名称产生冲突,官方推荐主题的名称中不要同时包含下划线和点。

2.3.1.2.分区:Partition
(1)Kafka 消息传输采用发布-订阅模式,所以消息生产者必须将数据发送到一个主题,假如发送给这个主题的数据非常多,那么主题所在 broker 节点的负载和吞吐量就会受到极大的考验,甚至有可能因为热点问题引起 broker 节点故障,导致服务不可用。一个好的方案就是将一个主题从物理上分成几块,然后将不同的数据块均匀地分配到不同的 broker 节点上,这样就可以缓解单节点的负载问题。这个主题的分块我们称之为分区 (Partition)。默认情况下,主题创建时分区数量为 1,也就是一块分区,可以通过指定参数 --partitions 改变。Kafka 的分区解决了单一主题线性扩展的问题,也解决了负载均衡的问题。
(2)主题的每个分区都会用一个编号进行标记,一般是从 0 开始的连续整数数字。分区是物理上的概念,也就意味着会以数据文件的方式真实存在。每个主题包含一个或多个分区,每个分区都是一个有序的队列。分区中每条消息都会分配一个有序的 ID,称之为偏移量 (Offset)。

2.3.1.3.副本:Replication
(1)分布式系统出现错误是比较常见的,只要保证集群内部依然存在可用的服务节点即可,当然效率会有所降低,不过只要能保证系统可用就可以了。Kafka 的主题也存在类似的问题,也就是说,如果一个主题划分了多个分区,那么这些分区就会均匀地分布在不同的 broker 节点上,一旦某一个 broker 节点出现了问题,那么在这个节点上的分区就会出现问题,那么主题的数据就不完整了。所以一般情况下,为了防止出现数据丢失的情况,我们会给分区数据设定多个备份,这里的备份,我们称之为副本 (Replication)。
(2)Kafka 支持多副本,使得主题可以做到更多容错性,牺牲性能与空间去换取更高的可靠性。

注意:这里不能将多个备份放置在同一个 broker 中,因为一旦出现故障,多个副本就都不能用了,那么副本的意义就没有了。
2.3.1.4.副本类型:Leader & Follower
假设我们有一份文件,一般情况下,我们对副本的理解应该是有一个正式的完整文件,然后这个文件的备份,我们称之为副本。但是在 Kafka 中不是这样的,所有的文件都称之为副本,只不过会选择其中的一个文件作为主文件,称之为Leader(主导)副本,其他的文件作为备份文件,称之为Follower(追随)副本 。在 Kafka 中,这里的文件就是分区,每一个分区都可以存在 1 个或多个副本,只有 Leader 副本才能进行数据的读写,Follower 副本只做备份使用。

2.3.1.5.日志:Log
Kafka 最开始的应用场景就是日志场景或 MQ 场景,更多的扮演着一个日志传输和存储系统,这是 Kafka 的立家之本。所以 Kafka 接收到的消息数据最终都是存储在 log 日志文件中的,底层存储数据的文件的扩展名就是 log。主题创建后,会创建对应的分区数据 Log 日志。并打开文件连接通道,随时准备写入数据。
2.3.2.创建主题
创建主题 Topic 的方式有很多种:命令行,工具,客户端 API,自动创建。在 server.properties 文件中配置参数 auto.create.topics.enable=true 时,如果访问的主题不存在,那么 Kafka 就会自动创建主题。由于我们学习的重点在于学习原理和基础概念,所以这里我们选择比较基础的命令行方式即可。
2.3.2.1.客户端 API
java
package com.atguigu.kafka.admin;
import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.CreateTopicsResult;
import org.apache.kafka.clients.admin.NewTopic;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
public class AdminTopicTest {
public static void main(String[] args) {
//配置对象
Map<String, Object> configMap = new HashMap<>();
configMap.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//创建管理者对象
final Admin admin = Admin.create(configMap);
//主题名称
String topicName = "test1";
//分区数量
int partitionCount = 1;
//副本数量
short replicationCount = 1;
NewTopic topic1 = new NewTopic(topicName, partitionCount, replicationCount);
String topicName1 = "test2";
int partitionCount1 = 2;
short replicationCount1 = 2;
NewTopic topic2 = new NewTopic(topicName1, partitionCount1, replicationCount1);
final CreateTopicsResult result = admin.createTopics(
Arrays.asList(
topic1, topic2
)
);
//关闭管理者对象
admin.close();
}
}
执行上述代码后,查看创建的两个主题:

以 test2 的 Partition 0 为例,其 2 个副本分别在 9092 和 9093 节点上,即对应 kafka-node-2 和 kafka-node-3:


2.3.5.1.创建主题流程
Kafka 中主题、分区以及副本的概念都和数据存储相关,所以是非常重要的。前面演示了一下创建主题的具体操作和现象,那么接下来,我们就通过图解来了解一下 Kafka 是如何创建主题,并进行分区副本分配的。
2.3.5.1.1.命令行提交创建指令

(1)通过命令行提交指令,指令中会包含操作类型 (--create)、topic的名称 (--topic)、主题分区数量 (--partitions)、主题分区副本数量 (--replication-facotr)、副本分配策略 (--replica-assignment) 等参数。
(2)指令会提交到客户端进行处理,客户端获取指令后,会首先对指令参数进行校验。
- 操作类型取值:create、list、alter、describe、delete,只能存在一个;
- 分区数量为大于 1 的整数;
- 主题是否已经存在;
- 分区副本数量大于 1 且小于 Short.MaxValue,一般取值小于等于 Broker 数量;
(3)将参数封装主题对象(NewTopic)。
(4)创建通信对象,设定请求标记 (CREATE_TOPICS),查找 Controller,通过通信对象向 Controller 发起创建主题的网络请求。
2.3.5.1.2.Controller 接收创建主题请求

(1)Controller 节点接收到网络请求 (Acceptor),并将请求数据封装成请求对象放置在队列 (RequestQueue) 中。
(2)请求控制器 (KafkaRequestHandler) 周期性从队列中获取请求对象 (BaseRequest)。
(3)将请求对象转发给请求处理器 (KafkaApis),根据请求对象的类型调用创建主题的方法。
2.3.5.1.3.创建主题

(1)请求处理器 (KafkaApis) 校验主题参数:
- 如果分区数量没有设置,那么会采用 Kafka 启动时加载的配置项:num.partitions(默认值为 1)
- 如果副本数量没有设置,那么会采用 Kafka 启动时记载的配置项:default.replication.factor(默认值为 1)
(2)在创建主题时,如果使用了 replica-assignment 参数,那么就按照指定的方案来进行分区副本的创建;如果没有指定 replica-assignment 参数,那么就按照 Kafka 内部逻辑来分配,内部逻辑按照机架信息分为两种策略:【未指定机架信息】和【指定机架信息】。当前课程中采用的是【未指定机架信息】副本分配策略:
- 分区起始索引设置 0;
- 轮询所有分区,计算每一个分区的所有副本位置:
- 副本起始索引 = (分区编号 + 随机值)% BrokerID 列表长度;
- 其他副本索引 = 随机值(基本算法为使用随机值执行多次模运算);
- 通过索引位置获取副本节点 ID;
- 保存分区以及对应的副本 ID 列表;
powershell
##################################################################
# 假设
# 当前分区编号 : 0
# BrokerID 列表 :【1,2,3,4】
# 副本数量 : 4
# 随机值(BrokerID列表长度): 2
# 副本分配间隔随机值(BrokerID 列表长度): 2
##################################################################
# 第一个副本索引:(分区编号 + 随机值)% BrokerID 列表长度 =(0 + 2)% 4 = 2
# 第一个副本所在 BrokerID : 3
# 第二个副本索引(第一个副本索引 + (1 +(副本分配间隔 + 0)% (BrokerID 列表长度 - 1))) % BrokerID 列表长度 = (2 +(1+(2+0)%3))% 4 = 1
# 第二个副本所在 BrokerID:2
# 第三个副本索引:(第一个副本索引 + (1 +(副本分配间隔 + 1)% (BrokerID 列表长度 - 1))) % BrokerID 列表长度 = (2 +(1+(2+1)%3))% 4 = 3
# 第三个副本所在 BrokerID:4
# 第四个副本索引:(第一个副本索引 + (1 +(副本分配间隔 + 2)% (BrokerID 列表长度 - 1))) % BrokerID 列表长度 = (2 +(1+(2+2)%3))% 4 = 0
# 第四个副本所在 BrokerID:1
# 最终分区 0 的副本所在的 Broker 节点列表为【3,2,4,1】
# 其他分区采用同样算法
(3)通过 ZK 客户端在 ZK 端创建节点:
- 在
/config/topics节点下,增加当前主题节点,节点类型为持久类型; - 在
/brokers/topics节点下,增加当前主题及相关节点,节点类型为持久类型;
(4)Controller 节点启动后,会在 /brokers/topics 节点增加监听器,一旦节点发生变化,会触发相应的功能:
- 获取需要新增的主题信息
- 更新当前 Controller 节点保存的主题状态数据
- 更新分区状态机的状态为:NewPartition
- 更新副本状态机的状态:NewReplica
- 更新分区状态机的状态为:OnlinePartition,从正常的副本列表中的获取第一个作为分区的 Leader 副本,所有的副本作为分区的同步副本列表,我们称之为 ISR( In-Sync Replica)。在 ZK 路径
/brokers/topics/主题名上增加分区节点/partitions,及状态/state节点。 - 更新副本状态机的状态:OnlineReplica
(5)Controller 节点向主题的各个分区副本所属 Broker 节点发送 LeaderAndIsrRequest 请求,向所有的 Broker 发送 UPDATE_METADATA 请求,更新自身的缓存。
- Controller 向分区所属的 Broker 发送请求
- Broker 节点接收到请求后,根据分区状态信息,设定当前的副本为 Leader 或 Follower,并创建底层的数据存储文件目录和空的数据文件。文件目录名为主题名 + 分区编号,目录中的主要文件说明:
| 文件名 | 说明 |
|---|---|
| 0000000000000000.log | 数据文件,用于存储传输的小心 |
| 0000000000000000.index | 索引文件,用于定位数据 |
| 0000000000000000.timeindex | 时间索引文件,用于定位数据 |

(6)此外为了防止 Kafka 的分配方案产生 Leader 副本分配不均匀的情况(从而产品 IO 的热点问题),在创建主题时可以自定义分配方案:
java
//自己分配副本方案,可以防止 Kafka 的分配方案产生 Leader 副本分配不均匀的情况
String topicName2 = "test3";
Map<Integer, List<Integer>> map = new HashMap<>();
// 0 号分区有 2 个副本,分别放到 id 为 3 和 1 节点上
map.put(0, Arrays.asList(3, 1));
// 1 号分区有 2 个副本,分别放到 id 为 2 和 3 节点上
map.put(1, Arrays.asList(2, 3));
// 2 号分区有 2 个副本,分别放到 id 为 1 和 2 节点上
map.put(2, Arrays.asList(1, 2));
NewTopic topic3 = new NewTopic(topicName2, map);
2.4.生产数据
(1)主题已经创建好了,接下来我们就可以向该主题生产消息了,这里我们采用 Java 代码通过 Kafka Producer API 的方式生产数据。
(2)Kafka 生产数据的总体框架图如下图所示:

2.4.1.生产消息的基本步骤
2.4.1.1.创建配置对象
创建 Map 类型的配置对象,根据场景增加相应的配置属性:
| 参数名 | 参数作用 | 类型 | 默认值 | 推荐值 |
|---|---|---|---|---|
bootstrap.servers |
集群地址,格式为: brokerIP1:端口号,brokerIP2:端口号 |
必须 | - | - |
key.serializer |
对生产数据Key进行序列化的类完整名称 | 必须 | - | Kafka提供的字符串序列化类:StringSerializer |
value.serializer |
对生产数据Value进行序列化的类完整名称 | 必须 | - | Kafka提供的字符串序列化类:StringSerializer |
interceptor.classes |
拦截器类名,多个用逗号隔开 | 可选 | - | - |
batch.size |
数据批次字节大小。此大小会和数据最大估计值进行比较,取大值。 估值 = 61 + 21 + (keySize + 1 + valueSize + 1 + 1) | 可选 | 16K | - |
retries |
重试次数 | 可选 | 整型最大值 | 0 或 整型最大值 |
request.timeout.ms |
请求超时时间 | 可选 | 30s | - |
linger.ms |
数据批次在缓冲区中停留时间 | 可选 | - | - |
acks |
请求应答类型:all(-1), 0, 1 |
可选 | all(-1) |
根据数据场景进行设置 |
retry.backoff.ms |
两次重试之间的时间间隔 | 可选 | 100ms | - |
buffer.memory |
数据收集器缓冲区内存大小 | 可选 | 32M | 64M |
max.in.flight.requests.per.connection |
每个节点连接的最大同时处理请求的数量 | 可选 | 5 | 小于等于 5 |
enable.idempotence |
幂等性 | 可选 | true |
根据数据场景进行设置 |
partitioner.ignore.keys |
是否放弃使用数据key选择分区 | 可选 | false |
- |
partitioner.class |
分区器类名 | 可选 | null |
- |
2.4.1.2.创建待发送数据
(1)在 Kafka 中传递的数据我们称之为消息 (message) 或记录 (record),所以 Kafka 发送数据前,需要将待发送的数据封装为指定的数据模型:


(2)相关属性必须在构建数据模型时指定,其中 topic 和 value 的值是必须要传递的。如果配置中开启了自动创建主题,那么 Topic 主题可以不存在。value 就是我们需要真正传递的数据了,而 Key 可以用于数据的分区定位。
2.4.1.3.创建生产者对象并发送生产的数据
根据前面提供的配置信息创建生产者对象,通过这个生产者对象向 Kafka 服务器节点发送数据,而具体的发送是由生产者对象创建时,内部构建的多个组件实现的,多个组件的关系有点类似于生产者消费者模式。

- 数据生产者 (KafkaProducer) :生产者对象,用于对我们的数据进行必要的转换和处理,将处理后的数据放入到数据收集器中,类似于生产者消费者模式下的生产者。这里我们简单介绍一下内部的数据转换处理:
- 如果配置拦截器栈 (interceptor.classes),那么将数据进行拦截处理。某一个拦截器出现异常并不会影响后续的拦截器处理。
- 因为发送的数据为 KV 数据,所以需要根据配置信息中的序列化对象对数据中 Key 和 Value 分别进行序列化处理。
- 计算数据所发送的分区位置。
- 将数据追加到数据收集器中。
- 数据收集器 (RecordAccumulator) :用于收集,转换我们产生的数据,类似于生产者消费者模式下的缓冲区。为了优化数据的传输,Kafka并不是生产一条数据就向Broker发送一条数据,而是通过合并单条消息,进行批量(批次)发送,提高吞吐量,减少带宽消耗。
- 默认情况下,一个发送批次的数据容量为 16K,这个可以通过参数
batch.size进行改善。 - 批次是和分区进行绑定的。也就是说发往同一个分区的数据会进行合并,形成一个批次。
- 如果当前批次能容纳数据,那么直接将数据追加到批次中即可,如果不能容纳数据,那么会产生新的批次放入到当前分区的批次队列中,这个队列使用的是 Java 的双端队列 Deque。旧的批次关闭不再接收新的数据,等待发送。
- 默认情况下,一个发送批次的数据容量为 16K,这个可以通过参数
- 数据发送器 (Sender) :线程对象,用于从收集器对象中获取数据,向服务节点发送。类似于生产者消费者模式下的消费者。因为是线程对象,所以启动后会不断轮询获取数据收集器中已经关闭的批次数据。对批次进行整合后再发送到 Broker 节点中
- 因为数据真正发送的地方是 Broker 节点,不是分区。所以需要将从数据收集器中收集到的批次数据按照可用 Broker 节点重新组合成 List 集合。
- 将组合后的
<节点,List<批次>>的数据封装成客户端请求(请求键为:Produce)发送到网络客户端对象的缓冲区,由网络客户端对象通过网络发送给Broker节点。 - Broker 节点获取客户端请求,并根据请求键进行后续的数据处理:向分区中增加数据。

2.4.2.生产消息的基本代码
java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;
public class KafkaProducerTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
producer.send(record);
}
//关闭生产者连接
producer.close();
}
}
2.4.3.发送消息
2.4.3.1.拦截器
生产者 API 在数据准备好发送给 Kafka 服务器之前,允许我们对生产的数据进行统一的处理,比如校验,整合数据等等。这些处理我们是可以通过 Kafka 提供的拦截器完成。因为拦截器不是生产者必须配置的功能,所以大家可以根据实际的情况自行选择使用。
但是要注意,这里的拦截器是可以配置多个的。执行时,会按照声明顺序执行完一个后,再执行下一个。并且某一个拦截器如果出现异常,只会跳出当前拦截器逻辑,并不会影响后续拦截器的处理。所以开发时,需要将拦截器的这种处理方法考虑进去。

2.4.3.1.1.增加拦截器类
(1)实现生产者拦截器接口 ProducerInterceptor
java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.ProducerInterceptor;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import java.util.Map;
public class ValueInterceptorTest implements ProducerInterceptor<String, String> {
@Override
public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
//改变 value 的值
return new ProducerRecord<>(record.topic(), record.key(), record.value() + record.value());
}
@Override
public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> configs) {
}
}
(2)实现接口中的方法,根据业务功能重写具体的方法:
| 方法名 | 作用 |
|---|---|
onSend |
数据发送前,会执行此方法,进行数据发送前的预处理 |
onAcknowledgement |
数据发送后,获取应答时,会执行此方法 |
close |
生产者关闭时,会执行此方法,完成一些资源回收和释放的操作 |
configure |
创建生产者对象的时候,会执行此方法,可以根据场景对生产者对象的配置进行统一修改或转换 |
2.4.3.1.2.配置拦截器
java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;
public class KafkaProducerInterceptorTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//配置自定义拦截器
configMap.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ValueInterceptorTest.class.getName());
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
producer.send(record);
}
//关闭生产者连接
producer.close();
}
}
执行上述代码,然后在 Kafka Tool 中查看主题 test 中的数据:


2.4.3.2.回调方法
Kafka 发送数据时,可以同时传递回调对象 (Callback) 用于对数据的发送结果进行对应处理,具体代码实现采用匿名类或 Lambda 表达式都可以。
java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;
public class KafkaProducerCallbackTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
//数据发送成功后,会回调此方法
if (exception != null) {
System.err.println("数据发送失败: {}" + exception.getMessage());
} else {
System.out.println("数据发送成功: " + metadata);
}
}
});
}
//关闭生产者连接
producer.close();
}
}
执行上述代码,打印结果如下:

2.4.3.3.异步发送
Kafka 发送数据时,底层的实现类似于生产者消费者模式。对应的,底层会由主线程代码作为生产者向缓冲区中放数据,而数据发送线程会从缓冲区中获取数据进行发送。Broker 接收到数据后进行后续处理。如果 Kafka 通过主线程代码将一条数据放入到缓冲区后,无需等待数据的后续发送过程,就直接发送一下条数据的场合,我们就称之为异步发送。

java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;
public class KafkaProducerCallbackTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
//数据发送成功后,会回调此方法
if (exception != null) {
System.err.println("数据发送失败: {}" + exception.getMessage());
} else {
System.out.println("数据发送成功: " + metadata);
}
}
});
System.out.println("发送数据");
}
//关闭生产者连接
producer.close();
}
}
执行上述代码,打印结果如下:

2.4.3.4.同步发送
(1)Kafka发送数据时,底层的实现类似于生产者消费者模式。对应的,底层会由主线程代码作为生产者向缓冲区中放数据,而数据发送线程会从缓冲区中获取数据进行发送。Broker接收到数据后进行后续处理。
(2)如果 Kafka 通过主线程代码将一条数据放入到缓冲区后,需等待数据的后续发送操作的应答状态,才能发送一下条数据的场合,我们就称之为同步发送。所以这里的所谓同步,就是生产数据的线程需要等待发送线程的应答(响应)结果。
(3)代码实现上,采用的是 JDK1.5 增加的 JUC 并发编程的 Future 接口的 get 方法实现。

java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
/**
* @ClassName KafkaProducerTest
* @Description KafkaProducerTest
* @Date 2025/11/23 15:23
* @Version 1.0
*/
public class KafkaProducerCallbackTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
Future<RecordMetadata> send = producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
//数据发送成功后,会回调此方法
if (exception != null) {
System.err.println("数据发送失败: {}" + exception.getMessage());
} else {
System.out.println("数据发送成功: " + metadata);
}
}
});
System.out.println("发送数据");
send.get();
}
//关闭生产者连接
producer.close();
}
}
执行上述代码,打印结果如下:

有关 Future 模式的具体知识,可以查看 Java 并发编程面试题------Future 模式这篇文章。
2.4.4.消息分区
2.4.4.1.指定分区
Kafka 中主题是对数据逻辑上的分类,而分区才是数据真正存储的物理位置。所以在生产数据时,如果只是指定主题的名称,其实 Kafka 是不知道将数据发送到哪一个 Broker 节点的。我们可以在构建数据传递主题参数的同时,也可以指定数据存储的分区编号。

java
for (int i = 0; i < 1; i++) {
// ProducerRecord 中的入参:主题名称、分区编号、key、value
ProducerRecord<String, String> record = new ProducerRecord<String, String>("test", 0, "key" + i, "value" + i);
producer.send(record);
}
2.4.4.2.未指定分区
(1)指定分区传递数据是没有任何问题的。Kafka 会进行基本简单的校验,比如是否为空,是否小于 0 之类的,但是你的分区是否存在就无法判断了,所以需要从 Kafka 中获取集群元数据信息,此时会因为长时间获取不到元数据信息而出现超时异常。所以如果不能确定分区编号范围的情况,不指定分区还是一个不错的选择。
(2)如果不指定分区,Kafka会根据集群元数据中的主题分区来通过算法来计算分区编号并设定:
- 如果指定了分区,直接使用;
- 如果指定了自定义分区器 (通过实现接口
Partitioner中的partition方法),通过分区器计算分区编号,如果有效,直接使用; - 如果指定了数据 key,且使用 key 选择分区的场合(是否使用 key 根据全局配置
partitioner.ignore.keys来判断,默认为 false,具体细节见官方文档:https://kafka.apache.org/documentation/),采用 murmur2 非加密散列算法(类似于 hash)计算数据 key 序列化后的值的散列值,然后对主题分区数量模运算取余,最后的结果就是分区编号; - 如果未指定数据 key,或不使用 key 选择分区,那么Kafka会采用优化后的粘性分区策略 进行分区选择:
- 没有分区数据加载状态信息时,会从分区列表中随机选择一个分区:

- 如果存在分区数据加载状态信息时,根据分区数据队列加载状态,通过随机数获取一个权重值:

- 根据这个权重值在队列加载状态中进行二分查找法,查找权重值的索引值:

- 将这个索引值加 1 就是当前设定的分区:

- 增加数据后,会根据当前粘性分区中生产的数据量进行判断,是不是需要切换其他的分区。判断地标准就是大于等于批次大小 (16K) 的 2 倍,或大于一个批次大小 (16K) 且需要切换。如果满足条件,下一条数据就会放置到其他分区。
- 没有分区数据加载状态信息时,会从分区列表中随机选择一个分区:
2.4.4.3.分区器
(1)在某些场合中,指定的数据我们是需要根据自身的业务逻辑发往指定的分区的。所以需要自己定义分区编号规则,而不是采用 Kafka 自动设置就显得尤其必要了。Kafka 早期版本中提供了两个分区器,不过在当前 Kafka 版本中已经不推荐使用了。

(2)接下来我们就说一下当前版本 Kafka 中如何定义我们自己的分区规则:分区器
2.4.4.3.1.增加分区器类
首先我们需要创建一个类,然后实现 Kafka 提供的分区类接口 Partitioner,接下来重写方法。这里我们只关注 partition 方法即可,因为此方法的返回结果就是需要的分区编号。
java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import java.util.Map;
public class MyKafkaPartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
//自定义分区编号逻辑
return 0;
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> configs) {
}
}
2.4.4.3.2.配置分区器
java
public class KafkaProducerPartitionerTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//配置自定义分区器
configMap.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, MyKafkaPartitioner.class.getName());
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
producer.send(record);
}
//关闭生产者连接
producer.close();
}
}
2.4.5.消息可靠性
(1)对于生产者发送的数据,我们有的时候是不关心数据是否已经发送成功的,我们只要发送就可以了。在这种场景中,消息可能会因为某些故障或问题导致丢失,我们将这种情况称之为消息不可靠。虽然消息数据可能会丢失,但是在某些需要高吞吐,低可靠的系统场景中,这种方式也是可以接受的,甚至是必须的。
(2)但是在更多的场景中,我们是需要确定数据是否已经发送成功了且 Kafka 正确接收到数据的,也就是要保证数据不丢失,这就是所谓的消息可靠性保证 。而这个确定的过程一般是通过 Kafka 给我们返回的响应确认结果 (Acknowledgement) 来决定的,这里的响应确认结果我们也可以简称为 ACK 应答。根据场景,Kafka 提供了 3 种应答处理,可以通过配置对象进行配置:
java
configMap.put(ProducerConfig.ACKS_CONFIG, "-1");

2.4.5.1.ACK = 0
(1)当生产数据时,生产者对象将数据通过网络客户端将数据发送到网络数据流中的时候,Kafka 就对当前的数据请求进行了响应(确认应答),如果是同步发送数据,此时就可以发送下一条数据了。如果是异步发送数据,回调方法就会被触发。

(2)通过图形,明显可以看出,这种应答方式,数据已经走网络给 Kafka 发送了,但这其实并不能保证 Kafka 能正确地接收到数据,在传输过程中如果网络出现了问题,那么数据就丢失了。也就是说这种应答确认的方式,数据的可靠性是无法保证的。不过相反,因为无需等待 Kafka 服务节点的确认,通信效率倒是比较高的,也就是系统吞吐量会非常高。
2.4.5.2.ACK = 1
(1)当生产数据时,Kafka Leader 副本将数据接收到并写入到了日志文件后,就会对当前的数据请求进行响应(确认应答),如果是同步发送数据,此时就可以发送下一条数据了。如果是异步发送数据,回调方法就会被触发。

(2)通过图形,可以看出这种应答方式,数据已经存储到了分区 Leader 副本中,那么数据相对来讲就比较安全了,也就是可靠性比较高。之所以说相对来讲比较安全,就是因为现在只有一个节点存储了数据,而数据并没有来得及进行备份到 Follower 副本,那么一旦当前存储数据的 Broker 节点出现了故障,数据也依然会丢失。
2.4.5.3.ACK = -1 或者 all(默认)
(1)生产数据时,Kafka Leader 副本和 Follower 副本都已经将数据接收到并写入到了日志文件后,再对当前的数据请求进行响应(确认应答),如果是同步发送数据,此时就可以发送下一条数据了。如果是异步发送数据,回调方法就会被触发。

(2)通过图形,可以看出,这种应答方式,数据已经同时存储到了分区 Leader 副本和 Follower 副本中,那么数据已经非常安全了,可靠性也是最高的。此时,如果 Leader 副本出现了故障,那么 Follower 副本能够开始起作用,因为数据已经存储了,所以数据不会丢失。
(3)不过这里需要注意,如果假设我们的分区有 5 个 Follower 副本,编号为 1、2、3、4、5

但是此时只有 3 个副本处于和 Leader 副本之间处于数据同步状态,那么此时分区就存在一个同步副本列表,我们称之为 In Syn Replica,简称为 ISR。此时,Kafka 只要保证 ISR 中所有的 4 个副本接收到了数据,就可以对数据请求进行响应了。无需 5 个副本全部收到数据。

2.4.6 消息去重 & 有序
2.4.6.1.数据重试
(1)由于网络或服务节点的故障,Kafka 在传输数据时,可能会导致数据丢失,所以我们才会设置 ACK 应答机制,尽可能提高数据的可靠性。但其实在某些场景中,数据的丢失并不是真正地丢失,而是"虚假丢失",比如将 ACK 应答设置为 1,也就是说一旦 Leader 副本将数据写入文件后,Kafka 就可以对请求进行响应了。

(2)此时,如果假设由于网络故障的原因,Kafka 并没有成功将 ACK 应答信息发送给 Producer,那么此时对于 Producer 来讲,以为 Kafka 没有收到数据,所以就会一直等待响应,一旦超过某个时间阈值,就会发生超时错误,也就是说在 Kafka Producer 眼里,数据已经丢失了。

(3)所以在这种情况下,Kafka Producer 会尝试对超时的请求数据进行重试 (retry) 操作。通过重试操作尝试将数据再次发送给 Kafka。

(4)如果此时发送成功,那么 Kafka 就又收到了数据,而这两条数据是一样的,也就是说,导致了数据重复。

2.4.6.2.数据乱序
(1)数据重试 (retry) 功能除了可能会导致数据重复以外,还可能会导致数据乱序。假设我们需要将编号为 1、2、3 的三条连续数据发送给 Kafka。每条数据会对应于一个连接请求:

(2)此时,如果第 1 个数据的请求出现了故障,而第 2 个数据和第 3 个数据的请求正常,那么 Broker 就收到了第 2 个数据和第 3 个数据,并进行了应答。

(3)为了保证数据的可靠性,此时,Kafka Producer 会将第 1 条数据重新放回到缓冲区的第一个。进行重试操作。

(4)如果重试成功,Broker 收到第一条数据,此时据的顺序已经被打乱了,而某些实际场景下对数据顺序是有严格要求的。

2.4.6.3.数据幂等性
(1)为了解决 Kafka 传输数据时,所产生的数据重复 和数据乱序 问题,Kafka 引入了幂等性操作 ,所谓的幂等性,就是 Producer 同样的一条数据,无论向 Kafka 发送多少次,Kafka 都只会存储一条。注意,这里的同样的一条数据,指的不是内容一致的数据,而是指的不断重试的数据。
(2)幂等性配置在 Kafka 0.11 - 1.x 中默认是关闭的,Kafka 2.0+则默认开启。在生产者对象中的具体配置如下。
| 配置项 | 配置值 | 说明 |
|---|---|---|
enable.idempotence |
true |
开启幂等性 |
max.in.flight.requests.per.connection |
小于等于 5 | 每个连接的在途请求数,不能大于 5,取值范围为 [1, 5] |
acks |
all(-1) |
确认应答,固定值,不能修改 |
retries |
>0 | 重试次数,推荐使用 Int 最大值 |
java
Map<String, Object> configMap = new HashMap<>();
//开启幂等性
configMap.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
//会自动设置,但显式声明更清晰
configMap.put(ProducerConfig.ACKS_CONFIG, "all");
//重试次数
configMap.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
//每个连接的在途请求数
configMap.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
(3)Kafka 实现数据幂等性操作总体流程如下:
- 开启幂等性后,为了保证数据不会重复,那么就需要给每一个请求批次的数据增加唯一性标识 ,Kafka 中,这个标识采用的是连续的序列号数字
sequenceNumer,但是不同的生产者 Producer 可能序列号是一样的,所以仅仅靠 sequenceNumer 还无法唯一标记数据,所以还需要同时对生产者进行区分,所以 Kafka 采用申请生产者 IDproducerId的方式对生产者进行区分。这样,在发送数据前,我们就需要提前申请producerId以及序列号sequenceNumer。

- Broker 中会给每一个分区记录生产者的生产者状态 :采用队列的方式缓存最近的 5 个批次数据。队列中的数据按照 sequenceNumer 进行升序排列。这里的数字 5 是经过压力测试,均衡空间效率和时间效率所得到的值,所以为固定值,无法配置且不能修改。

- 如果 Borker 中当前新的请求批次数据在缓存的 5 个旧的批次中存在相同的,如果有相同的,那么说明有重复,当前批次数据不做任何处理。

- 如果 Broker 中当前的请求批次数据在缓存中没有相同的,那么判断当前新的请求批次的序列号是否为缓存的最后一个批次的序列号加 1,如果是,说明是连续的,顺序没乱。那么继续,如果不是,那么说明数据已经乱了,发生异常。

- Broker 根据异常返回响应,通知 Producer 进行重试。Producer 重试前,需要在缓冲区中将数据重新排序,保证正确的顺序后。再进行重试即可。
- 如果请求批次不重复,且有序,那么更新缓冲区中的批次数据。将当前的批次放置再队列的结尾,将队列的第一个移除,保证队列中缓冲的数据最多 5 个。

(4)从上面的流程可以看出,Kafka 的幂等性是通过消耗时间和性能的方式提升了数据传输的有序和去重,在一些对数据敏感的业务中是十分重要的。但是通过原理也能明白,这种幂等性还是有缺陷的:
- 幂等性的 Producer 仅做到单分区上的幂等性,即单分区消息有序不重复,多分区无法保证幂等性;
- 只能保持生产者单个会话的幂等性,无法实现跨会话的幂等性,也就是说如果一个 Producer 挂掉再重启,那么重启前和重启后的 Producer 对象会被当成两个独立的生产者,从而获取两个不同的独立的
producerId,导致 Broker 端无法获取之前的状态信息,所以无法实现跨会话的幂等。要想解决这个问题,可以采用后续的事务功能。
2.4.6.4.数据事务
(1)对于幂等性的缺陷,Kafka 可以采用事务的方式解决跨会话的幂等性。基本的原理就是通过事务功能管理生产者 ID,保证事务开启后,生产者对象总能获取一致的生产者 ID。
(2)为了实现事务,Kafka 引入了事务协调器 (TransactionCoodinator) 负责事务的处理,所有的事务逻辑包括分派生产者 ID 等都是由TransactionCoodinator 负责实施的。TransactionCoodinator 会将事务状态持久化到该主题中。
(3)事务基本的实现思路就是将配置的事务 ID 与生产者 ID 进行绑定,然后存储在 Kafka 专门管理事务的内部主题 __transaction_state 中,而内部主题的操作是由事务协调器 (TransactionCoodinator) 对象完成的,这个协调器对象有点类似于数据发送时的那个副本 Leader。其实这种设计是很巧妙的,因为 Kafka 将事务 ID 和生产者 ID 看成了消息数据,然后将数据发送到一个内部主题中。这样,使用事务处理的流程和自己发送数据的流程是很像的。接下来,我们就把这两个流程简单做一个对比。
2.4.6.4.1.普通数据发送流程

2.4.6.4.2.事务数据发送流程

通过两张图大家可以看到,基本的事务操作和数据操作是很像的,不过要注意,我们这里只是简单对比了数据发送的过程,其实它们的区别还在于数据发送后的提交过程。普通的数据操作,只要数据写入了日志,那么对于消费者来讲。数据就可以读取到了,但是事务操作中,如果数据写入了日志,但是没有提交的话,其实数据默认情况下也是不能被消费者看到的。只有提交后才能看见数据。
2.4.6.4.3.事务提交流程
(1)Kafka 中的事务是分布式事务,所以采用的也是二阶段提交:
- 第一个阶段提交事务协调器会告诉生产者事务已经提交了,所以也称之预提交操作 ,事务协调器会修改事务为预提交状态

- 第二个阶段提交事务协调器会向分区 Leader 节点中发送数据标记,通知 Broker 事务已经提交,然后事务协调器会修改事务为完成提交状态

(2)特殊情况下,事务已经提交成功,但还是读取不到数据,那是因为当前提交成功只是一阶段提交成功,事务协调器会继续向各个 Partition 发送 marker 信息,此操作会无限重试,直至成功。
(3)但是不同的 Broker 可能无法全部同时接收到 marker 信息,此时有的 Broker 上的数据还是无法访问,这也是正常的,因为 Kafka 的事务不能保证强一致性,只能保证最终数据的一致性,无法保证中间的数据是一致的。不过对于常规的场景这里已经够用了,事务协调器会不遗余力的重试,直至成功。
2.4.6.4.4.事务操作代码
java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
public class KafkaProducerTransactionTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//开启幂等性配置
configMap.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
configMap.put(ProducerConfig.ACKS_CONFIG, "-1");
//重试次数
configMap.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
//增加事务 id,事务是基于幂等性操作的,produceId 与 transactionId 绑定,如果这个生产者实例换了事务 id,那么 produceId 也会不同
configMap.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "my-tx-id");
//配置事务超时时间
configMap.put(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, 5);
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//初始化事务
producer.initTransactions();
try {
//开启事务
producer.beginTransaction();
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
Future<RecordMetadata> send = producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
//数据发送成功后,会回调此方法
if (exception != null) {
System.err.println("数据发送失败: {}" + exception.getMessage());
} else {
System.out.println("数据发送成功: " + metadata);
}
}
});
System.out.println("发送数据");
send.get();
}
//提交事务
producer.commitTransaction();
} catch (Exception e) {
e.printStackTrace();
//终止事务
producer.abortTransaction();
} finally {
//关闭生产者连接
producer.close();
}
}
}
2.4.6.5.数据传输语义
| 传输语义 | 说明 | 例子 |
|---|---|---|
| at most once | 最多一次:不管是否能接收到,数据最多只传一次。这样数据可能会丢失。 | Socket, ACK=0 |
| at least once | 最少一次:消息不会丢失,如果接收不到,那么就继续发,所以会发送多次,直到收到为止,有可能出现数据重复。 | ACK=1 |
| Exactly once | 精准一次:消息只会发送一次,不会丢失,也不会重复。 | 幂等 + 事务 + ACK=-1 |
2.5.存储消息
数据已经由生产者 Producer 发送给 Kafka 集群,当 Kafka 接收到数据后,会将数据写入本地文件中。

2.5.1.存储组件
KafkaApis:Kafka 应用接口组件,当 Kafka Producer 向 Kafka Broker 发送数据请求后,Kafka Broker 接收请求,会使用 KafkaApis 组件进行请求类型的判断,然后选择相应的方法进行处理。ReplicaManager:副本管理器组件,用于提供主题副本的相关功能,在数据的存储前进行 ACK 校验和事务检查,并提供数据请求的响应处理Partition:分区对象,主要包含分区状态变换的监控,分区上下线的处理等功能,在数据存储是主要用于对分区副本数量的相关校验,并提供追加数据的功能UnifiedLog:同一日志管理组件,用于管理数据日志文件的新增,删除等功能,并提供数据日志文件偏移量的相关处理。LocalLog:本地日志组件,管理整个分区副本的数据日志文件。假设当前主题分区中有 3 个日志文件,那么 3 个文件都会在组件中进行管理和操作。LogSegment:文件段组件,对应具体的某一个数据日志文件,假设当前主题分区中有 3 个日志文件,那么 3 个文件每一个都会对应一个LogSegment组件,并打开文件的数据管道 FileChannel。数据存储时,就是采用组件中的 FileChannel 实现日志数据的追加。LogConfig:日志配置对象,常用的数据存储配置。
| 参数名 | 参数作用 | 类型 | 默认值 | 推荐值 |
|---|---|---|---|---|
| min.insync.replicas | 最小同步副本数量 | 推荐 | 1 | 2 |
| log.segment.bytes | 文件段字节数据大小限制 | 可选 | 1G = 102410241024 byte | |
| log.roll.hours | 文件段强制滚动时间阈值 | 可选 | 7天 = 24 * 7 * 60 * 60 * 1000L ms | |
| log.flush.interval.messages | 满足刷写日志文件的数据条数 | 可选 | Long.MaxValue | 不推荐 |
| log.flush.interval.ms | 满足刷写日志文件的时间周期 | 可选 | Long.MaxValue | 不推荐 |
| log.index.interval.bytes | 刷写索引文件的字节数 | 可选 | 4 * 1024 | |
| replica.lag.time.max.ms | 副本延迟同步时间 | 可选 | 30s |
2.5.2.数据存储
Kafka Broker 节点从获取到生产者的数据请求到数据存储到文件的过程相对比较简单,只是中间会进行一些基本的数据检查和校验。所以接下来我们就将数据存储的基本流程介绍一下:
2.5.2.1.ACKS 校验
(1)Producer 将数据发送给Kafka Broker时,会告知Broker当前生产者的数据生产场景,从而要求Kafka对数据请求进行应答响应确认数据的接收情况,Producer获取应答后可以进行后续的处理。这个数据生产场景主要考虑的就是数据的可靠性和数据发送的吞吐量。由此,Kafka将生产场景划分为3种不同的场景:
ACKS = 0:Producer 端将数据发送到网络输出流中,此时 Kafka 就会进行响应。在这个场景中,数据的应答是非常快的,但是因为仅仅将数据发送到网络输出流中,所以是无法保证 Kafka Broker 节点能够接收到消息,假设此时网络出现抖动不稳定导致数据丢失,而由于 Kafka 已经做出了确认收到的应答,所以此时 Producer 端就不会再次发送数据,而导致数据真正地丢失了。所以此种场景,数据的发送是不可靠的。ACKS = 1:Producer 端将数据发送到 Broker 中,并保存到当前节点的数据日志文件中,Kafka 就会进行确认收到数据的响应。因为数据已经保存到了文件中,也就是进行了持久化,那么相对于 ACKS = 0,数据就更加可靠。但是也要注意,因为 Kafka 是分布式的,所以集群的运行和管理是非常复杂的,难免当前 Broker 节点出现问题而宕掉,那么此时,消费者就消费不到我们存储的数据了,此时,数据我们还是会认为丢失了。ACKS = -1(all):Kafka 在管理分区时,会了数据的可靠性和更高的吞吐量,提供了多个副本,而多个副本之间,会选出一个副本作为数据的读写副本,称之为 Leader 领导副本,而其他副本称之 Follower 追随副本。普通场景中,所有的这些节点都是需要保存数据的。而 Kafka 会优先将 Leader 副本的数据进行保存,保存成功后,再由 Follower 副本向 Leader 副本拉取数据,进行数据同步。一旦所有的这些副本数据同步完毕后,Kafka 再对 Producer 进行收到数据的确认。此时 ACKS 应答就是 -1(all)。明显此种场景,多个副本本地文件都保存了数据,那么数据就更加可靠,但是相对,应答时间更长,导致 Kafka 吞吐量降低。
(2)基于上面的三种生产数据的场景,在存储数据前,需要校验生产者需要的应答场景是否合法有效。
2.5.2.2.内部主题校验
Producer 向 Kafka Broker 发送数据时,必须指定主题,但是这个主题的名称不能是 Kafka 的内部主题名称 。Kafka 为了管理的需要,创建了 2 个内部主题,一个是用于事务处理的 __transaction_state 内部主题,还有一个是用于处理消费者偏移量的 __consumer_offsets 内部主题。生产者是无法对这两个主题生产数据的,所以在存储数据之前,需要对主题名称进行校验有效性校验。
2.5.2.3.ACKS 应答及副本数量关系校验
(1)Kafka 为了数据可靠性更高一些,需要分区的所有副本都能够存储数据,但是分布式环境中难免会出现某个副本节点出现故障,暂时不能同步数据。在 Kafka 中,能够进行数据同步的所有副本,我们称之为 In Sync Replicas,简称 ISR 列表。
(2)当生产者 Producer 要求的数据 ACKS 应答为 -1 的时候,那么就必须保证能够同步数据的所有副本能够将数据保存成功后,再进行数据的确认应答。但是一种特殊情况就是,如果当前 ISR 列表中只有一个 Broker 存在,那么此时只要这一个 Broker 数据保存成功了,那么就产生确认应答了,数据依然是不可靠的,那么就失去了设置 ACK = -1 的意义了,所以此时还需要对 ISR 列表中的副本数量进行约束,至少不能少于 2 个。这个数量是可以通过配置文件配置的。参数名为 min.insync.replicas。默认值为 1(不推荐)所以存储数据前,也需要对 ACK 应答和最小分区副本数量的关系进行校验。
2.5.2.4.日志文件滚动判断
(1)数据存储到文件中,如果数据文件太大,对于查询性能是会有很大影响的,所以副本数据文件并不是一个完整的大的数据文件,而是根据某些条件分成很多的小文件,每个小文件我们称之为文件段 。其中的一个条件就是文件大小,参数名为 log.segment.bytes。默认值为 1G。如果当前日志段剩余容量可能无法容纳新消息集合,因此有必要创建一个新的日志段来保存待写入的所有消息。此时日志文件就需要滚动生产新的。
(2)除了文件大小外,还有时间间隔,如果文件段第一批数据有时间戳,那么当前批次数据的时间戳和第一批数据的时间戳间隔大于滚动阈值,那么日志文件也会滚动生产新的。如果文件段第一批数据没有时间戳,那么就用当前时间戳和文件创建时间戳进行比对,如果大于滚动阈值,那么日志文件也会滚动生产新的。这个阈值参数名为 log.roll.hours,默认为 7 天。如果时间到达,但是文件不满 1G,依然会滚动生产新的数据文件。
(3)如果索引文件或时间索引文件满了,或者索引文件无法存放当前索引数据了,那么日志文件也会滚动生产新的。基于以上的原则,需要在保存数据前进行判断。
2.5.2.5.请求数据重复性校验
因为 Kafka 允许生产者进行数据重试操作,所以因为一些特殊的情况,就会导致数据请求被 Kafka 重复获取导致数据重复,所以为了数据的幂等性操作,需要在 Broker 端对数据进行重复性校验。这里的重复性校验只能对同一个主题分区的 5 个在途请求中数据进行校验,所以需要在生产者端进行相关配置。
2.5.2.6.请求数据序列号校验
因为 Kafka 允许生产者进行数据重试操作,所以因为一些特殊的情况,就会导致数据请求被 Kafka 重复获取导致数据顺序发生改变从而引起数据乱序。为了防止数据乱序,需要在 Broker 端对数据的序列号进行连续性(插入数据序列号和 Broker 缓冲的最后一个数据的序列号差值为 1)校验。
2.5.2.7.数据存储
将数据通过 LogSegment 中 FileChannel 对象。将数据写入日志文件,写入完成后,更新当前日志文件的数据偏移量。
2.5.3.存储文件格式
我们已经将数据存储到了日志文件中,当然除了日志文件还有其他的一些文件,所以接下来我们就了解一下这些文件:
2.5.3.1.数据日志文件
(1)Kafka 系统早期设计的目的就是日志数据的采集和传输,所以数据是使用 log 文件进行保存的 。我们所说的数据文件就是以 .log 作为扩展名的日志文件。文件名长度为 20 位长度的数字字符串,数字含义为当前日志文件的第一批数据的基础偏移量,也就是文件中保存的第一条数据偏移量。字符串数字位数不够的,前面补 0。
(2)为了能快速看到日志文件中的内容,server.properties 配置文件中的相关配置如下(实际这么配置会导致性能灾难,下面的配置只是为了达到日志文件中有内容的目的):
yml
# 每收到 1 条消息 就强制将数据从操作系统缓存刷写到磁盘
log.flush.interval.messages=1
# 每个日志段文件最大 200 字节
log.segment.bytes=200
# 每 5 毫秒 就强制滚动创建新的日志段文件
log.roll.ms=5
执行下面的生产者代码:
java
package com.atguigu.kafka.producer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.HashMap;
import java.util.Map;
public class KafkaProducerTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<>();
//配置属性:Kafka 服务器集群地址
configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性:Kafka 生产的数据为 KV 对,所以在生产数据进行传输前需要分别对 K,V 进行对应的序列化操作
configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
configMap.put(ProducerConfig.BATCH_SIZE_CONFIG, 2);
//创建 Kafka 生产者对象,建立 Kafka 连接,构造对象时,需要传递配置参数
KafkaProducer<String, String> producer = new KafkaProducer<>(configMap);
//准备数据,定义泛型,构造对象时需要传递 【Topic 主题名称】,【Key】,【Value】三个参数
for (int i = 0; i < 10; i++) {
ProducerRecord<String, String> record = new ProducerRecord<>(
"test", "key" + i, "value" + i
);
//生产(发送)数据
producer.send(record);
}
//关闭生产者连接
producer.close();
}
}
进入目录 E:\kafka_2.12-3.6.1\data\kafka\test-0 中查看:

(3)查看 log 文件内容
powershell
# 在 E:\kafka_2.12-3.6.1\bin\windows 目录的控制命令行中执行如下查看 log 文件内容的命令
kafka-run-class.bat kafka.tools.DumpLogSegments --files E:/kafka_2.12-3.6.1/data/kafka/test-0/00000000000000000000.log --print-data-log

java
Log starting offset: 0
baseOffset: 0 lastOffset: 1 count: 2 baseSequence: 0 lastSequence: 1 producerId: 0 producerEpoch: 0 partitionLeaderEpoch: 0 isTransactional: false isControl: false deleteHorizonMs: OptionalLong.empty position: 0 CreateTime: 1766904573879 size: 95 magic: 2 compresscodec: none crc: 1430640964 isvalid: true
| offset: 0 CreateTime: 1766904573871 keySize: 4 valueSize: 6 sequence: 0 headerKeys: [] key: key0 payload: value0
| offset: 1 CreateTime: 1766904573879 keySize: 4 valueSize: 6 sequence: 1 headerKeys: [] key: key1 payload: value1
baseOffset: 2 lastOffset: 3 count: 2 baseSequence: 2 lastSequence: 3 producerId: 0 producerEpoch: 0 partitionLeaderEpoch: 0 isTransactional: false isControl: false deleteHorizonMs: OptionalLong.empty position: 95 CreateTime: 1766904573879 size: 95 magic: 2 compresscodec: none crc: 2042993898 isvalid: true
| offset: 2 CreateTime: 1766904573879 keySize: 4 valueSize: 6 sequence: 2 headerKeys: [] key: key2 payload: value2
| offset: 3 CreateTime: 1766904573879 keySize: 4 valueSize: 6 sequence: 3 headerKeys: [] key: key3 payload: value3
我们的常规数据主要分为两部分:批次头 + 数据体。
2.5.3.1.1.批次头
| 数据项 | 含义 | 长度 |
|---|---|---|
| BASE_OFFSET_OFFSET | 基础偏移量偏移量 | 8 |
| LENGTH_OFFSET | 长度偏移量 | 4 |
| PARTITION_LEADER_EPOCH_OFFSET | Leaader 分区纪元偏移量 | 4 |
| MAGIC_OFFSET | 魔数偏移量 | 1 |
| ATTRIBUTES_OFFSET | 属性偏移量 | 2 |
| BASE_TIMESTAMP_OFFSET | 基础时间戳偏移量 | 8 |
| MAX_TIMESTAMP_OFFSET | 最大时间戳偏移量 | 8 |
| LAST_OFFSET_DELTA_OFFSET | 最后偏移量偏移量 | 4 |
| PRODUCER_ID_OFFSET | 生产者 ID 偏移量 | 8 |
| PRODUCER_EPOCH_OFFSET | 生产者纪元偏移量 | 2 |
| BASE_SEQUENCE_OFFSET | 基础序列号偏移量 | 4 |
| RECORDS_COUNT_OFFSET | 记录数量偏移量 | 4 |
| CRC_OFFSET | CRC校验偏移量 | 4 |
批次头总的字节数为:61 byte
2.5.3.1.2.数据体
| 数据项 | 含义 | 长度 |
|---|---|---|
| size | 固定值 | 1 |
| offsetDelta | 固定值 | 1 |
| timestampDelta | 时间戳 | 1 |
| keySize | Key字节长度 | 1(动态) |
| keySize(Varint) | Key变量压缩长度算法需要大小 | 1(动态) |
| valueSize | value字节长度 | 1(动态) |
| valueSize(Varint) | Value变量压缩长度算法需要大小 | 1(动态) |
| Headers | 数组固定长度 | 1(动态) |
| sizeInBytes | 上面长度之和的压缩长度算法需要大小 | 1 |
表中的后 5 个值为动态值,需要根据数据的中 key、value 变化计算得到。此处以数据 key=key1、value=value1 为例来进行计算:
yml
# 压缩长度算法:
中间值1 = (算法参数 << 1) ^ (算法参数 >> 31));
中间值2 = Integer.numberOfLeadingZeros(中间值1);
结果 = (38 - 中间值2) / 7 + 中间值2 / 32;
假设当前 key 为 key1,调用算法时,参数为 key.length = 4
中间值1 = (4<<1) ^ (4>>31) = 8
中间值2 = Integer.numberOfLeadingZeros(8) = 28
结果 = (38-28)/7 + 28/32 = 1 + 0 = 1
所以如果 key 取值为 key1,那么key的变长长度就是1
按照上面的计算公式可以计算出,如果我们发送的数据是一条为 (key1, value1) 的数据, 那么 Kafka 当前会向日志文件增加的数据大小为:
yml
# 追加数据字节计算
批次头 = 61
数据体 = 1 + 1 + 1 + 4 + 1 + 6 + 1 + 1 + 1 = 17
总的字节大小为61 + 17 = 78

如果我们发送的数据是两条为 (key1, value1)、(key2, value2) 的数据, 那么 Kafka 当前会向日志文件增加的数据大小为:
yml
# 追加数据字节计算
第一条数据:
批次头 = 61
数据体 = 1 + 1 + 1 + 4 + 1 + 6 + 1 + 1 + 1 = 17
第二条数据:
# 因为字节少,没有满足批次要求,所以两条数据是在一批中的,那么批次头不会重新计算,直接增加数据体即可
数据体 = 1 + 1 + 1 + 4 + 1 + 6 + 1 + 1 + 1 = 17
总的字节大小为61 + 17 + 17 = 95

2.5.3.1.3.数据含义
| 数据项 | 含义 |
|---|---|
| baseOffset | 当前 batch 中第一条消息的位移 |
| lastOffset | 最新消息的位移相对于第一条消息的唯一增量 |
| count | 当前 batch 有的数据数量,Kafka 在进行消息遍历的时候,可以通过该字段快速的跳跃到下一个 batch 进行数据读取 |
| partitionLeaderEpoch | 记录了当前消息所在分区的 leader 的服务器版本(纪元),主要用于进行一些数据版本的校验和转换工作 |
| crc | 当前整个 batch 的数据 crc 校验码,主要用于对数据进行差错校验的 |
| compresscode | 数据压缩格式,主要有 GZIP、LZ4、Snappy、zstd 四种 |
| baseSequence | 当前批次中的基础序列号 |
| lastSequence | 当前批次中的最后一个序列号 |
| producerId | 生产者 ID |
| producerEpoch | 记录了当前消息所在分区的 Producer 的服务器版本(纪元) |
| isTransactional | 是否开启事务 |
| magic | 魔数(Kafka 服务程序协议版本号) |
| CreateTime | 数据创建的时间戳 |
| isControl | 控制类数据,false - 普通消息,应用程序通过 Producer 发送,true - 控制消息,Kafka 内部自动生成的事务控制元数据 (Marker) |
| compresscodec | 压缩格式,默认无 |
| isvalid | 数据是否有效 |
| offset | 数据偏移量,从 0 开始 |
| key | 数据 key |
| payload | 数据 value |
| sequence | 当前批次中数据的序列号 |
| CreateTime | 当前批次中最后一条数据的创建时间戳 |
2.5.3.2.数据索引文件
(1)Kafka 的基础设置中,数据日志文件到达 1G 才会滚动生产新的文件。那么从 1G 文件中想要快速获取我们想要的数据,效率还是比较低的。通过前面的介绍,如果我们能知道数据在文件中的位置 (position),那么定位数据就会快很多,问题在于我们如何才能在知道这个位置呢?
(2)Kafka 在存储数据时,都会保存数据的偏移量信息,而偏移量是从 0 开始计算的。简单理解就是数据的保存顺序。比如第一条保存的数据,那么偏移量就是 0,第二条保存的数据偏移量就是 1,但是这个偏移量只是告诉我们数据的保存顺序,却无法定位数据,不过需要注意的是,每条数据的大小是可以确定的(参考上一个小节的内容)。既然可以确定,那么数据存放在文件的位置起始也就是确定了,所以 Kafka 在保存数据时,其实是可以同时保存位置的,那么我们在访问数据时,只要通过偏移量其实就可以快速定位日志文件的数据了。

(3)不过这依然有问题,就是数据量太多了,对应的偏移量也太多了,并且主题分区的数据文件会有很多,那我们是如何知道数据在哪一个文件中呢 ?为了定位方便 Kafka 在提供日志文件保存数据的同时,还提供了用于数据定位的索引文件,索引文件中保存的就是逻辑偏移量和数据物理存储位置(偏移量)的对应关系。并且还记得吗?每个数据日志文件的名称就是当前文件中数据䣌起始偏移量,所以通过偏移量就可以快速选取文件以及定位数据的位置从而快速找到数据。这种感觉就有点像 Java 的 HashMap 通过 Key 可以快速找到Value的感觉一样,如果不知道 Key,那么从 HashMap 中获取 Value 是不是就特别慢。道理是一样的。
(4)Kafka 的数据索引文件都保存了什么呢?来看一下:
shell
# 在 E:\kafka_2.12-3.6.1\bin\windows 目录的控制命令行中执行如下查看 index 文件内容的命令
kafka-run-class.bat kafka.tools.DumpLogSegments --files E:/kafka_2.12-3.6.1/data/kafka/test-0/00000000000000000000.index --print-data-log

通过图片可以看到,索引文件中保存的就是逻辑偏移量和物理偏移量位置的关系。

有了这个索引文件,那么我们根据数据的顺序获取数据就非常的方便和高效了。不过,相信大家也注意到了,那就是索引文件中的 offset 并不连续。那如果我想获取 offset 等于 3 的数据怎么办?其实也不难,因为 offset 等于 3 不就是 offset 等于 2 的一下条吗?那我使用 offset 等于 2 的数据的 position + size 不就定位了 offset 等于 3 的位置了吗,当然了我举得例子有点过于简单了,不过本质确实差的不多,Kafka 在查询定位时其实采用的就是二分查找法。
(5)不过,为什么 Kafka 的索引文件是不连续的呢?那是因为如果每条数据如果都把偏移量的定位保存下来,数据量也不小,还有就是,如果索引数据丢了几条,其实并不会太影响查询效率,比如咱们之前举的 offset 等于 3 的定位过程。因为 Kafka 底层实现时,采用的是虚拟内存映射技术 mmap (Memory-Map),将内存和文件进行双向映射,操作内存数据就等同于操作文件,所以效率是非常高的,但是因为是基于内存的操作,所以并不稳定,容易丢数据,因此 Kafka 的索引文件中的索引信息是不连续的,而且为了效率,Kafka 默认情况下,4kb 的日志数据才会记录一次索引,但是这个是可以进行配置修改的,参数为 log.index.interval.bytes,默认值为 4096。所以我们有的时候会将 Kafka 的不连续索引数据称之为稀疏索引。
2.5.3.3.数据时间索引文件
(1)某些场景中,我们不想根据顺序(偏移量)获取 Kafka 的数据,而是想根据时间来获取的数据。这个时候,可没有对应的偏移量来定位数据,那么查找的效率就非常低了,因为 Kafka 还提供了时间索引文件,咱们来看看它的内容是什么
shell
# 在 E:\kafka_2.12-3.6.1\bin\windows 目录的控制命令行中执行如下查看 index 文件内容的命令
kafka-run-class.bat kafka.tools.DumpLogSegments --files E:/kafka_2.12-3.6.1/data/kafka/test-0/00000000000000000000.timeindex --print-data-log

(2)通过图片,大家可以看到,这个时间索引文件起始就是将时间戳和偏移量对应起来了,那么此时通过时间戳就可以找到偏移量,再通过偏移量找到定位信息,再通过定位信息找到数据就非常方便了。
2.5.4.数据刷写
(1)在 Linux 系统中,当我们把数据写入文件系统之后,其实数据在操作系统的 PageCache(页缓冲)里面,并没有刷到磁盘上。如果操作系统挂了,数据就丢失了。一方面,应用程序可以调用 fsync 这个系统调用来强制刷盘,另一方面,操作系统有后台线程,定时刷盘。频繁调用 fsync 会影响性能,需要在性能和可靠性之间进行权衡。实际上,Kafka 提供了参数进行数据的刷写:
log.flush.interval.messages:达到消息数量时,会将数据 flush 到日志文件中log.flush.interval.ms:间隔多少时间 (ms),执行一次强制的 flush 操作flush.scheduler.interval.ms:所有日志刷新到磁盘的频率
(2)log.flush.interval.messages 和 log.flush.interval.ms 无论哪个达到,都会 flush。官方不建议通过上述的三个参数来强制写盘,数据的可靠性应该通过 replica 来保证,而强制 flush 数据到磁盘会对整体性能产生影响。
2.5.5.副本同步
Kafka 中,分区的某个副本会被指定为 Leader,负责响应客户端的读写请求。分区中的其他副本自动成为 Follower,主动拉取(同步)Leader 副本中的数据,写入自己本地日志,确保所有副本上的数据是一致的。

2.5.6.1.启动数据同步线程
(1)Kafka 创建主题时,会根据副本分配策略向指定的 Broker 节点发出请求,将不同的副本节点设定为 Leader 或 Follower。一旦某一个 Broker 节点设定为 Follower 节点,那么 Follower 节点会启动数据同步线程 ReplicaFetcherThread,从 Leader 副本节点同步数据。
(2)线程运行后,会不断重复两个操作:截断 (truncate) 和抓取 (fetch)。
- 截断:为了保证分区副本的数据一致性,当分区存在 Leader Epoch 值时,会将副本的本地日志截断到 Leader Epoch 对应的最新位移处.如果分区不存在对应的 Leader Epoch 记录,那么依然使用原来的高水位机制,将日志调整到高水位值处。
- 抓取:向 Leader 同步最新的数据。
2.5.6.2.生成数据同步请求
启动线程后,需要周期地向 Leader 节点发送 fetch 请求,用于从 Leader 获取数据。

等待Leader节点的响应的过程中,会阻塞当前同步数据线程。
2.5.6.3.处理数据响应
当 Leader 副本返回响应数据时,其中会包含多个分区数据,当前副本会遍历每一个分区,将分区数据写入数据文件中。

2.5.6.4.更新数据偏移量
当 Leader 副本返回响应数据时,除了包含多个分区数据外,还包含了和偏移量相关的数据 HW 和 LSO,副本需要根据场景对 Leader 返回的不同偏移量进行更新。
2.5.6.4.1.Offset
Kafka 的每个分区的数据都是有序的,所谓的数据偏移量 (Offset),指的就是 Kafka 在保存数据时,用于快速定位数据的标识,类似于 Java 中数组的索引,从 0 开始。

Kafka的数据文件以及数据访问中包含了大量和偏移量的相关的操作。
2.5.6.4.1.LSO
起始偏移量 (Log Start Offset, LSO),每个分区副本都有起始偏移量,用于表示副本数据的起始偏移位置,初始值为 0。LSO一般情况下是无需更新的,但是如果数据过期,或用户手动删除数据时,Leader 的 Log Start Offset 可能发生变化,Follower 副本的日志需要和 Leader 保持严格的一致,因此,如果 Leader 的该值发生变化,Follower 自然也要发生变化保持一致。
2.5.6.4.3.LEO
日志末端位移 (Log End Offset, LEO),表示下一条待写入消息的 offset,每个分区副本都会记录自己的 LEO。对于 Follower 副本而言,它能读取到 Leader 副本 LEO 值以下的所有消息。
2.5.6.4.1.HW
高水位值 (High Watermark),定义了消息可见性,标识了一个特定的消息偏移量 (offset),消费者只能拉取到这个水位 offset 之前的消息,同时这个偏移量还可以帮助 Kafka 完成副本数据同步操作。
2.5.6.数据一致性
2.5.6.1.数据一致性
(1)Kafka 的设计目标是:高吞吐、高并发、高性能。为了做到以上三点,它必须设计成分布式的,多台机器可以同时提供读写,并且需要为数据的存储做冗余备份。

(2)上图中的主题有 3 个分区,每个分区有 3 个副本,这样数据可以冗余存储,提高了数据的可用性。并且 3 个副本有两种角色,Leader 和 Follower,Follower 副本会同步 Leader 副本的数据。一旦 Leader 副本挂了,Follower 副本可以选举成为新的 Leader 副本, 这样就提升了分区可用性,但是相对的,在提升了分区可用性的同时,也就牺牲了数据的一致性。
(3)我们来看这样的一个场景:一个分区有 3 个副本,一个 Leader 和两个 Follower。Leader 副本作为数据的读写副本,所以生产者的数据都会发送给 Leader 副本,而两个 Follower 副本会周期性地同步 Leader 副本的数据,但是因为网络,资源等因素的制约,同步数据的过程是有一定延迟的,所以 3 个副本之间的数据可能是不同的。具体如下图所示:

(4)此时,假设 Leader 副本因为意外原因宕掉了,那么 Kafka 为了提高分区可用性,此时会选择 2 个 Follower 副本中的一个作为 Leader 对外提供数据服务。此时我们就会发现,对于消费者而言,之前 Leader 副本能访问的数据是 d,但是重新选择 Leader 副本后,能访问的数据就变成了 c,这样消费者就会认为数据丢失了,也就是所谓的数据不一致了。

(5)为了提升数据的一致性,Kafka 引入了高水位 (HW: High Watermark) 机制,Kafka 在不同的副本之间维护了一个水位线的机制(其实也是一个偏移量的概念),消费者只能读取到水位线以下的的数据。这就是所谓的木桶理论:木桶中容纳水的高度,只能是水桶中最短的那块木板的高度。这里将整个分区看成一个木桶,其中的数据看成水,而每一个副本就是木桶上的一块木板,那么这个分区(木桶)可以被消费者消费的数据(容纳的水)其实就是数据最少的那个副本的最后数据位置(木板高度)。
(6)也就是说,消费者一开始在消费 Leader 的时候,虽然 Leader 副本中已经有 a、b、c、d 这 4 条数据,但是由于高水位线的限制,所以也只能消费到 a、b 这两条数据。

这样即使 Leader 挂掉了,但是对于消费者来讲,消费到的数据其实还是一样的,因为它能看到的数据是一样的,也就是说,消费者不会认为数据不一致。

不过也要注意,因为 Follower 要求和 Leader 的日志数据严格保持一致,所以就需要根据现在 Leader 的数据偏移量值对其他的副本进行数据截断 (truncate) 操作。

2.5.6.2.HW 在副本之间的传递
(1)HW 高水位线会随着 Follower 的数据同步操作,而不断上涨,也就是说,Follower 同步的数据越多,那么水位线也就越高,那么消费者能访问的数据也就越多。接下来,我们就看一看,Follower 在同步数据时 HW 的变化。
(2)首先,初始状态下,Leader 和 Follower 都没有数据,所以和偏移量相关的值都是初始值 0,而由于 Leader 需要管理 Follower,所以也包含着 Follower 的相关偏移量 (LEO) 数据。

生产者向 Leader 发送两条数据,Leader 收到数据后,会更新自身的偏移量信息。
java
// Leader 副本偏移量更新:
LEO=LEO+2=2

接下来,Follower 开始同步Leader的数据,同步数据时,会将自身的 LEO 值作为参数传递给 Leader。此时,Leader 会将数据传递给 Follower,且同时 Leader 会根据所有副本的 LEO 值更新 HW。

java
// Leader 副本偏移量更新:
HW = Math.max[HW, min(LeaderLEO,F1-LEO,F2-LEO)]=0

由于两个 Follower 的数据拉取速率不一致,所以 Follower-1 抓取了 2 条数据,而 Follower-2 抓取了 1 条数据。Follower 再收到数据后,会将数据写入文件,并更新自身的偏移量信息。
java
// Follower-1 副本偏移量更新:
LEO=LEO+2=2
HW = Math.min[LeaderHW, LEO]=0
// Follower-2 副本偏移量更新:
LEO=LEO+1=1
HW = Math.min[LeaderHW, LEO]=0

接下来 Leader 收到了生产者的数据 c,那么此时会根据相同的方式更新自身的偏移量信息
java
// Leader 副本偏移量更新:
LEO=LEO+1=3

Follower 接着向 Leader 发送 Fetch 请求,同样会将最新的 LEO 作为参数传递给 Leader。Leader 收到请求后,会更新自身的偏移量信息。
java
// Leader 副本偏移量更新:
HW = Math.max[HW, min(LeaderLEO,F1-LEO,F2-LEO)]=0

此时,Leader 会将数据发送给 Follower,同时也会将 HW 一起发送。


Follower 收到数据后,会将数据写入文件,并更新自身偏移量信息
java
// Follower-1 副本偏移量更新:
LEO=LEO+1=3
HW = Math.min[LeaderHW, LEO]=1
// Follower-2 副本偏移量更新:
LEO=LEO+1=2
HW = Math.min[LeaderHW, LEO]=1


因为 Follower 会不断重复 Fetch 数据的过程,所以前面的操作会不断地重复。最终,Follower 副本和 Leader 副本的数据和偏移量是保持一致的。

上面演示了副本列表 ISR 中 Follower 副本和 Leader 副本之间 HW 偏移量的变化过程,但特殊情况是例外的。比如当前副本列表 ISR 中只剩下了 Leader 一个副本的场合下,是不需要等待其他副本的,直接推高 HW 即可。
2.5.6.3.ISR (In-Sync Replicas) 变化和传播
(1)在 Kafka 中,一个 Topic(主题)包含多个 Partition(分区),Topic 是逻辑概念,而 Partition 是物理分组。一个 Partition 包含多个Replica(副本),副本有两种类型 Leader Replica/Follower Replica,Replica 之间是一个 Leader 副本对应多个 Follower 副本。注意:分区数可以大于节点数,但副本数不能大于节点数。因为副本需要分布在不同的节点上,才能达到备份的目的。
(2)Kafka 的分区副本中只有 Leader 副本具有数据写入的功能,而 Follower 副本需要不断向 Leader 发出申请,进行数据的同步。这里所有同步的副本会形成一个列表,我们称之为同步副本列表 (In-Sync Replicas) ,也可以简称 ISR,除了 ISR 以外,还有已分配的副本列表 (Assigned Replicas),简称 AR。这里的 AR 其实不仅仅包含 ISR,还包含了没有同步的副本列表 (Out-of-Sync Replicas),即从 ISR 中被踢出的副本(例如同步过程持续失败导致),简称 OSR。
(3)生产者 Producer 生产数据时,ACKS 应答机制如果设置为 all,那此时就需要保证同步副本列表 ISR 中的所有副本全部接收完毕后,Kafka 才会进行确认应答。数据存储时,只有 ISR 中的所有副本 LEO 数据都更新了,才有可能推高 HW 偏移量的值。这就可以看出,ISR 在 Kafka 集群的管理中是非常重要的。
(4)在 Broker 节点中,有一个副本管理器组件 (ReplicaManager),除了读写副本、管理分区和副本的功能之外,还有一个重要的功能,那就是管理 ISR。这里的管理主要体现在两个方面:
- 周期性地查看 ISR 中的副本集合是否需要收缩。这里的收缩是指,把 ISR 副本集合中那些与 Leader 差距过大的副本移除的过程。相对的,有收缩,就会有扩大,在Follower 抓取数据时,判断副本状态,满足扩大 ISR 条件后,就可以提交分区变更请求。完成 ISR 列表的变更。

- 向集群 Broker 传播 ISR 的变更。ISR 发生变化(包含搜收缩和扩大)都会执行传播逻辑。ReplicaManager 每间隔 2500 毫秒就会根据条件,将 ISR 变化的结果传递给集群的其他 Broker。

2.6.消费消息
2.6.1.消费消息的基本步骤
(1)创建 Map 类型的配置对象,根据场景增加相应的配置属性:
| 参数名 | 参数作用 | 类型 | 默认值 | 推荐值 |
|---|---|---|---|---|
| bootstrap.servers | 集群地址,格式为 brokerIP1:端口号,brokerIP2:端口号 | 必须 | ||
| key.deserializer | 对数据 Key 进行反序列化的类完整名称 | 必须 | Kafka 提供的字符串反序列化类:org.apache.kafka.common.serialization.StringDeserializer | |
| value.deserializer | 对数据 Value 进行反序列化的类完整名称 | 必须 | Kafka 提供的字符串反序列化类:org.apache.kafka.common.serialization.StringDeserializer | |
| group.id | 消费者组 ID,用于标识完整的消费场景,一个组中可以包含多个不同的消费者对象。 | 必须 | ||
| auto.offset.reset | 当消费者组中没有初始偏移量或服务器上不再存在当前偏移量(例如,数据被删除)时,应该怎么办 | 可选 | latest | earliest / latest / none |
| group.instance.id | 消费者实例 ID,如果指定,那么在消费者组中使用此 ID 作为 memberId 前缀 | 可选 | null | |
| partition.assignment.strategy | 分区分配策略 | 可选 | RangeAssignor, CooperativeStickyAssignor | RangeAssignor, RoundRobinAssignor, StickyAssignor, CooperativeStickyAssignor |
| enable.auto.commit | 启用偏移量自动提交 | 可选 | true | true / false |
| auto.commit.interval.ms | 自动提交周期 | 可选 | 5000ms | 根据业务可靠性要求调整 |
| fetch.max.bytes | 消费者获取服务器端一批消息最大的字节数。如果服务器端一批次的数据大于该值(50m)仍然可以拉取回来这批数据,因此,这不是一个绝对最大值。一批次的大小受message.max.bytes (broker config)or max.message.bytes (topic config)影响 | 可选 | 52428800(50 m) | |
| offsets.topic.num.partitions | 偏移量消费主题分区数 | 可选 | 50 |
(2)创建消费者对象:根据配置创建消费者对象 KafkaConsumer,向 Kafka 订阅 (subscribe) 主题消息,并向 Kafka 发送请求 (poll) 获取数据。
(3)获取数据:Kafka 会根据消费者发送的参数,返回数据对象 ConsumerRecord。返回的数据对象中包括指定的数据。
| 数据项 | 数据含义 |
|---|---|
| topic | 主题名称 |
| partition | 分区号 |
| offset | 偏移量 |
| timestamp | 数据时间戳 |
| key | 数据 key |
| value | 数据 value |

(4)关闭消费者:消费者消费完数据后,需要将对象关闭用以释放资源。一般情况下,消费者无需关闭。
2.6.2.消费消息的基本代码
java
package com.atguigu.kafka.consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class KafkaConsumerOffsetTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<String, Object>();
//配置属性:Kafka 集群地址
configMap.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性: Kafka 传输的数据为KV对,所以需要对获取的数据分别进行反序列化
configMap.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
configMap.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
//配置属性: 消费者组
configMap.put(ConsumerConfig.GROUP_ID_CONFIG, "atguigu");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(configMap);
//消费者订阅指定主题的数据
consumer.subscribe(Collections.singletonList("test"));
while (true) {
//每隔 100 毫秒,抓取一次数据
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
//打印抓取的数据
for (ConsumerRecord<String, String> record : records) {
System.out.println(record);
}
}
}
}
2.6.3.消费消息的基本原理
从数据处理的角度来讲,消费者和生产者的处理逻辑都相对比较简单。生产者的基本数据处理逻辑就是向 Kafka 发送数据,并获取 Kafka 的数据接收确认响应。

而消费者的基本数据处理逻辑就是向 Kafka 请求数据,并获取 Kafka 返回的数据。

逻辑确实很简单,但是 Kafka 为了能够构建高吞吐,高可靠性,高并发的分布式消息传输系统,所以在很多细节上进行了扩展和改善:比如生产者可以指定分区,可以异步和同步发送数据,可以进行幂等性操作和事务处理。对应的,消费者功能和处理细节也进行了扩展和改善。
2.6.3.1.消费者组
2.6.3.1.1.消费数据的方式:push & pull
(1)Kafka 的主题如果就一个分区的话,那么在硬件配置相同的情况下,消费者 Consumer 消费主题数据的方式没有什么太大的差别。

(2)不过,Kafka 为了能够构建高吞吐,高可靠性,高并发的分布式消息传输系统,它的主题是允许多个分区的,那么就会发现不同的消费数据的方式区别还是很大的。
- 如果数据由 Kafka 进行推送 (push),那么多个分区的数据同时推送给消费者进行处理,明显一个消费者的消费能力是有限的,那么消费者无法快速处理数据,就会导致数据的积压,从而导致网络,存储等资源造成极大的压力,影响吞吐量和数据传输效率。

- 如果 Kafka 的分区数据在内部可以存储的时间更长一些,再由消费者根据自己的消费能力向 Kafka 申请(拉取)数据,那么整个数据处理的通道就会更顺畅一些。Kafka 的 Consumer 就采用的这种拉取数据的方式。
2.6.3.1.2.消费者组 Consumer Group
(1)消费者可以根据自身的消费能力主动拉取 Kafka 的数据,但是毕竟自身的消费能力有限,如果主题分区的数据过多,那么消费的时间就会很长。对于 Kafka 来讲,数据就需要长时间的进行存储,那么对 Kafka 集群资源的压力就非常大。如果希望提高消费者的消费能力,并且减少 Kafka 集群的存储资源压力。所以有必要对消费者进行横向伸缩,从而提高消息消费速率。

(2)不过这么做有一个问题,就是每一个消费者是独立,那么一个消费者就不能消费主题中的全部数据,简单来讲,就是对于某一个消费者个体来讲,主题中的部分数据是没有消费到的,也就会认为数据丢了,这个该如何解决呢?那如果我们将这多个消费者当成一个整体,是不是就可以了呢?这就是所谓的消费者组 (Consumer Group)。在 Kafka 中,每个消费者都对应一个消费组,消费者可以是一个线程,一个进程,一个服务实例,如果 Kafka 想要消费消息,那么需要指定消费那个主题的消息以及自己的消费组 id (groupId)。

2.6.3.2.调度(协调)器 Coordinator
(1)消费者想要拉取数据,首先必须要加入到一个组中,成为消费组中的一员,同样道理,如果消费者出现了问题,也应该从消费者组中剥离。而这种加入组和退出组的处理,都应该由专门的管理组件进行处理,这个组件在 Kafka 中,我们称之为消费者组调度(协调)器 (Group Coordinator)
(2)Group Coordinator 是 Broker 上的一个组件,用于管理和调度消费者组的成员、状态、分区分配、偏移量等信息。每个 Broker 都有一个 Group Coordinator 对象,负责管理多个消费者组,但每个消费者组只有一个 Group Coordinator。

2.6.3.3.消费者分配策略 Assignor
(1)消费者想要拉取主题分区的数据,首先必须要加入到一个组中。

(2)但是一个组中有多个消费者的话,那么每一个消费者该如何消费呢,是不是像图中一样的消费策略呢?如果是的话,那假设消费者组中只有 2 个消费者或有 4 个消费者,和分区的数量不匹配,怎么办?所以这里,我们需要给大家介绍一下,Kafka 中基本的消费者组中的消费者和分区之间的分配规则:
- 同一个消费者组的消费者都订阅同一个主题,所以消费者组中的多个消费者可以共同消费一个主题中的所有数据。
- 为了避免数据被重复消费,所以主题一个分区的数据只能被组中的一个消费者消费,也就是说不能两个消费者同时消费一个分区的数据。但是反过来,一个消费者是可以消费多个分区数据的 。

- 消费者组中的消费者数量最好不要超出主题分区的数据 ,就会导致多出的消费者是无法消费数据的,造成了资源的浪费。

(3)消费者中的每个消费者到底消费哪一个主题分区,这个分配策略其实是由消费者的 Leader 决定的,这个 Leader 我们称之为群主。群主是多个消费者中,第一个加入组中的消费者,其他消费者我们称之为 Follower,称呼上有点类似与分区的 Leader 和 Follower。

(4)当消费者加入群组的时候,会发送一个 JoinGroup 请求。群主负责给每一个消费者分配分区。每个消费者只知道自己的分配信息,只有群主知道群组内所有消费者的分配信息。指定分配策略的基本流程如下:
- 第一个消费者设定 group.id 为
test,向当前负载最小的节点发送请求查找消费调度器

- 找到消费调度器后,消费者向调度器节点发出 JoinGroup 请求,加入消费者组

- 当前消费者当选为群主后,根据消费者配置中分配策略设计分区分配方案,并将分配好的方案告知调度器

- 此时第二个消费者设定 group.id 为
test,申请加入消费者组

- 加入成功后,Kafka 将消费者组状态切换到
准备 Rebalance,关闭和消费者的所有链接,等待它们重新加入。客户端重新申请加入,Kafka 从消费者组中挑选一个作为 Leader,其它的作为 Follower。(步骤和之前相同,我们假设还是之前的消费者为 Leader)

- Leader 会按照分配策略对分区进行重分配,并将方案发送给调度器,由调度器通知所有的成员新的分配方案。组成员会按照新的方案重新消费数据

(5)Kafka 提供的分区分配策略常用的有 4 个:
RoundRobinAssignor(轮询分配策略):每个消费者组中的消费者都会含有一个自动生产的 UUID 作为 memberid。

轮询策略中会将每个消费者按照 memberid 进行排序,所有消费者消费的主题分区根据主题名称进行排序。

将主题分区轮询分配给对应的订阅用户,注意未订阅当前轮询主题的消费者会跳过。


从图中可以看出,轮询分配策略是存在缺点的,并不是那么的均衡,如果 test1-2 分区能够分配给消费者 ccc 是不是就完美了。
RangeAssignor(范围分配策略):按照每个主题的 partition 数计算出每个消费者应该分配的分区数量,然后分配,分配的原则就是一个主题的分区尽可能的平均分,如果不能平均分,那就按顺序向前补齐即可。
java
所谓按顺序向前补齐就是:
假设【1,2,3,4,5】5个分区分给2个消费者:
5 / 2 = 2, 5 % 2 = 1 => 剩余的一个补在第一个中[2+1][2] => 结果为[1,2,3][4,5]
假设【1,2,3,4,5】5个分区分到3个消费者:
5 / 3 = 1, 5 % 3 = 2 => 剩余的两个补在第一个和第二个中[1+1][1+1][1] => 结果为[1,2][3,4][5]

缺点:范围分配策略针对单个 Topic 的情况下显得比较均衡,但是假如 Topic 多的话,member 排序靠前的可能会比 member 排序靠后的负载多很多。是不是也不够理想。

还有就是如果新增或移除消费者成员,那么会导致每个消费者都需要去建立新的分区节点的连接,更新本地的分区缓存,效率比较低。

StickyAssignor(粘性分配策略):在第一次分配后,每个组成员都保留分配给自己的分区信息。如果有消费者加入或退出,那么在进行分区再分配时(一般情况下,消费者退出 45s 后,才会进行再分配,因为需要考虑可能又恢复的情况),尽可能保证消费者原有的分区不变,重新对加入或退出消费者的分区进行分配。


从图中可以看出,粘性分区分配策略分配的会更加均匀和高效一些。
CooperativeStickyAssignor(协作粘性分配策略):前面的三种分配策略再进行重分配时使用的是 EAGER 协议,会让当前的所有消费者放弃当前分区,关闭连接,资源清理,重新加入组和等待分配策略。明显效率是比较低的,所以从 Kafka 2.4 版本开始,在粘性分配策略的基础上,优化了重分配的过程,使用的是 COOPERATIVE 协议,特点就是在整个再分配的过程中从图中可以看出,粘性分区分配策略分配的会更加均匀和高效一些,COOPERATIVE 协议将一次全局重平衡,改成每次小规模重平衡,直至最终收敛平衡的过程。
Kafka 3.0+ 消费者默认的分区分配就是 CooperativeStickyAssignor
2.6.3.4.偏移量 Offset
偏移量 Offset 是消费者消费数据的一个非常重要的属性。默认情况下,消费者如果不指定消费主题数据的偏移量,那么消费者启动消费时,无论当前主题之前存储了多少历史数据,消费者只能从连接成功后当前主题最新的数据偏移位置读取,而无法读取之前的任何数据,如果想要获取之前的数据,就需要设定配置参数或指定数据偏移量。
2.6.3.4.1.起始偏移量
在消费者的配置中,我们可以增加偏移量相关参数 auto.offset.reset,用于从最开始获取主题数据,
java
configMap.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
参数取值有 3 个:

earliest:对于同一个消费者组,从头开始消费。就是说如果这个 topic 有历史消息存在,现在新启动了一个消费者组,且auto.offset.reset=earliest,那将会从头开始消费(未提交偏移量的场合)。

latest:对于同一个消费者组,消费者只能消费到连接 topic 后,新产生的数据(未提交偏移量的场合)。

none:生产环境不使用
2.6.3.4.2.指定偏移量消费
除了从最开始的偏移量或最后的偏移量读取数据以外,Kafka 还支持从指定的偏移量的位置开始消费数据。
java
package com.atguigu.kafka.consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class KafkaConsumerOffsetTest {
public static void main(String[] args) {
//配置属性集合
Map<String, Object> configMap = new HashMap<String, Object>();
//配置属性:Kafka 集群地址
configMap.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
//配置属性: Kafka 传输的数据为KV对,所以需要对获取的数据分别进行反序列化
configMap.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
configMap.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
//配置属性: 消费者组
configMap.put(ConsumerConfig.GROUP_ID_CONFIG, "atguigu1");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(configMap);
//订阅主题
String topic = "test";
consumer.subscribe(Collections.singletonList(topic));
//拉取数据,获取基本集群信息
consumer.poll(Duration.ofMillis(100));
//根据集群的基本信息配置需要消费的主题及偏移量
final Set<TopicPartition> assignment = consumer.assignment();
for (TopicPartition topicPartition : assignment) {
if (topic.equals(topicPartition.topic()) ) {
//偏移量设置为 2
consumer.seek(topicPartition, 2);
}
}
//消费者订阅指定主题的数据
consumer.subscribe(Collections.singletonList("test"));
while (true) {
//每隔 100 毫秒,抓取一次数据
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
//打印抓取的数据
for (ConsumerRecord<String, String> record : records) {
System.out.println(record);
}
}
}
}
2.6.3.4.4.偏移量提交
(1)生产环境中,消费者可能因为某些原因或故障重新启动消费,那么如果不知道之前消费数据的位置,重启后再消费,就可能重复消费 或漏消费 。所以 Kafka 提供了保存消费者偏移量 的功能,而这个功能需要由消费者进行提交操作。这样消费者重启后就可以根据之前提交的偏移量进行消费了。注意,一旦消费者提交了偏移量,那么 Kafka 会优先使用提交的偏移量进行消费。此时,auto.offset.reset 参数是不起作用的。
(2)自动提交 :所谓的自动提交就是消费者消费完数据后,无需告知 Kafka 当前消费数据的偏移量,而是由消费者客户端 API 周期性地将消费的偏移量提交到 Kafka 中。这个周期默认为 5000ms,可以通过配置 auto.commit.interval.ms 进行修改。
java
//启用自动提交消费者偏移量,默认值为 true
configMap.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
//设置自动提交偏移量的时间周期为 1000ms,默认为 5000ms
configMap.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 1000);
(3)手动提交 :基于时间周期的偏移量提交,是我们无法控制的,一旦参数设置的不合理,或单位时间内数据量消费的很多,却没有来及的自动提交,那么数据就会重复消费。所以 Kafka 也支持消费偏移量的手动提交,也就是说当消费者消费完数据后,自行通过 API 进行提交。不过为了考虑效率和安全,Kafka 同时提供了异步提交 和同步提交 两种方式供我们选择。注意需要禁用自动提交 auto.offset.reset=false,才能开启手动提交。
- 异步提交:指向 Kafka 发送偏移量 Offset 提交请求后,就可以直接消费下一批数据,因为无需等待 Kafka 的提交确认,所以无法知道当前的偏移量一定提交成功,所以安全性比较低,但相对,消费性能会提高。
- 同步提交:必须等待 Kafka 完成 Offset 提交请求的响应后,才可以消费下一批数据,一旦提交失败,会进行重试处理,尽可能保证偏移量提交成功,但是依然可能因为以外情况导致提交请求失败。此种方式消费效率比较低,但是安全性高。
java
configMap.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
//...
while (true) {
//每隔 100 毫秒,抓取一次数据
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
//打印抓取的数据
for (ConsumerRecord<String, String> record : records) {
System.out.println(record);
}
//手动保存偏移量
consumer.commitAsync(); //异步提交
//consumer.commitSync(); //同步提交
}
2.6.3.5.消费者事务
(1)无论偏移量使用自动提交还是,手动提交,特殊场景中数据都有可能会出现重复消费。

如果提前提交偏移量,再处理业务,又可能出现数据丢失的情况。

(2)对于单独的消费者来讲,事务保证会比较弱,尤其是无法保证提交的信息被精确消费,主要原因就是消费者可以通过偏移量访问信息,而不同的数据文件生命周期不同,同一事务的信息可能会因为重启导致被删除的情况。所以一般情况下,想要完成 Kafka 消费者端的事务处理,需要将数据消费过程和偏移量提交过程进行原子性绑定,也就是说数据处理完了,必须要保证偏移量正确提交,才可以做下一步的操作,如果偏移量提交失败,那么数据就恢复成处理之前的效果。
(3)对于生产者事务而言,消费者消费的数据也会受到限制。默认情况下,消费者只能消费到生产者提交的数据,也就是未提交完成的数据,消费者是看不到的。如果想要消费到未提交的数据,需要更高消费事务隔离级别。
java
//隔离级别:已提交读,读取已经提交事务成功的数据(默认)
paramMap.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
//隔离级别:未提交读,读取已经提交事务成功和未提交事务成功的数据
paramMap.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_uncommitted");
2.6.3.6.偏移量的保存
(1)由于消费者在消费消息的时候可能会由于各种原因而断开消费,当重新启动消费者时我们需要让它接着上次消费的位置 Offset 继续消费,因此消费者需要实时的记录自己以及消费的位置。
(2)Kafka 0.90 版本之前,这个信息是记录在 Zookeeper 内的,在 0.90 之后的版本,Offset 保存在 __consumer_offsets 这个主题内。
每个 consumer 会定期将自己消费分区的 Offset 提交给 Kafka 内部主题 __consumer_offsets,提交过去的时候,key 是consumerGroupId+topic+分区号。value 就是当前 Offset 的值,Kafka 会定期清理主题里的消息,最后就保留最新的那条数据。


(3)因为主题 __consumer_offsets 可能会接收高并发的请求,Kafka 默认给其分配 50 个分区(可以通过 offsets.topic.num.partitions 设置),均匀分配到 Kafka 集群的多个 Broker 中。Kafka 采用 hash(consumerGroupId) % __consumer_offsets 主题的分区数来计算我们的偏移量提交到哪一个分区。因为偏移量也是保存到主题中的,所以保存的过程和生产者生产数据的过程基本相同。
2.6.3.7.消费数据
消费者消费数据时,一般情况下,只是设定了订阅的主题名称,那是如何消费到数据的呢?这里说一下服务端拉取数据的基本流程。

- 服务端获取到用户拉取数据的请求:Kafka 消费客户端会向 Broker 发送拉取数据的请求 FetchRequest,服务端 Broker 获取到请求后根据请求标记 FETCH 交给应用处理接口 KafkaApis 进行处理。
- 通过副本管理器拉取数据:副本管理器需要确定当前拉取数据的分区,然后进行数据的读取操作。
- 判定首选副本:Kafka 2.4 版本之前,数据读写的分区都是 Leader 分区,从 2.4 版本后,Kafka 支持 Follower 副本进行读取。主要原因就是跨机房或者说跨数据中心的场景,为了节约流量资源,可以从当前机房或数据中心的副本中获取数据。这个副本称之未首选副本。
- 拉取分区数据:Kafka 的底层读取数据是采用日志段 LogSegment 对象进行操作的。
- 零拷贝:为了提高数据读取效率,Kafka 的底层采用 NIO 提供的 FileChannel 零拷贝技术,直接从操作系统内核中进行数据传输,提高数据拉取的效率。