【Kafka原理你真懂了吗?】四万字Kafka教程

概述

Kafka 是一个分布式的,支持多分区、多副本,基于 Zookeeper 的分布式消息流平台,它同时也是一款开源的基于发布订阅模式的消息引擎系统

使用场景

  • 活动跟踪:Kafka 可以用来跟踪用户行为,比如我们经常回去淘宝购物,你打开淘宝的那一刻,你的登陆信息,登陆次数都会作为消息传输到 Kafka ,当你浏览购物的时候,你的浏览信息,你的搜索指数,你的购物爱好都会作为一个个消息传递给 Kafka ,这样就可以生成报告,可以做智能推荐,购买喜好等。
  • 传递消息:Kafka 另外一个基本用途是传递消息,应用程序向用户发送通知就是通过传递消息来实现的,这些应用组件可以生成消息,而不需要关心消息的格式,也不需要关心消息是如何发送的。
  • 度量指标:Kafka也经常用来记录运营监控数据。包括收集各种分布式应用的数据,生产各种操作的集中反馈,比如报警和报告。
  • 日志记录:Kafka 的基本概念来源于提交日志,比如我们可以把数据库的更新发送到 Kafka 上,用来记录数据库的更新时间,通过kafka以统一接口服务的方式开放给各种consumer,例如hadoop、Hbase、Solr等。
  • 流式处理:流式处理是有一个能够提供多种应用程序的领域。
  • 限流削峰:Kafka 多用于互联网领域某一时刻请求特别多的情况下,可以把请求写入Kafka 中,避免直接请求后端程序导致服务崩溃。

kafka的特性

  • 高吞吐、低延迟:kakfa 最大的特点就是收发消息非常快,kafka 每秒可以处理几十万条消息,它的最低延迟只有几毫秒。
  • 高伸缩性: 每个主题(topic) 包含多个分区(partition),主题中的分区可以分布在不同的主机(broker)中。
  • 持久性、可靠性: Kafka 能够允许数据的持久化存储,消息被持久化到磁盘,并支持数据备份防止数据丢失,Kafka 底层的数据存储是基于 Zookeeper 存储的,Zookeeper 我们知道它的数据能够持久存储。
  • 容错性: 允许集群中的节点失败,某个节点宕机,Kafka 集群能够正常工作。
  • 高并发: 支持数千个客户端同时读写。

消息队列的流派

消息队列的选型需要根据具体应用需求而定,ZeroMQ小而美,RabbitMQ大而稳,Kakfa和RocketMQ快而强劲。

image-20221008132552362

区别:

重topic:Kafka、RocketMQ、ActiveMQ整个broker,依据topic来进⾏消息的中转。在重topic的消息队列⾥必然需要topic的存在
轻topic:RabbitMQ topic只是⼀种中转模式。RabbitMQ的几种消息模型

kafka环境搭建

Docker安装Kafka

环境:CentOS7 docker

  1. 安装zookeeper

    docker pull zookeeper
    
    docker run -d --restart=always --log-driver json-file --log-opt max-size=100m --log-opt max-file=2  --name zookeeper -p 2181:2181 -v /etc/localtime:/etc/localtime zookeeper
    
    
  2. 安装Kafka

    docker run -d --restart=always --log-driver json-file --log-opt max-size=100m --log-opt max-file=2 --name kafka -p 9092:9092 
    -e KAFKA_BROKER_ID=0 
    -e KAFKA_ZOOKEEPER_CONNECT=zookeeperIP地址:2181/kafka 
    -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://外网IP地址:9092 
    -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 
    -v /etc/localtime:/etc/localtime wurstmeister/kafka
    
    参数说明:
    -e KAFKA_BROKER_ID=0  在kafka集群中,每个kafka都有一个BROKER_ID来区分自己
     
    -e KAFKA_ZOOKEEPER_CONNECT=172.21.10.10:2181/kafka 配置zookeeper管理kafka的路径172.21.10.10:2181/kafka
     
    -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://172.21.10.10:9092  把kafka的地址端口注册给zookeeper,如果是远程访问要改成外网IP,类如Java程序访问出现无法连接。
     
    -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 配置kafka的监听端口
     
    -v /etc/localtime:/etc/localtime 容器时间同步虚拟机的时间
    
  3. 测试Kafka

    进入kafka
    docker exec -it kafka bash
    
    生产者:
    ./kafka-console-producer.sh --broker-list localhost:9092 --topic test
    > hello world
    
    消费者:
    ./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning
    hello world
    
    

    生产:

    image-20220809111428679

    消费:

    image-20220809111442722

Kafka配置

#broker的全局唯一编号,不能重复
broker.id=0
#删除topic功能使能
delete.topic.enable=true
#处理网络请求的线程数量
num.network.threads=3
#用来处理磁盘IO的现成数量
num.io.threads=8
#发送套接字的缓冲区大小
socket.send.buffer.bytes=102400
#接收套接字的缓冲区大小
socket.receive.buffer.bytes=102400
#请求套接字的缓冲区大小
socket.request.max.bytes=104857600
#kafka运行日志存放的路径
log.dirs=/opt/module/kafka/logs
#topic在当前broker上的分区个数
num.partitions=1
#用来恢复和清理data下数据的线程数量
num.recovery.threads.per.data.dir=1
#segment文件保留的最长时间,超时将被删除
log.retention.hours=168
#配置连接Zookeeper集群地址
zookeeper.connect=zk01:2181,zk02:2181,zk03:2181

Kafka操作

1️⃣通过Docker进入kafka容器内部

image-20220928154108418

2️⃣ 常用操作

  1. 查看当前服务器中所有的topic

    kafka-topics.sh --zookeeper zkIP:2181/kafka --list
    
    # KAFKA_ZOOKEEPER_CONNECT如果配置了/kafka, 在命令中也要写/kafka
    
  2. 创建topic

    kafka-topics.sh --zookeeper zkIP:2181/kafka --create --replication-factor 3 --partitions 1 --topic first
    
    --topic 定义topic名
    --replication-factor  定义副本数
    --partitions  定义分区数
    
  3. 删除topic

    kafka-topics.sh --zookeeper zkIP:2181/kafka --delete --topic first
    
    #需要server.properties中设置delete.topic.enable=true否则只是标记删除。
    
  4. 发送消息

    kafka-console-producer.sh --broker-list KafkaIP:9092 --topic first
    > hello world
    > wz
    
  5. 消费消息

    kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic first --from-beginning
    
  6. 查看某个topic详情

    kafka-topics.sh --zookeeper zkIP:2181/kafka --describe --topic first
    
    Topic: first    TopicId: izkEI9-GR9uoIiHpmZSulw PartitionCount: 1       ReplicationFactor: 1    
    Configs:Topic: test     Partition: 0    Leader: 0       Replicas: 0     Isr: 0
    
  7. 修改分区数量

    kafka-topics.sh --zookeeper zkIP:2181/kafka --alter --topic first --partitions 6
    
  8. 修改某一Topic的数据留存时间

    ./kafka-configs.sh --zookeeper localhost:2181 --entity-type topics --entity-name topicName --alter --add-config log.retention.hours=120
    

线上集群部署方案:

既然是集群,那必然就要有多个 Kafka 节点机器,因为只有单台机器构成的 Kafka 伪集群只能用于日常测试之用,根本无法满足实际的线上生产需求。而真正的线上环境需要仔细地考量各种因素,结合自身的业务需求而制定。

引用于:极客时间-Kafka核心技术与实战

从操作系统、磁盘、磁盘容量、带宽等方面考虑:

1️⃣ 操作系统:

Kafka 由 Scala 语言和 Java 语言编写而成,编译之后的源代码就是普通的“.class”文件。本来部署到哪个操作系统应该都是一样的,但是不同操作系统的差异还是给 Kafka 集群带来了相当大的影响。

如果考虑操作系统与 Kafka 的适配性,Linux 系统显然要比其他两个特别是 Windows 系统更加适合部署 Kafka。虽然这个结论可能你不感到意外,但其中具体的原因你也一定要了解。主要是在下面这三个方面上,Linux 的表现更胜一筹。

  • I/O 模型的使用: Kafka 客户端底层使用了 Java的 selector,selector 在 Linux 上的实现机制是 epoll,而在 Windows 平台上的实现机制
    是 select。因此在这一点上将 Kafka 部署在 Linux 上是有优势的,因为能够获得更高效的I/O 性能。
  • 数据网络传输效率: 在 Linux 部署 Kafka 能够享受到零拷贝技术所带来的快速数据传输特性。
  • 社区支持度: Windows 平台上部署 Kafka 只适合于个人测试或用于功能验证,千万不要应用于生产环境。

2️⃣ 磁盘:

Kafka 大量使用磁盘不假,可它使用的方式多是顺序读写操作,一定程度上规避了机械磁盘最大的劣势,即随机读写操作慢。从这一点上来说,使用 SSD 似乎并没有太大的性能优势,毕竟从性价比上来说,机械磁盘物美价廉,而它因易损坏而造成的可靠性差等缺陷,又由 Kafka 在软件层面提供机制来保证,故使用普通机械磁盘是很划算的。

需不需要使用RAID【[磁盘阵列](RAID(独立磁盘冗余阵列)简介 - 菜鸟-传奇 - 博客园 (cnblogs.com))】?

RAID的两个主要优势是:提供冗余的磁盘存储空间,提供负载均衡。这两个优势对于Kafka而言,Kafka自身实现了冗余机制来提供高可用,另一方面Kafka也通过分区的概念在软件层面自行实现了负载均衡。当然不考虑性价比的情况下,RAID可以搭建。

3️⃣ 磁盘容量:

Kafka 集群到底需要多大的存储空间?这是一个非常经典的规划问题。Kafka 需要将消息保存在底层的磁盘上,这些消息默认会被保存一段时间然后自动被删除。虽然这段时间是可以配置的,但你应该如何结合自身业务场景和存储需求来规划 Kafka 集群的存储容量呢?

在规划磁盘容量时你需要考虑下面这几个元素:

  • 新增消息数
  • 消息留存时间
  • 平均消息大小
  • 备份数
  • 是否启用压缩

例如:你所在公司有个业务每天需要向Kafka 集群发送 1 亿条消息,每条消息保存两份以防止数据丢失,另外消息默认保存两周时间。现在假设消息的平均大小是 1KB。

数据实际需要总空间:
1 亿 * 1KB * 2 / 1000 / 1000 = 200GB。

预留10%空间,保存两周
220GB * 14 = 3TB

Kafka支持数据压缩,假设比例0.750.75 * 3 = 2.25TB

4️⃣ 带宽:

对于 Kafka 这种通过网络大量进行数据传输的框架而言,带宽特别容易成为瓶颈。

与其说是带宽资源的规划,其实真正要规划的是所需的 Kafka 服务器的数量。假设你公司的机房环境是千兆网络,即 1Gbps,现在你有个业务,其业务目标或 SLA 是在 1 小时内处理 1TB 的业务数据。那么问题来了,你到底需要多少台 Kafka 服务器来完成这个业务呢?

根据实际使用经验,超过 70% 的阈值就有网络丢包的可能性了,故 70% 的设定是一个比较合理的值,也就是说单台 Kafka 服务器最多也就能使用大约 700Mb 的带宽资源。这只是它能使用的最大带宽资源,你不能让 Kafka 服务器常规性使用这么多资源,故通常要再额外预留出 2/3 的资源,即单台服务器使用带宽 700Mb / 3 ≈ 240Mbps。需要提示的是,这里的 2/3 其实是相当保守的,你可以结合你自己机器的使用情况酌情减少此值。

好了,有了 240Mbps,我们就可以计算 1 小时内处理 1TB 数据所需的服务器数量了。根据这个目标,我们每秒需要处理 2336Mb 的数据(1024*1024/3600*8),除以 240,约等于 10 台服务器。如果消息还需要额外复制两份,那么总的服务器台数还要乘以 3,即 30 台。

计算机网络相关知识:带宽资源一般用Mbps而不是MBps衡量

集群参数配置

最最最重要的集群参数配置

Kafka架构和工作流程

基本概念

整体架构图:

image-20220928150112849

生产者Producer。消息生产者,就是向Kafka broker发消息的客户端。

消费者: Consumer。消息消费者,向Kafka borker取消息的客户端。

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

image-20220928150507472

消息Record。这里的消息就是指 Kafka 处理的主要对象。

主题Topic。主题是承载消息的逻辑容器,在实际使用中多用来区分具体的业务;可以理解为一个队列,生产者和消费者面向的都是一个topic

分区Partition。一个有序不变的消息序列。每个主题下可以有多个分区。(为了实现扩展性,一个非常大的topic可以分布到多个broker(即服务器)上,一个topic可以分为多个partition,每个partition是一个有序的队列;)

批次:为了提高效率, 消息会分批次写入 Kafka,批次就代指的是一组消息。

消息位移Offset。表示分区中每条消息的位置信息,是一个单调递增且不变的值。

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

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

leader:每个分区多个副本的“主”,生产者发送数据的对象,以及消费者消费数据的对象都是leader。

follower:每个分区多个副本中的“从”,实时从leader中同步数据,保持和leader数据的同步。leader发生故障时,某个follower会成为新的follower。

总结图示:

image-20220928144219885

工作流程

image-20220928151805240

topic是逻辑上的概念,而partition是物理上的概念,每个partition对应于一个log文件,**该log文件中存储的就是producer生产的数据。**Producer生产的数据会被不断追加到该log文件末端,且每条数据都有自己的offset。消费者组中的每个消费者,都会实时记录自己消费到了哪个offset,以便出错恢复时,从上次的位置继续消费。

文件存储(持久化)机制

磁盘存储位置可以在config/server.properties中的log.dirs中配置;

image-20220930113644177

进入到目录下:

image-20220930113832762

image-20220930114009495

打开.log文件,可以看到之前发送的消息:

image-20220930114114603

(抱歉,我的终端工具似乎有问题,乱码了,fuck是因为后续测试了消息脏话过滤器)

文件存储机制原理:

由于生产者生产的消息会不断追加到log文件末尾,为防止log文件过大导致数据定位效率低下,Kafka采取了分片索引机制。

将每个partition分为多个segment。每个segment对应两个文件——“.index”文件和“.log”文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic名称+分区序号。例如,first这个topic有三个分区,则其对应的文件夹为first-0,first-1,first-2。

Segment 被译为段,将 Partition 进一步细分为若干个 segment,每个 segment 文件的大小相等。

image-20220928155253939

index和log文件以当前segment的第一条消息的offset命名。

# 几个index和log文件例子:
00000000000000000000.index
00000000000000000000.log
00000000000000170410.index
00000000000000170410.log
00000000000000239430.index
00000000000000239430.log
  • “.index”文件存储大量的索引信息

  • “.log”文件存储大量的数据

  • 索引文件中的元数据指向对应数据文件中message的物理偏移地址。

下图为index文件和log文件的结构示意图:

image-20220928155718005

Kafka默认消息保留时间是7天,若想更改可以到server.properties中修改 log.retention.hours属性

image-20220930111430167

Kafka生产者细节

分区策略

1️⃣分区的原因:

(1)方便在集群中扩展,每个Partition可以通过调整以适应它所在的机器,而一个topic又可以有多个Partition组成,因此整个集群就可以适应任意大小的数据了;

(2)可以提高并发,因为可以以Partition为单位读写了。

2️⃣ 分区的原则:

我们需要将producer发送的数据封装成一个ProducerRecord对象。

image-20220928160833334

(1)指明 partition 的情况下,直接将指明的值直接作为 partiton 值;

(2)没有指明 partition 值但有 key 的情况下,将 key 的 hash 值与 topic 的 partition 数进行取余得到 partition 值;

(3)既没有 partition 值又没有 key 值的情况下,第一次调用时随机生成一个整数(后面每次调用在这个整数上自增),将这个值与 topic 可用的 partition 总数取余得到 partition 值,也就是常说的 round-robin 算法。

数据可靠性保障-ACK

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

收到ACK的情况:

image-20220928161632206

没收到ACK的情况:

image-20220928161745303

何时发送ACK??

image-20220928161833437

副本同步策略

方案优点缺点
半数以上完成同步,就发送ack延迟低选举新的leader时,容忍n台节点的故障,需要2n+1个副本
全部完成同步,才发送ack选举新的leader时,容忍n台节点的故障,需要n+1个副本延迟高

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

1.同样为了容忍n台节点的故障,第一种方案需要2n+1个副本,而第二种方案只需要n+1个副本,而Kafka的每个分区都有大量的数据,第一种方案会造成大量数据的冗余。

2.虽然第二种方案的网络延迟会比较高,但网络延迟对Kafka的影响较小。

ISR

采用第二种方案之后,设想以下情景:leader收到数据,所有follower都开始同步数据,但有一个follower,因为某种故障,迟迟不能与leader进行同步,那leader就要一直等下去,直到它完成同步,才能发送ack。这个问题怎么解决呢?

Leader维护了一个动态的in-sync replica set (ISR),意为和leader保持同步的follower集合。当ISR中的follower完成数据的同步之后,leader就会给follower发送ack。如果follower长时间未向leader同步数据,则该follower将被踢出ISR,该时间阈值由replica.lag.time.max.ms参数设定。Leader发生故障之后,就会从ISR中选举新的leader。

ACK应答机制

对于某些不太重要的数据,对数据的可靠性要求不是很高,能够容忍数据的少量丢失,所以没必要等ISR中的follower全部接收成功。

所以Kafka为用户提供了三种可靠性级别,用户根据对可靠性和延迟的要求进行权衡,选择以下的配置。

ack参数配置:

  • 0:producer不等待broker的ack,这一操作提供了一个最低的延迟,broker一接收到还没有写入磁盘就已经返回,当broker故障时有可能丢失数据;

  • 1:producer等待broker的ack,partition的leader落盘成功后返回ack,如果在follower同步成功之前leader故障,那么将会丢失数据;

  • -1(all):producer等待broker的ack,partition的leader和follower全部落盘成功后才返回ack。但是如果在follower同步完成后,broker发送ack之前,leader发生故障,那么会造成数据重复。

0,1两种配置容易理解,-1的情况如下图所示:

image-20220928163216997

offset细节 LEO、 HW

LEO:指的是每个副本最大的offset;

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

image-20220928163550240

故障处理细节

(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同步数据。

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

Exactily Once

翻译:【精确一次】

  • 将服务器的ACK级别设置为**-1**,可以保证Producer到Server之间不会丢失数据,即At Least Once语义。
  • 将服务器的ACK级别设置为0,可以保证生产者每条消息只会被发送一次,即At Most Once语义。

但是,对于一些非常重要的信息,比如说交易数据,下游数据消费者要求数据既不重复也不丢失,即Exactly Once语义

在0.11版本以前的Kafka,对此是无能为力的,只能保证数据不丢失,再在下游消费者对数据做全局去重。对于多个下游应用的情况,每个都需要单独做全局去重,这就对性能造成了很大影响。

image-20220928164652247

0.11版本的Kafka,引入了一项重大特性:幂等性。所谓的幂等性就是指Producer不论向Server发送多少次重复数据,Server端都只会持久化一条。幂等性结合At Least Once语义,就构成了Kafka的Exactly Once语义。即:

At Least Once + 幂等性 = Exactly Once

要启用幂等性,只需要将Producer的参数中enable.idompotence设置为true即可。

Kafka的幂等性实现其实就是将原来下游需要做的去重放在了数据上游。开启幂等性的Producer在初始化的时候会被分配一个PID,发往同一Partition的消息会附带Sequence Number。而Broker端会对<PID, Partition, SeqNumber>做缓存,当具有相同主键的消息提交时,Broker只会持久化一条。

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

拓展: 如果想要实现跨分区跨会话上的消息无重复该怎么做呢?—> 事务

Kafka消费者细节

消费方式

  • consumer采用pull(拉)模式从broker中读取数据。
  • push(推)模式很难适应消费速率不同的消费者,因为消息发送速率是由broker决定的。

push 模式中broker的目标是尽可能以最快速度传递消息,但是这样很容易造成consumer来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。

pull模式则可以根据consumer的消费能力以适当的速率消费消息。

**pull模式不足之处:**如果kafka没有数据,消费者可能会陷入循环中,一直返回空数据。针对这一点,Kafka的消费者在消费数据时会传入一个时长参数timeout,如果当前没有数据可供消费,consumer会等待一段时间之后再返回,这段时长即为timeout。

轮询

我们知道,Kafka 是支持订阅/发布模式的,生产者发送数据给 Kafka Broker,那么消费者是如何知道生产者发送了数据呢?其实生产者产生的数据消费者是不知道的,KafkaConsumer 采用轮询的方式定期去 Kafka Broker 中进行数据的检索,如果有数据就用来消费,如果没有就再继续轮询等待。

轮询源码:

try {
  while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(100));
    for (ConsumerRecord<String, String> record : records) {
      int updateCount = 1;
      if (map.containsKey(record.value())) {
        updateCount = (int) map.get(record.value() + 1);
      }
      map.put(record.value(), updateCount);
    }
  }
}finally {
  consumer.close();
}
  • 这是一个无限循环。消费者实际上是一个长期运行的应用程序,它通过轮询的方式向 Kafka 请求数据。
  • 第三行代码非常重要,Kafka 必须定期循环请求数据,否则就会认为该 Consumer 已经挂了,会触发重平衡,它的分区会移交给群组中的其它消费者。**传给 poll() 方法的是一个超时时间,**用 java.time.Duration 类来表示,如果该参数被设置为 0 ,poll() 方法会立刻返回,否则就会在指定的毫秒数内一直等待 broker 返回数据。
  • poll() 方法会返回一个记录列表。每条记录都包含了记录所属主题的信息,记录所在分区的信息、记录在分区中的偏移量,以及记录的键值对。我们一般会遍历这个列表,逐条处理每条记录。
  • 在退出应用程序之前使用 close() 方法关闭消费者。网络连接和 socket 也会随之关闭,并立即触发一次重平衡,而不是等待群组协调器发现它不再发送心跳并认定它已经死亡。

分区分配策略

一个consumer group中有多个consumer,一个 topic有多个partition,所以必然会涉及到partition的分配问题,即确定那个partition由哪个consumer来消费。

Kafka有两种分配策略,RoundRobinRange

RoundRobin:

image-20220928174218347

Range:

image-20220928174329602

offset的维护

由于consumer在消费过程中可能会出现断电宕机等故障,consumer恢复后,需要从故障前的位置的继续消费,所以consumer需要实时记录自己消费到了哪个offset,以便故障恢复后继续消费。

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

image-20220928185750890

consumer.properties中:

#这个参数用于是否把内部topic的信息(例如offset)暴露给cosumer,如果设置为true,就只能通过订阅的方式来获取内部topic的数据。
exclude.internal.topics=false

消费者重平衡

情景说明:

最初是一个消费者订阅一个主题并消费其全部分区的消息,后来有一个消费者加入群组,随后又有更多的消费者加入群组,而新加入的消费者实例分摊了最初消费者的部分消息,这种把分区的所有权通过一个消费者转到其他消费者的行为称为重平衡

img

重平衡非常重要,它为消费者群组带来了高可用性伸缩性,我们可以放心的添加消费者或移除消费者,不过在正常情况下我们并不希望发生这样的行为。在重平衡期间,消费者无法读取消息,造成整个消费者组在重平衡的期间都不可用。另外,当分区被重新分配给另一个消费者时,消息当前的读取状态会丢失,它有可能还需要去刷新缓存,在它重新恢复状态之前会拖慢应用程序。

消费者通过向组织协调者(Kafka Broker)发送心跳来维护自己是消费者组的一员并确认其拥有的分区。对于不同不的消费群体来说,其组织协调者可以是不同的。只要消费者定期发送心跳,就会认为消费者是存活的并处理其分区中的消息。当消费者检索记录或者提交它所消费的记录时就会发送心跳。

如果过了一段时间 Kafka 停止发送心跳了,会话(Session)就会过期,组织协调者就会认为这个 Consumer 已经死亡,就会触发一次重平衡。如果消费者宕机并且停止发送消息,组织协调者会等待几秒钟,确认它死亡了才会触发重平衡。在这段时间里,死亡的消费者将不处理任何消息。在清理消费者时,消费者将通知协调者它要离开群组,组织协调者会触发一次重平衡,尽量降低处理停顿。

总结:

  1. 重平衡会影响我们程序的吞吐量
  2. 排查重平衡发生的原因,避免Consumer端异常情况下的重平衡
  3. 通过设定Consumer端的参数,或者进行JVM调优避免不必要的重平衡

重平衡案例: 当同一个消费者组中有其它消费者加入时:
在这里插入图片描述

拓展资料:极客时间-Kafka核心技术实战:25|消费者组重平衡全流程解析

Kafka高效读写数据

顺序写磁盘

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

零复制(零拷贝)技术

从字面上理解就是数据不需要多次拷贝,系统性能大幅度提升。其实,不仅在kafka中,Java NIO,netty,rocketMQ等框架中也都用到了零拷贝。

缓存I/O又被称作标准I/O,是大多少文件系统默认I/O。为了减少读盘的次数,同时也为了保护系统本身的安全,缓存I/O在一定程度上分离了内核空间和用户空间。但也因此,数据在传输过程中需要在用户空间和内核空间之间进行多次的拷贝,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。

下图示例列出了一次缓存IO读和写需要经过的步骤:

img

对于缓存I/O,一个读操作有3次数据拷贝,一次写操作又会有3次的数据拷贝。

读操作:磁盘->内核缓存区->用户空间缓存区->应用程序内存。

写操作:应用程序内存->用户空间缓存区->Socket缓存区->网络。

直接I/O

直接IO就是指没有用户级的缓存区,但是内核缓存区还是有的。这样就减少一次从内核缓冲区到用户程序缓存的数据拷贝。如下图所示:

img

内存映射文件

首先,映射的意思就是建立一种一一对应关系,是指硬盘上文件的位置与进程逻辑地址的对应关系。这种对应关系纯属是逻辑上的概念,物理上是不存在的。在内存映射的过程中,并没有实际的数据拷贝,文件没有被载入内存,只是逻辑上被放入了内存,内存中实际只是一个逻辑地址。

img

如上图所示,数据不再经过应用程序内存,直接从内核缓存区到socket缓冲区。

零拷贝

零拷贝连内核缓存区到socket缓存区也省了,底层的网卡驱动程序直接读取内核缓存区的数据并发送到网络。在整个过程中,只发生了2次数据拷贝。一次是从磁盘到内核缓存区,另一次是从内核缓存区到网络。既然有发生数据拷贝,为什么还叫“零拷贝”,那是因为所说的零拷贝是指数据在内存中没有发生数据拷贝。

img

Kafka的Controller

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

Controller的管理工作都是依赖于Zookeeper的。

下面是partition 的 leader 选举过程:

image-20220929112330683

如果leader节点挂掉了:

image-20220929113101782

Kafka事务

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

Producer事务

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

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

Consumer事务

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

Kafka的压缩机制

Kafka 的消息分为两层:消息集合 和 消息。一个消息集合中包含若干条日志项,而日志项才是真正封装消息的地方。Kafka 底层的消息日志由一系列消息集合日志项组成。Kafka 通常不会直接操作具体的一条条消息,它总是在消息集合这个层面上进行写入操作。

在 Kafka 中,压缩会发生在两个地方:Kafka Producer 和 Kafka Consumer,为什么启用压缩?说白了就是消息太大,需要变小一点 来使消息发的更快一些。

Kafka Producer 中使用 compression.type 来开启压缩

private Properties properties = new Properties();
properties.put("bootstrap.servers","192.168.1.9:9092");
properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("compression.type", "gzip");

Producer<String,String> producer = new KafkaProducer<String, String>(properties);

ProducerRecord<String,String> record =
  new ProducerRecord<String, String>("CustomerCountry","Precision Products","France");

上面代码表明该 Producer 的压缩算法使用的是 GZIP

有压缩必有解压缩,Producer 使用压缩算法压缩消息后并发送给服务器后,由 Consumer 消费者进行解压缩,因为采用的何种压缩算法是随着 key、value 一起发送过去的,所以消费者知道采用何种压缩算法。

Kafka API

Producer API

消息发送流程

Kafka的Producer发送消息采用的是异步发送的方式。在消息发送的过程中,涉及到了两个线程——main线程和Sender线程,以及一个线程共享变量——RecordAccumulator【记录累计器,充当一个队列】。main线程将消息发送给RecordAccumulator,Sender线程不断从RecordAccumulator中拉取消息发送到Kafka broker。

image-20220929131415227

消息发送的流程图:

img

相关参数:

  • batch.size: 只有数据积累到batch.size之后,sender才会发送数据
  • linger.ms: 如果数据迟迟未到达batch.size,sender等待linger.time 之后就会发送数据。

image-20220929131942844

异步发送API
  1. 导入依赖

            <dependency>
                <groupId>org.apache.kafka</groupId>
                <artifactId>kafka-clients</artifactId>
            </dependency>
    
  2. 需要的类:

    - KafkaProducer:需要创建一个生产者对象,用来发送数据
    - ProducerConfig:获取所需的一系列配置参数
    - ProducerRecord:每条数据都要封装成一个ProducerRecord对象
    
  3. API

    不带回调函数的API:

    public class CustomProducer {
    
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            Properties props = new Properties();
    
            //kafka集群,broker-list
            props.put("bootstrap.servers","kafka集群地址");
    
            props.put("acks", "all");
    
            //重试次数
            props.put("retries", 1); 
    
            //批次大小
            props.put("batch.size", 16384); 
    
            //等待时间
            props.put("linger.ms", 1); 
    
            //RecordAccumulator缓冲区大小
            props.put("buffer.memory", 33554432);
    
            props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
            props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    
            Producer<String, String> producer = new KafkaProducer<>(props);
    
            for (int i = 0; i < 100; i++) {
                producer.send(new ProducerRecord<String, String>("first", Integer.toString(i), Integer.toString(i)));
            }
    
            producer.close();
        }
    }
    
    

    带回调函数的API

    回调函数会在producer收到ack时调用,为异步调用,该方法有两个参数,分别是RecordMetadata和Exception,如果Exception为null,说明消息发送成功,如果Exception不为null,说明消息发送失败。

    public class CustomProducer1 {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            Properties props = new Properties();
    
            //kafka集群,broker-list kafka集群地址:9092
            props.put("bootstrap.servers","kafka集群地址");
    
            props.put("acks", "all");
    
            //重试次数
            props.put("retries", 1);
    
            //批次大小
            props.put("batch.size", 16384);
    
            //等待时间
            props.put("linger.ms", 1);
    
            //RecordAccumulator缓冲区大小
            props.put("buffer.memory", 33554432);
    
            props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
            props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    
            Producer<String, String> producer = new KafkaProducer<>(props);
    
            for (int i = 0; i < 100; i++) {
                producer.send(new ProducerRecord<String, String>("first", Integer.toString(i), Integer.toString(i)), new Callback() {
    
                    //回调函数,该方法会在Producer收到ack时调用,为异步调用
                    @Override
                    public void onCompletion(RecordMetadata metadata, Exception exception) {
                        if (exception == null) {
                            System.out.println("success->" + metadata.offset());
                        } else {
                            exception.printStackTrace();
                        }
                    }
                });
            }
    
            producer.close();
        }
    }
    
    

    注意:消息发送失败会自动重试,不需要我们在回调函数中手动重试。

发送到指定分区:

ProducerRecord<String, String> producerRecord = new 
ProducerRecord<String, String>(TOPIC_NAME, 0, order.getOrderId().toString(), JSON.toJSONString(order));

不指定的话,会通过业务key的hash运算,算出消息发送到哪个分区

//未指定发送分区,具体发送的分区计算公式:hash(key)%partitionNum
ProducerRecord<String, String> producerRecord = new
ProducerRecord<String, String>(TOPIC_NAME, order.getOrderId().toString(), JSON.toJSONString(order));
同步发送API

同步发送的意思就是,一条消息发送之后,会阻塞当前线程,直至返回ack。

由于send方法返回的是一个Future对象,根据Futrue对象的特点,我们也可以实现同步发送的效果,只需在调用Future对象的get方发即可。

public class CustomProducer3 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {

        Properties props = new Properties();

        props.put("bootstrap.servers", "kafka集群地址");//kafka集群,broker-list

        props.put("acks", "all");

        props.put("retries", 1);//重试次数

        props.put("batch.size", 16384);//批次大小

        props.put("linger.ms", 1);//等待时间

        props.put("buffer.memory", 33554432);//RecordAccumulator缓冲区大小

        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

        Producer<String, String> producer = new KafkaProducer<>(props);
        for (int i = 0; i < 100; i++) {
            producer.send(new ProducerRecord<String, String>("first", Integer.toString(i), Integer.toString(i))).get();
        }
        producer.close();
    }

}

Consumer API

Consumer消费数据时的可靠性是很容易保证的,因为数据在Kafka中是持久化的,故不用担心数据丢失问题。

由于consumer在消费过程中可能会出现断电宕机等故障,consumer恢复后,需要从故障前的位置的继续消费,所以consumer需要实时记录自己消费到了哪个offset,以便故障恢复后继续消费。

所以offset的维护是Consumer消费数据是必须考虑的问题。

消费消息的过程
img
自动提交offeset
  1. 导入依赖

            <dependency>
                <groupId>org.apache.kafka</groupId>
                <artifactId>kafka-clients</artifactId>
            </dependency>
    
  2. 需要用到的类

    - KafkaConsumer:需要创建一个消费者对象,用来消费数据
    - ConsumerConfig:获取所需的一系列配置参数
    - ConsuemrRecord:每条数据都要封装成一个ConsumerRecord对象
    

    为了使我们能够专注于自己的业务逻辑,Kafka提供了自动提交offset的功能。

    自动提交offset的相关参数:

    enable.auto.commit:是否开启自动提交offset功能

    auto.commit.interval.ms:自动提交offset的时间间隔

  3. API

    public class CustomConsumer {
        public static void main(String[] args) {
    
            Properties props = new Properties();
    
            props.put("bootstrap.servers", "KafkaIP:9092");
    
            props.put("group.id", "test");
    
            props.put("enable.auto.commit", "true"); //开启自动提交offset
    
            props.put("auto.commit.interval.ms", "1000"); //自动提交间隔
    
            props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
            props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
    
            KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
    
            consumer.subscribe(Arrays.asList("first"));
    
            while (true) {
    
                ConsumerRecords<String, String> records = consumer.poll(100);
    
                for (ConsumerRecord<String, String> record : records) {
    
                    System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
                }
            }
        }
    }
    

    运行结果:

    13:53:30.459 [main] DEBUG org.apache.kafka.clients.consumer.internals.ConsumerCoordinator - [Consumer clientId=consumer-test-1, groupId=test] Sending asynchronous auto-commit of offsets {first-0=OffsetAndMetadata{offset=300, leaderEpoch=null, metadata=''}}
    
手动提交offset

虽然自动提交offset十分简介便利,但由于其是基于时间提交的,开发人员难以把握offset提交的时机。因此Kafka还提供了手动提交offset的API。

手动提交offset的方法有两种:分别是commitSync(同步提交)和commitAsync(异步提交)。两者的相同点是,**都会将本次poll的一批数据最高的偏移量提交;**不同点是,commitSync阻塞当前线程,一直到提交成功,并且会自动失败重试(由不可控因素导致,也会出现提交失败);而commitAsync则没有失败重试机制,故有可能提交失败。

1️⃣ 同步提交offset

由于同步提交offset有失败重试机制,故更加可靠,以下为同步提交offset的示例。

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

        Properties props = new Properties();

        props.put("bootstrap.servers", "KafkaIP:9092");

        props.put("group.id", "test");

        props.put("enable.auto.commit", "false");


        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

        consumer.subscribe(Arrays.asList("first"));

        while (true) {

            ConsumerRecords<String, String> records = consumer.poll(100);

            for (ConsumerRecord<String, String> record : records) {

                System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
            }
            //同步提交,当前线程会阻塞直到offset提交成功
            consumer.commitSync();
        }
    }
}

2️⃣ 异步提交

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

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

        Properties props = new Properties();

        props.put("bootstrap.servers", "KafkaIP:9092");

        props.put("group.id", "test");

        props.put("enable.auto.commit", "false");


        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

        consumer.subscribe(Arrays.asList("first"));

        while (true) {

            ConsumerRecords<String, String> records = consumer.poll(100);

            for (ConsumerRecord<String, String> record : records) {

                System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
            }
            //异步提交
            consumer.commitAsync(new OffsetCommitCallback() {
                @Override
                public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
                    if (exception != null) {
                        System.err.println("Commit failed for" + offsets);
                    }
                }
            });
        }

    }
}
数据漏消费和重复消费

无论是同步提交还是异步提交offset,都有可能会造成数据的漏消费或者重复消费。

  • 先提交offset后消费,有可能造成数据的漏消费;
  • 而先消费后提交offset,有可能会造成数据的重复消费。
自定义存储offset

Kafka 0.9版本之前,offset存储在zookeeper,0.9版本及之后,默认将offset存储在Kafka的一个内置的topic中。除此之外,Kafka还可以选择自定义存储offset。

offset的维护是相当繁琐的,因为需要考虑到消费者的Rebalace。

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

消费者发生Rebalance之后,每个消费者消费的分区就会发生变化。因此消费者要首先获取到自己被重新分配到的分区,并且定位到每个分区最近提交的offset位置继续消费。

要实现自定义存储offset,需要借助ConsumerRebalanceListener,以下为示例代码,其中提交和获取offset的方法,需要根据所选的offset存储系统自行实现。

public class CustomConsumer {

    private static Map<TopicPartition, Long> currentOffset = new HashMap<>();

public static void main(String[] args) {

		//创建配置信息
        Properties props = new Properties();

		//Kafka集群
        props.put("bootstrap.servers", "KafkaIP:9092"); 

		//消费者组,只要group.id相同,就属于同一个消费者组
        props.put("group.id", "test"); 

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

        //Key和Value的反序列化类
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");

        //创建一个消费者
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

        //消费者订阅主题
        consumer.subscribe(Arrays.asList("first"), new ConsumerRebalanceListener() {
            
            //该方法会在Rebalance之前调用
            @Override
            public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
                commitOffset(currentOffset);
            }

            //该方法会在Rebalance之后调用
            @Override
            public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
                currentOffset.clear();
                for (TopicPartition partition : partitions) {
                    consumer.seek(partition, getOffset(partition));//定位到最近提交的offset位置继续消费
                }
            }
        });

        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(100);//消费者拉取数据
            for (ConsumerRecord<String, String> record : records) {
                System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
                currentOffset.put(new TopicPartition(record.topic(), record.partition()), record.offset());
            }
            commitOffset(currentOffset);//异步提交
        }
    }

    //获取某分区的最新offset
    private static long getOffset(TopicPartition partition) {
        return 0;
    }

    //提交该消费者所有分区的offset
    private static void commitOffset(Map<TopicPartition, Long> currentOffset) {

    }
}

自定义Interceptor

拦截器原理

Producer拦截器(interceptor)是在Kafka 0.10版本被引入的,主要用于实现clients端的定制化控制逻辑。

对于producer而言,interceptor使得用户在消息发送前以及producer回调逻辑前有机会对消息做一些定制化需求,比如修改消息等。同时,producer允许用户指定多个interceptor按序作用于同一条消息从而形成一个拦截链(interceptor chain)。Intercetpor的实现接口是org.apache.kafka.clients.producer.ProducerInterceptor,其定义的方法包括:

  1. configure(configs)

​ 获取配置信息和初始化数据时调用。

  1. onSend(ProducerRecord):

​ 该方法封装进KafkaProducer.send方法中,即它运行在用户主线程中。**Producer确保在消息被序列化以及计算分区前调用该方法。**用户可以在该方法中对消息做任何操作,但最好保证不要修改消息所属的topic和分区,否则会影响目标分区的计算。

  1. onAcknowledgement(RecordMetadata, Exception):

该方法会在消息从RecordAccumulator成功发送到Kafka Broker之后,或者在发送过程中失败时调用。并且通常都是在producer回调逻辑触发之前。onAcknowledgement运行在producer的IO线程中,因此不要在该方法中放入很重的逻辑,否则会拖慢producer的消息发送效率。

  1. close:

关闭interceptor,主要用于执行一些资源清理工作

如前所述,interceptor可能被运行在多个线程中,因此在具体实现时用户需要自行确保线程安全。另外倘若指定了多个interceptor,则producer将按照指定顺序调用它们,并仅仅是捕获每个interceptor可能抛出的异常记录到错误日志中而非在向上传递。这在使用过程中要特别留意。

拦截器案例

需求:

实现一个简单的双interceptor组成的拦截链。第一个interceptor会在消息发送前将时间戳信息加到消息value的最前部;第二个interceptor会在消息发送后更新成功发送消息数或失败发送消息数。

image-20220929142417086

实现:

1️⃣ 时间拦截器:

public class TimeInterceptor implements ProducerInterceptor<String, String> {

	@Override
	public void configure(Map<String, ?> configs) {

	}

	@Override
	public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {

		// 创建一个新的record,把时间戳写入消息体的最前部
		return new ProducerRecord(record.topic(), record.partition(), record.timestamp(), record.key(),
				System.currentTimeMillis() + "," + record.value().toString());
	}

	@Override
	public void onAcknowledgement(RecordMetadata metadata, Exception exception) {

	}

	@Override
	public void close() {

	}
}

2️⃣统计发送成功与失败消息数的,并在producer关闭时打印这两个计数器:

public class CounterInterceptor implements ProducerInterceptor<String, String>{
    private int errorCounter = 0;
    private int successCounter = 0;

	@Override
	public void configure(Map<String, ?> configs) {
		
	}

	@Override
	public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
		 return record;
	}

	@Override
	public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
		// 统计成功和失败的次数
        if (exception == null) {
            successCounter++;
        } else {
            errorCounter++;
        }
	}

	@Override
	public void close() {
        // 保存结果
        System.out.println("Successful sent: " + successCounter);
        System.out.println("Failed sent: " + errorCounter);
	}
}

3️⃣ 主程序

public class InterceptorProducer {

	public static void main(String[] args) throws Exception {
		// 1 设置配置信息
		Properties props = new Properties();
		props.put("bootstrap.servers", "KafkaIP:9092");
		props.put("acks", "all");
		props.put("retries", 3);
		props.put("batch.size", 16384);
		props.put("linger.ms", 1);
		props.put("buffer.memory", 33554432);
		props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
		props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
		
		// 2 构建拦截链
		List<String> interceptors = new ArrayList<>();
		interceptors.add("路径.interceptor.TimeInterceptor"); 	
        interceptors.add("路径.interceptor.CounterInterceptor"); 
		props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, interceptors);
		 
		String topic = "first";
		Producer<String, String> producer = new KafkaProducer<>(props);
		
		// 3 发送消息
		for (int i = 0; i < 10; i++) {
			
		    ProducerRecord<String, String> record = new ProducerRecord<>(topic, "message" + i);
		    producer.send(record);
		}
		 
		// 4 一定要关闭producer,这样才会调用interceptor的close方法
		producer.close();
	}
}

4️⃣ 测试

启动一个消费者后,运行拦截器生产者,消费者控制台输出:

image-20220929143818935

生产者控制台输出:

image-20220929143929809

SpringBoot 操作 Kafka

SpringBoot整合Kafka

1️⃣ 引入依赖

        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
        </dependency>

2️⃣ 配置Kafka

有两种配置方式,这里采用配置文件配置的方式:

spring:  
  kafka:
    bootstrap-servers: KafkaIP:9092
    producer:
      retries: 1  #重试次数
      batch-size: 16384 #批量大小
      buffer-memory: 33554432 #缓冲区大小
      acks: 1 #leader落盘返回ack
      #指定消息和消息体的编解码方式
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
    consumer:
      enable-auto-commit: true #自动提交offset
      auto-commit-interval: 200
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      max-poll-records: 500

配置说明:

image-20220929165357793

auto.offset.reset有以下三个可选值:

  • latest (默认)
  • earliest
  • none

三者均有共同定义:
对于同一个消费者组,若已有提交的offset,则从提交的offset开始接着消费

意思就是,只要这个消费者组消费过了,不管auto.offset.reset指定成什么值,效果都一样,每次启动都是已有的最新的offset开始接着往后消费

不同的点为:

  • latest(默认):对于同一个消费者组,若没有提交过offset,则只消费消费者连接topic后,新产生的数据
  • earliest:对于同一个消费者组,若没有提交过offset,则从头开始消费
  • none:对于同一个消费者组,若没有提交过offset,会抛异常
  • 直接抛出异常

3️⃣ 编写生产者

@RestController
@Slf4j
public class ProducerController{

    @Resource
    private KafkaTemplate<String,String> kafkaTemplate;

    @PostMapping("/send")
    public void send(String message){
        ListenableFuture<SendResult<String, String>> future = kafkaTemplate.send("first", message);
        future.addCallback(new ListenableFutureCallback<SendResult<String, String>>() {
            @Override
            public void onSuccess(SendResult<String, String> result) {
                log.info("成功发送消息:{},offset=[{}]", message, result.getRecordMetadata().offset());
            }

            @Override
            public void onFailure(Throwable ex) {
                log.error("消息:{} 发送失败,原因:{}", message, ex.getMessage());
            }
        });
    }

}

4️⃣ 编写消费者

@Component
@Slf4j
public class ConsumerListener {

    @KafkaListener(topics = "first",groupId = "test") //配置文件中配置了或指定都可以
    public void listen(String message){
        log.info("Kafka监听器接收的消息:{}",message);
    }

}

5️⃣ 测试

发送请求,观察控制台输出

image-20220929170346870

@KafkaListener详解

@KafkaListener除了可以指定Topic名称和分组id外,我们还可以同时监听来自多个Topic的消息:

@KafkaListener(topics = "topic1, topic2")

我们还可以通过@Header注解来获取当前消息来自哪个分区(partitions):

@KafkaListener(topics = "test", groupId = "test-consumer")
public void listen(@Payload String message,
                   @Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition) {
    logger.info("接收消息: {},partition:{}", message, partition);
}

我们可以通过@KafkaListener来指定只接收来自特定分区的消息:

@KafkaListener(groupId = "test-consumer", 
	topicPartitions = @TopicPartition(topic = "test", partitions = { "0", "1" }))

消息过滤器

类似于API部分的拦截器案例,可以进行如下配置:

@Configuration
public class KafkaConfig {

    @Bean
    public ProducerFactory<String, String> producerFactory() {

        // 2 构建拦截链
        List<String> interceptors = new ArrayList<>();
        interceptors.add("com.example.studyproject.kafka.interceptor.TimeInterceptor");
        interceptors.add("com.example.studyproject.kafka.interceptor.CounterInterceptor");

        Map<String, Object> configProps = new HashMap<>();
        configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"KafkaIP:9092");
        configProps.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, interceptors);
        configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,StringSerializer.class);
        return new DefaultKafkaProducerFactory<>(configProps);
    }

    @Bean
    public KafkaTemplate<String, String> kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }


}

image-20220929173040461

过滤器的配置:

@Bean
public ConsumerFactory<String, String> consumerFactory() {
    Map<String, Object> props = new HashMap<>();
    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
    props.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId);
    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    return new DefaultKafkaConsumerFactory<>(props);
}

@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
    ConcurrentKafkaListenerContainerFactory<String, String> factory
            = new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory());
    // ------- 过滤配置 --------
    factory.setRecordFilterStrategy(
            //脏话过滤
            r -> r.value().contains("fuck")
    );
    return factory;
}

image-20220929173903480

拓展和总结

Kafka的监控-Eagle

smartloli/EFAK: A easy and high-performance monitoring system, for comprehensive monitoring and management of kafka cluster. (github.com)

Kafka延时请求和时间轮算法

f649b5f166c55a3c6b340cdd670b5229.png
延时请求

延时请求(Delayed Operation),也称延迟请求,是指因未满足条件而暂时无法被处理的 Kafka 请求。

举个例子,配置了 acks=all 的生产者发送的请求可能一时无法完成,因为Kafka 必须确保 ISR 中的所有副本都要成功响应这次写入。因此,通常情况下,这些请求没法被立即处理。只有满足了条件或发生了超时,Kafka 才会把该请求标记为完成状态。这就是所谓的延时请求。

延时处理的机制——分层时间轮算法 官方解释

时间轮(TimingWheel)算法

时间轮的应用范围非常广。很多操作系统的定时任务调度以及通信框架(如 Dubbo、Netty 等)都利用了时间轮的思想。几乎所有的时间任务调度系统都是基于时间轮算法的。Kafka 应用基于时间轮算法管理延迟请求的代码简洁精炼,而且和业务逻辑代码完全解耦。

如果是你,你会怎么实现 Kafka中的延时请求呢?
针对这个问题,第一反应是使用 Java 的 DelayQueue。毕竟,这个类是 Java 天然提供的延时队列,非常适合建模延时对象处理。实际上,Kafka 的第一版延时请求就是使用DelayQueue 做的。
但是,DelayQueue 有一个弊端:它插入和删除队列元素的时间复杂度是 O(logN)。对于Kafka 这种非常容易积攒几十万个延时请求的场景来说,该数据结构的性能是瓶颈。当然,这一版的设计还有其他弊端,比如,它在清除已过期的延迟请求方面不够高效,可能会出现内存溢出的情形。后来,社区改造了延时请求的实现机制,采用了基于时间轮的方案。

**时间轮的类型:**两者各有利弊,也都有各自的使用场景。Kafka 采用的是分层时间轮。

  • 简单时间轮(Simple Timing Wheel)
  • 分层时间轮(Hierarchical TimingWheel)【想想我们生活中的手表。手表由时针、分针和秒针组成,它们各自有独立的刻度,但又彼此相关:秒针转动一圈,分针会向前推进一格;分针转动一圈,时针会向前推进一格。这就是典型的分层时间轮。】

Kafka 自己有专门的术语。在 Kafka 中,手表中的“一格”叫**“一个桶(Bucket)”**,而“推进”对应于 Kafka 中的“滴答”,也就是 tick。除此之外,每个 Bucket 下也不是白板一块,它实际上是一个双向循环链表(DoublyLinked Cyclic List),里面保存了一组延时请求。在 Bucket 下插入和删除一个元素的时间复杂度是 O(1),是典型的空间换时间的优化思想。

image-20220930155024816

在 Kafka 中,具体是怎么应用分层时间轮实现请求队列的呢?

时间轮(TimingWheel)是一个存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表(TimerTaskList)。
- TimerTaskList 是一个环形的双向链表
- 链表中的每一项表示的都是定时任务项(TimerTaskEntry)
- 其中封装了真正的定时任务 TimerTask。

时间轮.png

时间轮基本模型的构成:

  • tickMs(基本时间跨度):时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度(tickMs)。

  • wheelSize(时间单位个数):时间轮的时间格个数是固定的,可用(wheelSize)来表示,那么整个时间轮的总体时间跨度(interval)可以通过公式
    tickMs × wheelSize计算得出。

  • currentTime(当前所处时间):时间轮还有一个表盘指针(currentTime),用来表示时间轮当前所处的时间,currentTime 是 tickMs 的整数倍。currentTime 可以将整个时间轮划分为到期部分和未到期部分,currentTime 当前指向的时间格也属于到期部分,表示刚好到期,需要处理此时间格所对应的 TimerTaskList 的所有任务

处理流程分析

  1. 若时间轮的 tickMs=1ms,wheelSize=20,那么可以计算得出 interval 为 20ms;
  2. 初始情况下表盘指针 currentTime 指向时间格 0,此时有一个定时为 2ms 的任务插入进来会存放到时间格为 2 的 TimerTaskList 中;
  3. 随着时间的不断推移,指针 currentTime 不断向前推进,过了 2ms 之后,当到达时间格 2 时,就需要将时间格 2 所对应的 TimeTaskList 中的任务做相应的到期操作;
  4. 此时若又有一个定时为 8ms 的任务插入进来,则会存放到时间格 10 中,currentTime 再过 8ms 后会指向时间格 10。

Kafka中任务添加和驱动时间滚轮的核心流程:

img
Kafka时间轮源码

源码位置:

image-20221008142953827

由于是scala写的,为了减少学习成本,下面有一个Java实现时间轮的版本:

基于Kafka实现的Java版时间轮

Kafka的脚本汇总

image-20220930135839293

可以使用–help来查看具体脚本的使用方法,下面是几个常用脚本的功能:

image-20220930140747943

生产消息: kafka-console-producer.sh
消费消息: kafka-console-consumer.sh
测试生产者性能: kafka-producer-perf-test.sh
测试消费者性能: kafka-consumer-perf-test.sh
查看消息文件数据: kafka-dump-log.sh
查询消费者组位移: kafka-consumer-groups.sh
主题操作: kafka-topics.sh

Kafka Raft

Apache Kafka Raft 是一种共识协议,它的引入是为了消除 Kafka 对 ZooKeeper 的元数据管理的依赖,被社区称之为 Kafka Raft metadata mode,简称 KRaft 模式。

KRaft 运行模式的 Kafka 集群,不会将元数据存储在 Apache ZooKeeper中。即部署新集群的时候,无需部署 ZooKeeper 集群,因为 Kafka 将元数据存储在 controller 节点的 KRaft Quorum中。KRaft 可以带来很多好处,比如可以支持更多的分区,更快速的切换 controller ,也可以避免 controller 缓存的元数据和Zookeeper存储的数据不一致带来的一系列问题。

在3.0版本中可以体验 KRaft 集群模式的运行效果(请注意目前还不成熟,官方不建议生产使用)。

问题总结

Kafka为什么这么快?

Kafka 实现了零拷贝原理来快速移动数据,避免了内核之间的切换。Kafka 可以将数据记录分批发送,从生产者到文件系统(Kafka 主题日志)到消费者,可以端到端的查看这些批次的数据。

批处理能够进行更有效的数据压缩并减少 I/O 延迟,Kafka 采取顺序写入磁盘的方式,避免了随机磁盘寻址的浪费,更多关于磁盘寻址的了解,请参阅 程序员需要了解的硬核知识之磁盘

总结一下其实就是四个要点

  • 顺序读写
  • 零拷贝
  • 消息压缩
  • 分批发送
幂等性与事务的区别?

简单来说,幂等性 Producer 和事务型 Producer 都是Kafka 社区力图为 Kafka 实现精确一次处理语义所提供的工具,只是它们的作用范围是不同的。

  • 幂等性 Producer 只能保证单分区、单会话上的消息幂等性;
  • 而事务能够保证跨分区、跨会话间的幂等性。从交付语义上来看,自然是事务型Producer 能做的更多。

不过,切记天下没有免费的午餐。比起幂等性 Producer,事务型 Producer 的性能要更差,在实际使用过程中,我们需要仔细评估引入事务的开销,切不可无脑地启用事务。

如何防止消息丢失?

生产者:

  • 使用同步发送
  • 把ack设置为1 或者 all,设置同步的分区数>2

消费者:

  • 把自动提交改成手动提交
如何防止消息的重复消费?

在防止消息丢失的方案中,如果生产者发送完消息后,因为网络抖动,没有收到ack,但实际上broker已经收到了,这时候生产者进行重试,broker就会收到多条重复的消息,而造成消费者的重复消费。

解决方案:

  • 生产者关闭重试:会造成消息丢失【不建议】

  • 消费者解决非幂等性消费的问题:

    所谓的幂等性:多次访问的结果是一样的。

    • 在数据库中创建联合主键,防止相同的主键 创建出多条记录。
    • 使用分布式锁,以业务id为锁。保证只有一条记录能够创建成功。

image-20220930093248340

如何做到顺序消费?

**生产者:**保证消息按顺序消费,且消息不丢失——使用同步的发送,ack设置成非0的值。

**消费者:**主题只能设置一个分区,消费组中只能有一个消费者。

Kafka的顺序消费场景不多,因为牺牲掉了性能,但是不如rocketmq在这一块有专门的功能。

如何解决消息积压?

image-20220930093613654

消息积压问题的出现原因?

消息的消费者的消费速度 << 生产者生产消息的速度,导致Kafka中有大量的数据没有被消费。随着没有被消费的数据堆积越多,消费者寻址的性能会越来越差,最后导致整个Kafka对外提供的服务性能越来越差,造成服务雪崩。

消息积压的解决方案:

  • 在这个慢的消费者中,使用多线程,充分利用及其的性能进行消息消费。
  • 通过业务的架构设计,提升业务层面消费的性能。
  • 创建多个消费组,多个消费者,部署到其它机器上一起消费,提高消费者速度
  • 创建一个消费者,该消费者在Kafka上另建一个主题,配上多个分区,多个分区再配上多个消费者。该消费者将poll下来的消息不进行消费,直接转发到新建的主题上,让其他消费者消费【不常用】
消费者组消费进度监控怎么实现?

​ 对于 Kafka 消费者来说,最重要的事情就是监控它们的消费进度了,或者说是监控它们消费的滞后程度。这个滞后程度有个专门的名称:消费者 Lag 或 Consumer Lag。
所谓滞后程度,就是指消费者当前落后于生产者的程度。比方说,Kafka 生产者向某主题成功生产了 100 万条消息,你的消费者当前消费了 80 万条消息,那么我们就说你的消费者滞后了 20 万条消息,即 Lag 等于 20 万。通常来说,Lag 的单位是消息数,而且我们一般是在主题这个级别上讨论 Lag 的,但实际上,Kafka 监控 Lag 的层级是在分区上的。如果要计算主题级别的,你需要手动汇总所有主题分区的 Lag,将它们累加起来,合并成最终的 Lag 值。
​ 我们刚刚说过,对消费者而言,Lag 应该算是最最重要的监控指标了。它直接反映了一个消费者的运行情况。一个正常工作的消费者,它的 Lag 值应该很小,甚至是接近于 0 的,这表示该消费者能够及时地消费生产者生产出来的消息,滞后程度很小。反之,如果一个消费者 Lag 值很大,通常就表明它无法跟上生产者的速度,最终 Lag 会越来越大,从而拖慢下游消息的处理速度。
​ 更可怕的是,由于消费者的速度无法匹及生产者的速度,极有可能导致它消费的数据已经不在操作系统的页缓存中了,那么这些数据就会失去享有 Zero Copy 技术的资格。这样的话,消费者就不得不从磁盘上读取它们,这就进一步拉大了与生产者的差距,进而出现马太效应,即那些 Lag 原本就很大的消费者会越来越慢,Lag 也会越来越大。
​ 鉴于这些原因,你在实际业务场景中必须时刻关注消费者的消费进度。一旦出现 Lag 逐步增加的趋势,一定要定位问题,及时处理,避免造成业务损失。

  1. 使用 Kafka 自带的命令行工具 kafka-consumer-groups 脚本。【推荐指数:⭐️ *3】
  2. 使用 Kafka Java Consumer API 编程。【推荐指数:⭐️ *3】
  3. 使用 Kafka 自带的 JMX 监控指标。【推荐指数:⭐️ *5】
如何实现延迟队列

1.可以通过系统的逻辑设计来实现延时队列。

- 创建多个topic,每个topic表示延时的间隔
topic_5s: 延时5s执⾏的队列
topic_1m: 延时1分钟执⾏的队列
topic_30m: 延时30分钟执⾏的队列

- 消息发送者发送消息到相应的topic,并带上消息的发送时间

- 消费者订阅相应的topic,消费时轮询消费整个topic中的消息
如果消息的发送时间,和消费的当前时间超过预设的值,⽐如30分钟
如果消息的发送时间,和消费的当前时间没有超过预设的值,则不消费当前的offset及之后的offset的所有消息都消费
下次继续消费该offset处的消息,判断时间是否已满⾜预设值

应用场景

订单创建后,超过30分钟没有⽀付,则需要取消订单,这种场景可以通过延时队列来实现

实现思路:

image-20220929174533940

  • kafka中创建创建相应的主题

  • 消费者消费该主题的消息(轮询)

  • 消费者消费消息时判断消息的创建时间和当前时间是否超过30分钟(前提是订单没⽀付)

    • 如果是:去数据库中修改订单状态为已取消

    • 如果否:记录当前消息的offset,并不再继续消费之后的消息。等待1分钟后,再次向kafka拉取该offset及之后的消息,继续进⾏判断,以此反复。

实现案例:

kafka:延时队列 - 掘金 (juejin.cn)

拓展资料:

Kafka也能实现消息延时了?-云社区-华为云 (huaweicloud.com)

Kakfa实现延时队列

几种消息中间件延时队列对比

6个延时队列的实现方案 - DockOne.io


本文正文参考资料:

  • 极客时间-Kafka核心技术与实战
  • 尚硅谷-Kafka教程
  • 极客时间-Kafka核心源码解读
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

结构化思维wz

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值