<Kafka核心技术与实战>学习笔记 -- 深入Kafka内核

Kafka副本机制详解

副本机制(Replication)

也叫备份机制,通常是指分布式系统在多台网络互联的机器上保存有相同的数据拷贝

副本机制的好处:

  1. 提供数据冗余。即使系统部分组件失效,系统依然能够继续运转,因而增加了整体可用性以及数据持久性
  2. 提供高伸缩性。支持横向扩展,能够通过增加机器的方式来提升读性能,进而提高读操作吞吐量
  3. 改善数据局部性。允许将数据放入与用户地理位置相近的地方,从而降低系统延时

目前Kafka只能享受到副本机制带来的第 1 个好处,也就是提供数据冗余实现高可用性和高持久性

Kafka 副本定义

Kafka 是有主题概念的,而每个主题又进一步划分成若干个分区
副本的概念实际上是在分区层级下定义的,每个分区配置有若干个副本

副本(Replica),本质就是一个只能追加写消息的提交日志
同一个分区下的所有副本保存有相同的消息序列,这些副本分散保存在不同的 Broker 上,从而能够对抗部分 Broker 宕机带来的数据不可用

在实际生产环境中,每台 Broker 都可能保存有各个主题下不同分区的不同副本,因此,单个 Broker 上存有成百上千个副本的现象是非常正常的

在这里插入图片描述

如图展示的是一个有 3 台 Broker 的 Kafka 集群上的副本分布情况。主题 1 分区 0 的 3 个副本分散在 3 台 Broker 上,其他主题分区的副本也都散落在不同的 Broker 上,从而实现数据冗余

基于领导者(Leader-based)的副本机制

如何确保副本中所有的数据都是一致的呢?
基于领导者(Leader-based)的副本机制
工作原理:
在这里插入图片描述

  1. 每个分区在创建时都要选举一个副本,称为领导者副本(Leader Replica),其余的副本自动称为追随者副本(Follower Replica)
  2. 在 Kafka 中,追随者副本是不对外提供服务的
    所有的读写请求都必须发往<领导者副本>所在的 Broker,由该 Broker 负责处理
    追随者副本不处理客户端请求,它唯一的任务就是从领导者副本异步拉取消息,并写入到自己的提交日志中,从而实现与领导者副本的同步
    这就是 Kafka 副本没有提供高伸缩性和改善数据局部性的原因
  3. 当领导者副本挂掉了,或者说领导者副本所在的 Broker 宕机时,Kafka 依托于 ZooKeeper 提供的监控功能能够实时感知到,并立即开启新一轮的领导者选举,从追随者副本中选一个作为新的领导者。老 Leader 副本重启回来后,只能作为追随者副本加入到集群中

对于客户端用户而言,Kafka 的追随者副本没有任何作用,它既不能像 MySQL 那样帮助领导者副本“抗读”,也不能实现将某些副本放到离客户端近的地方来改善数据局部性

Kafka副本机制的好处

  1. 方便实现“Read-your-writes”
    当你使用生产者 API 向 Kafka 成功写入消息后,马上使用消费者 API 去读取刚才生产的消息
    eg:
    发完微博希望立马看到
    如果允许追随者副本对外提供服务,由于副本同步是异步的,因此有可能出现追随者副本还没有从领导者副本那里拉取到最新的消息,从而使得客户端看不到最新写入的消息
  2. 方便实现单调读(Monotonic Reads)一致性
    对于一个消费者用户而言,在多次消费消息时,它不会看到某条消息一会儿存在一会儿不存在 (单调读不一致)
    eg
    如果允许追随者副本提供读服务,那么假设当前有 2 个追随者副本 F1 和 F2,它们异步地拉取领导者副本数据
    倘若 F1 拉取了 Leader 的最新消息而 F2 还未及时拉取,那么,此时如果有一个消费者先从 F1 读取消息之后又从 F2 拉取消息,它可能会看到这样的现象:第一次消费时看到的最新消息在第二次消费时不见了,这就不是单调读一致性
    但是,如果所有的读请求都是由 Leader 来处理,那么 Kafka 就很容易实现单调读一致性

In-sync Replicas(ISR)

追随者副本不提供服务,只是定期地异步拉取领导者副本中的数据,存在着不可能与 Leader 实时同步的风险

ISR 副本集合:
ISR 中的副本都是与 Leader 同步的副本

Leader 副本天然就在 ISR 中
ISR 不只是追随者副本集合,它必然包括 Leader 副本。甚至在某些情况下,ISR 只有 Leader 这一个副本

在这里插入图片描述

Leader 副本当前写入了 10 条消息,
Follower1 副本同步了其中的 6 条消息,
而 Follower2 副本只同步了其中的 3 条消息
副本同步消息的数量并不是判断是否与Leader副本同步的标准

Kafka 判断 Follower 是否与 Leader 同步的标准:
Broker 端参数 replica.lag.time.max.ms , 默认10秒
只要一个 Follower 副本落后 Leader 副本的时间不连续超过 10 秒,那么 Kafka 就认为该 Follower 副本与 Leader 是同步的,即使此时 Follower 副本中保存的消息明显少于 Leader 副本中的消息

Follower 副本唯一的工作就是不断地从 Leader 副本拉取消息,然后写入到自己的提交日志中
如果这个同步过程的速度持续慢于 Leader 副本的消息写入速度,那么在 replica.lag.time.max.ms 时间后,此 Follower 副本就会被认为是与 Leader 副本不同步的,因此不能再放入 ISR 中
此时,Kafka 会自动收缩 ISR 集合,将该副本“踢出”ISR

倘若该副本后面慢慢地追上了 Leader 的进度,那么它是能够重新被加回 ISR 的
ISR 是一个动态调整的集合,而非静态不变的

Unclean 领导者选举(Unclean Leader Election)

既然 ISR 是可以动态调整的,那么自然就可以出现这样的情形:ISR 为空
因为 Leader 副本天然就在 ISR 中,如果 ISR 为空了,就说明 Leader 副本也“挂掉”了,Kafka 需要重新选举一个新的 Leader

Kafka 把所有不在 ISR 中的存活副本都称为非同步副本
通常来说,非同步副本落后 Leader 太多,因此,如果选择这些副本作为新 Leader,就可能出现数据的丢失

在 Kafka 中,选举这种副本的过程称为 Unclean 领导者选举
Broker 端参数 unclean.leader.election.enable 控制是否允许 Unclean 领导者选举。

  • 开启 Unclean 领导者选举可能会造成数据丢失,但好处是,它使得分区 Leader 副本一直存在,不至于停止对外提供服务,因此提升了高可用性
  • 禁止 Unclean 领导者选举的好处在于维护了数据的一致性,避免了消息丢失,但牺牲了高可用性。

分布式系统 CAP 理论
一个系统通常只能同时满足一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)中的两个
强烈建议不要开启, 可以通过其他的方式来提升高可用性(Availability), 不值得牺牲数据一致性(Consistency)

小结

在这里插入图片描述

Kafka在启动的时候会开启两个任务

  • 一个任务用来定期地检查是否需要缩减或者扩大ISR集合,这个周期是 replica.lag.time.max.ms 的一半,默认5000ms
    当检测到ISR集合中有失效副本时,就会收缩ISR集合,当检查到有Follower的 HighWatermark 追赶上Leader时,就会扩充ISR
    除此之外,当ISR集合发生变更的时候还会将变更后的记录缓存到 isrChangeSet
  • 另外一个任务会周期性地检查这个Set,如果发现这个Set中有ISR集合的变更记录,那么它会在zk中持久化一个节点
    然后因为Controllr在这个节点的路径上注册了一个Watcher,所以它就能够感知到ISR的变化,并向它所管理的broker发送更新元数据的请求。最后删除该路径下已经处理过的节点

此外,在0.9X版本之前,Kafka中还有另外一个参数replica.lag.max.messages,它也是用来判定失效副本的,当一个副本滞后leader副本的消息数超过这个参数的大小时,则判定它处于同步失效的状态。它与replica.lag.time.max.ms参数判定出的失效副本取并集组成一个失效副本集合

不过这个参数本身很难给出一个合适的值。以默认的值4000为例,对于消息流入速度很低的主题(比如TPS为10),这个参数就没什么用;对于消息流入速度很高的主题(比如TPS为2000),这个参数的取值又会引入ISR的频繁变动。所以从0.9x版本开始,Kafka就彻底移除了这一个参数


一个分区有3个副本,一个leader,2个follower
producer向leader写了10条消息,follower1从leader处拷贝了5条消息,follower2从leader处拷贝了3条消息,
那么leader副本的LEO就是10,HW=3;follower1副本的LEO是5


replica.lag.time.max.ms
如果leader发现flower超过这个参数所设置的时间没有向它发起fech请求(也就是复制请求),那么leader考虑将这个flower从ISR移除

思考

目前 Follower 副本不对外提供服务

如果允许 Follower 副本处理客户端消费者发来的请求, 可以用于改善云上数据的局部性,更好地服务地理位置相近的客户
但是应该如何避免或缓解因 Follower 副本与 Leader 副本不同步而导致的数据不一致的情形呢?

Kafka请求处理

Apache Kafka 自己定义了一组请求协议,用于实现各种各样的交互操作

  • PRODUCE 请求是用于生产消息的
  • FETCH 请求是用于消费消息的
  • METADATA 请求是用于请求 Kafka 集群元数据信息的

截止到 2.3 版本,Kafka 共定义了多达 45 种请求格式
所有的请求都是通过 TCP 网络以 Socket 的方式进行通讯的

两种常见的请求处理方案

  1. 顺序处理请求
while (true) {
	Request request = accept(connection);
	handle(request);
}

吞吐量太差
每个请求都必须等待前一个请求处理完毕才能得到处理
只适用于请求发送非常不频繁的系统

  1. 每个请求使用单独线程处理

while (true) {
	Request = request = accept(connection);
	Thread thread = new Thread(() -> {
		handle(request);
	});
	thread.start();
}

为每个入站请求都创建一个新的线程来异步处理
完全采用异步的方式, 不会阻塞下一个请求
开销极大

Reactor 模式

事件驱动架构的一种实现方式,特别适合应用于处理多个客户端并发向服务器端发送请求的场景

在这里插入图片描述

Reactor 有个请求分发线程 Dispatcher,也就是图中的 Acceptor,它会将不同的请求下发到多个工作线程中处理

Acceptor 线程只是用于请求分发,不涉及具体的逻辑处理,非常得轻量级,因此有很高的吞吐量表现

工作线程可以根据实际业务处理需要任意增减,从而动态调节系统负载能力

Kafka:
在这里插入图片描述
Kafka 的 Broker 端有个 SocketServer 组件,类似于 Reactor 模式中的 Dispatcher
它也有对应的 Acceptor 线程和一个工作线程池,叫网络线程池

Kafka 提供了 Broker 端参数 num.network.threads,用于调整该网络线程池的线程数
其默认值是 3,表示每台 Broker 启动时会创建 3 个网络线程,专门处理客户端发送的请求

Acceptor 线程采用轮询的方式将入站请求公平地发到所有网络线程中
因此,在实际使用过程中,这些线程通常都有相同的几率被分配到待处理请求
这种轮询策略编写简单,同时也避免了请求处理的倾斜,有利于实现较为公平的请求处理调度

网络线程收到请求后

在这里插入图片描述

当网络线程拿到请求后,它不是自己处理,而是将请求放入到一个共享请求队列
Broker 端还有个 IO 线程池,负责从该队列中取出请求,执行真正的处理

  • 如果是 PRODUCE 生产请求,则将消息写入到底层的磁盘日志中
  • 如果是 FETCH 请求,则从磁盘或页缓存中读取消息

IO 线程池处中的线程才是执行请求逻辑的线程
Broker 端参数 num.io.threads 控制了这个线程池中的线程数
目前该参数默认值是 8,表示每台 Broker 启动后自动创建 8 个 IO 线程处理请求
可以根据实际硬件条件设置此线程池的个数

请求队列是所有网络线程共享的,而响应队列则是每个网络线程专属
Dispatcher 只是用于请求分发而不负责响应回传,因此只能让每个网络线程自己发送 Response 给客户端,所以这些 Response 也就没必要放在一个公共的地方

Purgatory 组件,“炼狱”组件
用来缓存延时请求(Delayed Request),那些一时未满足条件不能立刻处理的请求
eg: 设置了 acks=all 的 PRODUCE 请求,一旦设置了 acks=all,那么该请求就必须等待 ISR 中所有副本都接收了消息后才能返回,此时处理该请求的 IO 线程就必须等待其他 Broker 的写入结果
当请求不能立刻处理时,它就会暂存在 Purgatory 中
稍后一旦满足了完成条件,IO 线程会继续处理该请求,并将 Response 放入对应网络线程的响应队列中

控制类请求和数据类请求分离

Kafka Broker 对所有请求是一视同仁的

数据类的请求:

  • PRODUCE 请求
  • FETCH 请求之外

控制类的请求: 并不是操作消息数据的,而是用来执行特定的 Kafka 内部动作的

  • 负责更新 Leader 副本、Follower 副本以及 ISR 集合的 LeaderAndIsr 请求
  • 负责勒令副本下线的 StopReplica 请求

控制类请求可以直接令数据类请求失效

eg1:
1个主题, 1 个分区, 2个副本,其中 Leader 副本保存在 Broker 0 上,Follower 副本保存在 Broker 1 上
Broker 0 积压了很多的 PRODUCE 请求
使用 Kafka 命令强制将该主题分区的 Leader、Follower 角色互换
Kafka 内部的控制器组件(Controller)会发送 LeaderAndIsr 请求给 Broker 0,当前它不再是 Leader,而是 Follower 了
Broker 1 上的 Follower 副本因为被选为新的 Leader,因此停止向 Broker 0 拉取消息

如果刚才积压的 PRODUCE 请求都设置了 acks=all,那么这些在 LeaderAndIsr 发送之前的请求就都无法正常完成了
它们会被暂存在 Purgatory 中不断重试,直到最终请求超时返回给客户端

如果 Kafka 能够优先处理 LeaderAndIsr 请求,Broker 0 就会立刻抛出 NOT_LEADER_FOR_PARTITION 异常,快速地标识这些积压 PRODUCE 请求已失败,这样客户端不用等到 Purgatory 中的请求超时就能立刻感知,从而降低了请求的处理时间
即使 acks 不是 all,积压的 PRODUCE 请求能够成功写入 Leader 副本的日志,但处理 LeaderAndIsr 之后,Broker 0 上的 Leader 变为了 Follower 副本,也要执行显式的日志截断(Log Truncation,即原 Leader 副本成为 Follower 后,会将之前写入但未提交的消息全部删除),依然做了很多无用功

eg2:
积压大量数据类请求的 Broker 上,当你删除主题的时候,Kafka 控制器向该 Broker 发送 StopReplica 请求
如果该请求不能及时处理,主题删除操作会一直 hang 住,从而增加了删除主题的延时

数据类请求和控制类请求的分离 --> 优先级队列
无法处理请求队列已满的情形
当请求队列已经无法容纳任何新的请求时,纵然有优先级之分,它也无法处理新的控制类请求

社区解决方案:
Kafka Broker 启动后,会在后台分别创建两套网络线程池和 IO 线程池的组合,它们分别处理数据类请求和控制类请求
至于所用的 Socket 端口,自然是使用不同的端口了,需要提供不同的 listeners 配置,显式地指定哪套端口用于处理哪类请求

summary

think time

如何规避优先级队列方案中队列已满的问题


有两种方法:
1 是直接替换数据处理队列中的最前面的数据进行处理,处理完控制队列,再将这个消息插队到队头;
2 双队列设计,不过双队列,如果先处理控制消息,如果一直来控制消息,数据队列的消息岂不会被延迟很大;

关于复制一套,我看了下面评论,我和部分网友的理解不一样,我觉得是复制一套网络线程持+中间队列+IO线程池;也就是有两个网络线程池,+2个中间队列,和2套IO线程持;

网络线程池作用将数据分发到中间队列,和接受IO线程池的处理结果回复给客户端。我理解为什么要加这个中间队列是为了将网络处理的线程数和IO处理的线程数解耦,达到高性能和资源少占用的目的。


双队列设计,分别存放数据类和控制类请求,每次先处理完所有控制类请求再处理数据类请求

消费者组重平衡全流程解析

消费者组的重平衡: 让组内所有的消费者实例就消费哪些主题分区达成一致
重平衡需要借助 Kafka Broker 端的 Coordinator 组件,在 Coordinator 的帮助下完成整个消费者组的分区重分配

重平衡触发与通知机制

重平衡的 3 个触发条件:

  1. 组成员数量发生变化 - 最常见
    消费者组中的消费者实例依次启动
    每次消费者组启动时,必然会触发重平衡过程
  2. 订阅主题数量发生变化
  3. 订阅主题的分区数发生变化

重平衡过程是如何通知到其他消费者实例的?
->
靠消费者端的心跳线程(Heartbeat Thread)
Kafka Java 消费者需要定期地发送心跳请求(Heartbeat Request)到 Broker 端的协调者,以表明它还存活着

  • 在 Kafka 0.10.1.0 版本之前,发送心跳请求是在消费者主线程完成的,也就是调用 KafkaConsumer.poll 方法的那个线程
    问题在于,消息处理逻辑也是在这个线程中完成的
    一旦消息处理消耗了过长的时间,心跳请求将无法及时发到协调者那里,导致协调者“错误地”认为该消费者已“死”
  • 自 0.10.1.0 版本开始,社区引入了一个单独的心跳线程来专门执行心跳请求发送,避免了这个问题

重平衡的通知机制正是通过心跳线程来完成的
当协调者决定开启新一轮重平衡后,它会将“REBALANCE_IN_PROGRESS”封装进心跳请求的响应中,发还给消费者实例
当消费者实例发现心跳响应中包含了“REBALANCE_IN_PROGRESS”,就能立马知道重平衡又开始了,这就是重平衡的通知机制

消费者端参数 heartbeat.interval.ms: 设置了心跳的间隔时间, 控制重平衡通知的频率

消费者组状态机

重平衡一旦开启,Broker 端的协调者组件就要开始忙了,主要涉及到控制消费者组的状态流转

消费者组状态机(State Machine)

Kafka 为消费者组定义了 5 种状态,它们分别是:Empty、Dead、PreparingRebalance、CompletingRebalance 和 Stable
在这里插入图片描述

状态机的各个状态流转

在这里插入图片描述

一个消费者组最开始是 Empty 状态,当重平衡过程开启后,它会被置于 PreparingRebalance 状态等待成员加入,
之后变更到 CompletingRebalance 状态等待分配方案,最后流转到 Stable 状态完成重平衡

当有新成员加入或已有成员退出时,消费者组的状态从 Stable 直接跳到 PreparingRebalance 状态,此时,所有现存成员就必须重新申请加入组
当所有成员都退出组后,消费者组状态变更为 Empty
Kafka 定期自动删除过期位移的条件就是,组要处于 Empty 状态
因此,如果消费者组停掉了很长时间(超过 7 天),那么 Kafka 很可能就把该组的位移数据删除了

Kafka 的日志输出:
Removed ✘✘✘ expired offsets in ✘✘✘ milliseconds.
Kafka 在尝试定期删除过期位移

只有 Empty 状态下的组,才会执行过期位移删除的操作

消费者端重平衡流程

在消费者端,重平衡分为两个步骤:

  • 加入组, JoinGroup 请求
  • 等待领导者消费者(Leader Consumer)分配方案, SyncGroup 请求
消费者 JoinGroup 请求的处理过程

当组内成员加入组时,它会向协调者发送 JoinGroup 请求
在该请求中,每个成员都要将自己订阅的主题上报,这样协调者就能收集到所有成员的订阅信息
一旦收集了全部成员的 JoinGroup 请求后,协调者会从这些成员中选择一个担任这个消费者组的领导者

通常情况下,第一个发送 JoinGroup 请求的成员自动成为领导者
注意区分这里的领导者和之前介绍的领导者副本,不是一个概念
这里的领导者是具体的消费者实例,它既不是副本,也不是协调者
领导者消费者的任务是收集所有成员的订阅信息,然后根据这些信息,制定具体的分区消费分配方案

选出领导者之后,协调者会把消费者组订阅信息封装进 JoinGroup 请求的响应体中,然后发给领导者,由领导者统一做出分配方案后,进入到下一步:发送 SyncGroup 请求
在这里插入图片描述

消费者 SyncGroup 请求的处理流程

在这一步中,领导者向协调者发送 SyncGroup 请求,将刚刚做出的分配方案发给协调者
值得注意的是,其他成员也会向协调者发送 SyncGroup 请求,只不过请求体中并没有实际的内容
这一步的主要目的是让协调者接收分配方案,然后统一以 SyncGroup 响应的方式把领导者制定的分配方案下发给各个组内成员,这样组内所有成员就都知道自己该消费哪些分区了
当所有成员都成功接收到分配方案后,消费者组进入到 Stable 状态,即开始正常的消费工作
在这里插入图片描述

Broker 端重平衡场景剖析

注意: 场景一二三的图中的SyncGroup请求,不是等待Leader分配方案,而是提供方案

场景一:新成员入组

新成员入组是指组处于 Stable 状态后,有新成员加入
如果是全新启动一个消费者组,Kafka 是有一些自己的小优化的,流程上会有些许的不同
讨论的是,组稳定了之后有新成员加入的情形
当协调者收到新的 JoinGroup 请求后,它会通过心跳请求响应的方式通知组内现有的所有成员,强制它们开启新一轮的重平衡
具体的过程和之前的客户端重平衡流程是一样的

协调者一端是如何处理新成员入组时序图

在这里插入图片描述

场景二:组成员主动离组

主动离组: 指消费者实例所在线程或进程调用 close() 方法主动通知协调者它要退出

协调者收到 LeaveGroup 请求后,依然会以心跳响应的方式通知其他成员

在这里插入图片描述

场景三:组成员崩溃离组
  • 崩溃离组: 消费者实例出现严重故障,突然宕机导致的离组
    被动的,协调者通常需要等待一段时间才能感知到,这段时间一般是由消费者端参数 session.timeout.ms 控制的
  • 主动离组: 主动发起的离组,协调者能马上感知并处理

在这里插入图片描述

场景四:重平衡时协调者对组内成员提交位移的处理

正常情况下,每个组内成员都会定期汇报位移给协调者
当重平衡开启时,协调者会给予成员一段缓冲时间,要求每个成员必须在这段时间内快速地上报自己的位移信息,然后再开启正常的 JoinGroup/SyncGroup 请求发送

在这里插入图片描述

小结

在这里插入图片描述

讨论

在整个重平衡过程中,组内所有消费者实例都会暂停消费
用 JVM GC 的术语来说就是,重平衡过程是一个 stop the world 操作
是否能允许部分消费者在重平衡过程中继续消费,以提升消费者端的可用性以及吞吐量?


重平衡参照JVM中的Minor gc和Major gc,将重平衡分为两步,在资源的角度讲集群进行分区,这里的资源可以理解为分区,因为后两种变化都是涉及到分区——新主题或已有主题的分区数量变化,对于现有的三种重平衡情况分别做如下处理:
1、新成员入区,在当前区内进行重平衡,不要影响其他的分区
2、资源分区中需要消费的分区队列数量发生的变化,也只是涉及到当前分区的重平衡
这样设计的话就需要处理一个资源分区太空闲和太繁忙时的问题,可以参考m树的节点分裂和合并,这么做比m树更简单,因为它没有层级关系,只是资源分区的整合和划分而已,实现的时候还能兼顾到网络的局部特性

Kafka 控制器

控制器组件(Controller): 在 Apache ZooKeeper 的帮助下管理和协调整个 Kafka 集群
集群中任意一台 Broker 都能充当控制器的角色,但是在运行过程中,只能有一个 Broker 成为控制器,行使其管理和协调的职责
每个正常运转的 Kafka 集群,在任意时刻都有且只有一个控制器
JMX 指标 activeController, 帮助实时监控控制器的存活状态

Apache ZooKeeper 是一个提供高可靠性的分布式协调服务框架
它使用的数据模型类似于文件系统的树形结构,根目录也是以“/”开始
该结构上的每个节点被称为 znode,用来保存一些元数据协调信息

如果以 znode 持久性来划分,znode 可分为

  • 持久性 znode , 不会因为 ZooKeeper 集群重启而消失
  • 临时 znode, 与创建该 znode 的 ZooKeeper 会话绑定,一旦会话结束,该节点会被自动删除

Watch 通知功能, ZooKeeper 赋予客户端监控 znode 变更的能力
一旦 znode 节点被创建、删除,子节点数量发生变化,抑或是 znode 所存的数据本身变更,ZooKeeper 会通过节点变更监听器 (ChangeHandler) 的方式显式通知客户端

ZooKeeper 常被用来实现集群成员管理、分布式锁、领导者选举等功能
Kafka 控制器大量使用 Watch 功能实现对集群的协调管理

Kafka 在 ZooKeeper 中创建的 znode 分布图
在这里插入图片描述

控制器选举规则

Broker 在启动时,会尝试去 ZooKeeper 中创建 /controller 节点
Kafka 当前选举控制器的规则是:
第一个成功创建 /controller 节点的 Broker 会被指定为控制器

控制器职责

控制器是起协调作用的组件, 其职责有:

  1. 主题管理(创建、删除、增加分区)
    控制器对 Kafka 主题的创建、删除以及分区增加的操作
    当执行 kafka-topics 脚本时,大部分的后台工作都是控制器来完成的
  2. 分区重分配
    kafka-reassign-partitions 脚本 提供的对已有主题分区进行细粒度的分配功能
  3. Preferred 领导者选举
    Kafka 为了避免部分 Broker 负载过重而提供的一种换 Leader 的方案
  4. 集群成员管理(新增 Broker、Broker 主动关闭、Broker 宕机)
    自动检测新增 Broker、Broker 主动关闭及被动宕机, 依赖于 Watch 功能和 ZooKeeper 临时节点组合实现的
    控制器组件会利用 Watch 机制检查 ZooKeeper 的 /brokers/ids 节点下的子节点数量变更
    eg:
    目前,当有新 Broker 启动后,它会在 /brokers 下创建专属的 znode 节点
    一旦创建完毕,ZooKeeper 会通过 Watch 机制将消息通知推送给控制器
    这样,控制器就能自动地感知到这个变化,进而开启后续的新增 Broker 作业
    侦测 Broker 存活性则是依赖于刚刚提到的另一个机制:临时节点
    每个 Broker 启动后,会在 /brokers/ids 下创建一个临时 znode
    当 Broker 宕机或主动关闭后,该 Broker 与 ZooKeeper 的会话结束,这个 znode 会被自动删除
    同理,ZooKeeper 的 Watch 机制将这一变更推送给控制器,这样控制器就能知道有 Broker 关闭或宕机了,从而进行“善后”。
  5. 数据服务
    控制器的最后一大类工作,就是向其他 Broker 提供数据服务
    控制器上保存了最全的集群元数据信息,其他所有 Broker 会定期接收控制器发来的元数据更新请求,从而更新其内存中的缓存数据

控制器中保存的数据

在这里插入图片描述

比较重要的数据有:

  • 所有主题信息。包括具体的分区信息,比如领导者副本是谁,ISR 集合中有哪些副本等
  • 所有 Broker 信息。包括当前都有哪些运行中的 Broker,哪些正在关闭中的 Broker 等
  • 所有涉及运维任务的分区。包括当前正在进行 Preferred 领导者选举以及分区重分配的分区列表

注意:
这些数据其实在 ZooKeeper 中也保存了一份
每当控制器初始化时,它都会从 ZooKeeper 上读取对应的元数据并填充到自己的缓存中
有了这些数据,控制器就能对外提供数据服务了
这里的对外主要是指对其他 Broker 而言,控制器通过向这些 Broker 发送请求的方式将这些数据同步到其他 Broker 上

控制器故障转移(Failover)

在 Kafka 集群运行过程中,只能有一台 Broker 充当控制器的角色,那么这就存在单点失效(Single Point of Failure)的风险
Kafka应对单点失效: 为控制器提供故障转移功能,Failover
当运行中的控制器突然宕机或意外终止时,Kafka 能够快速地感知到,并立即启用备用控制器来代替之前失败的控制器
该过程是自动完成的,无需手动干预

控制器故障转移的过程
最开始时,Broker 0 是控制器
当 Broker 0 宕机后,ZooKeeper 通过 Watch 机制感知到并删除了 /controller 临时节点
之后,所有存活的 Broker 开始竞选新的控制器身份
Broker 3 最终赢得了选举,成功地在 ZooKeeper 上重建了 /controller 节点
之后,Broker 3 会从 ZooKeeper 中读取集群元数据信息,并初始化到自己的缓存中
至此,控制器的 Failover 完成,可以行使正常的工作职责了
在这里插入图片描述

控制器内部设计原理

在 Kafka 0.11 版本之前,控制器的设计是相当繁琐的,代码更是有些混乱,这就导致社区中很多控制器方面的 Bug 都无法修复
控制器是多线程的设计,会在内部创建很多个线程
比如,控制器需要为每个 Broker 都创建一个对应的 Socket 连接,然后再创建一个专属的线程,用于向这些 Broker 发送特定请求
如果集群中的 Broker 数量很多,那么控制器端需要创建的线程就会很多
另外,控制器连接 ZooKeeper 的会话,也会创建单独的线程来处理 Watch 机制的通知回调
除了以上这些线程,控制器还会为主题删除创建额外的 I/O 线程

比起多线程的设计,更糟糕的是,这些线程还会访问共享的控制器缓存数据
多线程访问共享可变数据是维持线程安全最大的难题
为了保护数据安全性,控制器不得不在代码中大量使用 ReentrantLock 同步机制,这就进一步拖慢了整个控制器的处理速度

鉴于这些原因,社区于 0.11 版本重构了控制器的底层设计,最大的改进就是,把多线程的方案改成了单线程加事件队列的方案

在这里插入图片描述
单线程 + 队列的实现方式

  • 社区引入了一个事件处理线程,统一处理各种控制器事件,然后控制器将原来执行的操作全部建模成一个个独立的事件,发送到专属的事件队列中,供此线程消费
  • 这里的单线程不代表之前提到的所有线程都被“干掉”了,控制器只是把缓存状态变更方面的工作委托给了这个线程而已
  • 好处
    控制器缓存中保存的状态只被一个线程处理,因此不再需要重量级的线程同步机制来维护线程安全,Kafka 不用再担心多线程并发访问的问题,非常利于社区定位和诊断控制器的各种问题

针对控制器的第二个改进:

  • 将之前同步操作 ZooKeeper 全部改为异步操作
  • 当有大量主题分区发生变更时,ZooKeeper 容易成为系统的瓶颈
  • 采用异步 API 写入 ZooKeeper,ZooKeeper 写入提升了 10 倍

之前 Broker 对接收的所有请求都是一视同仁的,不会区别对待
这种设计对于控制器发送的请求非常不公平,因为这类请求应该有更高的优先级

eg
假设删除了某个主题,那么控制器就会给该主题所有副本所在的 Broker 发送一个名为 StopReplica 的请求
如果此时 Broker 上存有大量积压的 Produce 请求,那么这个 StopReplica 请求只能排队等
问题: 如果这些 Produce 请求就是要向该主题发送消息的话, 主题都要被删除了,处理这些 Produce 请求没有意义
方案: 赋予 StopReplica 请求更高的优先级,使它能够得到抢占式的处理

自 2.2 开始,Kafka 正式支持这种不同优先级请求的处理
简单来说,Kafka 将控制器发送的请求与普通数据类请求分开,实现了控制器请求单独处理的逻辑

小结

生活小窍门:
当你觉得控制器组件出现问题时,比如主题无法删除了,或者重分区 hang 住了,不用重启 Kafka Broker 或控制器
去 ZooKeeper 中手动删除 /controller 节点
具体命令是 rmr /controller
好处: 既可以引发控制器的重选举,又可以避免重启 Broker 导致的消息处理中断

在这里插入图片描述

关于高水位和Leader Epoch的讨论

高水位(High Watermark)

水位一词多用于流式处理领域

教科书经典定义:
在时刻 T,任意创建时间(Event Time)为 T’,且 T’≤T 的所有事件都已经到达或被观测到,那么 T 就被定义为水位

“Streaming System”一书:
水位是一个单调增加且表征最早未完成工作(oldest work not yet completed)的时间戳

在这里插入图片描述

标注“Completed”的蓝色部分代表已完成的工作
标注“In-Flight”的红色部分代表正在进行中的工作
两者的边界就是水位线

Kafka 的水位不是时间戳,更与时间无关。它是和位置信息绑定的,具体来说,它是用消息位移来表征的

高水位的作用

在 Kafka 中,高水位的作用主要有 2 个

  1. 定义消息可见性,即用来标识分区下的哪些消息是可以被消费者消费的
  2. 帮助 Kafka 完成副本同步

在这里插入图片描述

假设这是某个分区 Leader 副本的高水位图
在分区高水位以下的消息被认为是已提交消息,反之就是未提交消息
消费者只能消费已提交消息,即图中位移小于 8 的所有消息
注意,这里不讨论 Kafka 事务,因为事务机制会影响消费者所能看到的消息的范围,它不只是简单依赖高水位来判断
它依靠一个名为 LSO(Log Stable Offset)的位移值来判断事务型消费者的可见性

位移值等于高水位的消息也属于未提交消息
也就是说,高水位上的消息是不能被消费者消费的

日志末端位移的概念,即 Log End Offset,简写是 LEO
它表示副本写入下一条消息的位移值
注意,数字 15 所在的方框是虚线,这就说明,这个副本当前只有 15 条消息,位移值是从 0 到 14,下一条新消息的位移是 15
显然,介于高水位和 LEO 之间的消息就属于未提交消息
同一个副本对象,其高水位值不会大于 LEO 值

高水位和 LEO 是副本对象的两个重要属性
Kafka 所有副本都有对应的高水位和 LEO 值,而不仅仅是 Leader 副本
只不过 Leader 副本比较特殊,Kafka 使用 Leader 副本的高水位来定义所在分区的高水位
换句话说,分区的高水位就是其 Leader 副本的高水位

高水位更新机制

在 Leader 副本所在的 Broker 上,还保存了其他 Follower 副本的 LEO 值

在这里插入图片描述

Kafka 把 Broker 0 上保存的这些 Follower 副本又称为远程副本(Remote Replica)
Kafka 副本机制在运行过程中,会更新 Broker 1 上 Follower 副本的高水位和 LEO 值,同时也会更新 Broker 0 上 Leader 副本的高水位和 LEO 以及所有远程副本的 LEO,但它不会更新远程副本的高水位值,也就是图中标记为灰色的部分

在 Broker 0 上保存这些远程副本
主要作用是,帮助 Leader 副本确定其高水位,也就是分区高水位

更新机制:
在这里插入图片描述

与 Leader 副本保持同步判断条件:

  1. 该远程 Follower 副本在 ISR 中
  2. 该远程 Follower 副本 LEO 值落后于 Leader 副本 LEO 值的时间,不超过 Broker 端参数 replica.lag.time.max.ms 的值。如果使用默认值的话,就是不超过 10 秒

目前某个副本能否进入 ISR 就是靠第 2 个条件判断的
但是会出现情况: Follower 副本已经“追上”了 Leader 的进度,却不在 ISR 中
eg:
比如某个刚刚重启回来的副本

如果 Kafka 只判断第 1 个条件的话,就可能出现某些副本具备了“进入 ISR”的资格,但却尚未进入到 ISR 中的情况
此时,分区高水位值就可能超过 ISR 中副本 LEO,而高水位 > LEO 的情形是不被允许的


Leader 副本:
处理生产者请求的逻辑如下:

  1. 写入消息到本地磁盘。
  2. 更新分区高水位值。
    i. 获取 Leader 副本所在 Broker 端保存的所有远程副本 LEO 值(LEO-1,LEO-2,……,LEO-n)。
    ii. 获取 Leader 副本高水位值:currentHW。
    iii. 更新 currentHW = max{currentHW, min(LEO-1, LEO-2, ……,LEO-n)}。

处理 Follower 副本拉取消息的逻辑如下:

  1. 读取磁盘(或页缓存)中的消息数据。
  2. 使用 Follower 副本发送请求中的位移值更新远程副本 LEO 值。
  3. 更新分区高水位值(具体步骤与处理生产者请求的步骤相同)

Follower 副本:
从 Leader 拉取消息的处理逻辑如下:

  1. 写入消息到本地磁盘。
  2. 更新 LEO 值。
  3. 更新高水位值。
    i. 获取 Leader 发送的高水位值:currentHW。
    ii. 获取步骤 2 中更新过的 LEO 值:currentLEO。
    iii. 更新高水位为 min(currentHW, currentLEO)。

副本同步机制解析

单分区, 2副本, 副本同步流程举例

  1. 初始状态
    在这里插入图片描述

  2. 当生产者给主题分区发送一条消息后
    在这里插入图片描述
    此时, Leader 副本成功将消息写入了本地磁盘,故 LEO 值被更新为 1

  3. Follower 再次尝试从 Leader 拉取消息
    这次有消息可以拉取了
    在这里插入图片描述
    这时,Follower 副本也成功地更新 LEO 为 1。此时,Leader 和 Follower 副本的 LEO 都是 1,但各自的高水位依然是 0,还没有被更新。

  4. 下一轮的拉取更新HW
    在这里插入图片描述

在新一轮的拉取请求中,由于位移值是 0 的消息已经拉取成功,因此 Follower 副本这次请求拉取的是位移值 =1 的消息。
Leader 副本接收到此请求后,更新远程副本 LEO 为 1,然后更新 Leader 高水位为 1。

做完这些之后,它会将当前已更新过的高水位值 1 发送给 Follower 副本。
Follower 副本接收到以后,也将自己的高水位值更新成 1。

至此,一次完整的消息同步周期就结束了。

Leader Epoch 登场

Kafka 既界定了消息的对外可见性,又实现了异步的副本同步机制

存在的问题:
Follower 副本的高水位更新需要一轮额外的拉取请求才能实现
如果是多个 Follower 副本, 也许需要多轮拉取请求

Leader 副本高水位更新和 Follower 副本高水位更新在时间上是存在错配的
这种错配是很多“数据丢失”或“数据不一致”问题的根源

社区在 0.11 版本正式引入了 Leader Epoch 概念,来规避因高水位更新错配导致的各种不一致问题

所谓 Leader Epoch,大致可以认为是 Leader 版本。它由两部分数据组成:

  1. Epoch。一个单调增加的版本号。每当副本领导权发生变更时,都会增加该版本号。小版本号的 Leader 被认为是过期 Leader,不能再行使 Leader 权力。
  2. 起始位移(Start Offset)。Leader 副本在该 Epoch 值上写入的首条消息的位移。

假设现在有两个 Leader Epoch<0, 0> 和 <1, 120>,
第一个 Leader Epoch 表示版本号是 0,这个版本的 Leader 从位移 0 开始保存消息,一共保存了 120 条消息。
之后,Leader 发生了变更,版本号增加到 1,新版本的起始位移是 120。

Kafka Broker 会在内存中为每个分区都缓存 Leader Epoch 数据,同时它还会定期地将这些信息持久化到一个 checkpoint 文件中。
当 Leader 副本写入消息到磁盘时,Broker 会尝试更新这部分缓存。
如果该 Leader 是首次写入消息,那么 Broker 会向缓存中增加一个 Leader Epoch 条目,否则就不做更新。
这样,每次有 Leader 变更时,新的 Leader 副本会查询这部分缓存,取出对应的 Leader Epoch 的起始位移,以避免数据丢失和不一致的情况。

eg: Leader Epoch 是如何防止数据丢失
在这里插入图片描述

稍微解释一下,单纯依赖高水位是怎么造成数据丢失的
开始时,副本 A 和副本 B 都处于正常状态,A 是 Leader 副本。某个使用了默认 acks 设置的生产者程序向 A 发送了两条消息,A 全部写入成功,此时 Kafka 会通知生产者说两条消息全部发送成功。
现在假设 Leader 和 Follower 都写入了这两条消息,而且 Leader 副本的高水位也已经更新了,但 Follower 副本高水位还未更新——这是可能出现的
Follower 端高水位的更新与 Leader 端有时间错配
倘若此时副本 B 所在的 Broker 宕机,当它重启回来后,副本 B 会执行日志截断操作,将 LEO 值调整为之前的高水位值,也就是 1。
这就是说,位移值为 1 的那条消息被副本 B 从磁盘中删除,此时副本 B 的底层磁盘文件中只保存有 1 条消息,即位移值为 0 的那条消息。
当执行完截断操作后,副本 B 开始从 A 拉取消息,执行正常的消息同步。
如果就在这个节骨眼上,副本 A 所在的 Broker 宕机了,那么 Kafka 就别无选择,只能让副本 B 成为新的 Leader,
此时,当 A 回来后,需要执行相同的日志截断操作,即将高水位调整为与 B 相同的值,也就是 1。
这样操作之后,位移值为 1 的那条消息就从这两个副本中被永远地抹掉了。
这就是这张图要展示的数据丢失场景。

严格来说,这个场景发生的前提是 Broker 端参数 min.insync.replicas 设置为 1。
此时一旦消息被写入到 Leader 副本的磁盘,就会被认为是“已提交状态”,但现有的时间错配问题导致 Follower 端的高水位更新是有滞后的。
如果在这个短暂的滞后时间窗口内,接连发生 Broker 宕机,那么这类数据的丢失就是不可避免的。

如何利用 Leader Epoch 机制来规避这种数据丢失:
在这里插入图片描述

引用 Leader Epoch 机制后,Follower 副本 B 重启回来后,需要向 A 发送一个特殊的请求去获取 Leader 的 LEO 值
在这个例子中,该值为 2。
当获知到 Leader LEO=2 后,B 发现该 LEO 值不比它自己的 LEO 值小,而且缓存中也没有保存任何起始位移值 > 2 的 Epoch 条目,因此 B 无需执行任何日志截断操作
这是对高水位机制的一个明显改进,即副本是否执行日志截断不再依赖于高水位进行判断
现在,副本 A 宕机了,B 成为 Leader。
同样地,当 A 重启回来后,执行与 B 相同的逻辑判断,发现也不用执行日志截断,至此位移值为 1 的那条消息在两个副本中均得到保留。
后面当生产者程序向 B 写入新消息时,副本 B 所在的 Broker 缓存中,会生成新的 Leader Epoch 条目:[Epoch=1, Offset=2]。
之后,副本 B 会使用这个条目帮助判断后续是否执行日志截断操作。
这样,通过 Leader Epoch 机制,Kafka 完美地规避了这种数据丢失场景。

小结

高水位在界定 Kafka 消息对外可见性以及实现副本机制等方面起到了非常重要的作用,但其设计上的缺陷给 Kafka 留下了很多数据丢失或数据不一致的潜在风险

社区引入了 Leader Epoch 机制,尝试规避掉这类风险。事实证明,它的效果不错,在 0.11 版本之后,关于副本数据不一致性方面的 Bug 的确减少了很多

在这里插入图片描述

讨论


额外的数据丢失场景

假设集群中有两台Broker,Leader为A,Follower为B。A中有两条消息m1和m2,他的HW为1,LEO为2;B中有一条消息m1,LEO和HW都为1.假设A和B同时挂掉,然后B先醒来,成为了Leader(假设此时的min.insync.replicas参数配置为1)。然后B中写入一条消息m3,并且将LEO和HW都更新为2.然后A醒过来了,向B发送FetchrRequest,B发现A的LEO和自己的一样,都是2,就让A也更新自己的HW为2。但是其实,虽然大家的消息都是2条,可是消息的内容是不一致的。一个是(m1,m2),一个是(m1,m3)。

这个问题也是通过引入leader epoch机制来解决的。

现在是引入了leader epoch之后的情况:B恢复过来,成为了Leader,之后B中写入消息m3,并且将自己的LEO和HW更新为2,注意这个时候LeaderEpoch已经从0增加到1了。
紧接着A也恢复过来成为Follower并向B发送一个OffsetForLeaderEpochRequest请求,这个时候A的LeaderEpoch为0。B根据0这个LeaderEpoch查询到对应的offset为1并返回给A,那么A就要对日志进行截断,删除m2这条消息。然后用FetchRequest从B中同步m3这条消息。这样就解决了数据不一致的问题。



小结

1:啥是高水位?
水位,我的理解就是水平面当前的位置,可以表示水的深度。在kafka中水位用于表示消息在分区中的位移或位置,高水位用于表示已提交的消息的分界线的位置,在高水位这个位置之前的消息都是已提交的,在高水位这个位置之后的消息都是未提交的。所以,高水位可以看作是已提交消息和未提交消息之间的分割线,如果把分区比喻为一个竖起来的水容器的话,这个表示就更明显了,在高水位之下的消息都是已提交的,在高水位之上的消息都是未提交的。
高水位的英文是High Watermark ,所以其英文缩写为HW。
值得注意的是,Kafka 中也有低水位(Low Watermark,英文缩写为LW),它是与 Kafka 删除消息相关联的概念。
再加一个概念,LEO——Log End Offset 的缩写——意思是日志末端位移,它表示副本写入下一条消息的位移值——既分区中待写入消息的位置。这个位置和高水位之间的位置包括高水位的那个位置,就是所有未提交消息的全部位置所在啦——未提交的消息是不能被消费者消费的。所以,同一个副本对象,其高水位值不会大于 LEO 值。
高水位和 LEO 是副本对象的两个重要属性。Kafka 所有副本都有对应的高水位和 LEO 值,而不仅仅是 Leader 副本。只不过 Leader 副本比较特殊,Kafka 使用 Leader 副本的高水位来定义所在分区的高水位。换句话说,分区的高水位就是其 Leader 副本的高水位。

2:高水位有啥用?
2-1:定义消息可见性,即用来标识分区下的哪些消息是可以被消费者消费的——已提交的消息是可以被消费者消费的。
2-2:帮助 Kafka 完成副本同步——明确那些消息已提交那些未提交,才好进行消息的同步。

3:高水位怎么管理?
这个不好简单的描述,牢记高水位的含义,有助于理解更新高水的时机以及具体步骤。
高水位——用于界定分区中已提交和未提交的消息。

4:高水有舍缺陷?
Leader 副本高水位更新和 Follower 副本高水位更新在时间上是存在错配的。这种错配是很多“数据丢失”或“数据不一致”问题的根源。

5:啥是 leader epoch?
可以大致认为就是leader的版本。
它由两部分数据组成。
5-1:Epoch。一个单调增加的版本号。每当副本领导权发生变更时,都会增加该版本号。小版本号的 Leader 被认为是过期 Leader,不能再行使 Leader 权力。
5-2:起始位移(Start Offset)。Leader 副本在该 Epoch 值上写入的首条消息的位移。

6:leader epoch 有啥用?
通过 Leader Epoch 机制,Kafka 规避了因为Leader 副本高水位更新和 Follower 副本高水位更新在时间上是存在错配,而引起的很多“数据丢失”或“数据不一致”的问题。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值