文章目录

  • 概述
  • 消息队列的概念
  • 消息队列的两种模式
  • 点对点模式(`一对一`,消费者主动拉取数据,消息收到后消息清除)
  • 发布/订阅模式(一对多,消费者消费数据之后不会清除消息)
  • 使用消息队列的场景分析
  • Kafka集群安装
  • Kafka安装步骤
  • Topic管理篇(DDL)
  • Kafka 架构
  • 日志&分区
  • Kafka生产者
  • 数据可靠性保证(ack)
  • ISR(In-Sync Replicas)
  • replica.lag.time.max.ms的误区
  • OSR(Out-Sync Replicas):
  • 故障处理细节(保证消费数据的一致性)
  • Exactly Once语义
  • Kafka消费者
  • 分区分配策略
  • RoundRobin
  • Range(默认使用规则)
  • Rebalance
  • offset 的维护
  • Kafka生产者和消费者总结
  • Kafka事务
  • 生产者事务
  • 消费者事务
  • Kafka 高效读写数据
  • 顺序写磁盘
  • Zookeeper 在Kafka中的作用
  • Kafka API实战(JDK1.8+)
  • Topic管理
  • 生产者
  • 异步发送 API
  • 不带回调函数的API(send中不带callBack参数)
  • 带回调函数的API(send方法中带callBack参数)
  • 同步发送 API
  • 消费者
  • consumer的两种订阅模式
  • subscribe使用
  • assign使用
  • 自动提交offset
  • 手动提交 offset
  • 同步提交offset
  • 异步提交offset
  • 读取数据偏移量控制
  • 指定某个分区消费开始偏移量
  • subscribe
  • assign
  • 三种消费模式
  • Kafka事务
  • Kafka发送/接收Object
  • ConsumerRebalanceListener再均衡监听器
  • SpringBoot整合Kafka


概述

Kafka是Apache软件基金会的开源的流处理平台,该平台提供了消息的订阅与发布,能够基于Kafka实现对网络日志流实时在线处理,在这个维度上弥补了Hadoop的离线分析系统的不足。因为基于hadoop的MapReduce系统分析离线数据延迟较高,而且不支持动态数据处理和分析。Kafka的流处理平台不仅仅可以为离线系统储备数据(通常使用Kafka作为数据缓冲),而且Kafka自身也提供了一套数据流的处理机制,实现对数据流在线处理,比如: 统计。

消息队列的概念

可以用于系统间通讯的一个组件-middle ware(中间件),该组件可以用于做消息缓冲的中间件(持久化)解决一些 并发处理、数据库缓冲等实现对高并发的业务场景的削峰填谷。

消息队列的两种模式

点对点模式(一对一,消费者主动拉取数据,消息收到后消息清除)

消息生产者生产消息发送到Queue中,然后消息消费者从Queue中取出并且消费消息。消息被消费以后,queue 中不再有存储,所以消息消费者不可能消费到已经被消费的消息Queue 支持存在多个消费者,但是对一个消息而言,只会有一个消费者可以消费

发布/订阅模式(一对多,消费者消费数据之后不会清除消息)

消息生产者(发布)将消息发布到 topic 中,同时有多个消息消费者(订阅)消费该消息。和点对点方式不同,发布到 topic 的消息会被所有订阅者消费。kafka使用的就是此模式

使用消息队列的场景分析

1.异步消息发送:

使用Kafka MQ功能实现模块间异步通信,把一些费时的操作交给额外的服务或者设备去执行,这样可以提升系统运行效率,加速连接释放的速度,例如:用户注册模块,在用户注册成功后,业务系统需要给用户发送一个通知短信,通知用户登录邮箱去激活刚注册的用户信息。这种业务场景如图所示,因为短信通知和邮件发送是一个比较耗时的操作,所以在这里没必要将短信和邮件发送作为注册模块的流程,使用Message Queue功能可以将改业务和主业务注册分离,这样可以缩短用户浏览器和服务建立的链接时间,同时也能满足发送短信和邮件的业务。

2.系统间解耦合

①在某些高吞吐的业务场景下,可能会出现在某一个时间段系统负载写入的负载压力比较大,短时间有大量的数据需要持久化到数据库中,但是由于数据的持久化需要数据库提供服务,由于传统的数据库甚至一些NoSQL产品也不能很好的解决高并发写入,因为数据库除去要向用户提供链接之外,还需要对新来的数据做持久化,这就需要一定的时间才能将数据落地到磁盘。因此在高并发写入的场景,就需要用户集成Message Queue在数据库前作为缓冲队列。在队列的另一头只需要程序有条不紊的将数据写入到数据库即可,这就保证无论外界写入压力有多么大都可以借助于Message Queue缓解数据库的压力。

②Message Queue除了解决对数据缓冲的压力之外,还可以充当业务系统的中间件(Middleware)作为系统服务间解耦的组件存在,例如上图所示订单模块和库存模块中就可以使用Message Queue作为缓冲队列实现业务系统服务间的解耦,也就意味着即使服务在运行期间库存系统宕机也并不会影响订单系统的正常运行

Kafka集群安装

环境准备
准备三台主机名分别为CentOSA|CentOSB|CentOSC的Linux系统主机
分别关闭防火墙、相互做主机名映射、校对物理时钟、安装配置JDK8配置JAVA_HOME
安装Zookeeper集群确保Kafka集群的正常运行

tar -zxf zookeeper-3.4.6.tar.gz -C /usr/
mkdir /root/zkdata

#分别在三台机器执行以下命令
echo 1 >> /root/zkdata/myid
echo 2 >> /root/zkdata/myid
echo 3 >> /root/zkdata/myid

touch /usr/zookeeper-3.4.6/conf/zoo.cfg
vim /usr/zookeeper-3.4.6/conf/zoo.cfg

zoo.cfg配置文件

tickTime=2000
dataDir=/root/zkdata
clientPort=2181
initLimit=5
syncLimit=2

server.1=CentOSA:2887:3887
server.2=CentOSB:2887:3887
server.3=CentOSC:2887:3887

启动zookeeper并查看zookeeper当前状态

/usr/zookeeper-3.4.6/bin/zkServer.sh start zoo.cfg     #启动
/usr/zookeeper-3.4.6/bin/zkServer.sh status zoo.cfg    #查看状态

Kafka安装步骤

  • 下载Kafka服务安装包http://archive.apache.org/dist/kafka/2.2.0/kafka_2.11-2.2.0.tgz
tar -zxf kafka_2.11-2.2.0.tgz -C /usr
vim /usr/kafka_2.11-2.2.0/config/server.properties
############################# Server Basics #############################
broker.id=[0|1|2]  #三台机器分别 0/1/2
############################# Socket Server Settings #############################
listeners=PLAINTEXT://CentOS[A|B|C]:9092 #三台机器分别A、B、C
############################# Log Basics #############################
# A comma separated list of directories under which to store log files
log.dirs=/usr/kafka-logs
############################# Zookeeper #############################
zookeeper.connect=CentOSA:2181,CentOSB:2181,CentOSC:2181

本案例中安装的是kafka_2.11-2.2.0.tgz版本,由于Kafka底层使用的Scala和Java混编,因此在kafka发行版本例如:kafka_2.11-2.2.0.tgz其中2.11是Scala的编译版本,因为Scala兼容Java所以运行Kafka无需安装Scala环境;2.2.0是kafka的版本号。Kafka从0.11.x以后加入事务等特性的支持。

配置说明

配置项

说明

broker.id

每一台Kafka服务的id信息,必须设置不同。

delete.topic.enable

配置该属性开启删除topic的能力,否则kafka无法删除Topic信息。

listeners

配置Kafka服务的监听服务入口。

log.dirs

配置Kafka日志存储路径,存储消息信息。

log.retention.hours

日志存储时间,一旦日志数据超过改时间,系统会自动删除过期日志。

zookeeper.connect

zookeeper链接参数信息,用于保存Kafka元数据信息。

启动服务(每个机器只能单独启动,没有集群启动命令)

cd /usr/kafka_2.11-2.2.0/
#以下两个命令都是启动kafka,如果不加-daemon,kafka是前台进程,窗口不可操作且不可关闭,很不方便
./bin/kafka-server-start.sh config/server.properties
./bin/kafka-server-start.sh -daemon config/server.properties
#jps   #使用jps查看kafka进程
5507 Kafka

关闭服务
kafka-server-stop.sh自带的服务脚本中存在的一些问题,用户需要修改该脚本文件,具体修改内容如下所示:
[root@CentOSX kafka_2.11-2.2.0]# vi bin/kafka-server-stop.sh

SIGNAL=${SIGNAL:-TERM}
PIDS=$(jps | grep  Kafka | awk '{print $1}')

if [ -z "$PIDS" ]; then
echo "No kafka server to stop"
exit 1
else
kill -s $SIGNAL $PIDS
fi

Kafka自带脚本中PIDS参数获取存在问题,导致每次获取的PIDS都是空信息。

验证是否启动成功

  • 创建Tocpic
[root@CentOSA kafka_2.11-2.2.0]#./bin/kafka-topics.sh
--zookeeper CentOSA:2181,CentOSB:2181,CentOSC:2181 --create --topic topic01 --partitions 3 --replication-factor 3

partitions:日志分区数;replication-factor:分区副本因子

  • 消费者
[root@CentOSA kafka_2.11-2.2.0]#./bin/kafka-console-consumer.sh 
--bootstrap-server CentOSA:9092,CentOSB:9092,CentOSC:9092 
--topic topic01

//--from-beginning:会把主题中以往所有的数据都读取出来。
[root@CentOSA kafka_2.11-2.2.0]#./bin/kafka-console-consumer.sh 
--bootstrap-server CentOSA:9092,CentOSB:9092,CentOSC:9092 
--from-beginning  --topic topic01
  • 生产者
[root@CentOSB kafka_2.11-2.2.0]#./bin/kafka-console-producer.sh 
--broker-list CentOSA:9092,CentOSB:9092,CentOSC:9092 
--topic topic01
> hello kafka

观察CentOSA控制台输出,如果有hello kafka说明安装成功!

Topic管理篇(DDL)

  • 创建Tocpic
[root@CentOSA kafka_2.11-2.2.0]# ./bin/kafka-topics.sh
--zookeeper CentOSA:2181,CentOSB:2181,CentOSC:2181
--create
--topic topic01
--partitions 3
--replication-factor 3

//此时配置文件夹log.dirs目录中会有topic01的信息,如下图:
  • Topic详细信息
./bin/kafka-topics.sh  --describe  --zookeeper CentOSA:2181,CentOSB:2181,CentOSC:2181  --topic topic01
  • 删除Topic
./bin/kafka-topics.sh 
--zookeeper CentOSA:2181,CentOSB:2181,CentOSC:2181  --delete  --topic topic01

如果用户没有配置delete.topic.enable=true,则Topic删除不起作用。

  • Topic列表
./bin/kafka-topics.sh  --zookeeper CentOSA:2181,CentOSB:2181,CentOSC:2181  --list

Kafka 架构

集群模式

kafka 消息队列 定时任务 kafka做消息队列_kafka

Producer :消息生产者,就是向kafka broker发消息的客户端;

Consumer :消息消费者,从kafka broker取消息的客户端;

Consumer Group (CG):消费者组,由多个consumer组成。消费者组内每个消费者负责消费不同分区的数据,一个分区只能由一个组内消费者消费;消费者组之间互不影响。所有的消费者都属于某个消费者组,即消费者组是逻辑上的一个订阅者。

Broker :一台kafka服务器就是一个broker。一个集群由多个broker组成。一个broker可以容纳多个topic。

Topic :可以理解为一个队列,Kafka集群以Topic形式负责管理集群中的Record,每一个Record属于一个Topic。

Partition:分区,为了实现扩展性,可以提高并发,一个非常大的topic可以分布到多个broker(即服务器)上。

Replica:副本,为保证集群中的某个节点发生故障时,该节点上的partition数据不丢失,且kafka仍然能够继续工作,kafka提供了副本机制,一个topic的每个分区都有若干个副本,一个leader和若干个follower

leader:每个分区多个副本的“主”,生产者发送数据的对象,以及消费者消费数据的对象都是leader。Leader负责对应分区的读写操作。
follower:每个分区多个副本中的“从”,实时从leader中同步数据,保持和leader数据的同步。leader 发生故障时,某个 follower 会成为新的 leader。分区的Leader的选举是通过Zookeeper一些特性实现的,这里就不在概述了。Follower负责数据备份操作。

Topic的分区,可增不可减; 增:kafka获取当前可用分区投入使用; 减:已存在分区的数据没办法处理

日志&分区

Kafka集群是通过日志形式存储Topic中的Record,Record会根据分区策略计算得到的分区数存储到相应分区的文件中。每个分区都是一个有序的,不可变的记录序列,不断附加到结构化的commit-log中。每个分区文件会为Record进去分区的顺序进行编排。每一个分区中的Record都有一个id,该id标示了该record进入分区的先后顺序,通常将该id称为record在分区中的offset偏移量从0开始,依次递增。

kafka 消息队列 定时任务 kafka做消息队列_kafka_02


Kafka集群持久地保留所有已发布的记录 - 无论它们是否已被消耗 - 使用可配置的保留时间。例如,如果保留策略设置为2天,则在发布记录后的2天内,它可供使用,之后将被丢弃以释放空间。Kafka的性能在数据大小方面实际上是恒定的,因此长时间存储数据不是问题。

kafka 消息队列 定时任务 kafka做消息队列_数据_03


由于生产者生产的消息会不断追加到 log 文件末尾,为防止 log 文件过大导致数据定位效率低下,Kafka 采取了分片和索引机制,将每个 partition 分为多个 segment。每个 segment对应两个文件:“.index”文件和“.log”文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic 名称+分区序号。例如,topic01 这个 topic 有三个分区,则其对应的文件夹为 topic01-0,topic01-1,topic01-2。

kafka 消息队列 定时任务 kafka做消息队列_kafka 消息队列 定时任务_04


index和log文件以当前segment的第一条消息的offset命名。下图为index文件和log文件的结构示意图。

kafka 消息队列 定时任务 kafka做消息队列_big data_05


“.index”文件存储大量的索引信息,“.log”文件存储大量的数据,索引文件中的元数据指向对应数据文件中message的物理偏移地址。

分区数目决定系统对外的吞吐能力,分区数目越大吞吐性能越好。通常来说队列一定保证FIFO,但是由于Kafka采取了hash(key)%分区数的分区策略将数据发送到对应的分区中,因此Kafka的Topic只能保证分区内部数据遵循FIFO策略。

Kafka生产者

生产者负责发送Record到Kafka集群中的Topic中。在发布消息的时候,首先先计算Record分区计算方案有三种:
1. 如果指定了分区信息,就按照指定的分区信息选择对应的分区;2. 如果用户没有指定分区但是指定了key信息,生产者会根据hash(key)%分区数计算该Record所属分区信息3. 如果生产者在发送消息的时候并没有key,也没有指定分区数,生产者会使用轮询策略选择分区信息。 当分区参数确定以后生产者会找到相应分区的Leader节点将Record记录写入到Topic日志存储分区中。

数据可靠性保证(ack)

为保证生产者发送的数据,能可靠的发送到指定的topic,topic的每个partition收到生产者发送的数后,都需要向生产者发送ack(acknowledgement 确认收到),如果生产者收到ack,就会进行下一轮的发送,否则重新发送数据。

kafka 消息队列 定时任务 kafka做消息队列_big data_06

方案

优点

缺点

半数以上完成同步,就发送 ack

延迟低

选举新的leader时,容忍n台节点的故障,需要2n+1个副本

全部完成同步,才发送ack

选举新的leader时,容忍n台节点的故障,需要n+1个副本

延迟高

Kafka 选择了第二种方案,原因如下:

  1. 同样为了容忍n台节点的故障,第一种方案需要2n+1个副本,而第二种方案只需要n+1个副本,而 Kafka的每个分区都有大量的数据,第一种方案会造成大量数据的冗余。
  2. 虽然第二种方案的网络延迟会比较高,但网络延迟对Kafka的影响较小。
ISR(In-Sync Replicas)

采用第二种方案之后,设想以下情景:leader收到数据,所有follower都开始同步数据,但有一个follower,因为某种故障,迟迟不能与 leader 进行同步,那leader就要一直等下去,直到它完成同步,才能发送ack。这个问题怎么解决呢?
Leader维护了一个动态的 in-sync replica set (ISR),意为和leader保持同步的follower集合。当ISR中的follower完成数据的同步之后,leader就会给producer发送ack。如果follower长时间未向leader同步数据 ,则该follower将被踢出ISR ,该时间阈值由replica.lag.time.max.ms 参数设定。Leader发生故障之后,就会从ISR中选举新的leader。

0.9.0.0版本之前是依据两个配置将follower移除ISR的
//基于时间间隔
replica.lag.time.max.ms		//默认10000,即10秒;
//基于消息条数
replica.lag.max.messages 	//允许follower副本落后leader副本的消息数量,超过这个数量后,follower会被踢出 ISR;

0.9.0.0版本之后,移除了replica.lag.max.messages 配置
因为producer是可以批量发送消息的,很容易超过replica.lag.max.messages,
那么被踢出ISR的follower就是受了无妄之灾。
例如:设置相差消息条数为100,但批量发送为120条,那么就会把所有follower提出ISR
replica.lag.time.max.ms的误区

【只要在 replica.lag.time.max.ms 时间内 follower 有同步消息,即认为该 follower 处于 ISR 中】
网上看博客,很多博客表达的就是以上这句话这个意思,不过实际不是这个意思
如果leader副本的消息流入速度大于follower副本的拉取速度时,你follower就是实时同步有什么用?(理解为蓄水池一个放水一个注水的问题,leader注水速度大于follower放水速度)
典型的出工不出力,消息只会越差越多,这种follower肯定是要被踢出ISR的。
当follower副本将leader副本的LEO之前的日志全部同步时,则认为该follower副本已经追赶上leader副本。所以replica.lag.time.max.ms的正确理解是:follower在过去的replica.lag.time.max.ms时间内,已经追赶上leader一次了。 官网解释:
Replicas that are still fetching messages from leaders but did not catch up to the latest messages in replica.lag.time.max.ms will be considered out of sync. (副本仍然从leader获取消息,但在replica.lag.time.max.ms中没有赶上最新消息将被认为是不同步的。)

OSR(Out-Sync Replicas):

不能和leader保持同步的follower集合;follower重新追上了leader,就会回到ISR中。

ack应答机制
对于某些不太重要的数据,对数据的可靠性要求不是很高,能够容忍数据的少量丢失,所以没必要等ISR中的follower全部接收成功。所以 Kafka 为用户提供了三种可靠性级别:

acks = 0:producer不等待broker的ack,这一操作提供了一个最低的延迟,broker一接收到还没有写入磁盘就已经返回,当 broker 故障时有可能丢失数据;
acks = 1:producer等待broker的ack,partition的leader落盘成功后返回ack,如果在follower同步成功之前leader故障,那么将会丢失数据; acks = -1(all):producer等待broker的ack,partition的leader和follower全部落盘成功后才返回ack。但是如果在follower同步完成后,broker发送ack之前,leader发生故障,那么会造成数据重复。

kafka 消息队列 定时任务 kafka做消息队列_kafka_07

故障处理细节(保证消费数据的一致性)

kafka 消息队列 定时任务 kafka做消息队列_apache_08

LEO:指的是每个副本最大的 offset;
HW:指的是消费者能见到的最大的 offset,ISR队列中最小的LEO。

  1. follower 故障
    follower发生故障后会被临时踢出ISR,待该follower恢复后,follower会读取本地磁盘记录的上次的HW,并将log文件高于HW的部分截取掉,从HW开始向leader进行同步。等该follower的LEO大于等于该 Partition的HW,即follower追上leader之后,就可以重新加入ISR了。
  2. leader 故障
    leader 发生故障之后,会从ISR中选出一个新的leader,之后,为保证多个副本之间的数据一致性,其余的follower会先将各自的log文件高于HW的部分截掉,然后从新的leader同步数据。

注意:这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。

Exactly Once语义

将服务器的ack级别设置为-1(all),可以保证Producer到Server(Topic)之间不会丢失数据,即At Least Once语义(至少发送一次)。相对的,将服务器的ack设置为0,可以保证生产者每条消息最多被发送一次,即At Most Once(最多发送一次)At Least Once可以保证数据不丢失,但不能保证数据不重复;同样,At Most Once可以保证数据不重复,但不保证数据不丢失。但对一些非常重要的信息,比如交易数据,下游数据消费者要求数据既不重复也不丢失,即Exactly Once语义。在0.11版本以前的Kafka,对此是无能为力的,只能保证数据不丢失,在下游消费者对数据做全局去重,对于多个下游应用的情况(多个消费组),每个都需要单独做全局去重,这就对性能造成了很大影响。
0.11版本的Kafka,引入了一项重大特性:幂等性。所谓幂等性就是指Producer无论向Server(Topic)发送多少次重复数据,Server端都只会持久化一条,幂等性结合At Least Once语义,就构成了Kafka的Exactly Once语义:At Least Once+幂等性=Exactly Once 启用幂等性,需要将Producer端参数 enable.idempotence = true。Kafka的幂等性实现其实就是将原来下游需要做的去重放在了数据上游,开启幂等性的Producer在初始化时被分配一个PID,发往同一个Partition的消息会附带Sequence Number,而Broker端会对<PID,Partition -> Sequence Number>做缓存,当具有相同主键的消息提交时,Broker只会持久化一条

Kafka内部会自动为每个Producer分配一个producer id(PID),broker端会为producer每个Partition维护一个
<PID,Partition> -> sequence number映射。sequence number时从0开始单调递增的。

对于新接受到的消息,broker端会进行如下判断:

1.如果新消息的sequence number正好是broker端维护的<PID,Partition> -> sequence number大1,说broker会接受处理这条消息。
2.如果新消息的sequence number比broker端维护的sequence number要小,说明重复消息,broker可以将其直接丢弃
3.如果新消息的sequence number比broker端维护的sequence number要大过1,说明中间存在了丢数据的情况,那么会响应该情况,
对应的Producer会抛出OutOfOrderSequenceException。

但是PID在Producer重启后就会变,同时不同的Partition也具有不同主键,所以幂等性无法保证跨分区跨会话的Exactly Once。

Kafka消费者

consumer采用pull(拉)模式从broker中读取数据

  • push(推)模式很难适应消费者速率不同的消费者,因为消息发送速率是由broker决定的
  • poll(拉)模式不足之处:如果kafka没有数据,消费者可能会陷入循环中,一直返回空数据。针对这一点,Kafka的消费者在消费数据时会传入一个时长timeout,如果当前没有数据可供消费,consumer会等待一段时间之后再返回,这段时长即为timeout。
分区分配策略

一个consumer group中有多个consumer,一个topic有多个partition,所以必然会涉及到partition的分配问题,即确定哪个partition由哪个consumer消费
Kafka有两种分配策略:RoundRobin(轮询)、Range(随机)

RoundRobin

将消费组内订阅的所有Topic的分区及所有消费者进行排序后尽量均衡的分配

  1. 如果消费组内,消费者订阅的Topic列表是相同的(每个消费者都订阅了相同的Topic),那么分配结果是尽量均衡的(消费者之间分配到的分区数的差值不会超过1)
  2. 如果订阅的Topic列表是不同的,那么分配结果是不保证“尽量均衡”的,因为某些消费者不参与一些Topic的分配

比如TopicA和B,现在有a1,a2,a3,b1,b2,b3,b4共7个分区,消费组中有两个消费者C1,C2;那么可以理解为A和B的分区先加再进行轮询分配;好处:消费者之间最多只相差一个分区的数据

kafka 消息队列 定时任务 kafka做消息队列_big data_09

kafka 消息队列 定时任务 kafka做消息队列_apache_10


如果消费组中消费者订阅的Topic不一样,那么可能会出现某个消费者消费的分区偏多;如下:

kafka 消息队列 定时任务 kafka做消息队列_数据_11

造成的原因:消费组中订阅的Topic不一样

Range(默认使用规则)

对于每一个Topic,首先对分区按照分区ID进行排序,然后订阅这个Topic的消费组的消费者再进行排序,之后尽量均衡的将分区分配给消费者;通过 partitions数/consumer数 来决定每个消费者应该消费几个分区。如果除不尽,那么前面几个消费者将会多消费 1 个分区。

1个Topic7个分区/3个消费者,不能平均分,那就按范围分为3-2-2;如下:

kafka 消息队列 定时任务 kafka做消息队列_kafka_12

2个Topic和2个消费者,先把TopicA的3个分区/2,无法均分,按范围分为2-1;同理TopicB一样分为2-1;如下:

kafka 消息队列 定时任务 kafka做消息队列_kafka 消息队列 定时任务_13

不同的消费策略都可能出现问题,根据实际业务情况选择对应的消费策略

Rebalance

当有新的消费者加入消费者组、已有的消费者退出消费者组或者所订阅的主题的分区发生变化,就会触发到分区的重新分配,重新分配的过程叫做 Rebalance。

offset 的维护

事实上,基于每个消费者保留的唯一元数据是该消费者在日志中的偏移或位置。这种offset由消费者控制:通常消费者在读取记录时会线性地增加其偏移量,但事实上,由于消费者控制位置,它可以按照自己喜欢的任何顺序消费记录。例如,消费者可以重置为较旧的偏移量以重新处理过去的数据,或者跳到最近的记录并从“现在”开始消费。比如:由于 consumer 在消费过程中可能会出现断电宕机等故障,consumer 恢复后,需要从故障前的位置的继续消费,所以 consumer 需要实时记录自己消费到了哪个 offset,以便故障恢复后继续消费

Kafka 0.9 版本之前,consumer 默认将 offset 保存在 Zookeeper 中,从 0.9 版本开始,consumer 默认将 offset 保存在Kafka一个内置的topic中,该topic 为__consumer_offsets

kafka 消息队列 定时任务 kafka做消息队列_apache_14


注意,kafka生产者偏移量offset是从零开始的;消费者消费数据保存的偏移量是从1开始的,所以消费者提交消费位移时提交的是当前消费到的最新消息的offset+1

Kafka生产者和消费者总结

kafka 消息队列 定时任务 kafka做消息队列_kafka_15

Kafka事务

Kafka从0.11版本开始引入了事务支持。事务可以保证kafka在Exactly Once语义的基础上,生产和消费可以跨分区和会话,要么全部成功,要么全部失败。

生产者事务

为了实现跨分区跨会话的事务,需要引入一个全局唯一的 Transaction ID,并将 Producer获得的PID 和Transaction ID 绑定。这样当Producer 重启后就可以通过正在进行的 Transaction ID 获得原来的 PID。

为了管理 Transaction,Kafka 引入了一个新的组件 Transaction Coordinator。Producer 就是通过和 Transaction Coordinator 交互获得 Transaction ID 对应的任务状态。Transaction Coordinator 还负责将所有事务写入 Kafka 的一个内部 Topic,这样即使整个服务重启,由于事务状态得到保存,进行中的事务状态可以得到恢复,从而继续进行。

消费者事务

上述事务机制主要是从 Producer 方面考虑,对于 Consumer 而言,事务的保证就会相对较弱,尤其是无法保证 Commit 的信息被精确消费。这是由于 Consumer 可以通过 offset 访问任意信息,而且不同的 Segment File 生命周期不同,同一事务的消息可能会出现重启后被删除的情况。

Kafka 高效读写数据

顺序写磁盘

Kafka 的 producer 生产数据,要写入到 log 文件中,写的过程是一直追加到文件末端,为顺序写。官网有数据表明,同样的磁盘,顺序写能到 600M/s,而随机写只有 100K/s。这与磁盘的机械机构有关,顺序写之所以快,是因为其省去了大量磁头寻址的时间。

Zookeeper 在Kafka中的作用

Kafka 集群中有一个 broker 会被选举为 Controller,负责管理集群 broker 的上下线,所有 topic 的分区副本分配和 leader 选举等工作。Controller 的管理工作都是依赖于 Zookeeper 的。

kafka 消息队列 定时任务 kafka做消息队列_big data_16

Kafka API实战(JDK1.8+)

Maven依赖

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>2.2.0</version>
 </dependency>

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.25</version>
</dependency>
<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-log4j12</artifactId>
    <version>1.7.5</version>
</dependency>

引入log4j.properies

### set log levels ###
log4j.rootLogger = info,stdout 
### 输出到控制台 ###
log4j.appender.stdout = org.apache.log4j.ConsoleAppender
log4j.appender.stdout.Target = System.out
log4j.appender.stdout.layout = org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern =%p %d %c %m %n

在Windos配置主机名和IP映射关系

192.168.111.128 CentOSA
192.168.111.129 CentOSB
192.168.111.130 CentOSC

必须配置主机名和IP的映射关系,否则运行主机在连接kafka服务的时候,会抛出无法解析主机异常或者链接超时,这一点是很多初学者在使用Kafka的时候容易忽略的一点。

Topic管理

管理Topic的核心在于创建AdminClient,通过adminClient完成对Topic的基础管理

//创建AdminClient
Properties props = new Properties();
//按照上面搭建的集群,这里的链接参数为:CentOSA:9092,CentOSB:9092,CentOSC:9092
props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG,"链接参数");
AdminClient adminClient=KafkaAdminClient.create(props);

// todo your code here

//关闭连接
adminClient.close();

Topic创建

List<NewTopic> newTopics = Arrays.asList(new NewTopic("topic01", 3, (short)2));
adminClient.createTopics(newTopics);

Topic列表

ListTopicsResult topics = adminClient.listTopics();
topics.names()
.get()
.stream()
.forEach((topic)-> System.out.println(topic));

Topic详情

adminClient.describeTopics(Arrays.asList("topic01"))
			.all()
			.get()
			.entrySet()
			.stream()
			.forEach((entry)-> {
				String topic=entry.getKey();
				System.out.println(topic);
				TopicDescription descr = entry.getValue();
				List<TopicPartitionInfo> partitions = descr.partitions();
				for (TopicPartitionInfo partition : partitions) {
					System.out.println("\t"+partition);
				}
			});

Topic删除

adminClient.deleteTopics(Arrays.asList("topic01"));

删除topic中的数据,实际是删除此条数据偏移量之前的所有数据
deleteRecords()    目前使用Kafka API无法实现截断topic

集群状态

adminClient.describeCluster()
			.nodes()
			.get()
			.stream()
			.forEach((node)-> System.out.println(node) );

生产者

生产者负责产生消息,并且将生产的消息发送到kafka集群中,在Kaka集群中所有发送的消息都必须是以二进制分区日志形式存储,因此生产者在发送的数据之前需要指定数据序列化规则 生产者发送消息采用的是异步发送的方式。在消息发送的过程中,涉及到了两个线程——main 线程和 Sender 线程,以及一个线程共享变量——RecordAccumulator。main 线程将消息发送给RecordAccumulator,Sender 线程不断从 RecordAccumulator 中拉取消息发送到 Kafka broker。

kafka 消息队列 定时任务 kafka做消息队列_kafka_17

异步发送 API
不带回调函数的API(send中不带callBack参数)
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;

import java.text.DecimalFormat;
import java.util.Properties;

public class KafkaProducerDemo {
    public static void main(String[] args) {
        //1.配置生产者连接属性
        Properties props = new Properties();
        //这里props的key推荐使用ProducerConfig.属性,因为key值容易拼错,如下:
        
        //kafka 集群,broker-list
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
        //重试次数
        props.put("retries",1);
        //批次大小,关闭producer之前需要flush
		props.put("batch.size", 16384);
 		//等待时间
 		props.put("linger.ms", 1);
 		//RecordAccumulator 缓冲区大小
 		props.put("buffer.memory", 33554432);
 		//key value序列化
 		props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringSerializer");
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringSerializer");
 		
 		//开启幂等性
// 		props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG,true);
 		//所有broker备份后应答
// 		props.put(ProducerConfig.ACKS_CONFIG,"all");
 		//数据发送失败后,重试次数
//        props.put(ProducerConfig.RETRIES_CONFIG,3);
        //等待请求响应的最长时间:3s
//        props.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG,3000);


        //2.创建Kafka生产者
        KafkaProducer<String, String> producer = new KafkaProducer<String, String>(props);

        //3.构建ProducerRecord
        for (int i=0;i<10;i++){
            DecimalFormat decimalFormat = new DecimalFormat("000");
            ProducerRecord<String, String> record = new ProducerRecord<String, String>("topic04", decimalFormat.format(i), "value" + i);
            //4.发送消息
            producer.send(record);
        }
        //5.清空缓冲区
        producer.flush();
        //6.关闭生产者
        producer.close();
    }
}
带回调函数的API(send方法中带callBack参数)

回调函数会在 producer 收到 ack 时调用,为异步调用,该方法有两个参数,分别是RecordMetadata 和 Exception,如果 Exception 为 null,说明消息发送成功,如果Exception 不为 null,说明消息发送败。
注意:消息发送失败会自动重试,不需要我们在回调函数中手动重试。

for (int i=0;i<10;i++){
    DecimalFormat decimalFormat = new DecimalFormat("000");
    ProducerRecord<String, String> record = new ProducerRecord<String, String>("topic04", decimalFormat.format(i), "value" + i);
    //4.发送消息
    producer.send(record, new Callback() {
        @Override
        public void onCompletion(RecordMetadata metadata, Exception exception) {
			//exception为空,发送成功
            if (exception == null) {
                System.out.println("success->" + metadata.offset());
            } else {
            //exception不为空,发送失败,自动重新发送,不需要任何操作
                exception.printStackTrace();
            }
        }
    });
}
同步发送 API

同步发送的意思就是,一条消息发送之后,会阻塞当前线程,直至返回 ack。
由于 send 方法返回的是一个 Future 对象,根据 Futrue 对象的特点,我们也可以实现同步发送的效果,只需在调用 Future 对象的 get 方法即可(有异常,抛出或捕获根据自己业务逻辑定)。

for (int i=0;i<10;i++){
     DecimalFormat decimalFormat = new DecimalFormat("000");
     ProducerRecord<String, String> record = new ProducerRecord<String, String>("topic04", decimalFormat.format(i), "value" + i);
     //4.发送消息
     //get方法有异常,我这里是抛出了,代码没粘而已
     producer.send(record).get();
 }

生产者常见的属性配置及其含义

属性

默认值

含义

是否必须

bootstrap.servers

“”

连接kafka集群连接参数


key.serializer

null

key序列化规则


value.serializer

null

value序列化规则


acks

1

生产者要求leader在考虑完成请求之前收到的确认数量。


retries

2147483647(Integer.MAX_VALUE)

当没有在规定时间内acker,则认定发送失败,重试次数


batch.size

16384(16KB)

每个Batch要存放batch.size大小的数据后,才可以发送出去。


linger.ms

0

间隔多长时间构建一次batch发送


request.timeout.ms

30000

设置客户端最大等待超时时间


enable.idempotence

false

是否开启幂等性,可以保证生产者一个Record只发送一次给broker


buffer.memory

33554432(32M)

内存缓冲区大小(Kafka的客户端发送数据到服务器,先进入到客户端本地的内存缓冲里)


其他配置参考文档

消费者

consumer的两种订阅模式

subscribe:订阅一个或多个topic
assign:订阅topic中的一个或多个分区
注意:两者不能一起使用

subscribe使用

消费者负责消费集群中的消息,消费者消费Topic中的消息是按照group消费形式订阅的。

//1.消费者连接属性中需要配置groupID
props.put(ConsumerConfig.GROUP_ID_CONFIG,"group1");
//2.创建Kafka消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);
//3.订阅topics
consumer.subscribe(Arrays.asList("topic01"));
assign使用

消费者定于topic指定的分区

//0.无需在消费者连接属性中指定groupID
//1.创建Kafka消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);
//2.指定分区
consumer.assign(Arrays.asList(new TopicPartition("topic01",1)));
自动提交offset
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 java.time.Duration;
import java.util.Arrays;
import java.util.Properties;

public class KafkaConsumerDemo {
    public static void main(String[] args) {
        //1.配置消费者连接属性
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
        props.put(ConsumerConfig.GROUP_ID_CONFIG,"group1");
        //开启自动提交offset功能
        props.put("enable.auto.commit", "true");
        //自动提交offset的时间间隔,单位毫秒
        props.put("auto.commit.interval.ms", "1000");

        //2.创建Kafka消费者
        KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);

        //3.订阅topics
        consumer.subscribe(Arrays.asList("topic01"));
        //4.死循环读取消息
        while(true){
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
            if(records!=null && !records.isEmpty()){
                for (ConsumerRecord<String, String> record : records) {
                    int partition = record.partition();
                    long offset = record.offset();
                    long timestamp = record.timestamp();
                    String key = record.key();
                    String value = record.value();
                    System.out.println(partition+"\t"+offset+"\t"+timestamp+"\t"+key+"\t"+value);
                }
            }
        }
        //consumer.close();
    }
}
手动提交 offset

虽然自动提交offset十分简介便利,但由于其是基于时间提交的,开发人员难以把握offset提交的时机。因此Kafka还提供了手动提交offset的 API。
手动提交 offset 的方法有两种:分别是 commitSync(同步提交)和 commitAsync(异步提交)。两者的相同点是,都会将本次 poll 的一批数据最高的偏移量提交;不同点是,commitSync 阻塞当前线程,一直到提交成功,并且会自动失败重试(由不可控因素导致,也会出现提交失败);而 commitAsync 则没有失败重试机制,故有可能提交失败。

同步提交offset
//关闭自动提交offset功能
props.put("enable.auto.commit", "false");
//4.死循环读取消息
while(true){
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
    if(records!=null && !records.isEmpty()){
        for (ConsumerRecord<String, String> record : records) {
            int partition = record.partition();
            long offset = record.offset();
            long timestamp = record.timestamp();
            String key = record.key();
            String value = record.value();
            System.out.println(partition+"\t"+offset+"\t"+timestamp+"\t"+key+"\t"+value);
            //手动同步提交,当前线程会阻塞直到offset提交成功
            //commitSync有重载方法,可自己构建提交的偏移量,这里不带参数,使用的系统默认生成的偏移量
            consumer.commitSync();
            //以下是重载方法
            /*
            commitSync()
            commitSync(Duration timeout)
            commitSync(final Map<TopicPartition, OffsetAndMetadata> offsets)
            commitSync(final Map<TopicPartition, OffsetAndMetadata> offsets, final Duration timeout)
             */    
        }
    }
}
异步提交offset

虽然同步提交 offset 更可靠一些,但是由于其会阻塞当前线程,直到提交成功。因此吞吐量会收到很大的影响。因此更多的情况下,会选用异步提交 offset 的方式。

//关闭自动提交offset
props.put("enable.auto.commit", "false");

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
    if (records != null && !records.isEmpty()) {
        for (ConsumerRecord<String, String> record : records) {
            int partition = record.partition();
            long offset = record.offset();
            long timestamp = record.timestamp();
            String key = record.key();
            String value = record.value();
            System.out.println(partition + "\t" + offset + "\t" + timestamp + "\t" + key + "\t" + value);
            //异步提交,commitAsync也有重载方法
            consumer.commitAsync(new OffsetCommitCallback() {
                @Override
                public void onComplete(Map<TopicPartition,
                        OffsetAndMetadata> offsets, Exception exception) {
                    if (exception != null) {
                    	//返回值不为空,说明提交失败
                        System.err.println("Commit failed for" + offsets);
                    }
                }
            });
        }
    }
}
/*
commitAsync()
commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback)
commitAsync(OffsetCommitCallback callback)
*/

消费者常见的属性配置及其含义

属性

默认值

含义

是否必须

bootstrap.servers

“”

连接kafka服务器参数


key.deserializer

null

key反序列化


value.deserializer

null

value反序列化


group.id

“”

如果是订阅方式,必须指定组id


enable.auto.commit

true

offset自动提交


auto.commit.interval.ms

5000(毫秒)

自动提交offset的时间间隔


其他配置参考文档

读取数据偏移量控制

auto.offset.reset: 可理解为kafka consumer读取数据的策略
auto.offset.reset = [latest, earliest, none]
earliest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
latest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据 none:topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常

默认当用户使用subscribe方式订阅topic消息, 默认首次offset策略是latest。当用户第一次订阅topic在消费者订阅之前的数据是无法消费到消息的(比如:先生产10条数据,然后再订阅)。用户可以配置消费端参数auto.offset.reset控制kafka消费者行为。

Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.GROUP_ID_CONFIG,"group1");

//默认值 latest  使用earliest可能导致数据重复消费
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,"earliest");

指定某个分区消费开始偏移量

我们知道消息的拉取是根据poll()方法中的逻辑来处理的,这个poll()方法中的逻辑对于普通的开发人员而言是一个黑盒,无法精确地掌控其消费的起始位置。提供的auto.offset.reset 参数也只能在找不到消费位移或位移越界的情况下粗粒度地从开头或末尾开始消费。有些时候,我们需要一种更细粒度的掌控,可以让我们从特定的位移处开始拉取消息,而KafkaConsumer 中的seek()方法正好提供了这个功能,让我们得以追前消费或回溯消费。seek()方法的具体定义如下:

public void seek(TopicPartition partition , long offset)
seek()方法中的参数partition 表示分区,而offset参数用来指定从分区的哪个位置开始消费。seek()方法只能重置消费者分配到的分区的消费位置,而分区的分配是在poll()方法的调用过程中实现的。也就是说,在执行seek()方法之前需要先执行一次poll()方法, 等到分配到分区之后才可以重置消费位置如果用subscribe订阅的话就需要poll一次,如果用assign()手动订阅分区就不需要poll一次。

subscribe
//1.配置生产者连接属性
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.GROUP_ID_CONFIG,"group1");

//2.创建Kafka消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);

//首先poll一次,给消费者分配消费的分区
consumer.poll(Duration.ofSeconds(1));
Set<TopicPartition> assignment = consumer.assignment();
//每个分区都从offset = 10开始消费
for (TopicPartition topicPartition : assignment) {
    consumer.seek(topicPartition,10);
}
while (true){
    //重新拉取数据,从每个分区offset=10的位置开始
    ConsumerRecords<String, String> records  = consumer.poll(Duration.ofSeconds(1));
    if(records!=null && !records.isEmpty()){
        for (ConsumerRecord<String, String> record : records) {
            int partition = record.partition();
            long offset = record.offset();
            long timestamp = record.timestamp();
            String key = record.key();
            String value = record.value();
            System.out.println(partition+"\t"+offset+"\t"+timestamp+"\t"+key+"\t"+value);
        }
    }
}
assign

因为已经指定了消费的分区,所以不用先poll一次,可以直接指定消费开始位置

public class KafkaConsumerDemo {
    public static void main(String[] args) {
        //1.配置生产者连接属性
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
        
        //2.创建Kafka消费者
        KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);

        //3.指定分区:topic01的1分区
        consumer.assign(Arrays.asList(new TopicPartition("topic01",1)));
        //指定开始偏移量:从offset =3 的位置消费topic01的1分区
        consumer.seek(new TopicPartition("topic01",1),3);
        //4.死循环读取消息
        while(true){
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
            if(records!=null && !records.isEmpty()){
                for (ConsumerRecord<String, String> record : records) {
                    int partition = record.partition();
                    long offset = record.offset();
                    long timestamp = record.timestamp();
                    String key = record.key();
                    String value = record.value();
                    System.out.println(partition+"\t"+offset+"\t"+timestamp+"\t"+key+"\t"+value);
                }
            }
        }
    }
}

三种消费模式

从由kafka中读出指令到成功完成执行指令整个消息消费过程可能经历多个步骤。每个步骤都可能有失败的可能,从而中断过程影响数据消费结果。保存offset即offset-commit的时间点代表了三种消费模式的特性:

1、至多一次at-most-once:读出数据立即commit-offset,然后才开始消费数据。无论消费过程中发生异常与否,下次都会从新的位置开始读取,过去不再。如果一条数据在消费过程中发生事故中断了过程,那这条数据就没有发生应有的作用,就等于遗失了。
2、至少一次at-least-once:读出数据、消费数据、然后才commit-offset。如果消费过程出现问题中断,那么offset就得不到保存,下次再读取时还是从原先位置重新开始。所以,一条数据有可能被多次读取,造成重复消费的效果。
3、只此一次exactly-once把保存offse和消费过程放到同一个事务transaction里。这种模式需要数据库事物处理支持,也就是说offset-commit和数据处理都必须在同一种提供事物处理支持的数据库环境里进行。offset-commit只会在确保消费过程成功完成后才进行。
at-most-once和at-least-once都使用kafka内部commit机制保存offset。at-least-once可以利用kafka的自动commit机制实现offset保存,只要通过kafka配置就可以了。exactly-once参考下面的事务

Kafka事务

kafka 消息队列 定时任务 kafka做消息队列_big data_18


Kafka 的事务一共有如下 5 个 API

// 1 初始化事务
void initTransactions();
// 2 开启事务
void beginTransaction() throws ProducerFencedException;
// 3 在事务内提交已经消费的偏移量(主要用于消费者)
void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets,String consumerGroupId) throws ProducerFencedException
// 4 提交事务
void commitTransaction() throws ProducerFencedException;
// 5 放弃事务(类似于回滚事务的操作)
void abortTransaction() throws ProducerFencedExcepti

生产者事务

kafka生产者事务指的是在发送多个数据的时候,保证多个Record记录发送的原子性。如果有一条发送失败就回退,但是需要注意在使用kafka事务的时候需要调整消费者的事务隔离级别设置为read_committed(读已提交),因为kafka默认的事务隔离策略是read_uncommitted(读未提交)

transactional.id=transaction-1 //必须保证唯一
enable.idempotence=true //开启kafka的幂等性
public class KafkaProducerDemo {
    public static void main(String[] args) {

        //1.创建Kafka生产者
        KafkaProducer<String, String> producer = buildKafkaProducer();

        //2.初始化事务
        producer.initTransactions();
        //3.开启事务
        producer.beginTransaction();
        try {
            for (int i=5;i<10;i++){
                DecimalFormat decimalFormat = new DecimalFormat("000");
                ProducerRecord<String, String> record = new ProducerRecord<String, String>("topic07", decimalFormat.format(i), "user"+i);
                //发送数据
                producer.send(record);
            }
            producer.flush();
            //4.提交事务
            producer.commitTransaction();
        } catch (Exception e) {
            System.err.println(e.getMessage());
            //5.终止事务
            producer.abortTransaction();
        }
        //6.关闭生产者
        producer.close();
    }

    private static KafkaProducer<String, String> buildKafkaProducer() {
        //0.配置生产者连接属性
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringSerializer");
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringSerializer");

        props.put(ProducerConfig.ACKS_CONFIG,"all");
        props.put(ProducerConfig.RETRIES_CONFIG,3);
        props.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG,3000);
        props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG,true);

        props.put(ProducerConfig.BATCH_SIZE_CONFIG,1024);//1kb缓冲区
        props.put(ProducerConfig.LINGER_MS_CONFIG,1000);//设置逗留时常

        //开启事务
        props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG,"transaction-"+UUID.randomUUID().toString());
        return new KafkaProducer<String, String>(props);
    }
}

消费者事务隔离级别设置为isolation.level = read_committed

public class KafkaConsumerDemo {
    public static void main(String[] args) {

        //1.创建Kafka消费者
        KafkaConsumer<String, String> consumer = buildKafkaConsumer();

        //2.订阅topics
        consumer.subscribe(Arrays.asList("topic07"));
        //3.死循环读取消息
        while(true){
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
            if(records!=null && !records.isEmpty()){
                for (ConsumerRecord<String, String> record : records) {
                    int partition = record.partition();
                    long offset = record.offset();
                    long timestamp = record.timestamp();
                    String key = record.key();
                    String value = record.value();
                    System.out.println(partition+"\t"+offset+"\t"+timestamp+"\t"+key+"\t"+value);
                }
            }
        }
    }

    private static KafkaConsumer<String, String> buildKafkaConsumer() {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
        props.put(ConsumerConfig.GROUP_ID_CONFIG,"group1");
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,"earliest");
        props.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG,"read_committed");//消费者设置事务隔离级别
        return new KafkaConsumer<String, String>(props);
    }
}

Kafka发送/接收Object

生产Object:实现Serializer接口

public interface Serializer<T> extends Closeable {
   
    void configure(Map<String, ?> configs, boolean isKey);
    //重点实现serialize
    byte[] serialize(String topic, T data);
    default byte[] serialize(String topic, Headers headers, T data) {
        return serialize(topic, data);
    }
    @Override
    void close();
}

消费Object:实现Deserializer接口

public interface Deserializer<T> extends Closeable {

    void configure(Map<String, ?> configs, boolean isKey);
    //重点实现方法
    T deserialize(String topic, byte[] data);
    default T deserialize(String topic, Headers headers, byte[] data) {
        return deserialize(topic, data);
    }
    @Override
    void close();
}

实现序列化和反序列化

public class ObjectCodec implements Deserializer<Object>, Serializer<Object> {
    @Override
    public void configure(Map<String, ?> configs, boolean isKey) {
        
    }

    @Override
    public byte[] serialize(String topic, Object data) {
        return SerializationUtils.serialize((Serializable) data);
    }

    @Override
    public Object deserialize(String topic, byte[] data) {
        return SerializationUtils.deserialize(data);
    }

    @Override
    public void close() {

    }
}
//生产者:
如果把自定义对象作为key,那就修改生产者连接参数的key;作为value,修改连接参数的value
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,"包的路径");
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,"包的路径");
//消费者:解析时一样,修改对应的连接参数

ConsumerRebalanceListener再均衡监听器

当有新的消费者加入消费者组、已有的消费者退出消费者组或者所订阅的主题的分区发生变化,就会触发到分区的重新分配,重新分配的过程叫做 Rebalance。
消费者发生 Rebalance 之后,每个消费者消费的分区就会发生变化。因此消费者要首先获取到自己被重新分配到的分区,并且定位到每个分区最近提交的 offset 位置继续消费。需要借助 ConsumerRebalanceListener

ConsumerRebalanceListener再均衡监听器提供两个方法:
onPartitionsRevoked(Collection) 在均衡开始之前和消费者停止读取消息之后调用,一般用来提交偏移量onPartitionsAssigned(Collection) 在重新分配分区之后和消费者开始读取消息之前调用,一般用来指定消费偏移量

简单消费者–订阅主题带再均衡处理器

Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.GROUP_ID_CONFIG,"group1");

//1.创建Kafka消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
//2.消费者发生平衡操作时回调进行相应的业务处理
consumer.subscribe(Arrays.asList("topic01"),
         new ConsumerRebalanceListener() {
             // 在均衡开始之前和消费者停止读取消息之后调用
             @Override
             public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
                 // 提交偏移量(使用无参方法,下面一个案例使用自定义偏移量方法)
                 consumer.commitSync();
             }

             // 在重新分配分区之后和消费者开始读取消息之前调用
             @Override
             public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
                 long committedOffset = -1;
                 for (TopicPartition topicPartition : partitions) {
                     // 获取该分区已消费的位移
                     committedOffset = consumer.committed(topicPartition).offset();
                     // 重置位移到上一次提交的位移处开始消费
                     consumer.seek(topicPartition, committedOffset + 1);
                 }
             }
         });
//3.继续死循环读取消息
while(true){
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
    if(records!=null && !records.isEmpty()){
        for (ConsumerRecord<String, String> record : records) {
            int partition = record.partition();
            long offset = record.offset();
            long timestamp = record.timestamp();
            String key = record.key();
            String value = record.value();
            System.out.println(partition+"\t"+offset+"\t"+timestamp+"\t"+key+"\t"+value);
        }
    }
}
//consumer.close();

提交指定偏移量 和 再均衡处理器实现

Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"CentOSA:9092,CentOSB:9092,CentOSC:9092");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.GROUP_ID_CONFIG,"group1");

//1.创建Kafka消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
//2.创建要提交的偏移量数据(参考上面的手动提交offset)
Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
// 消费者发生平衡操作时回调进行相应的业务处理
consumer.subscribe(Arrays.asList("my-topic"),
         new ConsumerRebalanceListener() {
             // 在均衡开始之前和消费者停止读取消息之后调用
             @Override
             public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
                 System.out.println("Lost partitions in rebalance. committing current offsets:" + currentOffsets);
                 // 提交偏移量(使用自定义的偏移量数据)
                 consumer.commitSync(currentOffsets);
             }

             // 在重新分配分区之后和消费者开始读取消息之前调用
             @Override
             public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
                 // Do nothing, 也可以如下指定消费偏移量
//                        long committedOffset = -1;
//                        for (TopicPartition topicPartition : partitions) {
//                            // 获取该分区已消费的位移
//                            committedOffset = consumer.committed(topicPartition).offset();
//                            // 重置位移到上一次提交的位移处开始消费
//                            consumer.seek(topicPartition, committedOffset + 1);
//                        }
             }
         });
while(true){
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
    if(records!=null && !records.isEmpty()){
        for (ConsumerRecord<String, String> record : records) {
            int partition = record.partition();
            long offset = record.offset();
            long timestamp = record.timestamp();
            String key = record.key();
            String value = record.value();
            System.out.println(partition+"\t"+offset+"\t"+timestamp+"\t"+key+"\t"+value);
            //向自定义偏移量集合中添加偏移量数据
            currentOffsets.put(new TopicPartition(record.topic(), record.partition()),
                            new OffsetAndMetadata(record.offset() + 1, "no metadata")
        }
        // 手动异步提交指定偏移量
        consumer.commitAsync(currentOffsets, null);
    }
}
//consumer.close();

SpringBoot整合Kafka

  • pom.xml
<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <java.version>1.8</java.version>
    <kafka.version>2.2.0</kafka.version>
</properties>

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.5.RELEASE</version>
</parent>

<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
        <version>2.2.5.RELEASE</version>
    </dependency>
    <!-- kafka client处理 -->
    <dependency>
        <groupId>org.apache.kafka</groupId>
        <artifactId>kafka-clients</artifactId>
        <version>${kafka.version}</version>
    </dependency>
</dependencies>
  • application.properties
server.port=8888

# 生产者
spring.kafka.producer.bootstrap-servers=CentOSA:9092,CentOSB:9092,CentOSC:9092
spring.kafka.producer.acks=all
spring.kafka.producer.retries=1
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer

# 消费者
spring.kafka.consumer.bootstrap-servers=CentOSA:9092,CentOSB:9092,CentOSC:9092
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer
  • 代码
@SpringBootApplication
@EnableScheduling
public class KafkaApplicationDemo {
    @Autowired
    private KafkaTemplate kafkaTemplate;

    public static void main(String[] args) {
        SpringApplication.run(KafkaApplicationDemo.class,args);
    }
    @Scheduled(cron = "0/1 * * * * ?")
    public void send(){
        String[] message=new String[]{"this is a demo","hello world","hello boy"};
        ListenableFuture future = kafkaTemplate.send("topic07", message[new Random().nextInt(message.length)]);
        future.addCallback(o -> System.out.println("send-消息发送成功:" + message), throwable -> System.out.println("消息发送失败:" + message));
    }

    @KafkaListener(topics = "topic07",id="g1")
    public void processMessage(ConsumerRecord<?, ?> record) {
        System.out.println("record:"+record);
    }
}