Kafka
1、综述
同一主题下的不同分区包含的消息是不同的,分区在存储层面可以看作一个可追加的日志(Log)文件,消息在被追加到分区日志、文件的时候都会分配一个特定的偏移量(offset)。offset是消息在分区中的唯一标识,Kafka通过它来保证消息在分区内的顺序性,不过offset并不跨越分区,也就是说,Kafka保证的是分区有序而不是主题有序。
多副本与ISR
Kafka为分区引入了多副本(Replica)机制,通过增加副本数量可以提升容灾能力。同一分区的不同副本中保存的是相同的消息(在同一时刻,副本之间并非完全一样),副本之间是“一主多从”的关系,其中leader副本负责处理读写请求,follower副本只负责与leader副本的消息同步。副本处于不同的broker中,当leader副本出现故障时,从follower副本中重新选举新的leader副本对外提供服务。Kafka通过多副本机制实现了故障的自动转移,当Kafka集群中某个broker失效时仍然能保证服务可用。所以leader副本挂了以后,重新选举的follower副本数据可能不全,存在数据丢失。消费者和follower都用pull的方式去leader上拉数据。由leader副本负责所有读写请求有两个好处:1、实现单调读(Monotonic Reads)。单调读是对于一个消费者,在多次消费消息时,它不会看到某条消息一会儿存在一会儿不存在。如果允许follower提供读服务,当消费者在两个follower间切换时就不能实现单调读。2、实现“Read-your-writes”。Read-your-writes是,当你使用生产者API向Kafka成功写入消息后,马上使用消费者API去读取刚才的消息则一定能读取到。个人认为其实做不到Read-your-writes,因为对于partition来说,有高水位的概念,如果一条message只有leader replica有,ISR中其他replica没有的话,对于consumer来说这条message是不可见的。
分区中的所有副本统称为AR(AssignedReplicas)。所有与leader副本保持一定程度同步的副本(包括leader副本在内〕组成ISR集合(InSyncReplicas)。leader副本负责维护和跟踪ISR集合中所有follower副本的滞后状态。消息会先发送到leader副本,写入leader副本成功后follower副本才能从leader拉取消息进行同步,同步时follower副本相对于leader副本而言会有一定程度的滞后。当follower 副本追赶上leader 副本时, 会更新该副本的 lastCaughtUpTimeMs标识(更新条件见下)。Kafka 的ReplicaManager(单例,每个broker有一个)启动时会创建一个检测副本过期的定时任务(isr-expiration任务,周期是 replica.lag.time.max.ms / 2, A follower can lag behind leader for up to config.replicaLagTimeMaxMs x 1.5 before it is removed from ISR),这个定时任务会检查ISR集合中的副本,如果当前系统时间与该副本的 lastCaughtUpTimeMs差值 大于 replica.lag.time.max.ms ,则判定该follower副本同步失败,需要将此follower副本剔除出ISR集合而放入OSR集合(AR= ISR + OSR)。然后leader副本会更新zk中/state路径下的信息,更新成功后才会修改内存中的ISR集合,然后将ISR变更记录到内存中的isrChangeSet。如果更新zk失败(controller可能也在更新),则放弃本次修改,等下次定时任务运行时再更新。
// 副本的 lastCaughtUpTimeMs 更新逻辑
/*
* If the FetchRequest reads up to the log end offset of the leader when the current fetch request is received,
* set `lastCaughtUpTimeMs` to the time when the current fetch request was received.
* Else if the FetchRequest reads up to the log end offset of the leader when the previous fetch request was received,
* set `lastCaughtUpTimeMs` to the time when the previous fetch request was received.
*
* This is needed to enforce the semantics of ISR, i.e. a replica is in ISR if and only if it lags behind leader's LEO
* by at most `replicaLagTimeMaxMs`. These semantics allow a follower to be added to the ISR even if the offset of its
* fetch request is always smaller than the leader's LEO, which can happen if small produce requests are received at
* high frequency.
*/
// 这个函数是在收到 follower的fetchRequst,读完leader本地日志后调用,用于更新follower副本的状态信息。
def updateLogReadResult(logReadResult: LogReadResult) {
// logReadResult.leaderLogEndOffset是收到follower的fetchRequst,还未开始读leader日志时的LEO。
// follower 有个单独的线程,用于发送fetchRequst,然后同步等待,收到响应处理后,立刻发送下一个fetchRequst
// 条件1:follower副本将leader副本LEO (LogEndOffset)之前的日志全部同步,但是当leader上源源不断有ProduceRequest时,
// follower的LEO会一直跟不上leader的LEO,所以需要下一个条件,放宽 _lastCaughtUpTimeMs 的更新条件,让更多的follower能留在ISR。
if (logReadResult.info.fetchOffsetMetadata.messageOffset >= logReadResult.leaderLogEndOffset)
_lastCaughtUpTimeMs = math.max(_lastCaughtUpTimeMs, logReadResult.fetchTimeMs)
else if (logReadResult.info.fetchOffsetMetadata.messageOffset >= lastFetchLeaderLogEndOffset)
_lastCaughtUpTimeMs = math.max(_lastCaughtUpTimeMs, lastFetchTimeMs)
logStartOffset = logReadResult.followerLogStartOffset
// 更新follower的LEO
logEndOffset = logReadResult.info.fetchOffsetMetadata
lastFetchLeaderLogEndOffset = logReadResult.leaderLogEndOffset
lastFetchTimeMs = logReadResult.fetchTimeMs
}
ISR的缩小是有一个定时任务定时检查,而ISR的扩展是在Leader收到Follower的Fetch请求时。读完leader的本地日志后,并更新完follower的状态信息(包括 lastCaughtUpTimeMs,LEO),判断是否可以将该Follower重新加入ISR中。 判断规则是此副本的LEO是否大于等于leader副本的HW(HW更新规则见下),注意这里并不需要追上leader副本的LEO (因为consumer只能拉到leader副本HW之前的数据,这样leader挂了后,从ISR中新选举出来的leader的数据一定比现在consumer的数据都要完整)。ISR集合扩充之后同样会尝试更新 /brokers/topics/<topic>/partition/<partition>/state节点和 isrChangeSet。
收到follower的fetch请求:1、读leader磁盘或操作系统pageCache中的本地日志;2、更新follower副本的状态信息(包括 lastCaughtUpTimeMs,LEO);3、检查该follower是否可以加入ISR;4、不管该follower是否可以加入ISR,都尝试更新HW;5、如果当前读到的日志不够,创建延时任务,否则向follower返回数据;
/**
* Check and maybe increment the high watermark of the partition;
* this function can be triggered when
*
* 1. Partition ISR changed
* 2. Any replica's LEO changed(包括leader和follower的LEO变化时)
*
* The HW is determined by the smallest log end offset among all replicas that are in sync or are considered caught-up.
* This way, if a replica is considered caught-up, but its log end offset is smaller than HW, we will wait for this
* replica to catch up to the HW before advancing the HW. This helps the situation when the ISR only includes the
* leader replica and a follower tries to catch up. If we don't wait for the follower when advancing the HW, the
* follower's log end offset may keep falling behind the HW (determined by the leader's log end offset) and therefore
* will never be added to ISR.
*
* Returns true if the HW was incremented, and false otherwise.
* Note There is no need to acquire the leaderIsrUpdate lock here
* since all callers of this private API acquire that lock
*/
// 在 maybeExpandIsr maybeShrinkIsr appendRecordsToLeader 中调用
private def maybeIncrementLeaderHW(leaderReplica: Replica, curTime: Long = time.milliseconds): Boolean = {
// 当前的 ISR 和 (currentTime - lastCaughtUpTimeMs) <= replica.lag.time.max.ms的follower构成的集合 的LEO的最小值 是HW。
// 如果只考虑ISR,当ISR中只有leader时,leader的LEO就是HW,这会导致follower很难重新加入HW。
// 通过放宽 _lastCaughtUpTimeMs 的更新条件,让更多的follower能留在ISR,
// 也让那些不在ISR中的follower能参与更新HW(其实是暂停HW的增大),让follower能更容易重新加入ISR。
val allLogEndOffsets = assignedReplicas.filter { replica =>
curTime - replica.lastCaughtUpTimeMs <= replicaManager.config.replicaLagTimeMaxMs || inSyncReplicas.contains(replica)
}.map(_.logEndOffset)
val newHighWatermark = allLogEndOffsets.min(new LogOffsetMetadata.OffsetOrdering)
val oldHighWatermark = leaderReplica.highWatermark
// Ensure that the high watermark increases monotonically. We also update the high watermark when the new
// offset metadata is on a newer segment, which occurs whenever the log is rolled to a new segment.
// HW 只能单调递增
if (oldHighWatermark.messageOffset < newHighWatermark.messageOffset ||
(oldHighWatermark.messageOffset == newHighWatermark.messageOffset && oldHighWatermark.onOlderSegment(newHighWatermark))) {
leaderReplica.highWatermark = newHighWatermark
debug(s"High watermark updated to $newHighWatermark")
true
} else {
def logEndOffsetString(r: Replica) = s"replica ${r.brokerId}: ${r.logEndOffset}"
debug(s"Skipping update high watermark since new hw $newHighWatermark is not larger than old hw $oldHighWatermark. " +
s"All current LEOs are ${assignedReplicas.map(logEndOffsetString)}")
false
}
}
Kafka的ReplicaManager启动时除了创建isr-expiration,还会创建 isr-change-propagation周期任务, isr-change-propagation会周期性地检查isrChangeSet, 如果发现 isrChangeSet中有 ISR集合的变更记录,那么它会在 /isr_change_notification 路径下创建 一 个以 isr_change_ 开头的持久顺序节点(比如/isr_change_notification/isr_change_ 0000000000) ,并将isrChangeSet中的信息保存到这个节点中 。控制器为/isr_change_notification 添加了 一 个Watcher, 当这个节点中有子节点发生变化时会 触发控制器去partition的state节点重新读取最新的ISR信息,然后更新集群元数据信息并向其他broker节点发送UpdateMetadata 请求,最后删除 /isr_change_notification路径下已经处理过的节点 。频繁地触发Watcher会影响 控制器和ZK的性能 。为了避免这种情况,Kafka添加了限定条件, 当检测到分区的ISR集合发生变化时,还需要检查以下两个条件:(1)上 一 次ISR集合发生变化距离现在已经超过5s。(2)上 一次写入ZooKeeper的时间距离现在已经超过 60s。满足以上两个条件之一才可以将ISR集合的变化写入目标节点 。注意在kafka2.7 之后,ISR集合变更的传播不是通过写入zk节点,而是改成broker定时向Controller发起AlterIsrRequest请求。
默认情况下,当leader副本发生故障时,只有在ISR集合中的副本才有资格被选举为新的leader,而在OSR集合中的副本则没有任何机会(不过这个原则也可以通过修改相应的参数配置来改变)。一个分区的AR集合(包括leader)如果共有n个节点,则可以容忍n - 1个节点失败,每次producer的消息需要所有n个节点(包括leader)的确认。相比于zk的Quorum 模型,相同节点下可以容忍的失败节点更多,且能保证leader挂了以后选出来的新leader也是符合负载均衡的(Kafka的leader的选举顺序按照AR集合创建时的顺序,AR集合分配时已经考虑负载均衡)。
LEO、HW、LSO
LEO(Log End Offset)标识当前日志文件中下一条待写入消息的offset,图1-4中offset为9的位置即为当前日志文件的LEO, LEO的大小相当于当前日志分区中最后一条消息的offset值加1。分区ISR集合中的每个副本都会维护自身的LEO,leader副本负责维护partition的HW(High Watermark),ISR中的follower都同步的消息的offset+1即为HW。对消费者而言只能消费HW之前的消息(offset<HW的消息)。这个机制限制了消费者的消费速度比ISR中的所有follower慢,保证leader挂了后,从ISR中新选举出来的leader能够满足消费者的消息回溯功能,消费者的消息回溯功能一定是生效的,因为消费者消费过的数据在ISR的所有机器上都存在。当ISR集合发生增减,或者ISR集合中任一副本的LEO发生变化时,都可能会影响整个分区的HW。
LSO(Last Stable Offset) is the first message in the partition belonging to an open transaction. A read_committed consumer will only read up till the LSO and filter out any transactional messages which have been aborted.
如图10-8所示。 对未完成的事务而言, LSO一般小于HW(follower只能是read_uncommitted的,follower拉取消息不受LSO限制,这导致LSO < HW), 对已完成的事务而言,LSO等于HW, 所以我们可以得出一个结论: LSO<=HW<=LEO。
Kafka 的根目录下有cleaner-offset-checkpoint(见日志存储章节)、log-start-offset-checkpoint、 recovery-point-offset-checkpoint和replication-offset-checkpoint四个检查点文件,recovery-point-offset-checkpoint和replication-offset-checkpoint这两个文件分别对应了LEO 和HW。 Kafka 中会有两个定时任务负责将所有分区的LEO和HW刷写到 recovery-pointoffset-checkpoint 文件 和 replication-offset-checkpoint文件。
消息传输保障
一般而言, 消息中间件的消息传输保障有 3 个层级, 分别如下。
(1) at most once:至多 一次。 消息可能会丢失, 但绝对不会重复传输。
(2) at least once: 最少一次。 消息绝不会丢失, 但可能会重复传输。
(3) exactly once :恰好一次。 每条消息肯定会被传输一次且仅传输一次。
当生产者向 Kafka 发送消息时, 一旦消息被成功提 交到日志文件, 由于多副本机制的存在, 这条消息就不会丢失。 如果生产者发送消息到 Kafka 之后, 遇到了网络问题而造成通信中断, 那么生产者就无法判断该消息是否己经提交。 虽然 Kafka 无法确定网络故障期间发生了什么, 但生产者可以进行多次重试来确保消息已经写入 Kafka, 这个重试的过程中有可能会造成消息的重复写入, 所以这里 Kafka 提供的消息传输保障为 at least once 。
对消费者而言, 消费者处理消息和提交消费位移的顺序在很大程度上决定了消费者提供哪一种消息传输保障。 如果消费者在拉取完消息之后 ,上层应用逻辑先处理消息后提交消费位移(目前默认是这种方式),那 么在消息处理之后且在位移提交之前消费者看机了, 待它重新上线之后, 会从上一次位移提交的位置拉取, 这样就出现了重复消费, 因为有部分消息已经处理过了只是还没来得及提交消费 位移, 此时就对应 at least once 。如果消费者在拉完消息之后, 应用逻辑先提交消费位移后进行消息处理,那么在位移提交之后且在消息处理完成之前消费者岩机了,待它重新上线之后,会从己经提交的位移处开始重新消 费,但之前尚有部分消息未进行消 费 ,如此就会发生消息丢失, 此时就对应 at most once。
主题与分区
Kafka与zookeeper
如果让多个Kafka集群使用同一个ZooKeeper集群,这时候可以使用chroot参数。比如有两个Kafka集群,假设分别叫kafka1和kafka2,那么两套集群的zookeeper.connect参数可以这样指定:zk1:2181,zk2:2181,zk3:2181/kafka1 和 zk1:2181,zk2:2181,zk3:2181/kafka2。
我自己在zookeeper根目录下创建一个/kafka目录,Kafka的所有数据都放在这个目录下。
Kafka在 zookeeper的目录结构参考 https://blog.csdn.net/lbh199466/article/details/103401361
https://bgbiao.top/post/kafka在zookeeper中的存储结构/
https://cloud.tencent.com/developer/article/1875276
[zk: localhost:2181(CONNECTED) 13] ls /kafka
[cluster, controller_epoch, controller, brokers, admin, isr_change_notification, consumers, log_dir_event_notification, latest_producer_id_block, config]
[zk: localhost:2181(CONNECTED) 16] ls /kafka/brokers
[ids, topics, seqid]
broker 启动时会在 /brokers/ ids 路径下创建一个以当前 brokerId 为名称的虚节点,broker 下线时该虚节点会自动删除,其他节点可据此判断该 broker 的健康状态。
如果一个broker节点未手动配置broker.id,可以根据/brokers/seqid 自动生成brokerId,原理是先往 /brokers/seqid 节点中写入一个空字符串 ,然后获取返回的 Stat 信息中 的 version 值 ,将 version 的值和 reserved.broker. max.id 参数配置的值相加。
[zk: localhost:2181(CONNECTED) 16] get /kafka/brokers/ids/0
{"listener_security_protocol_map":{"PLAINTEXT":"PLAINTEXT"},"endpoints":["PLAINTEXT://192.168.0.101:9092"],"jmx_port":-1,"host":"192.168.0.101","timestamp":"1669438275755","port":9092,"version":4}
当创建一个主题时会在 zooKeeper 的/brokers/topics/目录下创建一个同名的实节点,该节点中记录了该主题的分区副本分配方案。示例如下 :
[root@node1 kafka_2.11-2.0.0] # bin/kafka-topics.sh --zookeeper localhost: 2181/kafka --create --topic topic-create --partitions 4 --replication-factor 2
Created topic "topic-create". #此为控制台输出结果
/brokers/topics/<topic> 节点存储了topic每个分区的分配信息。每个partition的AR集合,如果AR集合没有变化(分区重分配),这个节点不会被修改
[zk: localhost:2181(CONNECTED) 20] get /kafka/brokers/topics/topic-demo
{"version":1,"partitions":{"2":[0,2,1],"1":[2,1,0],"3":[1,2,0],"0":[1,0,2]}}
cZxid = 0x1a00000131
ctime = Tue Oct 12 12:08:30 CST 2021
mZxid = 0x1a00000131
mtime = Tue Oct 12 12:08:30 CST 2021
pZxid = 0x1a00000133
cversion = 1
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 76
numChildren = 1
/brokers/topics/<topic>/partitions/<partition>/state 记录了当前leader副本、leader—epoch、ISR等信息,且这个节点是由leader节点不断更新的。
[zk: localhost:2181(CONNECTED) 15] get /kafka/brokers/topics/topic-demo/partitions/0/state
{"controller_epoch":160,"leader":1,"version":1,"leader_epoch":227,"isr":[2,1,0]}
cZxid = 0x1a0000013b
ctime = Tue Oct 12 12:08:30 CST 2021
mZxid = 0x29000000af
mtime = Sat Nov 26 12:54:01 CST 2022
pZxid = 0x1a0000013b
cversion = 0
dataVersion = 451
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 80
numChildren = 0
其中 controller_epoch表示当前 Kafka控制器的epoch, leader表示当前分区的leader副本所在的broker的id编号,version表示版本号(当前固定为1) , leader_epoch 表示当前分区的leader纪元 ,isr表示变更后的ISR列表。
[zk: localhost:2181(CONNECTED) 14] ls /kafka/config
[changes, clients, brokers, topics, users]
zooKeeper 节点 /config/topics/[topic]保存了某个topic创建时通过 --config参数设置的主题的相关参数 , 示例如下 :
[root@nodel kafka_2.11- 2.0.0] # bin/kafka-topics.sh --zookeeper localhost:2181/kafka --create --topic topic-config --replication-factor 1 --partitions 1 --config cleanup.policy=compact --config max.message.bytes=lOOOO
[zk: localhost:2181/kafka(CONNECTED) 7] get /config/topics/topic-config
{"version": 1 , "config": { "max.message.bytes" : "10000", "cleanup.policy": "compact" }}
类似地 /config/brokers/[brokerId] 保存broker节点的配置信息
类似地 /config/clients/[clientId] 保存client节点(即KafkaProducer或KafkaConsumer的client.id参数的值)的配置信息
动态配置
https://www.cnblogs.com/lizherui/p/12271285.html
静态参数是必须在Kafka的配置文件server.properties中进行设置的参数,新增、修改、删除后必须重启Broker进程才能令它们生效。为了避免频繁重启broker,引入了动态参数。最新的2.3版本中的Broker端参数有200多个,社区并没有将每个参数都升级成动态参数,仅仅是把一部分参数变成了可动态调整。
打开Kafka官网,你会发现Broker Configs表中增加了Dynamic Update Mode列。该列有3类值,分别是read-only、per-broker和cluster-wide。
- read-only参数和原来的参数行为一样,只有重启Broker,才能令修改生效。
- per-broker参数属于动态参数,修改后只会在对应的Broker上生效。per-broker参数保存在 /config/broker/{brokerId} 路径下
- cluster-wide参数也属于动态参数,修改后会在整个集群范围内生效,也就是对所有Broker都生效。cluster-wide参数保存在 /config/brokers/default路径下
- log.retention.ms参数是cluster-wide级别的,Kafka允许为集群内所有Broker统一设置一个日志留存时间值。
参数优先级:per-broker参数 > cluster-wide参数 > read-only静态参数 > Kafka默认值。
/config目录
/config/[clients|brokers|topics|users] 下,/config/topics是用来保存主题级别参数的,虽然它们不属于动态Broker端参数,但其实它们也是能够动态变更的。/config/users和/config/clients 则是用于动态调整客户端配额(Quota)的znode节点。所谓配额,是指Kafka运维人员限制连入集群的客户端的吞吐量或者是限定它们使用的CPU资源。
用户修改配置时,除了将最新的配置写到/config/[clients|brokers|topics|users] 下,还需要写到 /config/changes目录下(持久顺序节点(PERSISTENT_SEQUENTIAL),比如 /config/changes/config_change_0000000012), 每个broker 监听 /config/changes 的子节点变化,每次触发获取 /config/changes的所有子节点的列表,获得比当前内存中last_seqNo大的seqNo的json内容,从中读取相对路径,由此判断从 /config/[topics|clients|users|brokers] 四种类型中读取哪个配置路径下的数据。同一个Broker在操作过程中任何时刻只能串行读写一种类型的配置,多个配置需要依次串行操作。
必须要及时清除/config/changes 下无用的seqNode集合,清除步骤如下:
当broker监听到子节点变化回调时,记录系统时间。获取 /config/changes下所有子节点,读取每个seqNode的创建时间。系统时间减去seqNode创建时间,如果时间差值大于过期时间就会被删除。过期时间默认15分钟,可配置。
/config/changes的作用
通知broker有新的动态配置产生,读取相应的动态配置
不用监听四种类型配置下大量的路径,每个broker只需要监听一个notification节点即可,高效且性能也高
创建主题时的分区副本默认分配方法
分区副本分配的几个原则:
- 将分区和副本平均分布在所有的 Broker 上。
- leader副本和follower副本一定不能在同一个broker上。
- 如果有机架信息的话, 尽量将 partition 的副本分配到不同的机架上。如果rack数量不够,有可能出现leader和follower在同一个机架上
为实现上面的目标,在没有机架感知的情况下,按照下面的方法分配 replica:
- 从 broker.list 随机选择一个 Broker,使用 round-robin 算法分配每个 partition 的第一个副本(优先副本);
- 对于这个 partition 的其他副本, 去掉优先副本所在的broker后,通过逐渐增加 Broker.id 来分配。
// kafka.admin.AdminUtils.scala
private def assignReplicasToBrokersRackUnaware(nPartitions: Int, //分区数
replicationFactor: Int, //副本因子
brokerList: Seq[Int], //集群中 broker 列表
fixedStartIndex: Int, // 起始索引,即第 一个副本分自己的位置,默认值为-1
startPartitionId: Int): // 起始分区编号,默认值为-1
Map[Int, Seq[Int]] = {
val ret = mutable.Map[Int, Seq[Int]]()
val brokerArray = brokerList.toArray
// startlndex 随机产生,这样可以在多个主题的情况下尽可能地均匀分布分区副本,
// 极端场景下,如果每个主题的分区数和副本因子都是1,startlndex的随机产生也会保证这些分区均匀分布
val startIndex = if (fixedStartIndex >= 0) fixedStartIndex else rand.nextInt(brokerArray.length)
var currentPartitionId = math.max(0, startPartitionId)
// nextReplicaShift 随机产生,尽可能地均匀分布分区副本
var nextReplicaShift = if (fixedStartIndex >= 0) fixedStartIndex else rand.nextInt(brokerArray.length)
for (_ <- 0 until nPartitions) {
/* Once it has completed the first round-robin, if there are more partitions to assign,
the algorithm will start shifting the followers. This is to ensure we will not always
get the same set of sequences. 实现新的分区的副本分布和之前的不同 */
if (currentPartitionId > 0 && (currentPartitionId % brokerArray.length == 0))
nextReplicaShift += 1
// 为该分区的leader副本分配broker,注意currentPartitionId是递增的
val firstReplicaIndex = (currentPartitionId + startIndex) % brokerArray.length
val replicaBuffer = mutable.ArrayBuffer(brokerArray(firstReplicaIndex))
for (j <- 0 until replicationFactor - 1)
// 为该分区的follower副本分配broker,注意j是递增的,此时firstReplicaIndex和 nextReplicaShift是不变的
replicaBuffer += brokerArray(replicaIndex(firstReplicaIndex, nextReplicaShift, j, brokerArray.length))
ret.put(currentPartitionId, replicaBuffer)
currentPartitionId += 1
}
ret
}
// replicaIndex 函数保证了对于一个partition而言,follower副本和leader副本不能在同一个Broker上。
// topic创建时要求replication-factor <= 可用的broker数量
private def replicaIndex(firstReplicaIndex: Int, secondReplicaShift: Int, replicaIndex: Int, nBrokers: Int): Int = {
// 注意这里算出来的shift属于[1, nBrokers-1],保证至少移动1个(secondReplicaShift可以为0),最多移动nBrokers-1个。
// 如果shift = nBrokers 的话,就会出现某个follower副本和leader副本在同一个Broker上
val shift = 1 + (secondReplicaShift + replicaIndex) % (nBrokers - 1)
(firstReplicaIndex + shift) % nBrokers
}
private def assignReplicasToBrokersRackAware(nPartitions: Int, //分区数
replicationFactor: Int, //副本因子
brokerMetadatas: Seq[BrokerMetadata],
fixedStartIndex: Int, // 起始索引,即第 一个副本分自己的位置,默认值为-1
startPartitionId: Int): // 起始分区编号,默认值为-1
Map[Int, Seq[Int]] = {
// key是broker,value是这个broker对应的rack
val brokerRackMap = brokerMetadatas.collect { case BrokerMetadata(id, Some(rack)) =>
id -> rack
}.toMap
val numRacks = brokerRackMap.values.toSet.size
// 经过特殊处理的broker列表,按照 rack1, rack2, rack3, rack1, rack2, rack3, ... 排序
val arrangedBrokerList = getRackAlternatedBrokerList(brokerRackMap)
val numBrokers = arrangedBrokerList.size
val ret = mutable.Map[Int, Seq[Int]]()
val startIndex = if (fixedStartIndex >= 0) fixedStartIndex else rand.nextInt(arrangedBrokerList.size)
var currentPartitionId = math.max(0, startPartitionId)
var nextReplicaShift = if (fixedStartIndex >= 0) fixedStartIndex else rand.nextInt(arrangedBrokerList.size)
for (_ <- 0 until nPartitions) {
if (currentPartitionId > 0 && (currentPartitionId % arrangedBrokerList.size == 0))
nextReplicaShift += 1
// 为该分区的leader副本分配broker,注意currentPartitionId是递增的
val firstReplicaIndex = (currentPartitionId + startIndex) % arrangedBrokerList.size
val leader = arrangedBrokerList(firstReplicaIndex)
val replicaBuffer = mutable.ArrayBuffer(leader)
// 把leader副本所有在rack加入set
val racksWithReplicas = mutable.Set(brokerRackMap(leader))
// 把leader副本加入set
val brokersWithReplicas = mutable.Set(leader)
var k = 0
// 依次为这个partition下面的所有follower副本分配broker
for (_ <- 0 until replicationFactor - 1) {
var done = false
while (!done) {
// replicaIndex 函数保证了对于一个partition而言,follower副本和leader副本不能在同一个Broker上。
// 这里只有变量k是递增的
val broker = arrangedBrokerList(replicaIndex(firstReplicaIndex, nextReplicaShift * numRacks, k, arrangedBrokerList.size))
val rack = brokerRackMap(broker)
// 对于某个follower副本,通过replicaIndex函数选出broker后,判断是否采用这个broker,如果没有被采用,递增变量k,继续选择arrangedBrokerList列表中的下一个broker来判断是否可以采用。如果采用这个broker,也会递增变量k,继续选择arrangedBrokerList列表中的下一个broker来判断是否可以作为下一个follower副本的broker。
// 如果rack数量不够,有可能出现leader和follower在同一个机架上
// Skip this broker if
// 1. there is already a broker in the same rack that has assigned a replica AND there is one or more racks
// that do not have any replica, or
// 2. the broker has already assigned a replica AND there is one or more brokers that do not have replica assigned
if ((!racksWithReplicas.contains(rack) || racksWithReplicas.size == numRacks)
&& (!brokersWithReplicas.contains(broker) || brokersWithReplicas.size == numBrokers)) {
replicaBuffer += broker
racksWithReplicas += rack
brokersWithReplicas += broker
done = true
}
k += 1
}
}
ret.put(currentPartitionId, replicaBuffer)
currentPartitionId += 1
}
ret
}
/**
* Given broker and rack information, returns a list of brokers alternated by the rack. Assume
* this is the rack and its brokers:
*
* rack1: 0, 1, 2
* rack2: 3, 4, 5
* rack3: 6, 7, 8
*
* This API would return the list of 0, 3, 6, 1, 4, 7, 2, 5, 8
*
* This is essential to make sure that the assignReplicasToBrokers API can use such list and
* assign replicas to brokers in a simple round-robin fashion, while ensuring an even
* distribution of leader and replica counts on each broker and that replicas are
* distributed to all racks.
*/
private[admin] def getRackAlternatedBrokerList(brokerRackMap: Map[Int, String]): IndexedSeq[Int] = {
val brokersIteratorByRack = getInverseMap(brokerRackMap).map { case (rack, brokers) =>
(rack, brokers.toIterator)
}
// racks列表是按照rack从小到达排序的
val racks = brokersIteratorByRack.keys.toArray.sorted
val result = new mutable.ArrayBuffer[Int]
var rackIndex = 0
while (result.size < brokerRackMap.size) {
val rackIterator = brokersIteratorByRack(racks(rackIndex))
if (rackIterator.hasNext)
result += rackIterator.next()
rackIndex = (rackIndex + 1) % racks.length
}
result
}
// 返回的结果是个map,map的value是某个rack对应的broker列表,这个列表是排序的
private[admin] def getInverseMap(brokerRackMap: Map[Int, String]): Map[String, Seq[Int]] = {
brokerRackMap.toSeq.map { case (id, rack) => (rack, id) }
.groupBy { case (rack, _) => rack }
.map { case (rack, rackAndIdList) => (rack, rackAndIdList.map { case (_, id) => id }.sorted) }
}
注意topic创建时要求replication-factor <= 可用的broker数量,否则会抛InvalidReplicationFactorException异常
主题的管理
创建(和修改)主题的实质都是在 ZooKeeper中的 /brokers/topics节点下创建(和修改)该主题对应的子节点并写入分区副本分配方案, 并且在 /config/topics/节点下创建与 该主题对应的子节点并写入主题相关的配置信息(这个步骤可以省略不执行)。 所以我们可以直接使用 ZooKeeper 的客户端在 /brokers/topics节点下创建相应的主题节点并写入预先设定好的分配方案, 这样就可以直接创建 一个新的主题了。这种创建主题的方式还可以绕过原本使用kafka-topics.sh脚本创建主题的一些限制, 比如分区的序号可以不用从0开始连续累加了。
删除主题本质上只是在ZooKeeper中的/admin/delete_topics 路径下创建一个与待删除主题同名的节点,以此标记该主题为待删除的状态。与创建主题相同的是,删除主题的操作也是异步的,真正创建和删除主题的动作是由Kafka的控制器负责完成的。
我们还可以通过手动的方式来删除主题(不在/admin/delete_topics下创建节点)。 主题中的元数据存储在 ZooKeeper 中的 /brokers/topics 和/config/topics路径下, 主题中的消息数据存储在log.dir 或 log.dirs配置的路径下, 我们只需要手动删除这些地方的内容即可。 下面的示例中演示了如何删除主题topic-delete, 总共分3个步骤, 第一步和第二步的顺序可以互换。
第一步, 使用ZK客户端 删除ZooKeeper中的节点/config/topics/topic-delete。
第二步, 使用ZK客户端 删除ZooKeeper中的节点/brokers/topics/topic-delete及其子节点。
第三步, 删除集群中所有与主题 topic-delete有关的文件。
#在集群中的各个broker节点中执行 rm -rf /tmp/kafka-logs/topic-delete命令来删除与主题 topic-delete有关的文件
[root@node1 kafka_2.11-2.0.0]# rm -rf /tmp/kafka-logs/topic-delete
[root@node2 kafka_2.11-2.0.0]# rm -rf /tmp/kafka-logs/topic-delete*
[root@node3 kafka_2.11-2.0.0]# rm -rf /tmp/kafka-logs/topic-delete*
注意, 删除主题是一个不可逆的操作。 一旦删除之后,与其相关的所有消息数据会被全部删除, 所以在执行这 操作的时候也要三思而后行。
创建主题
https://cloud.tencent.com/developer/article/1980425
controller写 /brokers/topics/,controller监听并响应/brokers/topics/下的更新

如果我手动在zk中添加/brokers/topics/{TopicName}节点并写入对应信息会怎么样?
根据上面的时序图可以发现,客户端发起创建Topic的请求,分为两个执行阶段:
- 第一个阶段是controller往zk中写入
- topic的配置信息 /config/topics/Topic名称 持久节点
- topic的分区信息/brokers/topics/Topic名称 持久节点
- 第二个阶段是 Controller监听zk节点/brokers/topics变更阶段
如果我们绕过第一阶段直接去写入数据,可以达到一样的效果,因为缺少第一阶段的校验,我们需要保证数据准确。
创建Topic的时候 什么时候在Broker磁盘上创建的日志文件?
在第二阶段,controller会向相关的broker发送leaderAndIsrRequest请求,broker收到后会创建本地log
分区扩容
https://blog.csdn.net/u010634066/article/details/117990173
Kafka允许增加主题的分区数,增加主题的分区数会影响既定消息的顺序,原本发往分区0的消息现在有可能会发往分区1或分区2。分区扩容不会改变已有分区的分配方式(即便手动指定已有分区的分配策略),新增的分区也会采用分区分配算法进行分配,然后将新的topic分配信息写入zk,之后controller监听到zk的节点有变化后便会真正开始创建partition的流程
目前Kafka只支持增加分区数而不支持减少分区数。为什么不支持减少分区?按照Kafka现有的代码逻辑, 此功能完全可以实现,不过也会使代码的复杂度急剧增大。实现此功能需要考虑的因素很多,反观这个功能的收益点却 是很低的, 如果真的需要实现此类功能,则完全可以重新创建一个分区数较小的主题,然后将现有主题中的消息按照既定的逻辑复制过去即可。Kafka允许增加或减少副本因子 replication-factor。
controller写 /brokers/topics/{topicName},controller监听并响应/brokers/topics/{topicName}下的更新

删除主题
https://www.szzdzhp.com/kafka/Source_code/source-del-topic.html#3-1-resumeDeletions-执行删除方法
用户程序写 /admin/delete_topics,controller监听并响应 /admin/delete_topics,等全部删除流程完成后,controller删除 /admin/delete_topics和/brokers/topics/{topicName}的数据

- 在3.3这步中,如果controller发现某个topic正在重分配或者存在 dead replica(因为broker挂了),会将这些topic暂时加入内存中的topicsIneligibleForDeletion队列中,等broker重新启动或者重分配完成会尝试重新删除,另外第4步中,如果发送StopReplicaRequest删除失败,也会将该topic加入topicsIneligibleForDeletion队列等待重新删除
- 4.3 这步,broker收到信息后会拒绝所有client的请求(UnknownTopicOrPartitionException),同时会让消费者组协调器去删除对应topic提交的offset数据。
controller启动或者发生failover后,controller会从 /admin/delete_topics 获取所有需要被删除的Topic,继续上述删除过程
为什么正在重新分配的Topic不能被删除?
正在重新分配的Topic,你都不知道它具体会落在哪些broker上,只能等重分配完成后再删除
从上述流程可以看出,我们可以在/admin/delete_topics/中手动写入一个topic触发删除过程
分区重分配(分区扩容 + 分区删除)
https://www.szzdzhp.com/kafka/Source_code/source-code-pr.html
Broker节点下线时只会触发leader副本的重新选举,并不会自动将这个broker节点上的follower副本转移到其他broker节点上,这样会导致某些partition的ISR集合变小。新扩容一个broker节点后,只有新创建的主题分区才有可能被分配到这个新节点上, 而之前的已经完成分配的主题分区并不会自动转移到新加入的节点中,因为在它们被创建时还没有这个新节点, 这样新节点的负载和原先节点的负载之间严重不均衡。将某个broker节点负责的副本迁移(移动)到其他broker节点叫做分区重分配,这个过程需要管理员手动触发。
-
client计算出每个分区新的副本分配方案, 往 /admin/reassign_partitions 路径下写入迁移任务(包含每个partition新的AR集合);还可以配合throttle参数,添加配额配置并写入到zk节点/config/broker/{brokerId}中 ,broker会实时响应/config/broker/{brokerId}中的配置。
- Kafka的配额有两种,producer_byte_rate,发布者单位时间(每秒)内可以发布到单台broker的字节数。consumer_byte_rate。消费者单位时间(每秒)内可以从单台broker拉取的字节数。kafka每个broker会统计每个client单位时间内发送或拉取的字节数,如果超过配额,kafka的处理方式是:延时回复给业务方,不使用特定返回码,无需客户端做backoff和retry的逻辑。如果Producer超额了,先把数据append到log文件,再计算延时时间,然后通过delayQueue延迟一段时间再返回response。如果Consumer超额了,先计算延时时间,在延时到期后再去从log读取数据并返回给Consumer。
-
controller会监听 zk 的 /admin/reassign_partitions 路径,当下面的节点有变化时,开始处理迁移任务
-
controller 会对 /brokers/topics/<topic>/partition/<partition>/state 节点注册一个监听器,监听partition的ISR集合的变化,当新分配的副本都位于ISR集合后,leader 会更新 isr 到 zk 中,此时会触发 Controller 继续处理迁移任务;
-
controller的副本迁移步骤:
- Update AR in ZK (更新 /brokers/topics/<topic>的内容) with OAR + RAR. (RAR = Reassigned replicas, OAR = Original list of replicas for partition, AR = current assigned replicas)
- Send LeaderAndIsr request to every replica in OAR + RAR (with AR as OAR + RAR). (会强制更新 zk 中 leader 的 epoch,副本收到LeaderAndIsr请求后,新的副本会开始创建并且同步数据)
- Start new replicas RAR - OAR (RAR集合减去OAR集合)by moving replicas in RAR - OAR to NewReplica state.
- Wait until all replicas in RAR are in sync with the leader. RAR 中的副本都在 isr 中才继续处理。
- 这一步是通过 /brokers/topics/<topic>/partition/<partition>/state 节点的监听器唤醒的,唤醒的时候是某个partition的同步完成了,后面的处理都是针对单个partition的。
- 如果刚好leader挂了,且ISR集合为空导致leader无法切换,这样leader就不会更新zk中的isr的信息,导致不会触发Controller执行后续的一些列操作,整个重分配任务会一直在进行中。解决办法把宕机的Broker重启
- Move all replicas in RAR to OnlineReplica state.
- Set AR to RAR in memory.
- Send LeaderAndIsr request with a potential new leader (if current leader not in RAR, elect a new leader from RAR) ,and a new AR (using AR = RAR) and same isr to every broker in RAR
- 选出leader后需要更新到 ZK 的 /brokers/topics/partition/state节点,但broker不会监听这个节点,所以还要给该partition下的 broker发LeaderAndIsr 请求
- Move all replicas in OAR - RAR to OfflineReplica state (force those replicas out of isr). As part of OfflineReplica state change, we shrink the isr to remove OAR - RAR in zookeeper and send a LeaderAndIsr ONLY to the Leader to notify it of the shrunk isr. After that, we send a StopReplica (delete = false) to the replicas in OAR - RAR. ( StopReplica (delete = false) 会让副本停止发起fetch请求)
- Move all replicas in OAR - RAR to NonExistentReplica state. This will send a StopReplica (delete = true) to the replicas in OAR - RAR to physically delete the replicas on disk.(将日志放到待删除队列中延迟删除)
- Update AR in ZK (更新 /brokers/topics/<topic>的内容) with RAR. Note that we have to update AR in ZK with RAR last since it's the only place where we store OAR persistently. This way, if the controller crashes before that step, we can still recover.
- Update the /admin/reassign_partitions path in ZK to remove this partition.(只从任务中删除当前partition的信息,如果所有partition都已完成,删除整个任务)Remove the ISR reassign listener(删除 /brokers/topics/{topicName}/partitions/{分区号}/state 的监听器)
- After electing leader, the replicas and isr information changes. So resend the update metadata request to every broker.
分区重分配的原理是先通过控制器为每个分区添加新副本(暂时增大副本因子),将分区原有的副本与新分配的副本合并成一个新的副本集合,新分配的副本努力追上Leader的offset,最终加入ISR。待全部副本都加入ISR之后,控制器会进行分区Leader选举,选举完后控制器删除原有副本(恢复为原先的副本因子数)。由于是最后选举完成才删除原副本,所以重分配的过程中,日志存储量是会大幅增加的。根据以上分析,在新副本复制过程中,Leader并没有发生变动,所以客户端不会阻塞,数据迁移完成后进行Leader选举时leader会发生变更,生产者发送消息可能会收到 NotLeaderForPartitionException 异常,生产者需要及时拉取最新的元数据,并重新进行消息发送。
Q: 假设分区副本 [0,1,2] 变更为[2,1,0] 会把副本删除之后再新增吗? 会触发leader选举吗?
A: 不会, 副本没有增多和减少就不会有新增和删除副本的流程; 最终只是在zk节点/broker/topics/{topicName} 修改了一下顺序而已, 产生影响只会在下一次进行优先副本选举的时候,让第一个副本作为Leader;
除了bin/kafka-reassign-partitions.sh 可以修改partition的AR集合外,我们也可以手动修改zk中 /brokers/topics/{topic名称} 的配置数据,修改某个partition的AR集合。但是目前的Kafka实现中,如果只是AR集合的顺序有变化并不会导致controller更新其内存,这样即使再次进行leader副本重新选举也不会导致leader的变化,我们可以删除zk中的 /Controller节点,让新controller重新加载配置,并且同时触发Leader选举。
优先副本
由于leader副本负责这个partition的所有读写请求,而follower副本只是和leader副本做同步,因此leader副本的负载会远高于follower副本。Kafka需要保证所有分区的leader副本在broker节点上均衡分布。如果leader副本分布过于集中,就会造成集群负载不均衡。
当一个broker节点挂了,如果这个broker节点上有某些partition的leader副本,则会触发这些partition的leader副本重新选举,从partition的ISR中选出新的leader,leader副本切换过程中,这个partition不可用。当这个broker节点重新上线时(或者扩容新机器),默认不会触发leader副本的重新选举(一般leader副本挂了才会重新选举),这样可能会导致某些broker节点有很多leader副本,而某些broker节点的leader副本非常少,出现负载不均衡的现象。
解决办法则是触发某些partition的leader副本的重新选举(即便此时leader副本是OK状态)。使得leader副本在broker节点上是均匀分布的。
下面的输出中,Replicas表示AR集合列表,AR集合列表中的第一个副本是优先副本(preferred replica),一个分区的AR集合在分区创建时就被确定, 只要不发生重分配的情况,AR集合内部副本的顺序是保待不变的。
比如Partition: 1 的优先副本为2。优先副本是在创建topic时分配好的,且在创建时是均匀分布的。
wangys@192 ~/Downloads/kafka/kafka_2.11-2.0.0-2 bin/kafka-topics.sh --zookeeper localhost:2181/kafka --describe --topic topic-demo
Topic:topic-demo PartitionCount:4 ReplicationFactor:3 Configs:
Topic: topic-demo Partition: 0 Leader: 1 Replicas: 1,0,2 Isr: 0,1,2
Topic: topic-demo Partition: 1 Leader: 1 Replicas: 2,1,0 Isr: 0,1,2
Topic: topic-demo Partition: 2 Leader: 0 Replicas: 0,2,1 Isr: 0,1,2
Topic: topic-demo Partition: 3 Leader: 1 Replicas: 1,2,0 Isr: 0,1,2
Kafka提供了周期自动检测选举功能(由controller实现),由参数auto.leader.rebalance.enable(默认为true)和 leader.imbalance.check.interval.seconds控制(默认值为300秒),以及手动检测选举功能(bin/kafka-preferred-replica-election.sh脚本),
用于检查partition当前的leader副本是否是其优先副本,如果不是会触发这个partition的leader副本的重新选举,
因为Leader的选举策略是按照AR集合的顺序找到第一个存活的且位于ISR中的节点(unclean.leader.election.enable如果为true可以不用是ISR中的),选举完成后会使得leader副本和其优先副本保持一致。
由于AR集合的顺序是固定的且在生成AR集合时已经考虑了负载均衡,这种leader选举算法能保证新leader也是负载均衡的,不会出现某个broker上partition的leader很多。
比如我们手动执行 kafka-perferred-replica-election.sh脚本后,会触发partition1的leader副本的重新选举。partition1的leader副本将会是2。
wangys@192 ~/Downloads/kafka/kafka_2.11-2.0.0-2 bin/kafka-topics.sh --zookeeper localhost:2181/kafka --describe --topic topic-demo
Topic:topic-demo PartitionCount:4 ReplicationFactor:3 Configs:
Topic: topic-demo Partition: 0 Leader: 1 Replicas: 1,0,2 Isr: 0,1,2
Topic: topic-demo Partition: 1 Leader: 2 Replicas: 2,1,0 Isr: 0,1,2
Topic: topic-demo Partition: 2 Leader: 0 Replicas: 0,2,1 Isr: 0,1,2
Topic: topic-demo Partition: 3 Leader: 1 Replicas: 1,2,0 Isr: 0,1,2
所谓的优先副本的选举 是指通过 一 定的方式促使优先副本 选举为 leader副本, 以此来促进 集群的负载均衡, 这个行为也可以称为 分区平衡 。
2、生产者
KafkaProducer是线程安全的,一般建议多个线程共享一个producer。
The producer consists of a pool of buffer space that holds records that haven't yet been transmitted to the server as well as a background I/O thread that is responsible for turning these records into requests and transmitting them to the cluster. KafkaConsumer不像KafkaProducer那样有单独的IO线程, KafkaConsumer包的发送是在调用方调用poll()方法的时候。
publicclassProducerRecord<K,V>{
private final String topic; //主题
private final Integer partion; //分区号
private final Headers headers; //消息头部
private final key; //键 如果没有指定分区号,则通过计算key的hash值对所有分区size取模确定分区号,如果key为null,则轮询发往各个可用分区。
private final value; //值
private final Long timestamp;//消息的时间戳
//省略其他成员方法和构造方法
}
消息发送过程中,有可能需要经过拦截器(Interceptor)、序列化器(Serializer)和分区器的才能被真正地发往broker。拦截器一般不是必需的,而序列化器是必需的。如果消息ProducerRecord中指定了partion字段,那么就不需要调用分区器,如果没有指定,那么就需要依赖分区器,根据key这个字段来计算partion的值。Kafka中提供的默认分区器是org.apache.kafka.clients.producer.intemals.DefaultPartitioner。但这个分区器有个问题,因为Producer在发送消息的时候会将消息放到一个ProducerBatch中, 这个Batch可能包含多条消息,然后再将Batch打包发送。这样做的好处就是能够提高吞吐量,减少发起请求的次数。但是有一个问题就是, 因为消息的发送必须要一个Batch满了或者linger.ms时间到了才会发送。如果生产的消息比较少的话,迟迟难以让Batch塞满,就意味着更高的延迟。DefaultPartitioner在key为null时,会将消息轮询到各个分区的, 如果消息数量较少,还给所有分区遍历的分配,那么每个ProducerBatch都很难满足发送条件。新版本的Kafka提供了粘性分区策略,DefaultPartitioner在不存在分区和key时,会使用粘性分区策略,先把一个分区的Batch填满了发送,然后从剩下的分区中随机选择下一个分区。
/**
* The default partitioning strategy:
* <ul>
* <li>If a partition is specified in the record, use it
* <li>If no partition is specified but a key is present choose a partition based on a hash of the key
* <li>If no partition or key is present choose a partition in a round-robin fashion
*/
public class DefaultPartitioner implements Partitioner {
private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new ConcurrentHashMap<>();
public void configure(Map<String, ?> configs) {}
/**
* Compute the partition for the given record.
*
* @param topic The topic name
* @param key The key to partition on (or null if no key)
* @param keyBytes serialized key to partition on (or null if no key)
* @param value The value to partition on or null
* @param valueBytes serialized value to partition on or null
* @param cluster The current cluster metadata
*/
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
if (keyBytes == null) {
int nextValue = nextValue(topic);
List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
if (availablePartitions.size() > 0) {
// 如果 key 为 null ,那么消息将会以轮询的方式发往主题内的各个“可用分区”。
int part = Utils.toPositive(nextValue) % availablePartitions.size();
return availablePartitions.get(part).partition();
} else {
// no partitions are available, give a non-available partition
return Utils.toPositive(nextValue) % numPartitions;
}
} else {
// 如果 key 不为 null ,会对 key 进行哈希,计算得到的是所有分区中的任意一个,不一定是可用分区
// hash the keyBytes to choose a partition
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
}
private int nextValue(String topic) {
AtomicInteger counter = topicCounterMap.get(topic);
if (null == counter) {
counter = new AtomicInteger(ThreadLocalRandom.current().nextInt());
AtomicInteger currentCounter = topicCounterMap.putIfAbsent(topic, counter);
if (currentCounter != null) {
counter = currentCounter;
}
}
return counter.getAndIncrement();
}
public void close() {}
}
生产者拦截器
生产者拦截器既可以用 来在消息发送前做一些准备工作 , 比如按照某个规则过滤不符合要求的消息、修改消 息的内容等, 也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。
拦截器有两个方法。 KafkaProducer 在将消息序列化和计算分区之前会调用生产者拦截器 的 onSend()方法来对消 息进行相应 的定制化操作。KafkaProducer 会在消息被应答之前或消息发送失败时调用生产者拦截器的onAcknowledgement()方法,优先于用户设定的 Callback 之前执行。KafkaProducer 中不仅可以指定一个拦截器,还可以指定多个拦截器以形成拦截链
序列化
broker端接收的消息必须以字节数组(byte[])的形式存在。生产者需要用序列化器(Serializer)把对象转换成字节数组才能通过网络发送给Kaflca。消费者需要用反序列化器(Deserializer)把从Kaflca中收到的字节数组转换成相应的对象。生产者使用的序列化器和消费者使用的反序列化器是需要一一对应的,如果生产者使用了某种序列化器,比如StringSerializer,而消费者使用了另一种序列化器,比如IntegerSerializer,那么是无法解析出想要的数据的。
元数据的获取与更新
Kafka 集群的元数据记录了集群中有哪些主题,这些主题有 哪些分区,每个分区的 lead副本 和 follower 副本分配在哪些节点上,哪些副本在 AR、 ISR 等集合中,集群中有哪些节点,控制器节点又是哪一个等信息。
Producer实例启动时会与bootstrap.servers参数中的每个Broker创建TCP连接,然后producer会向其中一台broker拉取集群元数据信息,当Producer更新了集群的元数据信息之后,如果发现与集群中某些Broker没有建立TCP连接,那么就会创建一个TCP连接,所以Producer启动后会依次与集群所有Broker创建TCP连接。虽然最开始会创建很多TCP连接,但Kafka会将空闲的TCP连接关闭( Producer端参数connections.max.idle.ms,默认9分钟)。但是当要发送消息时,Producer发现与目标Broker没有TCP连接,就会创建一个。
当生产者没有指定的元数据,或者超过metadata.max.age.ms时间没有更新元数据都会引起元数据的更新操作 。生产者会选择负载最低的Broker节点(leastLoadedNode)发送元数据更新请求,生产者是在单独的Sender线程中发送的元数据请求的,Sender线程会周期性发送请求,消费者是在调用poll函数时发送元数据请求的,leastLoadedNode是InFlightRequest最少的节点。
重要的生产者参数
-
acks
这个参数用来指定分区中必须要有多少个副本收到这条消息,之后生产者才会认为这条消 息是成功写入的。 acks 是生产者客户端中一个非常重要 的参数 ,它涉及消息的可靠性和吞吐 量之间的权衡 。 acks 参数只有 3种类型的值(没有其他值,比如2):- acks = 1 。默认值即为 1 。生产者发送消息之后,只要分区的 leader 副本成功写入消 息,那么它就会收到来自服务端的成功响应 。如果消息无法写入 leader 副本,比如在 leader 副本崩溃、重新选举新的 leader 副本的过程中,那么生产者就会收到一个错误 的响应,为了避免消息丢失,生产者可以选择重发消息 。如果消息写入 leader 副本并 返回成功响应给生产者,且在被其他 follower 副本拉取之前 leader 副本崩溃,那么此 时消息还是会丢失,因为新选举的 leader 副本中并没有这条对应的消息 。 acks 设置为 1 ,是消息可靠性和吞吐量之间的折中方案。
- acks = 0 。如果在消息从发送到 写入 Kafka 的过程中出现某些异常,导致 Kafka 并没有收到这条消息,那么生产者也 无从得知,消息也就丢失了。在其他配置环境相同的情况下, acks 设置为 0 可以达 到最大的吞吐量。
- acks = 一1 或 acks =all 。生产者在消息发送之后,需要等待 ISR 中的所有副本都成功 写入消息之后才能够收到来自服务端的成功响应。在其他配置环境相同的情况下, acks 设置为 -1 (all )可以达到最强的可靠性。但这并不意味着消息就一定可靠,因 为 ISR 中可能只有 leader 副本,这样就退化成了 acks= 1 的情况。要获得更高的消息 可靠性需要配合 min.insync.replicas 等参数的联动。
-
auto.offset.reset
- earliest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
- latest(默认):当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据
- none:topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
提交过offset,latest和earliest没有区别,但是在没有提交offset情况下,用latest直接会导致无法读取旧数据。Kafka有一种特别隐秘的消息丢失场景:增加主题分区。当增加主题分区后,Producer先于Consumer感知到新增加的分区,而Consumer设置的是latest,因此在Consumer感知到新分区前,Producer发送的这些消息就全部“丢失”了,或者说Consumer无法读取到这些消息。
-
retries 和 retry.backoff.ms
- retries 参数用来配置生产者重试的次数,默认值为 0,即在发生异常的时候不进行任何 重试动作。生产者可以通过配置 retries 大于 0 的值,以此通过 内 部重试来恢复而不是一昧地将异常抛给生产者的应用程序。 如果重试 达到设定的次数 ,那么生产者就会放弃重试并返回异常。重试还和另一个参数 retry . backoff.ms 有关,这个参数的默认值为 100 ,它用来设定两次重试之间的时间间隔,避免无效的频繁重试。
- 有些应用对于时延的要求很高,需要快速失败,设置retries>0会增加客户端对于异常的反馈时延。
-
max.in.flight.requests.per.connection
- 限制每个连接(也就是客户端与Broker节点之间的连接)最多缓存的未响应的请求数,默认值5。
- Kafka 可以保证同一个分区中的消息是有序的。如果生产者按照一定的顺序发送消息,那 么这些消息也会顺序地写入分区,进而消费者也可以按照同样的顺序消费它们。对于某些应用来说,顺序性非常重要 ,比如 MySQL 的 binlog 传输,如果出现错误(乱序)就会造成非常严重的后果 。 如果将 acks 和 retry参数配置为非零值,并且 max.in.flight.requests.per.connection 参数配置为大于 1 的值,那么就会出现错序的现象 : 如果第一批次消息写入失败, 而第二批次消息 写入成功,那么生产者会重试发送第一批次的消息, 此时如果第一批次的消息写入成功,那么 这两个批次的消息就出现了错序 。对此 要么放 弃 客 户 端 内 部的 重 试功能 , 要么将 max.in.flight.requests.per.connection 参数设置为1(这样也就放弃了吞吐)。 一般而言,在需要保证消息顺序的场合建议把参数 max.in.flight. requests . per.connection 配置为 1 ,而不是把 acks 配置为 0 或者把 retries 配置为0, 不过这样会影响整体的吞吐。(个人觉得这段话的表述有问题,Kafka的发送者下层使用的是TCP,TCP提供的是按序可靠的服务,TCP中如果某个包发送失败会自动重试,并且如果某个包发送失败,对于接收端应用层是不可能获取到这个包后面的字节流数据的,send() API的返回值如果<0,说明是连接中断了,此时应该重连而不是重试,重试的返回值也会是<0 https://man7.org/linux/man-pages/man2/send.2.html, max.in.flight.requests.per.connection > 1可能会有这种情况:发送方一次发了3个包出去,接收方收到了这3个包,但是接收方的TCP返回给发送方的ACK丢了,然后此时网络中断了,导致发送方重连,然后需要重新发送这3个包,这就导致消息重复, max.in.flight.requests.per.connection越大,重复的消息就越多)
-
request.timeout.ms
这个参数用来配置 Producer 等待请求响应的最长时间,默认值为 30000 ( ms )。请求超时 之后可以根据retries参数进行重试。注意这个参数需要 比 broker 端参数 replica. lag . time .max . ms 的 值要大 ,这样可 以减少因客户端重试而引起的消息重复的概率。 -
max.block.ms
默认值60000,用来控制KafkaProducer中send()方法和 partitionsFor()方法的阻塞时间。当生产者的发 送缓冲区已满,或者没有可用的元数据时, 这些方法就会阻塞,最后抛出异常
broker端可靠性相关参数
-
min.insync.replicas
- 这个参数也可以在topic层面配置。这个参数只在 acks= -1时生效,默认值为1,这个参数指定了ISR集合中最小的副本数, 当往topic写入数据时(比如收到producer的消息),如果 acks 参数是 all且ISR集合不满足条件,就会向producer抛出NotEnoughReplicasException或NotEnoughReplicasAfterAppendException 。 对于 acks = 0 或 1 的producer,即便ISR集合不满足min.insync.replicas 要求 ,仍可以成功写入。
- min.insync.replicas 参数在提升可靠性的时候会影响可用性。 试想如果ISR中只有一个 leader副本,那么最起码服务还可用,如果配置min.insync.replicas > 1, 则会使消息无法写入,但仍然可以提供read服务。
- 代码实现: broker处理ProduceRequest时, 会先把所有 topic的数据(ProduceRequest可能包含多个topic的多个partition的数据)append到本地日志中,对于每个partition,往本地日志写入前会检查,如果ISR集合不满足min.insync.replicas,这个partition的返回码设置为NOT_ENOUGH_REPLICAS ,partition写入后leader的LEO会增加,会检查是否能触发follower的fetchRequest,是否可以增大HW(ISR集合可能只有leader,leader的LEO会影响HW)。所有partition都写入完成后,如果acks = 0 或 1(ack = 0 也要等着消息写到leader日志后才返回结果),可以直接返回结果。 如果acks = -1(往consumer_offset和transaction_state主题写入数据acks默认是-1),会创建延时任务DelayedProduce(有超时时间,所有topic和partition共用一个延时任务,ProduceResponse中每个partition需要有一个返回码),当某个partition的leader的HW有增长时会对所有partition做检查,当发现leader的HW超过了写入消息的offset时,如果ISR集合满足min.insync.replicas ,则这个partition的返回码是OK,如果ISR集合不满足min.insync.replicas ,返回码是Errors.NOT_ENOUGH_REPLICAS_AFTER_APPEND。如果每个partition都有返回码,延时任务可以提前结束。如果延时任务超时,对于leader的HW没有达到写入消息的offset的partition,其返回码是Errors.REQUEST_TIMED_OUT。上述每个partition的返回码会在ProduceResponse中一起返回给client。
-
unclean.leader.election.enable
默认值为false, 如果设置为true,当leader下线时会先尝试找ISR中的存活节点作为新leader,如果没有找到才会找不是ISR中的存活节点。这样有可能造成数据的丢失。如果这个参数设置为false , 会降低可用性。 -
log.flush.interval.messages 和 log.flush.interval.ms
用来调整同步刷盘的策略,默认是不做控制而交由操作系统本身来进行处理。
Kafka没有提供同步刷盘的方式,只能通过调整刷盘间隔在性能和可靠性之间做权衡。因为每次强制fsync会极大影响性能,而且多副本机制已经极大提高系统可靠性。
3、消费者
KafkaConsumer不是线程安全的。
每个消费者只能消费所分配到的分区中的消息。换言之,每一个分区只能被一个消费组中的 一 个消费者所消费。对分区数固定的清况, 一味地增加消费者并不会让消费能力一直得到提升,如果消费者过多,出现了消费者的个数大于分区个数的清况,就会有消费者分配不到任何分区。其实可以实现多个消费者同时消费同一个分区,我们需要手动为这些消费者指定其消费的partition(assign方法,使用assign方法后Kafka的自动分区管理分配机制不再对这个consumer起作用,这个consumer将永远消费这些partition),手动为每个消费者指定其拉取数据的offset(seek方法),手动指定其提交的offset。这样可以打破原有的消费线程的个数不能超过分区数的限制, 进一步提高了消费的 能力。不过这种实现方式对于位移提交和顺序控制的处理就会变得非常复杂,实际应用中使用 得极少
Kafka中的消费是基于 拉模式的。消息的消费 一 般有两种模式:推模式和 拉模式。推模式 是服务端主动将消息推送给消费者, 而 拉模式是消费者主动向服务端发起请求来拉取消息。
在旧消费者客户端中,消费位移是存储在 ZooKeeper 中的 。 而在新消费者客户端中,消费 位移存储在 Kafka 内 部的主题 __consumer_offsets 中 。 这里把将消费位移存储起来(持久化)的 动作称为“提交’,消费者在消费完消息之后需要执行消费位移的提交。老版本的kafka其实是把位移保存在zookeeper中的,但是zookeeper并不适合这种高频写的场景。所以新版本已经是改进了这个方案,直接保存到kafka。毕竟kafka本身就适合高频写的场景,并且kafka也可以保证高可用性和高持久性。__consumer_offsets是个Kafka的topic,topic分区的数量取决于Broker 端参数 offsets.topic.num.partitions,默认是50个分区,而副本参数取决于offsets.topic.replication.factor,默认是3。
消费者提交的消费位移只在消费者重新启动和发生分片再均衡(消费者下线或新消费者加入)时使用。 The offsets committed using this API will be used on the first fetch after every rebalance and also on startup,consumer内部有个在内存中map记录每个partition的position(position = last consumed position + 1),下次从broker拉数据时使用这个位移,可以通过seek方法修改这个位移。当consumer内存中的position不可用时(比如刚启动)会从__consumer_offsets主题中查找持久化的消费位移,如果还是没查找到,会根据参数 auto.offset.reset(earliest, latest) 的配置来决定从何处开始进行消费。
不过需要非常明确的是,当前消费者需要提交的消 费位移并不是 x,而是 x+ 1 ,对应于图 3 -6 中的 position,它表示下一条需要拉取的消息的位置。
在 Kafka 中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数 enable.auto.commit 配置,默认值为 true。默认的自动提交采用周期提交的方式(consumer关闭时会提交一次),周期时间由客户端参数 auto.commit.interval.ms 配置,默认值为 5 秒,此参数生效的前提是 enable.auto.commit 参数为 true。自动位移提交的问题是,如果consumer没有新的消息可以消费,那就会一直提交之前已经提交过的消费位移。不过__consumer_offset主题有日志压缩策略。
在默认的方式下,消费者每隔 5 秒会将拉取到的每个分区中最大的消息位移进行提交。 自动位移提交的动作是在 poll()方法的逻辑里完成的,在每次真正向服务端发起拉取请求之前会检 查是否可以进行位移提交(是否距离上一次提交超过了5秒),如果可以,那么就会异步提交上一次poll后的位移(创建一个OffsetCommitRequest,请求中的offset是当前这个partition的position,然后将请求放入队列中)。本次poll拉到的消息(拉到消息后会更新position,更新position的时候其实这些消息还没有消费),会等到下次poll的时候才可能提交,因此如果处理本次拉到的消息的时候出现异常,一定要等异常处理完,这些消息都消费成功了才再次调用poll,使用默认的自动位移提交不可避免的会出现重复消费的问题(At Least Once)。
https://stackoverflow.com/questions/46546489/how-does-kafka-consumer-auto-commit-work
Kafka 中还提供了手动位移提交的方式,使用手动位移提交需要注意的是必须等所有消息的处理逻辑(包括异常)全部完成后才能提交,否则会发生消息丢失。commitSync()方法会根据poll()方法拉取的最新位移来进行提交,只要没有发生不可恢复的错误,它就会阻塞消费者线程直至位移提交完成。对于不可恢复的错误,我们可以将其捕获并做针对性的处理。异步提交的方式( commitAsync())在执行的时候消费者线程不会被阻塞,提交成功或失败会调用用户的方法,提交失败会将对应的异常作为入参传给用户方法 ,消费者可能在提交消费位移的结果还未返回之前就开始了新一次的拉取操作。
注意,如果消费者提交了两次,第一次是 x + m,第二次是x,则最终提交的位移是x,需要消费者自己保证提交位移的递增性。
再均衡是指分区的所属权从一个消费者转移到另一个消费者的行为。 在再均衡发生期间, 消费组内的消费者是无法读取消息的。 也就是说, 在再均衡发生期间的这一小段时间内, 消费组会变得不可用。消费者在订阅主题时可以设置再均衡监听器ConsumerRebalanceListener,当发生再均衡动作时会回调其中的接口
消费者拦截器
消费者拦截器实现Consumerlnterceptor接口的3个方法:onConsume(),onCommit(),close()。KafkaConsumer会在poll() 方法返回之前调用拦截器的onConsume()方法来对消息进行相应的定制化操作,比如修改返回的消息内容、按照某种规则过滤掉某些消息(比如过滤掉超过TTL时间的消息)(会减少poll()方法返回的消息的个数)。 KafkaConsumer会在提交完消费位移之后调用拦截器的onCommit()方法, 可以使用这个方法来记录跟踪所提交的位移信息。
消费者的分区分配策略
https://www.szzdzhp.com/kafka/theory/consumer-Assignor.html
Kafka提供了消费者客户端参数partition.assignment.strategy 来 设 置消费者与订阅主题之间的分区分配策略。 默认情况下, 此参数的值为org.apache.kafka. clients. consumer.RangeAssignor, 即采用 RangeAssignor分配策略。 除此之外, Kafka还提供了另 外 两 种分配策略: RoundRobinAssignor和StickyAssignor 。
RangeAssignor分配策略
对于每一个主题, RangeAssignor策略会将消费组内所有订阅这个主题的消费者按照名称的字典序排序,然后计算出每个消费者分配到的分区数量(如果partition数不能整除消费者数,那么字典序靠前的消费者会多分配一个分区),然后按partition的顺序依次为每个消费者分配。RangeAssignor是以单个Topic为一个维度来计算分配的, 负责将每一个Topic的分区尽可能均衡的分配给消费者。
如果新增一个consumer:
1、找到这个consumer所属的消费组A
2、对于这个消费者订阅的每个主题T,找到主题T被消费组A中的哪些consumer所订阅,这些订阅主题T的consumer组成集合S
3、对每个集合S使用RangeAssignor算法
假设n = 分区数/消费者数量,m = 分区数%消费者数量,那么前m个消费者每个分配 n+1 个分区,后面的(消费者数量-m)个消费者每个分配n个分区。
假设消费组内有2个消费者 C0 和 C1都订 阅了主题 t0和t1, 并且每个主题都有4个分区,那么主题t0的所有分区按分区号排序后可以表示为: t0p0、t0p1、t0p2、t0p3。主题t1的分区排序后为:t1p0、t1p1、t1p2、t1p3。
最终的分配结果为:
消费者C0: t0p0、t0p1、t1p0、t1p1
消费者C1: t0p2、t0p3、t1p2、t1p3
当topic的分区数能够整除消费者数量时分配得很均匀,但当不能整除时,排序靠前的消费者会比排序靠后的消费者分配更多的partition。
假设上面例子中2个主题每个都只有3个分区,主题t0的分区排序后为:t0p0、 t0p1、t0p2。主题t1的分区排序后为:t1p0、t1p1、t1p2。
最终的分配结果为:
消费者C0: t0p0、t0p1、t1p0、t1p1
消费者C1: t0p2、 t1p2
RoundRobinAssignor分配策略
RoundRobinAssignor分配策略的原理是,将消费组内所有消费者排序形成“消费者列表”,然后将所有消费者订阅的所有主题的分区排序形成“分区列表”,最后通过轮询方式将分区列表中的分区依次分配给每个消费者,分配的时候如果某个消费者没有订阅这个主题,则会“跳过”这个消费者选择消费者列表中的下一个消费者继续尝试分配。
假设消费组中有2个消费者 C0 和C1都订阅了主题 t0和t1,并且每个主题都有3个分区, 那么订阅的所有主题的分区排序后为:t0p0、 t0p1、t0p2、t1p0、t1p1、t1p2。
分配结果为:
消费者C0: t0p0、t0p2、t1p1
消费者C1: t0p1、t1p0、t1p2
如果同一个消费组内所有消费者的订阅信息都是相同的, 那么RoundRobinAssignor分配策略是均匀的。如果同一个消费组内的消费者订阅的信息是不相同的, 那么分区分配的时候就不是完全的轮询分配,可能导致分配得不均匀。
假设消费组内有3个消费者C0、C1和C2,它们共订阅了3个主题t0、t1、t2,这3个主题分别有1 、2、 3个分区, 分区排序后为:t0p0、 t1p0、t1p1、t2p0、t2p1、t2p2。
消费者 C0 订阅的是主题t0,消费者C1 订阅的是主题t0和t1, 消费者C2 订阅的是主题t0、t1和t2,那么最终的分配结果为:
消费者C0 ( 只订阅 t0 ): t0p0
消费者C1 ( 订阅t0, t1 ): t1p0
消费者C2(订阅t0,t1,t2): t1p1、t2p0、t2p1、t2p2
这样分配其实并不是最优解, 因为可以将分区t1p1 分配给消费者C1。
在这个例子中, RangeAssignor 和 RoundRobinAssignor 分配的结果是一样的。
总体来看 RoundRobinAssignor 比 RangeAssignor 更好些,分配更均匀些。因为一般情况下,一个消费组内的消费者订阅的主题都是一样的,但这些主题的partition数目可能并不能整除消费者数量,此时 RoundRobinAssignor 会让每个消费者分配的partition尽量均匀,但RangeAssignor 却无法做到分配均匀。
StickyAssignor分配策略
Kafka从0.11.x版本开始引入这种分配策略, 它主要有两个目标:
(1) 分区分配尽可能均匀。
(2) 分区分配尽可能与上次分配保待相同。这样可以减少分区转移造成的开销(需要commit offset和flush日志)
粘性分区的计算方式把离线的那个消费者所属的分区分配给其他的消费者, 在其他的消费者已拥有的分区不变的前提下,尽量的均衡。
消费者协调器和组协调器
消费者协调器和组协调器的概念是针对新版的消费者客户端 而言的。旧版的消费者客户端是使用ZooKeeper 的监听器(Watcher)来实现这些功能的
旧版消费者客户端的问题
旧版Kafka在ZooKeeper /consumers/<group>下记录了每个消费组的信息,/consumers/<group> 路径下有 [ids, offsets, owners]子节点。其中/consumers/<group>/ids 路径下使用临时节点表示此消费组下的所有消费者。/consumers/<group>/owner 路径下记录了这个消费组订阅的所有主题的分区和消费者的对应关系, /consumers/<group>/offsets 路径下记录了 这个消费组订阅的所有主题的每个分区的消费位移。
图7-4 旧版Kafka的ZooKeeper中与消费有关的路径节点
旧版Kafka每个消费者在启动时都会在 /consumers/<group>/ids 和 /brokers/ids 路径上注册一个监听器。当/consumers/<group>/ids路径下的子节点发生变化时,表示消费组 中的消 费者发生了变化;当/brokers/ids路径下的子节点发生变化时,表示broker出现了增减。这样通过ZooKeeper 所提供的Watcher, 每个消费者就可以监听消费组和Kafka集群的状态了。当触发再均衡操作时, 每个消费组下的所有消费者会 同时进行再均衡操作,而消费者之间并不知道彼此操作的结果,如果消费者之间配置的分配策略并不完全相同,这样可能导致Kafka工作在 一 个不正确的状态。与此同时,这种严重依赖于ZooKeeper集群的做法还有两个比较严重的问题:
- 羊群效应(HerdEffect) : 所谓的羊群效应是指ZooKeeper中 一个被监听的节点变化, 大量的 Watcher通知被发送到客户端,导致在通知期间的其他操作延迟 , 也有可能 发生类似死 锁的情况 。
- zk分布式锁避免羊群效应参考 https://blog.51cto.com/nileader/961809 。正确的方法是,(1)client调用create方法在zk上创建临时顺序节点;(2)client获取当前路径下所有节点的信息 (3)如果自己是序号最小的,说明已经获得锁,如果不是需要最小的,只需要调用exist监听序号比自己小的前一个节点,而不是在这个路径上注册子节点变更通知的Watcher。
新版本的分区再均衡原理
在新版的消费者中,每个消费组在服务端对应一个GroupCoordinator对其进行管理,消费者的 ConsumerCoordinator组件负责与GroupCoordinator 进行交互。
ConsumerCoordinator与GroupCoordinator之间最重要的职责就是负责执行消费者的分区分配和再均衡的操作。目前有如下几种情形会触发再均衡操作:
- 有新的消费者加入消费组。
- 有消费者下线。消费者并不一定需要真正下线, 例如遇到长时间的GC、网络延迟导致消费者长时间未向GroupCoordinator发送心跳等情况,GroupCoordinator会认为消费者已经下线。
- 有消费者主动退出消费组(发送LeaveGroupRequest 请求)。比如客户端调用了 unsubscrible()方法取消对某些主题的订阅 。
- 消费组所对应的GroupCoorinator节点发生了变更。
- 消费组订阅的Topic的分区数量有变化或Topic集合有变更(Topic新增删除),这个条件的触发时机是 KafkaConsumer.poll,消费者客户端在Poll数据进行消费的时候,会先去判断是否需要进行重平衡。
https://www.jianshu.com/p/80721b0bdd1b
在分区重平衡过程中,所有Consumer实例会停止消费(stop the world),等待Rebalance完成。这是Rebalance为人诟病的一个方面。目前Rebalance的设计是所有Consumer实例共同参与,全部重新分配所有分区,过程很慢,且会造成分区的移动以及消费状态的丢失,会导致重复消费问题。Coordinator发生Rebalance的时候并不会主动通知组内的所有Consumer重新加入组,而是当Consumer向Coordinator发送心跳的时候,Coordinator将REBALANCE标志放进心跳请求的响应中 。因此Consumer发送心跳请求的频率越高,虽然会额外消耗带宽资源,但好处是能够更快地知晓当前是否开启Rebalance。Rebalance流程整体可以分为两个步骤,一个是JOIN_GROUP,另外一个是SYNC GROUP。Coordinator每进行一次Rebalance,消费组的Generation会加1,消费者发给组协调器的请求中需要带上generation 标记 ,如果generation标记不是最新,组协调器会拒绝这个请求,比如消费者提交消费位移时可能会提交失败。Leader Consumer是由Coordinator选的,Leader Consumer除了负责partition分配,还负责整个消费组订阅的主题的监控,Leader Consumer会定期更新消费组订阅的主题信息,一旦发现主题信息发生了变化,Leader Consumer会通知Coordinator触发Rebalance机制。
下面举一个例子,当新的消费者加入消费组时, 消费者与及组协调器之间会经历的过程。
https://szzdzhp.blog.csdn.net/article/details/126705183
1、FIND_COORDINATOR阶段
这个阶段用于消费者找到它的消费组对应的GroupCoordinator所在的broker。如果消费者已经保存了自己的GroupCoordinator节点的信息,并且与它之间的网络连接是正常的,那么可以省略这个阶段。
消费者会向负载最低的broker节点(leastLoadedNode,参考元数据的更新部分如何确定leastLoadedNode)发送请求,请求中包含自己的groupId,broker收到请求后,会计算groupId的hash值的绝对值,然后对__consumer_offsets的分区数求余,找到对应分区的leader所在的broker(提交消费位移和保存消费组元数据信息时的分区策略也是这个方法,因此这个消费组下的消费者提交消费位移时也是将位移信息发给这个broker),这个broker就是这个消费组的GroupCoordinator,然后向消费者返回broker信息。
2、JOIN_GROUP阶段
消费者会向GroupCoordinator发送JoinGroupRequest加入消费组。请求中包括自己的group_id,member_id(第一次加入为null,第一次加入时由GroupCoordinator分配,后续使用),支持的分区分配策略(可以多个,默认为RangeAssignor),订阅的主题列表,session_timeout(对应消费端参数 session.timeout.ms),rebalance_timeout(对应消费端参数 max.poll.interval.ms,表示当消费组再平衡的时候, GroupCoordinator 等待各个消费者重新加入的最长等待时间)。
如果是原有的消费者重新加入消费组,那在发送请求之前还要执行一些准备工作:(1)如果消费端参数 enable.auto.commit 为 true(开启自动提交位移),需要向 GroupCoordinator 提交消费位移。(2)如果消 费者添加了自定义的再均衡监听器( ConsumerRebalanceListener ),调用其onPartitionsRevoked方法 (3)禁止现有的心跳检测,后续再重新开启。
broker收到请求后需要从消费组中选举出一个leader用于执行分区分配策略(如果消费组为空,那第一个加入的消费者即为 leader。只有当 leader 消费者主动或被动退出消费组时,才会随机选择一个消费者作为leader),GroupCoordinator需要从消费者支持的分区策略集合中选出一个分区分配策略(1、先选出所有消费者都支持的分配策略的集合;2、每个消费者对集合中的一个策略投票(对自己支持的策略列表中第一个位于集合中的策略投票);3、GroupCoordinator选择票数最多的策略作为消费组的分区分配策略)。然后向所有消费者返回generation_id(消费组的年代信息(类似epoch),避免受到过期请求的影响,消费组经过一次变化这个id就自增1, 比如新增Member、Leave Member 等操作都会引起generation_id增长),group_protocol(选出来的分区策略),member_id(每个消费者收到的member_id不同),leader_id(leader的member_id)。如果这个消费者是leader,还会收到这个消费组内每个消费者订阅的主题信息。如果新Member加入Group的时候, 带上的分配策略跟现有Group中所有Member(Group有Member的情况下)都支持的协议都不交叉,就会抛出异常:INCONSISTENT_GROUP_PROTOCOL
Leader Consumer除了负责partition分配,还负责整个消费组订阅的主题的监控,Leader Consumer会定期更新消费组订阅的主题信息,一旦发现主题信息发生了变化,Leader Consumer会通知Coordinator触发Rebalance机制。
3、SYNC GROUP阶段
leader消费者收到 GroupCoordinator 选出的分配策略 和 每个消费者的订阅信息 后,自己本地计算出每个消费者分配到的主题和分区信息,然后放在 SyncGroupRequest中发给GroupCoordinator。其他消费者也会向GroupCoordinator发送SyncGroupRequest询问分配方案。
GroupCoordinator收到leader消费者的分配方案后,会将消费组元数据信息(包含分配方案)以消息的形式发送到__consumer_offsets主题,然后通过SyncGroupResponse向每个消费者发送自己对应的分配方案。
当消费者收到自己的分配方案后会调用 PartitionAssignor接口 的 onAssignment()方法,再调用 自定义的ConsumerRebalanceListener接口中的 OnPartitionAssigned()方法。 之后开启心跳任务, 消费者通过单独的线程定期向GroupCoordinator 发送 HeartbeatRequest 来确定彼此在线。心跳间隔时间由参数 heartbeat.interval.ms 指定,默认值为3 秒 ,这个参数必须比 session.timeout.ms 参数设定的值要小,GroupCoordinator 在超过 session.timeout.ms 指定的时间内没有收到心跳报文(通过延时操作)则认为此消费者已经下线,会触发一次再均衡行为。session.timeout.ms越小可以让Coordinator更快地发现已经挂掉的Consumer。
还有一个参数 max.poll.interval.ms,如果consumer两次调用poll()的时间间隔超过了max.poll.interval.ms,consumer会主动向消费组协调器发送 LeaveGroupRequest请求。消费组协调器会设置rebalance_timeout 对应消费端参数 max.poll.interval.ms ,默认值 5 分钟 。表示当消费组再平衡的时候, GroupCoordinator 等待各个消费者 重新加入的最长等待时间 。
除了被动退出消费组,消费者还可以使用 LeaveGroupRequest请求主动退出消费组,比如客户端调用了unsubscrible()方法取消对某些主题的订阅,消费者退出消费组会触发分区rebalance;
http://matt33.com/2017/01/16/kafka-group/
Server 端,Consumer 的 Group 共定义了五个状态:
- Empty:Group 没有任何成员,如果所有提交的offsets 都过期的话就会变成 Dead,一般当 Group 新创建时是这个状态,也有可能这个 Group 仅仅用于 offset commits 并没有任何成员。group处于empty状态超过一定时间(默认7天),Kafka会自动删除其位移信息,然后 group会变为dead状态(Group has no more members, but lingers until all offsets have expired. This state also represents groups which use Kafka only for offset commits and have no members);
- PreparingRebalance:Group 正在准备进行 Rebalance(Group is preparing to rebalance);
- CompletingRebalance(AwaitingSync):Group 正在等待 group leader 的分区分配方案(Group is awaiting state assignment from the leader);
- Stable:稳定的状态(Group is stable);
- Dead:Group 内已经没有成员,并且它的元数据已经被移除(Group has no more members and its metadata is being removed)。
Consumer Group Rebalance流程示例
以上述可以再次触发rebalance的场景进行分析
-
如果是新的消费者加入group,消费者发起的join group请求中的memberId会为空。协调器收到join group请求后,如果消费者没有memberId,会为消费者生成 memberId(memberId = clientId + "-" + UUID.randomUUID()),将这个消费者加入group。如果join group请求中的memberId不为空,则检查memberId是否在group中,如果不在group中,返回UNKNOWN_MEMBER_ID错误让消费者重置memberId和generationId并重试。如果memberId在group中,说明是已有成员发起的join group请求。如果此时group状态是Stable ,协调器会让group进入PreparingRebalance状态。如果此时是CompletingRebalance状态,会先回调consumer的callback接口,让发送了sync group请求而等待的consumer重新发送join group请求,然后group进入PreparingRebalance状态。如果此时已经是PreparingRebalance状态,则让DelayedJoin延时操作检查group中的所有成员是否已经发送过join group请求。
-
在consumer不使用pattern订阅主题的情况下,目前有两种情况consumer会主动发起join group请求触发rebalance:
- 消费组订阅的主题的分区数发生了变化
consumer都会周期性的拉集群的元数据(consumer是在调用poll函数时发送元数据请求的),如果leader consumer发现消费组订阅的主题(协调器会将消费组每个成员订阅的主题发给leader consumer用于分区重分配,协调器不会保存消费组订阅的topic集合,只有leader consumer会保存)的分区数量发生了改变,leader consumer会在下次poll时检查并发送join group请求( only the leader is responsible for monitoring for metadata changes (i.e. partition changes))尝试触发rebalance,注意leader consumer在做partition的分配时,判断某个topic有多少个partition是以leader consumer上的元数据为准的。 - consumer订阅的topic集合发生了变化(通过KafkaConsumer.subscribe()方法可以随时修改)
此时consumer(包括非leader的consumer)会在下次poll时检查并发送join group请求尝试触发rebalance。
- 消费组订阅的主题的分区数发生了变化
-
如果consumer使用了pattern的订阅方式,consumer不会将自己的pattern发给协调者,leader consumer只能知道消费组成员订阅的主题的名字,不会知道其他consumer的pattern。partition数量的变化仍然是由leader consumer发现并重新发起join group的。每个consumer也会拉取集群元数据,当发现有新增的主题满足自己的pattern或者有主题被删除时会发起join group请求,请求中包含自己最新的订阅的topic集合,这样leader consumer就会知道消费组最新的主题的集合
-
-
当协调器收到消费者的leave group请求或者发现某个消费者心跳超时,会将这个消费者从group删除。如果此时group状态是Stable ,会进入PreparingRebalance状态;如果此时是CompletingRebalance状态,会先回调consumer的callback接口,让发送了sync group请求而等待的consumer重新发送join group请求,然后group进入PreparingRebalance状态。如果此时是PreparingRebalance状态,会让DelayedJoin延时操作检查group中的所有成员是否已经发送过join group请求。
-
当协调器修改group的状态为PreparingRebalance后,会创建一个DelayedJoin延时操作(延时操作中传入当前group,包括当前group的所有成员,用于检测所有成员是否在超时时间内完成join group,超时时间为group所有成员rebalanceTimeoutMs的最大值),注意此时协调器没有通知consumer正在rebalance。如果已经有延时操作(已经是PreparingRebalance状态),后续收到join group请求则检查group中的所有成员是否已经发送过join group请求。
-
consumer有个单独的心跳线程去发送心跳和接收响应(心跳只能由这个线程发送),如果此时group处于PreparingRebalance状态,协调器会在心跳响应中放置REBALANCE_IN_PROGRESS标识,心跳线程发现后会设置 rejoinNeeded = true。心跳线程也会检测如果consumer超过max.poll.interval.ms没有调用poll(),就主动向协调器发送leave group请求,然后清空memberId和generationId ,设置消费组状态为UNJOINED。提交消费位移时,如果消费组状态不是STABLE,会在本地直接抛异常。
-
consumer 下次调用poll时,会先调用coordinator.poll(),这个函数会首先检查协调器状态,如果协调器未知或状态不是Active,则发送find coordinator请求并等待直到成功,然后与组协调器建立连接。如果发现 rejoinNeeded = true,会先同步提交消费位移,调用ConsumerRebalanceListener.onPartitionsRevoked, 然后发起join group请求,并阻塞等待,直到收到sync group阶段的partition分配方案,然后调用ConsumerRebalanceListener.onPartitionsAssigned,从broker获取新分配的分区的消费位移(如果获取不到根据 auto.offset.reset 参数设置),最后检查是否需要自动提交消费位移。因此在rebalance期间,consumer不能通过poll获得新的数据。poll的入参是超时时间,如果poll执行超时会返回,但下次调用poll如果rebalance没结束依然会block。
- 提交消费位移要求group中存在这个consumer的memberId(因此group不能是empty或dead),consumer的generationId能对得上broker端的,group不能是CompletingRebalance状态,因此必须在发送join group请求前提交消费位移。
-
如果DelayedJoin延时操作超时或者收到了所有member的join group请求,则调用onCompleteJoin方法:将没有收到join group请求的消费者从group中删除(这个消费者的memberId后续是无效的,删除leader消费者时需要随机选择一个新的leader消费者),将group的generationId加1。如果此时group为空,将group状态设为Empty,并将group信息写到 __consumer_offsets主题;否则将group状态设置为CompletingRebalance,向group的成员发送join group的结果
-
consumer收到join group请求后紧接着会发送sync group请求,协调者收到sync group请求后,如果是非leader的请求,则为其设置一个callback(只有这里设置了后续才会回调),这个callback函数是用来发送SyncGroupResponse。如果是leader的请求,则将新的分配方案写入__consumer_offsets主题(如果写失败,group状态会变成PreparingRebalance并回调消费者的callback,让消费者重新发送join group请求),修改group状态为Stable,调用消费者的callback函数返回分区的分配方案。当group状态是Stable后,对于后续的sync group请求只需直接返回当前的分配方案。
- 注意当group状态进入CompletingRebalance后是没有设置超时时间的,会一直等leader消费者的sync group请求,如果leader消费者挂了,那心跳检测会将leader消费者从group中移除,此时会回调消费者的callback函数,让消费者重新发送join group请求,然后变更group状态为PreparingRebalance,重新进入rebalance状态。
消费组元数据信息
每个消费组的元数据信息都是一条消息,一条消息包括key和value部分,其中的version字段是固定值,group宇段是group_id。虽然key包含了 version 字段, 但确定这条信息所要存储的分区只根据group字段来计算(算出来就是GroupCoordinator所在的broker),这样就可以保证消费组 的元数据信息与消费组对应的GroupCoordinator 处于同一个 broker 节点上,省去中间转发的开销。 generation:前面提到的generation_id。 protocol : 消费组选取的分区分配策略。 members : 数组类型,包含了每个消费者的信息, subscription 是这个消费者的订阅信息,assignment是这个消费者的分区分配信息。
__consumer_offsets 剖析
消费者提交消费位移是通过向GroupCoordinator 发送 OffsetCommitRequest 请求实现的。提交消费位移要求group中存在这个consumer的memberId(因此group状态不能是empty或dead),group不能是CompletingRebalance状态。GroupCoordinator 收到 OffsetCommitRequest 请求后,先调用prepareOffsetCommit将消费位移放到pendingOffsetCommits Map中,然后将消费位移信息持久化到主题 __consumer_offsets,成功后再更新到消费组的offsets Map的缓存并删除pendingOffsetCommits;如果持久化失败则删除pendingOffsetCommits中的数据。
key 中version 字段是固定值1,还有 group 、 topic 、 partition 字段,分别表示消费组 的 group_id 、 主题名称和分区编号。虽然 key 中包含了 4 个字段,但最终确定这条消息所要存储的分区还是根据单独的 group 字段来计算的,这样就可以保证消费位移信息与消费组对应的 GroupCoordinator 处于同一个 broker 节点上,省去了中间转发的开销,这一点与消费组的元数据信息的存储是一样的 。value 中包含了 5 个字段,version宇段是固定值1,其余的 offset 、 metadata 、 commit_timestamp、 expire_timestamp 宇段分别表示消费位移、自定义的元数据信息、位移提交 到 Kafka 的时间戳、消费位移被判定为超时的时间戳。expire_timestamp = commit_timestamp + offsets.retention.minutes(默认值为1天),如果位移消息的时间超过 offsets.retention.minutes 的配置值,就会被删除。如果consumer启动时获取不到消费位移,只能根据客户端参数 auto.offset.reset 来决定开始消费的位置。__consumer_offsets采用的是日志压缩策略。
消费者可以通过向 GroupCoordinator发送 OffsetFetchRequest 请求获取上次提交的消费位移,GroupCoordinator收到OffsetFetchRequest 请求后会从消费组缓存的offsets Map中(对于新建的消费组,缓存是在消费组初始化新建的,对于已有消费组提交的位移信息,是在broker收到controller的leaderAndIsr请求时(broker启动或者leader转移),发现自己是leader,从本地日志中读取生成的)读出最新的消费位移返回给消费者 (旧版的Kafka是向zk读取offset),注意消费位移只从消费组缓存的offsets Map中读取,不会读日志。
主题被删除后会将提交的消费位移信息也删除。
KafkaConsumer.poll(final Duration timeout)方法解析
consumer 调用poll时,会先调用coordinator.poll(),这个函数会首先检查协调器状态,如果协调器未知或状态不是Active,则发送find coordinator请求并等待直到成功,然后与组协调器建立连接。如果发现 rejoinNeeded = true,会先同步提交消费位移(if auto-commit enabled),调用ConsumerRebalanceListener.onPartitionsRevoked(这个接口的调用时间不受timeout参数控制), 然后发起join group请求,并阻塞等待,直到收到sync group阶段的partition分配方案,然后调用ConsumerRebalanceListener.onPartitionsAssigned,从broker获取新分配的分区的消费位移(如果获取不到根据 auto.offset.reset 参数设置),最后检查是否需要自动提交消费位移。coordinator.poll()结束后,会从底层 fetcher 获取此时已经拉取到的数据,如果fetcher中没有数据,则发送FetchRequest并等待结果。在返回数据之前,发送下次的FetchRequest,避免用户在下次获取数据时线程 block。
因此在rebalance期间,consumer不能通过poll获得新的数据。poll的入参是超时时间,如果poll执行超时会返回,但下次调用poll如果rebalance没结束依然会block。
After subscribing to a set of topics, the consumer will automatically join the group when {@link #poll(Duration)} is invoked. The poll API is designed to ensure consumer liveness. As long as you continue to call poll, the consumer will stay in the group and continue to receive messages from the partitions it was assigned. Underneath the covers, the consumer sends periodic heartbeats to the server. If the consumer crashes or is unable to send heartbeats for a duration of {@code session.timeout.ms}, then the consumer will be considered dead and its partitions will be reassigned.
It is also possible that the consumer could encounter a "livelock" situation where it is continuing to send heartbeats, but no progress is being made. To prevent the consumer from holding onto its partitions
indefinitely in this case, we provide a liveness detection mechanism using the {@code max.poll.interval.ms} setting. Basically if you don't call poll at least as frequently as the configured max interval, then the client will proactively leave the group so that another consumer can take over its partitions. When this happens, you may see an offset commit failure (as indicated by a {@link CommitFailedException} thrown from a call to {@link #commitSync()}).
The consumer provides two configuration settings to control the behavior of the poll loop:
- max.poll.interval.ms
By increasing the interval between expected polls, you can give the consumer more time to handle a batch of records returned from {@link #poll(Duration)}. The drawback is that increasing this value may delay a group rebalance since the consumer will only join the rebalance inside the call to poll. You can use this setting to bound the time to finish a rebalance, but you risk slower progress if the consumer cannot actually call {@link #poll(Duration) poll} often enough. - max.poll.records
Use this setting to limit the total records returned from a single call to poll. This can make it easier to predict the maximum that must be handled within each poll interval. By tuning this value, you may be able to reduce the poll interval, which will reduce the impact of group rebalancing.
为什么协调器的rebalance timeout是max.poll.interval?Consumer的消息拉取和处理使用了事件循环event loop的方式,在订阅了一个topic之后,需要在一个event loop(死循环)中不断调用poll()函数,poll函数会自动完成find coordinator、rebalance和数据拉取。如果不调用poll是不会进行这些操作的。The Kafka consumer is NOT thread-safe. All network I/O happens in the thread of the application making the call.
4、服务端实现
延时操作的实现-时间轮
Kafka中的延时(定时)操作采用时间轮 + DelayQueue(堆)实现,而不是只通过DelayQueue实现,因为DelayQueue的每次插入和删除操作的时间复杂度为O(logn),会使得算法复杂度过高。
时间轮有 一 个表 盘指针(currentTime) , 用来表 示 时间轮当前所处的时间。 currentTime可以将整个时间轮划分 为到期部分和未到期部分。
当创建第一个延时任务时,就会创建第一层的时间轮。一个时间格对应一个TimerTaskList(不必有序),一个TimerTaskList中有多个TimerTaskEntry,一个TimerTaskList是作为一项添加到DelayQueue中,TimerTaskList在Delay
Queue中的超时时间是这个时间格的起始时间(一个时间格对应一个时间范围),根据任务的延迟时间将每个任务添加到对应层级时间轮的时间格的TimerTaskList中。因此时间轮的作用是判断每个任务应该添加到(或新创建)哪个TimerTaskList,而DelayQueue的作用是找到第一个超时的时间格,实现精准推进,不需要周期性地检查时间轮是否有任务达到超时时间。
当新添加的任务的延时时间超过了顶层时间轮的interval(周长时间),那需要创建更高层的时间轮,每层时间轮的起始时间为当前系统时间。
如果往时间轮添加一项任务导致新创建了一个TimerTaskList,那还需要把这个TimerTaskList插入到DelayQueue中,往堆插入元素是O(logN)的,因此此次新建任务的时间复杂度是O(logN)的。如果新添加的任务是加入到已有的TimerTaskList中,则不用修改DelayQueue,因此此次新建任务的时间复杂度是O(1)的。
当DelayQueue唤醒时,说明此时已经走到第一个有超时任务的时间格中,此时的系统时间应该是这个时间格的起始时间。此时需要遍历TimerTaskList,对于任务的超时时间等于当前系统时间(等于时间格的起始时间)的可以直接执行,如果当前时间轮不是最底层的时间轮会有一个降级操作,会将这个任务添加到下一层级时间轮中(大多数任务会不断降级到最底层的时间轮来执行)。注意由于最底层时间轮的任务无法降级,因此最底层时间轮的时间格范围(tickMs)应该是任务超时时间的最高精度(tickMs如果是2ms,则任务的延时时间应该是2ms的整数倍),否则会造成复杂度较高,有可能每个任务都需要O(logN)的复杂度去维护其执行顺序。
参考 https://www.tpvlog.com/article/364 Netty的HashedWheelTimer时间轮 和 Kafka的时间轮
服务端的延时操作
生产者发送消息时
服务端在将 生产者发送的消息写入 leader 副本的本地日志后,Kafka 会 根据客户端的acks参数是否为-1 创建 一 个延时生产操作(DelayedProduce), 用来处理消息正常写入所有副本 或 超时的清况, 以返回 相应的响应结果给客户端。
延时操作不同于定时操作,延时操作可以在超时时间之前由外部事件取消。就延时生产操作而言, 它的外部事件是partition的HW(高水位)发生增长。 也就是说, 随着follower副本不断地与leader副本进行消息同步,会促使HW增长, HW每增长一次都会检测是否能够取消对应的延时生产操作,然后向生产者返回正常响应。
follower和consumer拉取消息时
leader和follower之间的日志同步也是基于follower主动拉取消息的,follower拉取消息和consumer拉取消息本质是一样的。如果有两个follower副本都已经拉取到了leader副本的最新位置, 此时又向leader副本发送拉取请求,而leader副本并没有新的消息写入, 那么此 时leader副本该如何处理呢?可以直接返回空的拉取结果给follower副本, 不过在leader副本 一直没有新消息写入的情况下,follower副本会一直发送拉取请求, 并且总收到空的拉取结果, 这样徒耗资源, 显然不太合理(这就是拉模式的缺点)。
Kafka使用延时操作来处理这种情况。Kafka 在处理拉取请求时,会先读取 一 次日志文件, 如果收集不到足够多(fetchMinBytes , 由参数fetch.min.bytes配置,默认值为1)的消息, 那么就会创建 一 个延时拉取操作(DelayedFetch)以等待拉取到足够数量的消息。当延时拉取操作被外部事件成功取消时, 会再读取 一 次日志文件, 然后将拉取结果返回给follower副本。 follower和consumer发起的拉取请求的外部事件是不同的, follower副本的延时拉取, 外部事件就是消息追加到了leader副本的本地日志文件中(LEO有增加);consumer的延时拉取,它的外部事件是HW的增长。
Kafka还引入了事务的概念, 对于消费者或follower副本而言, 其默认的事务隔离级别为"read_uncommitted"。 不过消费者可以通过客户端参数isolation.level将事务隔离级别设置为"read_committed" ,注意follower副本只能将事务隔离级别设置为read_uncommitted。这样消费者拉取不到生产者已经写入却尚未提交的消息。 对于read_committed级别的消费者的延时拉取, 它的外部事件是由LSO(Last Stable Offset)的增长来触发
控制器
控制器的选举
在 Kafka 集群中会有 一 个或多个broker, 其中有且仅有 一 个broker 会被选举为控制器(Kafka Controller), 它负责管理整个集群中所有分区和副本的状态。控制器选举依赖于ZooKeeper, 成功竞选为控制器的broker会在ZooKeeper中创建 /controller这个临时(EPHEMERAL)节点, 此临时节点的内容参考如下:
{"version":1,"brokerid":0,"timestamp":"1669261529715"}
其中version在目前版本中固定为1, brokerid 表示成为控制器的broker的id编号, timestamp表示竞选成为控制器时的时间戳。
每个broker启动的时候会去尝试读取 /controller节点的brokerid的值,如果读取到brokerid的值不为-1, 则表示已经有其 他broker 节点成功竞选为控制器, 当前broker就会放弃竞选;如果ZooKeeper 中不存在 /controller节点,就会尝试去创建/controller节点。 这里采用的是基于zk创建分布式锁的另外一种方法,/controller节点是个临时节点,多个broker同时去尝试创建这个临时节点时, zk收到的第一个创建请求会创建成功,其余创建请求会返回nodeExist错误。 每个broker都会在内存中保存当前控制器的brokerid值, 这个值可以标识为activeContollerId。每个 broker 都会对 /controller 节点添加监听器并响应其修改(ControllerChangeHandler) ,当 /controller 节点的数据发生变化时, 每个 broker 都会更新自身内存中保存 的 activeControllerld 。 如果 broker 在数据变更前是控制器, 在数据变更后自身的 brokerid 值与 新的 activeControllerld 值不 一 致, 那么就需要 退位 , 关闭相应的资源, 注销相应的监听器等。 如果有特殊需要, 可以手动删除 或修改/controller 节点来触发新 一 轮的选举。
ZooKeeper 中还有 一 个与控制器有关的/controller_epoch节点, 这个节点是持久 (PERSISTENT)节点, 节点中存放的是 一 个整型的controller_epoch值。controller_epoch用于记录控制器发生变更的次数, 即记录当前的控制器是第几代控制器, 我们也可以称之为控制器的纪元 。controller_epcoh的初始值为1, 当控制器发生变更时,新的控制器会将该字段值加1并更新到zk中。每个和控制器交互的请求都会携带controller epoch这个字段,如果请求的controller_epoch值小于内存中的controller_epoch值, 则认为这个请求是向已经过期的控制器发送的请求(或者是由过期的控制器发过来的请求), 那么这个请求会被 认定为无效的请求 。 如果请求的controller_epoch值大于内存中的controller_eocph值, 那么说明已经有新的控制器当选了,自己不再是controller。 由此可见, Kafka通过controller_epoch来保证控制器的唯一性。进而保证相关操作的 一 致性。
控制器的职责
具备控制器身份的broker需要比其他普通的broker多 一 份职责, 具体细节如下:
- 监 听 和处理 主 题 相 关 的 变 化 。
- 为 /brokers/ topics 节 点 添 加 TopicChangeHandler , 用 来 处理主 题 增 减 的 变 化(TopicChangeHandler发现主题减少只会更新内存中的ControllerContext中的信息)
- 为/admin/delete_topics 节点添加TopicDeletionHandler, 用来处理删除主题的动作。
- 监听和处理分区相关的变化。
- 为每个 /brokers/topics/<topic>节点添加 PartitionModificationsHandler, 用来监听每个主题中分区数量的变化。
- 当某个分区故障时(broker挂了), 控制器会从 zk 中读取当前分区的ISR集合,调用配置的分区选举算法选择分区的leader, 并通知每个相关的Broker,要么将Broker上的分区变成leader,要么让Broker从新的leader分区中复制数据。
- 为 /isr_change_notification 节点注册 IsrChangeNotificationHandler, 用来处理 ISR 集合变更的动作。 每个broker负责维护leader分区的partition的ISR信息,当ISR有变化时,broker会在 /isr_change_notification 写入信息。 controller检测到某个分区的 ISR集合发生变化时, controller负责通知所有broker更新其元数据信息。
- 当ISR集合变更时,partition的leader其实还会更新 brokers/topics/{topic名称}/partitions/{分区号}/state节点的数据,但因为state节点太多了,每个分区一个state节点,controller只会在分区副本重分配的时候监听正在迁移的partition的state节点,其他情况都没有监听。
- 为 /admin/reassign_partition 节点注册 PartitionReassignmentHandler, 用来处理分区重分配的动作。
- 为/admin/preferred-replica-election 节点添加 PreferredReplicaElectionHandler, 用来触发优先副本的选举。
- 如果参数auto.leader.rebalance.enable设置为 true, 则还会开启 一 个名为 "auto-leader-rebalance-task"的定时任务来 负责维护分区的优先副本的均衡 。
- 启动并管理分区状态机和副本状态机。
- 监听broker相关的变化。
- 为 /brokers/ids节点添加BrokerChangeHandler, 用来处理broker增减的变化,分区leader的迁移。
- 维护和更新集群的元数据信息(ControllerContext),并将集群的变更信息发送给其他broker。
注意其他broker也会读写zk,比如broker更新partition的状态(leader副本在自己这里的)。但其他broker不用在zk上设置watcher,不用响应zk的变更,由控制器去响应,然后将结果通知给其他broker
控制器在选举成功之后会读取ZooKeeper 中各个节点 的数据来初始化上下文信息 (ControllerContext) , 并且需要维护这些上下文信息。 比如为某个主题增加了若干分区, 控制器在负责创建这些分区的同时要更新上下文信息, 并且需要将这些变更信息同步到其他普通的 broker节点中。
Kafka的早期版本并没有采用 Controller这样的概念来对集群的状态进行管理, 而是依赖于ZooKeeper, 每个 broker都会在ZooKeeper上为分区和副本注册大量的Watcher。 当分区 或副本状态变化时, 会唤醒很多不必要的监听器 , 这种严重依赖ZooKeeper 的设计会有脑裂、 羊群效应, 以及造成 ZooKeeper 过载的隐患(旧版的消费者客户 端存在同样的问题)。 在目前的新版本的设计中, 只有 Kafka Controller 在 ZooKeeper 上注册相应的监听器, 其他的 broker 极少需要再监听 ZooKeeper 中的数据变化(除了对/controller 节点的监听器)
如果Controller与Broker网络连接不通会怎么办?
Controller向Broker发送消息时会一直进行重试, 直到zookeeper发现Broker通信有问题,将这台Broker的节点移除,Controller就会收到通知,并将Controller与这台Broker的RequestSendThread线程shutdown;就不会再重试了; 如果zk跟Broker之间网络通信是正常的,只是发起的逻辑请求就是失败,则会一直进行重试
如果手动将zk中的 /brokers/ids/ 下的子节点删除会怎么样?
手动删除 /brokers/ids/Broker的ID, Controller收到变更通知,则将该Broker在Controller中处理下线逻辑; 此时该Broker已经游离于集群之外,即使它服务还是正常的,但是它却提供不了服务了; 只能重启该Broker重新在zk注册;
Controller与Broker的交互
https://matt33.com/2018/06/25/leaderAndIsr-process/
LeaderAndIsr 请求
LeaderAndIsr 请求是在一个 Partition 的 leader、isr、AR 变动时,Controller 向 相关的Broker(这个partition有副本在这个broker上) 发送的一种请求,在一个 LeaderAndIsr 请求中,会封装多个 Topic Partition 的信息,每个 Topic Partition 会对应一个 PartitionState 对象,这个对象主要成员变量如下:
public class PartitionState {
public final int controllerEpoch; // Broker 收到后,如果发现是过期的 Controller 请求,就会拒绝这个请求;
public final int leader;
public final int leaderEpoch; // leader、isr、AR 变动时,leaderEpoch 都会加1;如果请求中的 leader epoch 小于broker缓存中的 epoch 值,那么就过滤掉这个 PartitionState 信息
public final List<Integer> isr;
public final int zkVersion;
public final Set<Integer> replicas;
}
__consumer_offset 这个 Topic 如果发生了 leader 切换,GroupCoordinator 需要进行相应的处理
UpdateMetadata 请求
可以认为只要 Controller 修改了集群的元数据,就会向 Broker 发送更新元数据的请求,让它们更新集群元数据。
UpdateMetadata 和 LeaderAndIsr 请求的区别是,比如某个partition的leader切换后,该partition下的leader和follower所在的broker是收到的是LeaderAndIsr请求,其他broker收到的是UpdateMetadata请求
StopReplica 请求
见主题的删除流程
Broker的上下线
https://matt33.com/2018/06/17/broker-online-offline/
每台 Broker 在上线时,都会与 ZK 建立一个建立一个 session,并在 /brokers/ids 下注册一个节点,节点名字就是 broker id,这个节点是临时节点,该节点内部会有这个 Broker 的详细节点信息。Controller 会监听 /brokers/ids 这个路径下的所有子节点,如果有新的节点出现,那么就代表有新的 Broker 上线,如果有节点消失,就代表有 broker 下线,Controller 会进行相应的处理
Broker 上线:
- 向当前集群所有存活的 Broker 发送 Update Metadata 请求,使得其他节点知道当前的 Broker 上线了;
- 获取当前节点分配的所有的 Replica 列表,将这个 Replica 列表发给新上线的broker,并将 这些Replica 的状态转移为 OnlineReplica 状态;( the very first thing to do when a new broker comes up is send it the entire list of partitions that it is supposed to host. Based on that the broker starts the high watermark threads for the input list of partitions )
- 触发 NewPartition/OfflinePartition 状态的 Partition 进行 leader 选举,如果 leader 选举成功,那么该 Partition 的状态就会转移到 OnlinePartition 状态,否则状态转移失败;( when a new broker comes up, the controller needs to trigger leader election for all new and offline partitions to see if these brokers can become leaders for some/all of those)
- 如果副本迁移中有新的 Replica 落在这台新上线的节点上,那么开始执行副本迁移操作;(check if reassignment of some partitions need to be restarted)
- 如果之前由于这个 Topic 设置为删除标志,但是由于其中有 Replica 掉线而导致无法删除,这里在节点启动后,尝试重新执行删除操作(见topic的删除)。(check if topic deletion needs to be resumed. If at least one replica that belongs to the topic being deleted exists on the newly restarted brokers, there is a chance that topic deletion can resume )
broker下线:
- 首先找到 Leader 在该 Broker 上所有 Partition 列表,然后将这些 Partition 的状态全部转移为 OfflinePartition 状态;
- 触发 所有处于 NewPartition/OfflinePartition 状态的 Partition 进行 Leader 选举,如果 Leader 选举成功,那么该 Partition 的状态就会迁移到 OnlinePartition 状态,否则状态转移失败(Broker 上线/掉线、Controller 初始化时都会触发这个方法), leader 成功选举后,发送 LeaderAndIsr 请求给该分区所有存活的副本,然后更新zk /state路径中的数据;
- 获取在该 Broker 上的所有 Replica 列表,将其状态转移成 OfflineReplica 状态;
- 找到正在删除的,且有replica位于该broker的topic,暂停该topic的删除,将这些topic暂时加入内存中的topicsIneligibleForDeletion队列中,等broker重新启动再尝试重新删除;
- 向其他broker发送 Update Metadata 请求通知该broker的下线。
优雅关闭
Kafka自身提供了bin/kafka-server-stop.sh关闭服务器, 这个脚本使用kill -s TERM $PIDS 或kill -15 $PIDS 的方式来关闭进程, 注意千万 不要使用kill -9的方式 。
Kafka 服务入口程序中有 一 个名为"kafka-shutdownhock"的关闭钩子, 待Kafka进程捕获终止信号的时候会执行这个关闭钩子 中的内容, 其中除了正常关闭 一 些必要的资源, 还会执行 一 个控制关闭(ControlledShutdown)的动作 。 使用 ControlledShutdown的方式关闭Kafka有两个优点: 一 是可以让消息完全同步到磁盘上,在服务 下次重新上线时不需要进行日志的恢复操作; 二是ControllerShutdown 在关闭服务之前, 会对 其上的 leader副本进行迁移, 这样就可以减少分区的不可用时间 。
ControlledShutdown的整个执行 过程 :
Kafka控制器收到ControlledShutdownRequest请求之后,会与待关闭broker之间进行多次交互动作。如果分区的副本数大于1且 leader 副本位于待关闭 broker 上, 那么需要实施 leader 副本的选举迁移及新的 ISR 的变更,具体的选举分配的方案由专用的选举器 ControlledShutdownLeaderSelector 提供。如果这些分区的副本数只是 大于 1, leader 副本并不位于待关闭 broker 上,那controller就向要下线的节点发送 StopReplica 请求停止副本同步,并将该副本设置为 OfflineReplica 状态,。 如果这些分区的副本数只是为1, 那么这个副本的关闭动作会 在整个 ControlledShutdown 动作执行之后由副本管理器来具体实施。待关闭的broker在收到ControlledShutdownResponse响应后, 需要判断整个ControlledShutdown 动作是否执行成功 , 否则进行重试(默认3次)或继续执行接下来的关闭资源的动作。
partition leader 的选举
leader选取是在 doElectLeaderForPartitions 中实现的,先从zk的 /stete路径下读出该partition下最新的数据,包括ISR,leader,leader epoch,version信息,然后依据选举策略从ISR中选出新的leader,leader选举成功后controller需要先 更新 /broker/topics/{topic名称}/partitions/{分区号}/state 下的数据(更新需要使用version),更新成功后 controller才能更新自己本地内存中的数据,然后向该partition下相关的broker(partition的AR集合中的Broker)发送LeaderAndISR请求,向其他broker发送 UpdateMetadata 请求。注意这里发送LeaderAndISR请求时是没有顺序的,可能出现两种情况:
- 新leader比旧leader先收到LeaderAndISR请求
这样会暂时出现存在两个leader的情况,但是这个新leader一定是旧leader的ISR集合中的,对于使用ack = -1的生产者,旧leader会向ISR中(包括新leader)的broker发送消息,但此时新leader会拒绝旧leader的请求,这样旧leader上的请求不会成功。 - 旧leader比新leader先收到LeaderAndISR请求
这样会出现这个partition暂时没有leader的情况。
Leader的选举策略是按照AR集合的顺序找到第一个存活的且位于ISR中的节点(unclean.leader.election.enable即便为true,也会先尝试找ISR中的存活节点,如果没有找到才会找不是ISR中的存活节点)。
优先副本的选举策略有点不一样,只取出AR集合中第一个副本,检查这个副本是否存活且位于ISR,如果不满足则选举失败。
由于ISR集合是由leader负责维护和更新的,在unclean.leader.election.enable为false的情况下:
- 最开始ISR = {0, 1},如果节点0挂了,此时1是leader节点,节点1会更新ISR为{1},如果节点1也挂了,此时ISR没有人更新仍然是{1},即便节点0恢复也不会被controller选举为leader,只有节点1恢复才能被选举为leader。
- 最开始ISR = {0, 1},如果节点0和1同时挂了,则ISR 没有人更新仍然是 {0, 1},此时节点0或1任意一个恢复都会被选为leader
leader恢复可以提供read服务,但如果要为acks = -1的producer提供write服务,还需要ISR集合数量大于等于min.insync.replicas
leader epoch的引入
follower向leader拉取消息的请求中会带有自身的LEO信息, leader据此计算自己的HW,leader返回给follower的消息中含有leader的HW,follower 会据此更新自己的HW, follower更新HW的算法是取follower的LEO和leader的HW的最小值。Follower的HW的更新需要一轮额外的拉取请求流程,导致follower的HW一般落后于leader的HW。
在0.11.0.0版本之前, leader 和 follower 重启之后会根据自己之前 HW 位置(HW值会存入本地文件)进行日志截断 ,且如果 follower 的 HW 比 leader 的 HW 高,follower 还会做一次日志截断 ,将 HW 调整为 leader的HW 。如果follower的HW比leader低(因为follower更新HW比leader慢),且follower和leader相继重启导致原来的follower成为新leader,就会造成消息丢失(新leader(旧follower)的HW没有旧leader(新follower)HW高,新leader数据少于旧leader)。
Kafka 从 0.11.0.0 引入了 leader epoch 的概念,leader epoch 代表 leader 的纪元信息,初始值为 0。每当 leader 变更一次, leader epoch 的值就会加 1 ,相当于为 leader 增设了 一个版本号 。每个副本(follower或leader)还会维护一个映射关系<LeaderEpoch --> StartOffset>,其中 StartOffset 表示当前 LeaderEpoch 下写入的第一条消息的偏移量。当 leader epoch 变更时 ,每个副本会将映射关系保存到本地文件中。当 follower重启后,会先向leader发送 OffsetsForLeaderEpochRequest 请求,请求中包含 follower 当前 的 LeaderEpoch 值,leader会在response中返回follower的leader epoch对应的LastOffset (The leader responds with the LastOffset for that LeaderEpoch. LastOffset will be the start offset of the first Leader Epoch larger than the Leader Epoch passed in the request or the Log End Offset if the leader's current epoch is equal to the one requested.)(如果重启后自己是leader,就用自己本地文件中保存的映射关系来查找LastOffset),follower或leader根据这个LastOffset而不是HW来截断自己的本地日志文件。
Leader epoch is managed by the Controller, stored in the Partition State Info in Zookeeper and passed to each new leader as part of the LeaderAndIsrRequest from Controller. We propose stamping every message with the LeaderEpoch on the leader that accepts the produce request. This LeaderEpoch number is then propagated through the replication protocol, and used to replace the High Watermark, as a reference point for message truncation.
5、Kafka高性能的原因
Kafka依统文件系统(更底层地来说就是磁盘)来存储和缓存消息 。在传统的消息中间件 RabbitMQ中 ,就使用内存作为默认的存储介质, 而磁盘作为备选介质,以此实现高吞吐和低延迟的特性。 虽然我们一般认为磁盘比内存慢,但这取决于我们怎样使用磁盘。磁盘随机读写性能很差,但磁盘顺序读写性能很高,甚至高于内存的随机读写性能。因为操作系统可以针对线性读写做深层次的优化,比如基于page cache的预读技术(read-ahead, 提前将一个比较大的磁盘块读入内存)、后写技术(write -behind, 将很多小的逻辑写操作合并起来组成一个大的物理写操作)。而Kafka 一般只在日志文件的尾部追加新的消息,这属于典型的顺序写盘的操作。Kafka 中大量使用了页缓存, 这是Kafka实现高吞吐的重要因素之一 。Kafka 还使用 零拷贝( Zero-Copy )技术,零拷贝是指将数据直接从磁盘文件复制到socket的网卡设备中,而不需要经由应用程序中转,减少了内核和用户模式之间的上下文切换 。

浙公网安备 33010602011771号