This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
<audio id="audio" title="23 | Kafka副本机制详解" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/53/39/53086ba9da1792a3fc84bd24bfe34b39.mp3"></audio>
你好我是胡夕。今天我要和你分享的主题是Apache Kafka的副本机制。
所谓的副本机制Replication也可以称之为备份机制通常是指分布式系统在多台网络互联的机器上保存有相同的数据拷贝。副本机制有什么好处呢
1. **提供数据冗余**。即使系统部分组件失效,系统依然能够继续运转,因而增加了整体可用性以及数据持久性。
1. **提供高伸缩性**。支持横向扩展,能够通过增加机器的方式来提升读性能,进而提高读操作吞吐量。
1. **改善数据局部性**。允许将数据放入与用户地理位置相近的地方,从而降低系统延时。
这些优点都是在分布式系统教科书中最常被提及的但是有些遗憾的是对于Apache Kafka而言目前只能享受到副本机制带来的第1个好处也就是提供数据冗余实现高可用性和高持久性。我会在这一讲后面的内容中详细解释Kafka没能提供第2点和第3点好处的原因。
不过即便如此副本机制依然是Kafka设计架构的核心所在它也是Kafka确保系统高可用和消息高持久性的重要基石。
## 副本定义
在讨论具体的副本机制之前,我们先花一点时间明确一下副本的含义。
我们之前谈到过Kafka是有主题概念的而每个主题又进一步划分成若干个分区。副本的概念实际上是在分区层级下定义的每个分区配置有若干个副本。
**所谓副本Replica本质就是一个只能追加写消息的提交日志**。根据Kafka副本机制的定义同一个分区下的所有副本保存有相同的消息序列这些副本分散保存在不同的Broker上从而能够对抗部分Broker宕机带来的数据不可用。
在实际生产环境中每台Broker都可能保存有各个主题下不同分区的不同副本因此单个Broker上存有成百上千个副本的现象是非常正常的。
接下来我们来看一张图它展示的是一个有3台Broker的Kafka集群上的副本分布情况。从这张图中我们可以看到主题1分区0的3个副本分散在3台Broker上其他主题分区的副本也都散落在不同的Broker上从而实现数据冗余。
<img src="https://static001.geekbang.org/resource/image/3b/77/3b5f28c6d19b2c6fe592b2b78d3ebc77.jpg" alt="">
## 副本角色
既然分区下能够配置多个副本而且这些副本的内容还要一致那么很自然的一个问题就是我们该如何确保副本中所有的数据都是一致的呢特别是对Kafka而言当生产者发送消息到某个主题后消息是如何同步到对应的所有副本中的呢针对这个问题最常见的解决方案就是采用**基于领导者Leader-based的副本机制**。Apache Kafka就是这样的设计。
基于领导者的副本机制的工作原理如下图所示,我来简单解释一下这张图里面的内容。
<img src="https://static001.geekbang.org/resource/image/38/c2/381eda4b56991d52727934be7c7e6ec2.jpg" alt="">
第一在Kafka中副本分成两类领导者副本Leader Replica和追随者副本Follower Replica。每个分区在创建时都要选举一个副本称为领导者副本其余的副本自动称为追随者副本。
第二Kafka的副本机制比其他分布式系统要更严格一些。在Kafka中追随者副本是不对外提供服务的。这就是说任何一个追随者副本都不能响应消费者和生产者的读写请求。所有的请求都必须由领导者副本来处理或者说所有的读写请求都必须发往领导者副本所在的Broker由该Broker负责处理。追随者副本不处理客户端请求它唯一的任务就是从领导者副本**异步拉取**消息,并写入到自己的提交日志中,从而实现与领导者副本的同步。
第三当领导者副本挂掉了或者说领导者副本所在的Broker宕机时Kafka依托于ZooKeeper提供的监控功能能够实时感知到并立即开启新一轮的领导者选举从追随者副本中选一个作为新的领导者。老Leader副本重启回来后只能作为追随者副本加入到集群中。
你一定要特别注意上面的第二点,即**追随者副本是不对外提供服务的**。还记得刚刚我们谈到副本机制的好处时说过Kafka没能提供读操作横向扩展以及改善局部性吗具体的原因就在于此。
对于客户端用户而言Kafka的追随者副本没有任何作用它既不能像MySQL那样帮助领导者副本“抗读”也不能实现将某些副本放到离客户端近的地方来改善数据局部性。
既然如此Kafka为什么要这样设计呢其实这种副本机制有两个方面的好处。
1.**方便实现“Read-your-writes”**。
所谓Read-your-writes顾名思义就是当你使用生产者API向Kafka成功写入消息后马上使用消费者API去读取刚才生产的消息。
举个例子比如你平时发微博时你发完一条微博肯定是希望能立即看到的这就是典型的Read-your-writes场景。如果允许追随者副本对外提供服务由于副本同步是异步的因此有可能出现追随者副本还没有从领导者副本那里拉取到最新的消息从而使得客户端看不到最新写入的消息。
2.**方便实现单调读Monotonic Reads**。
什么是单调读呢?就是对于一个消费者用户而言,在多次消费消息时,它不会看到某条消息一会儿存在一会儿不存在。
如果允许追随者副本提供读服务那么假设当前有2个追随者副本F1和F2它们异步地拉取领导者副本数据。倘若F1拉取了Leader的最新消息而F2还未及时拉取那么此时如果有一个消费者先从F1读取消息之后又从F2拉取消息它可能会看到这样的现象第一次消费时看到的最新消息在第二次消费时不见了这就不是单调读一致性。但是如果所有的读请求都是由Leader来处理那么Kafka就很容易实现单调读一致性。
## In-sync ReplicasISR
我们刚刚反复说过追随者副本不提供服务只是定期地异步拉取领导者副本中的数据而已。既然是异步的就存在着不可能与Leader实时同步的风险。在探讨如何正确应对这种风险之前我们必须要精确地知道同步的含义是什么。或者说Kafka要明确地告诉我们追随者副本到底在什么条件下才算与Leader同步。
基于这个想法Kafka引入了In-sync Replicas也就是所谓的ISR副本集合。ISR中的副本都是与Leader同步的副本相反不在ISR中的追随者副本就被认为是与Leader不同步的。那么到底什么副本能够进入到ISR中呢
我们首先要明确的是Leader副本天然就在ISR中。也就是说**ISR不只是追随者副本集合它必然包括Leader副本。甚至在某些情况下ISR只有Leader这一个副本**。
另外能够进入到ISR的追随者副本要满足一定的条件。至于是什么条件我先卖个关子我们先来一起看看下面这张图。
<img src="https://static001.geekbang.org/resource/image/52/5f/521ff90472a5fd2e6cfac0e6176aa75f.jpg" alt="">
图中有3个副本1个领导者副本和2个追随者副本。Leader副本当前写入了10条消息Follower1副本同步了其中的6条消息而Follower2副本只同步了其中的3条消息。现在请你思考一下对于这2个追随者副本你觉得哪个追随者副本与Leader不同步
答案是要根据具体情况来定。换成英文就是那句著名的“It depends”。看上去好像Follower2的消息数比Leader少了很多它是最有可能与Leader不同步的。的确是这样的但仅仅是可能。
事实上这张图中的2个Follower副本都有可能与Leader不同步但也都有可能与Leader同步。也就是说Kafka判断Follower是否与Leader同步的标准不是看相差的消息数而是另有“玄机”。
**这个标准就是Broker端参数replica.lag.time.max.ms参数值**。这个参数的含义是Follower副本能够落后Leader副本的最长时间间隔当前默认值是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。可是ISR是空此时该怎么选举新Leader呢
**Kafka把所有不在ISR中的存活副本都称为非同步副本**。通常来说非同步副本落后Leader太多因此如果选择这些副本作为新Leader就可能出现数据的丢失。毕竟这些副本中保存的消息远远落后于老Leader中的消息。在Kafka中选举这种副本的过程称为Unclean领导者选举。**Broker端参数unclean.leader.election.enable控制是否允许Unclean领导者选举**。
开启Unclean领导者选举可能会造成数据丢失但好处是它使得分区Leader副本一直存在不至于停止对外提供服务因此提升了高可用性。反之禁止Unclean领导者选举的好处在于维护了数据的一致性避免了消息丢失但牺牲了高可用性。
如果你听说过CAP理论的话你一定知道一个分布式系统通常只能同时满足一致性Consistency、可用性Availability、分区容错性Partition tolerance中的两个。显然在这个问题上Kafka赋予你选择C或A的权利。
你可以根据你的实际业务场景决定是否开启Unclean领导者选举。不过我强烈建议你**不要**开启它,毕竟我们还可以通过其他的方式来提升高可用性。如果为了这点儿高可用性的改善,牺牲了数据一致性,那就非常不值当了。
## 小结
今天我主要跟你分享了Apache Kafka的副本机制以及它们实现的原理。坦率地说我觉得有些地方可能讲浅了如果要百分之百地了解Replication你还是要熟读一下Kafka相应的源代码。不过你也不用担心在专栏后面的内容中我会专门从源码角度分析副本机制特别是Follower副本从Leader副本拉取消息的全过程。从技术深度上来说那一讲应该算是本专栏中最贴近技术内幕的分析了你一定不要错过。
<img src="https://static001.geekbang.org/resource/image/d7/72/d75c01661ca5367cfd23ad92cc10e372.jpg" alt="">
## 开放讨论
到目前为止我反复强调了Follower副本不对外提供服务这件事情。有意思的是社区最近正在考虑是否要打破这个限制即允许Follower副本处理客户端消费者发来的请求。社区主要的考量是这能够用于改善云上数据的局部性更好地服务地理位置相近的客户。如果允许Follower副本对外提供读服务你觉得应该如何避免或缓解因Follower副本与Leader副本不同步而导致的数据不一致的情形
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View File

@@ -0,0 +1,105 @@
<audio id="audio" title="24 | 请求是怎么被处理的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/17/92/1738291bf0d6d80e191ae3d2748cbc92.mp3"></audio>
你好我是胡夕。今天我要和你分享的主题是Kafka请求是怎么被处理的。
无论是Kafka客户端还是Broker端它们之间的交互都是通过“请求/响应”的方式完成的。比如客户端会通过网络发送消息生产请求给Broker而Broker处理完成后会发送对应的响应给到客户端。
Apache Kafka自己定义了一组请求协议用于实现各种各样的交互操作。比如常见的PRODUCE请求是用于生产消息的FETCH请求是用于消费消息的METADATA请求是用于请求Kafka集群元数据信息的。
总之Kafka定义了很多类似的请求格式。我数了一下截止到目前最新的2.3版本Kafka共定义了多达45种请求格式。**所有的请求都是通过TCP网络以Socket的方式进行通讯的**。
今天我们就来详细讨论一下Kafka Broker端处理请求的全流程。
关于如何处理请求,我们很容易想到的方案有两个。
1.**顺序处理请求**。如果写成伪代码,大概是这个样子:
```
while (true) {
Request request = accept(connection);
handle(request);
}
```
这个方法实现简单,但是有个致命的缺陷,那就是**吞吐量太差**。由于只能顺序处理每个请求,因此,每个请求都必须等待前一个请求处理完毕才能得到处理。这种方式只适用于**请求发送非常不频繁的系统**。
**2.每个请求使用单独线程处理**。也就是说,我们为每个入站请求都创建一个新的线程来异步处理。我们一起来看看这个方案的伪代码。
```
while (true) {
Request = request = accept(connection);
Thread thread = new Thread(() -&gt; {
handle(request);});
thread.start();
}
```
这个方法反其道而行之,完全采用**异步**的方式。系统会为每个入站请求都创建单独的线程来处理。这个方法的好处是,它是完全异步的,每个请求的处理都不会阻塞下一个请求。但缺陷也同样明显。为每个请求都创建线程的做法开销极大,在某些场景下甚至会压垮整个服务。还是那句话,这个方法只适用于请求发送频率很低的业务场景。
既然这两种方案都不好那么Kafka是如何处理请求的呢用一句话概括就是Kafka使用的是**Reactor模式**。
谈到Reactor模式大神Doug Lea的“[Scalable IO in Java](http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf)”应该算是最好的入门教材了。即使你没听说过Doug Lea那你应该也用过ConcurrentHashMap吧这个类就是这位大神写的。其实整个java.util.concurrent包都是他的杰作
好了我们说回Reactor模式。简单来说**Reactor模式是事件驱动架构的一种实现方式特别适合应用于处理多个客户端并发向服务器端发送请求的场景**。我借用Doug Lea的一页PPT来说明一下Reactor的架构并借此引出Kafka的请求处理模型。
Reactor模式的架构如下图所示
<img src="https://static001.geekbang.org/resource/image/5b/3c/5bf8e3e8d35d1ac62yydb092700b683c.jpg" alt="">
从这张图中我们可以发现多个客户端会发送请求给到Reactor。Reactor有个请求分发线程Dispatcher也就是图中的Acceptor它会将不同的请求下发到多个工作线程中处理。
在这个架构中Acceptor线程只是用于请求分发不涉及具体的逻辑处理非常得轻量级因此有很高的吞吐量表现。而这些工作线程可以根据实际业务处理需要任意增减从而动态调节系统负载能力。
如果我们来为Kafka画一张类似的图的话那它应该是这个样子的
<img src="https://static001.geekbang.org/resource/image/b3/81/b3yy7c6ca1c6244f3f9d2268209a9081.jpg" alt="">
显然这两张图长得差不多。Kafka的Broker端有个SocketServer组件类似于Reactor模式中的Dispatcher它也有对应的Acceptor线程和一个工作线程池只不过在Kafka中这个工作线程池有个专属的名字叫网络线程池。Kafka提供了Broker端参数num.network.threads用于调整该网络线程池的线程数。其**默认值是3表示每台Broker启动时会创建3个网络线程专门处理客户端发送的请求**。
Acceptor线程采用轮询的方式将入站请求公平地发到所有网络线程中因此在实际使用过程中这些线程通常都有相同的几率被分配到待处理请求。这种轮询策略编写简单同时也避免了请求处理的倾斜有利于实现较为公平的请求处理调度。
好了你现在了解了客户端发来的请求会被Broker端的Acceptor线程分发到任意一个网络线程中由它们来进行处理。那么当网络线程接收到请求后它是怎么处理的呢你可能会认为它顺序处理不就好了吗实际上Kafka在这个环节又做了一层异步线程池的处理我们一起来看一看下面这张图。
<img src="https://static001.geekbang.org/resource/image/41/95/41e0a69ed649f9c5yyea390edcd79a95.jpg" alt="">
当网络线程拿到请求后它不是自己处理而是将请求放入到一个共享请求队列中。Broker端还有个IO线程池负责从该队列中取出请求执行真正的处理。如果是PRODUCE生产请求则将消息写入到底层的磁盘日志中如果是FETCH请求则从磁盘或页缓存中读取消息。
IO线程池处中的线程才是执行请求逻辑的线程。Broker端参数**num.io.threads**控制了这个线程池中的线程数。**目前该参数默认值是8表示每台Broker启动后自动创建8个IO线程处理请求**。你可以根据实际硬件条件设置此线程池的个数。
比如如果你的机器上CPU资源非常充裕你完全可以调大该参数允许更多的并发请求被同时处理。当IO线程处理完请求后会将生成的响应发送到网络线程池的响应队列中然后由对应的网络线程负责将Response返还给客户端。
细心的你一定发现了请求队列和响应队列的差别:**请求队列是所有网络线程共享的,而响应队列则是每个网络线程专属的**。这么设计的原因就在于Dispatcher只是用于请求分发而不负责响应回传因此只能让每个网络线程自己发送Response给客户端所以这些Response也就没必要放在一个公共的地方。
我们再来看看刚刚的那张图图中有一个叫Purgatory的组件这是Kafka中著名的“炼狱”组件。它是用来**缓存延时请求**Delayed Request的。**所谓延时请求,就是那些一时未满足条件不能立刻处理的请求**。比如设置了acks=all的PRODUCE请求一旦设置了acks=all那么该请求就必须等待ISR中所有副本都接收了消息后才能返回此时处理该请求的IO线程就必须等待其他Broker的写入结果。当请求不能立刻处理时它就会暂存在Purgatory中。稍后一旦满足了完成条件IO线程会继续处理该请求并将Response放入对应网络线程的响应队列中。
讲到这里Kafka请求流程解析的故事其实已经讲完了我相信你应该已经了解了Kafka Broker是如何从头到尾处理请求的。但是我们不会现在就收尾我要给今天的内容开个小灶再说点不一样的东西。
到目前为止我提及的请求处理流程对于所有请求都是适用的也就是说Kafka Broker对所有请求是一视同仁的。但是在Kafka内部除了客户端发送的PRODUCE请求和FETCH请求之外还有很多执行其他操作的请求类型比如负责更新Leader副本、Follower副本以及ISR集合的LeaderAndIsr请求负责勒令副本下线的StopReplica请求等。与PRODUCE和FETCH请求相比这些请求有个明显的不同它们不是数据类的请求而是控制类的请求。也就是说它们并不是操作消息数据的而是用来执行特定的Kafka内部动作的。
Kafka社区把PRODUCE和FETCH这类请求称为数据类请求把LeaderAndIsr、StopReplica这类请求称为控制类请求。细究起来当前这种一视同仁的处理方式对控制类请求是不合理的。为什么呢因为**控制类请求有这样一种能力:它可以直接令数据类请求失效!**
我来举个例子说明一下。假设我们有个主题只有1个分区该分区配置了两个副本其中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后会将之前写入但未提交的消息全部删除依然做了很多无用功。
再举一个例子同样是在积压大量数据类请求的Broker上当你删除主题的时候Kafka控制器我会在专栏后面的内容中专门介绍它向该Broker发送StopReplica请求。如果该请求不能及时处理主题删除操作会一直hang住从而增加了删除主题的延时。
基于这些问题社区于2.3版本正式实现了数据类请求和控制类请求的分离。其实在社区推出方案之前我自己尝试过修改这个设计。当时我的想法是在Broker中实现一个优先级队列并赋予控制类请求更高的优先级。这是很自然的想法所以我本以为社区也会这么实现的但后来我这个方案被清晰地记录在“已拒绝方案”列表中。
究其原因,这个方案最大的问题在于,它无法处理请求队列已满的情形。当请求队列已经无法容纳任何新的请求时,纵然有优先级之分,它也无法处理新的控制类请求了。
那么社区是如何解决的呢很简单你可以再看一遍今天的第三张图社区完全拷贝了这张图中的一套组件实现了两类请求的分离。也就是说Kafka Broker启动后会在后台分别创建两套网络线程池和IO线程池的组合它们分别处理数据类请求和控制类请求。至于所用的Socket端口自然是使用不同的端口了你需要提供不同的**listeners配置**,显式地指定哪套端口用于处理哪类请求。
## 小结
讲到这里Kafka Broker请求处理流程的解析应该讲得比较完整了。明确请求处理过程的最大意义在于它是你日后执行Kafka性能优化的前提条件。如果你能从请求的维度去思考Kafka的工作原理你会发现优化Kafka并不是一件困难的事情。
## 开放讨论
坦白来讲,我对社区否定优先级队列方案是有一点不甘心的。如果是你的话,你觉得应该如何规避优先级队列方案中队列已满的问题呢?
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View File

@@ -0,0 +1,121 @@
<audio id="audio" title="25 | 消费者组重平衡全流程解析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6f/91/6fc9dad7883a21662e42308dcf42cd91.mp3"></audio>
你好,我是胡夕。今天我要和你分享的主题是:消费者组重平衡全流程解析。
之前我们聊到过消费者组的重平衡流程它的作用是让组内所有的消费者实例就消费哪些主题分区达成一致。重平衡需要借助Kafka Broker端的Coordinator组件在Coordinator的帮助下完成整个消费者组的分区重分配。今天我们就来详细说说这个流程。
先提示一下我会以Kafka 2.3版本的源代码开启今天的讲述。在分享的过程中,对于旧版本的设计差异,我也会显式地说明。这样,即使你依然在使用比较旧的版本也不打紧,毕竟设计原理大体上是没有变化的。
## 触发与通知
我们先来简单回顾一下重平衡的3个触发条件
1. 组成员数量发生变化。
1. 订阅主题数量发生变化。
1. 订阅主题的分区数发生变化。
就我个人的经验来看在实际生产环境中因命中第1个条件而引发的重平衡是最常见的。另外消费者组中的消费者实例依次启动也属于第1种情况也就是说每次消费者组启动时必然会触发重平衡过程。
这部分内容我在专栏[第15讲](https://time.geekbang.org/column/article/105112)中已经详细介绍过了,就不再赘述了。如果你不记得的话,可以先去复习一下。
今天,我真正想引出的是另一个话题:**重平衡过程是如何通知到其他消费者实例的答案就是靠消费者端的心跳线程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端的协调者组件就要开始忙了主要涉及到控制消费者组的状态流转。当前Kafka设计了一套消费者组状态机State Machine来帮助协调者完成整个重平衡流程。严格来说这套状态机属于非常底层的设计Kafka官网上压根就没有提到过但你最好还是了解一下因为**它能够帮助你搞懂消费者组的设计原理比如消费者组的过期位移Expired Offsets删除等**。
目前Kafka为消费者组定义了5种状态它们分别是Empty、Dead、PreparingRebalance、CompletingRebalance和Stable。那么这5种状态的含义是什么呢我们一起来看看下面这张表格。
<img src="https://static001.geekbang.org/resource/image/3c/8b/3c281189cfb1d87173bc2d4b8149f38b.jpeg" alt="">
了解了这些状态的含义之后,我们来看一张图片,它展示了状态机的各个状态流转。
<img src="https://static001.geekbang.org/resource/image/a9/72/a97eb0e0ee2b97abaf2762b6e79d5b72.jpg" alt="">
我来解释一下消费者组启动时的状态流转过程。一个消费者组最开始是Empty状态当重平衡过程开启后它会被置于PreparingRebalance状态等待成员加入之后变更到CompletingRebalance状态等待分配方案最后流转到Stable状态完成重平衡。
当有新成员加入或已有成员退出时消费者组的状态从Stable直接跳到PreparingRebalance状态此时所有现存成员就必须重新申请加入组。当所有成员都退出组后消费者组状态变更为Empty。Kafka定期自动删除过期位移的条件就是组要处于Empty状态。因此如果你的消费者组停掉了很长时间超过7天那么Kafka很可能就把该组的位移数据删除了。我相信你在Kafka的日志中一定经常看到下面这个输出
>
**Removed ✘✘✘ expired offsets in ✘✘✘ milliseconds.**
这就是Kafka在尝试定期删除过期位移。现在你知道了只有Empty状态下的组才会执行过期位移删除的操作。
## 消费者端重平衡流程
有了上面的内容作铺垫,我们就可以开始介绍重平衡流程了。重平衡的完整流程需要消费者端和协调者组件共同参与才能完成。我们先从消费者的视角来审视一下重平衡的流程。
在消费者端重平衡分为两个步骤分别是加入组和等待领导者消费者Leader Consumer分配方案。这两个步骤分别对应两类特定的请求**JoinGroup请求和SyncGroup请求**。
当组内成员加入组时它会向协调者发送JoinGroup请求。在该请求中每个成员都要将自己订阅的主题上报这样协调者就能收集到所有成员的订阅信息。一旦收集了全部成员的JoinGroup请求后协调者会从这些成员中选择一个担任这个消费者组的领导者。
通常情况下第一个发送JoinGroup请求的成员自动成为领导者。你一定要注意区分这里的领导者和之前我们介绍的领导者副本它们不是一个概念。这里的领导者是具体的消费者实例它既不是副本也不是协调者。**领导者消费者的任务是收集所有成员的订阅信息,然后根据这些信息,制定具体的分区消费分配方案。**
选出领导者之后协调者会把消费者组订阅信息封装进JoinGroup请求的响应体中然后发给领导者由领导者统一做出分配方案后进入到下一步发送SyncGroup请求。
在这一步中领导者向协调者发送SyncGroup请求将刚刚做出的分配方案发给协调者。值得注意的是其他成员也会向协调者发送SyncGroup请求只不过请求体中并没有实际的内容。这一步的主要目的是让协调者接收分配方案然后统一以SyncGroup响应的方式分发给所有成员这样组内所有成员就都知道自己该消费哪些分区了。
接下来我用一张图来形象地说明一下JoinGroup请求的处理过程。
<img src="https://static001.geekbang.org/resource/image/33/66/33cff6094ce29ec1111c8cdc817bb266.jpg" alt="">
就像前面说的JoinGroup请求的主要作用是将组成员订阅信息发送给领导者消费者待领导者制定好分配方案后重平衡流程进入到SyncGroup请求阶段。
下面这张图描述的是SyncGroup请求的处理流程。
<img src="https://static001.geekbang.org/resource/image/84/5b/84b0ffeef5cc382913a4e6cc5a4c675b.jpg" alt="">
SyncGroup请求的主要目的就是让协调者把领导者制定的分配方案下发给各个组内成员。当所有成员都成功接收到分配方案后消费者组进入到Stable状态即开始正常的消费工作。
讲完这里,**消费者端**的重平衡流程我已经介绍完了。接下来,我们从**协调者端**来看一下重平衡是怎么执行的。
## Broker端重平衡场景剖析
要剖析协调者端处理重平衡的全流程,我们必须要分几个场景来讨论。这几个场景分别是新成员加入组、组成员主动离组、组成员崩溃离组、组成员提交位移。接下来,我们一个一个来讨论。
**场景一:新成员入组。**
新成员入组是指组处于Stable状态后有新成员加入。如果是全新启动一个消费者组Kafka是有一些自己的小优化的流程上会有些许的不同。我们这里讨论的是组稳定了之后有新成员加入的情形。
当协调者收到新的JoinGroup请求后它会通过心跳请求响应的方式通知组内现有的所有成员强制它们开启新一轮的重平衡。具体的过程和之前的客户端重平衡流程是一样的。现在我用一张时序图来说明协调者一端是如何处理新成员入组的。
<img src="https://static001.geekbang.org/resource/image/27/7e/2792e00ac3206f63d8036802f4fbd77e.jpg" alt="">
**场景二:组成员主动离组。**
何谓主动离组就是指消费者实例所在线程或进程调用close()方法主动通知协调者它要退出。这个场景就涉及到了第三类请求:**LeaveGroup请求**。协调者收到LeaveGroup请求后依然会以心跳响应的方式通知其他成员因此我就不再赘述了还是直接用一张图来说明。
<img src="https://static001.geekbang.org/resource/image/b0/59/b0d3bc97d7b59a697yy043f1f6b79059.jpg" alt="">
**场景三:组成员崩溃离组。**
**崩溃离组是指消费者实例出现严重故障,突然宕机导致的离组**。它和主动离组是有区别的因为后者是主动发起的离组协调者能马上感知并处理。但崩溃离组是被动的协调者通常需要等待一段时间才能感知到这段时间一般是由消费者端参数session.timeout.ms控制的。也就是说Kafka一般不会超过session.timeout.ms就能感知到这个崩溃。当然后面处理崩溃离组的流程与之前是一样的我们来看看下面这张图。
<img src="https://static001.geekbang.org/resource/image/c0/af/c033ea2f7d714fa25eb86e21612e38af.jpg" alt="">
**场景四:重平衡时协调者对组内成员提交位移的处理。**
正常情况下每个组内成员都会定期汇报位移给协调者。当重平衡开启时协调者会给予成员一段缓冲时间要求每个成员必须在这段时间内快速地上报自己的位移信息然后再开启正常的JoinGroup/SyncGroup请求发送。还是老办法我们使用一张图来说明。
<img src="https://static001.geekbang.org/resource/image/f6/44/f60a3852e743c0877753141ec5d2d944.jpg" alt="">
## 小结
好了消费者重平衡流程我已经全部讲完了。虽然全程我都是拿两个成员来举例子但你可以很容易地扩展到多个成员的消费者组毕竟它们的原理是相同的。我希望你能多看几遍今天的内容彻底掌握Kafka的消费者重平衡流程。社区正在对目前的重平衡流程做较大程度的改动如果你不了解这些基础的设计原理后面想深入学习这部分内容的话会十分困难。
<img src="https://static001.geekbang.org/resource/image/6f/73/6f0aaf535180899b16923dc3c76ad373.jpg" alt="">
## 开放讨论
在整个重平衡过程中组内所有消费者实例都会暂停消费用JVM GC的术语来说就是重平衡过程是一个stop the world操作。请思考一下针对这个问题我们该如何改进这个过程我们是否能允许部分消费者在重平衡过程中继续消费以提升消费者端的可用性以及吞吐量
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="26 | 你一定不能错过的Kafka控制器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2b/82/2bf32541ec259d9ae17d9999c1f93082.mp3"></audio>
你好我是胡夕。今天我要和你分享的主题是Kafka中的控制器组件。
**控制器组件Controller是Apache Kafka的核心组件。它的主要作用是在Apache ZooKeeper的帮助下管理和协调整个Kafka集群**。集群中任意一台Broker都能充当控制器的角色但是在运行过程中只能有一个Broker成为控制器行使其管理和协调的职责。换句话说每个正常运转的Kafka集群在任意时刻都有且只有一个控制器。官网上有个名为activeController的JMX指标可以帮助我们实时监控控制器的存活状态。这个JMX指标非常关键你在实际运维操作过程中一定要实时查看这个指标的值。下面我们就来详细说说控制器的原理和内部运行机制。
在开始之前我先简单介绍一下Apache ZooKeeper框架。要知道**控制器是重度依赖ZooKeeper的**因此我们有必要花一些时间学习下ZooKeeper是做什么的。
**Apache ZooKeeper是一个提供高可靠性的分布式协调服务框架**。它使用的数据模型类似于文件系统的树形结构,根目录也是以“/”开始。该结构上的每个节点被称为znode用来保存一些元数据协调信息。
如果以znode持久性来划分**znode可分为持久性znode和临时znode**。持久性znode不会因为ZooKeeper集群重启而消失而临时znode则与创建该znode的ZooKeeper会话绑定一旦会话结束该节点会被自动删除。
ZooKeeper赋予客户端监控znode变更的能力即所谓的Watch通知功能。一旦znode节点被创建、删除子节点数量发生变化抑或是znode所存的数据本身变更ZooKeeper会通过节点变更监听器(ChangeHandler)的方式显式通知客户端。
依托于这些功能ZooKeeper常被用来实现**集群成员管理、分布式锁、领导者选举**等功能。Kafka控制器大量使用Watch功能实现对集群的协调管理。我们一起来看一张图片它展示的是Kafka在ZooKeeper中创建的znode分布。你不用了解每个znode的作用但你可以大致体会下Kafka对ZooKeeper的依赖。
<img src="https://static001.geekbang.org/resource/image/4a/fb/4a2ec3372ff5e4639e5e9c780ec7fcfb.jpg" alt="">
掌握了ZooKeeper的这些基本知识现在我们就可以开启对Kafka控制器的讨论了。
## 控制器是如何被选出来的?
你一定很想知道控制器是如何被选出来的呢我们刚刚在前面说过每台Broker都能充当控制器那么当集群启动后Kafka怎么确认控制器位于哪台Broker呢
实际上Broker在启动时会尝试去ZooKeeper中创建/controller节点。Kafka当前选举控制器的规则是**第一个成功创建/controller节点的Broker会被指定为控制器**。
## 控制器是做什么的?
我们经常说控制器是起协调作用的组件那么这里的协调作用到底是指什么呢我想了一下控制器的职责大致可以分为5种我们一起来看看。
1.**主题管理(创建、删除、增加分区)**
这里的主题管理就是指控制器帮助我们完成对Kafka主题的创建、删除以及分区增加的操作。换句话说当我们执行**kafka-topics脚本**时大部分的后台工作都是控制器来完成的。关于kafka-topics脚本我会在专栏后面的内容中详细介绍它的使用方法。
2.**分区重分配**
分区重分配主要是指,**kafka-reassign-partitions脚本**(关于这个脚本,后面我也会介绍)提供的对已有主题分区进行细粒度的分配功能。这部分功能也是控制器实现的。
3.**Preferred领导者选举**
Preferred领导者选举主要是Kafka为了避免部分Broker负载过重而提供的一种换Leader的方案。在专栏后面说到工具的时候我们再详谈Preferred领导者选举这里你只需要了解这也是控制器的职责范围就可以了。
4.**集群成员管理新增Broker、Broker主动关闭、Broker宕机**
这是控制器提供的第4类功能包括自动检测新增Broker、Broker主动关闭及被动宕机。这种自动检测是依赖于前面提到的Watch功能和ZooKeeper临时节点组合实现的。
比如,控制器组件会利用**Watch机制**检查ZooKeeper的/brokers/ids节点下的子节点数量变更。目前当有新Broker启动后它会在/brokers下创建专属的znode节点。一旦创建完毕ZooKeeper会通过Watch机制将消息通知推送给控制器这样控制器就能自动地感知到这个变化进而开启后续的新增Broker作业。
侦测Broker存活性则是依赖于刚刚提到的另一个机制**临时节点**。每个Broker启动后会在/brokers/ids下创建一个临时znode。当Broker宕机或主动关闭后该Broker与ZooKeeper的会话结束这个znode会被自动删除。同理ZooKeeper的Watch机制将这一变更推送给控制器这样控制器就能知道有Broker关闭或宕机了从而进行“善后”。
5.**数据服务**
控制器的最后一大类工作就是向其他Broker提供数据服务。控制器上保存了最全的集群元数据信息其他所有Broker会定期接收控制器发来的元数据更新请求从而更新其内存中的缓存数据。
## 控制器保存了什么数据?
接下来,我们就详细看看,控制器中到底保存了哪些数据。我用一张图来说明一下。
<img src="https://static001.geekbang.org/resource/image/21/d4/2174fb81fa7db42122915fee856790d4.jpg" alt="">
怎么样图中展示的数据量是不是很多几乎把我们能想到的所有Kafka集群的数据都囊括进来了。这里面比较重要的数据有
- 所有主题信息。包括具体的分区信息比如领导者副本是谁ISR集合中有哪些副本等。
- 所有Broker信息。包括当前都有哪些运行中的Broker哪些正在关闭中的Broker等。
- 所有涉及运维任务的分区。包括当前正在进行Preferred领导者选举以及分区重分配的分区列表。
值得注意的是这些数据其实在ZooKeeper中也保存了一份。每当控制器初始化时它都会从ZooKeeper上读取对应的元数据并填充到自己的缓存中。有了这些数据控制器就能对外提供数据服务了。这里的对外主要是指对其他Broker而言控制器通过向这些Broker发送请求的方式将这些数据同步到其他Broker上。
## 控制器故障转移Failover
我们在前面强调过在Kafka集群运行过程中只能有一台Broker充当控制器的角色那么这就存在**单点失效**Single Point of Failure的风险Kafka是如何应对单点失效的呢答案就是为控制器提供故障转移功能也就是说所谓的Failover。
**故障转移指的是当运行中的控制器突然宕机或意外终止时Kafka能够快速地感知到并立即启用备用控制器来代替之前失败的控制器**。这个过程就被称为Failover该过程是自动完成的无需你手动干预。
接下来,我们一起来看一张图,它简单地展示了控制器故障转移的过程。
<img src="https://static001.geekbang.org/resource/image/fb/7d/fb9c538a27253fe069ff7ea2f02fa17d.jpg" alt="">
最开始时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版本重构了控制器的底层设计,最大的改进就是,**把多线程的方案改成了单线程加事件队列的方案**。我直接使用社区的一张图来说明。
<img src="https://static001.geekbang.org/resource/image/90/a3/90be543d426a6a450f360ab40e2734a3.jpg" alt="">
从这张图中,我们可以看到,社区引入了一个**事件处理线程**,统一处理各种控制器事件,然后控制器将原来执行的操作全部建模成一个个独立的事件,发送到专属的事件队列中,供此线程消费。这就是所谓的单线程+队列的实现方式。
值得注意的是,这里的单线程不代表之前提到的所有线程都被“干掉”了,控制器只是把缓存状态变更方面的工作委托给了这个线程而已。
这个方案的最大好处在于控制器缓存中保存的状态只被一个线程处理因此不再需要重量级的线程同步机制来维护线程安全Kafka不用再担心多线程并发访问的问题非常利于社区定位和诊断控制器的各种问题。事实上自0.11版本重构控制器代码后社区关于控制器方面的Bug明显少多了这也说明了这种方案是有效的。
针对控制器的第二个改进就是,**将之前同步操作ZooKeeper全部改为异步操作**。ZooKeeper本身的API提供了同步写和异步写两种方式。之前控制器操作ZooKeeper使用的是同步的API性能很差集中表现为**当有大量主题分区发生变更时ZooKeeper容易成为系统的瓶颈**。新版本Kafka修改了这部分设计完全摒弃了之前的同步API调用转而采用异步API写入ZooKeeper性能有了很大的提升。根据社区的测试改成异步之后ZooKeeper写入提升了10倍
除了以上这些社区最近又发布了一个重大的改进之前Broker对接收的所有请求都是一视同仁的不会区别对待。这种设计对于控制器发送的请求非常不公平因为这类请求应该有更高的优先级。
举个简单的例子假设我们删除了某个主题那么控制器就会给该主题所有副本所在的Broker发送一个名为**StopReplica**的请求。如果此时Broker上存有大量积压的Produce请求那么这个StopReplica请求只能排队等。如果这些Produce请求就是要向该主题发送消息的话这就显得很讽刺了主题都要被删除了处理这些Produce请求还有意义吗此时最合理的处理顺序应该是**赋予StopReplica请求更高的优先级使它能够得到抢占式的处理。**
这在2.2版本之前是做不到的。不过自2.2开始Kafka正式支持这种不同优先级请求的处理。简单来说Kafka将控制器发送的请求与普通数据类请求分开实现了控制器请求单独处理的逻辑。鉴于这个改进还是很新的功能具体的效果我们就拭目以待吧。
## 小结
好了有关Kafka控制器的内容我已经讲完了。最后我再跟你分享一个小窍门。当你觉得控制器组件出现问题时比如主题无法删除了或者重分区hang住了你不用重启Kafka Broker或控制器。有一个简单快速的方式是去ZooKeeper中手动删除/controller节点。**具体命令是rmr /controller**。这样做的好处是既可以引发控制器的重选举又可以避免重启Broker导致的消息处理中断。
<img src="https://static001.geekbang.org/resource/image/a7/07/a77479402c0fddbf7541d26d72a97707.jpg" alt="">
## 开放讨论
目前控制器依然是重度依赖于ZooKeeper的。未来如果要减少对ZooKeeper的依赖你觉得可能的方向是什么
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View File

@@ -0,0 +1,169 @@
<audio id="audio" title="27 | 关于高水位和Leader Epoch的讨论" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/ae/072b392fdc231111b7985de8905985ae.mp3"></audio>
你好我是胡夕。今天我要和你分享的主题是Kafka中的高水位和Leader Epoch机制。
你可能听说过高水位High Watermark但不一定耳闻过Leader Epoch。前者是Kafka中非常重要的概念而后者是社区在0.11版本中新推出的主要是为了弥补高水位机制的一些缺陷。鉴于高水位机制在Kafka中举足轻重而且深受各路面试官的喜爱今天我们就来重点说说高水位。当然我们也会花一部分时间来讨论Leader Epoch以及它的角色定位。
## 什么是高水位?
首先我们要明确一下基本的定义什么是高水位或者说什么是水位水位一词多用于流式处理领域比如Spark Streaming或Flink框架中都有水位的概念。教科书中关于水位的经典定义通常是这样的
>
在时刻T任意创建时间Event Time为T且T≤T的所有事件都已经到达或被观测到那么T就被定义为水位。
“Streaming System”一书则是这样表述水位的
>
水位是一个单调增加且表征最早未完成工作oldest work not yet completed的时间戳。
为了帮助你更好地理解水位,我借助这本书里的一张图来说明一下。
<img src="https://static001.geekbang.org/resource/image/84/77/8426888d04e1e9917619829b7e3de877.png" alt="">
图中标注“Completed”的蓝色部分代表已完成的工作标注“In-Flight”的红色部分代表正在进行中的工作两者的边界就是水位线。
在Kafka的世界中水位的概念有一点不同。Kafka的水位不是时间戳更与时间无关。它是和位置信息绑定的具体来说它是用消息位移来表征的。另外Kafka源码使用的表述是高水位因此今天我也会统一使用“高水位”或它的缩写HW来进行讨论。值得注意的是Kafka中也有低水位Low Watermark它是与Kafka删除消息相关联的概念与今天我们要讨论的内容没有太多联系我就不展开讲了。
## 高水位的作用
在Kafka中高水位的作用主要有2个。
1. 定义消息可见性,即用来标识分区下的哪些消息是可以被消费者消费的。
1. 帮助Kafka完成副本同步。
下面这张图展示了多个与高水位相关的Kafka术语。我来详细解释一下图中的内容同时澄清一些常见的误区。
<img src="https://static001.geekbang.org/resource/image/45/db/453ff803a31aa030feedba27aed17ddb.jpg" alt="">
我们假设这是某个分区Leader副本的高水位图。首先请你注意图中的“已提交消息”和“未提交消息”。我们之前在专栏[第11讲](https://time.geekbang.org/column/article/102931)谈到Kafka持久性保障的时候特意对两者进行了区分。现在我借用高水位再次强调一下。在分区高水位以下的消息被认为是已提交消息反之就是未提交消息。消费者只能消费已提交消息即图中位移小于8的所有消息。注意这里我们不讨论Kafka事务因为事务机制会影响消费者所能看到的消息的范围它不只是简单依赖高水位来判断。它依靠一个名为LSOLog Stable Offset的位移值来判断事务型消费者的可见性。
另外,需要关注的是,**位移值等于高水位的消息也属于未提交消息。也就是说,高水位上的消息是不能被消费者消费的**。
图中还有一个日志末端位移的概念即Log End Offset简写是LEO。它表示副本写入下一条消息的位移值。注意数字15所在的方框是虚线这就说明这个副本当前只有15条消息位移值是从0到14下一条新消息的位移是15。显然介于高水位和LEO之间的消息就属于未提交消息。这也从侧面告诉了我们一个重要的事实那就是**同一个副本对象其高水位值不会大于LEO值**。
**高水位和LEO是副本对象的两个重要属性**。Kafka所有副本都有对应的高水位和LEO值而不仅仅是Leader副本。只不过Leader副本比较特殊Kafka使用Leader副本的高水位来定义所在分区的高水位。换句话说**分区的高水位就是其Leader副本的高水位**。
## 高水位更新机制
现在我们知道了每个副本对象都保存了一组高水位值和LEO值但实际上在Leader副本所在的Broker上还保存了其他Follower副本的LEO值。我们一起来看看下面这张图。
<img src="https://static001.geekbang.org/resource/image/8b/de/8b1b8474a568e2ae40bf36bb03ca81de.jpg" alt="">
在这张图中我们可以看到Broker 0上保存了某分区的Leader副本和所有Follower副本的LEO值而Broker 1上仅仅保存了该分区的某个Follower副本。Kafka把Broker 0上保存的这些Follower副本又称为**远程副本**Remote Replica。Kafka副本机制在运行过程中会更新Broker 1上Follower副本的高水位和LEO值同时也会更新Broker 0上Leader副本的高水位和LEO以及所有远程副本的LEO但它不会更新远程副本的高水位值也就是我在图中标记为灰色的部分。
为什么要在Broker 0上保存这些远程副本呢其实它们的主要作用是**帮助Leader副本确定其高水位也就是分区高水位**。
为了帮助你更好地记忆这些值被更新的时机我做了一张表格。只有搞清楚了更新机制我们才能开始讨论Kafka副本机制的原理以及它是如何使用高水位来执行副本消息同步的。
<img src="https://static001.geekbang.org/resource/image/d6/41/d6d2f98c611e06ffb85f01031ca79b41.jpg" alt="">
在这里我稍微解释一下什么叫与Leader副本保持同步。判断的条件有两个。
1. 该远程Follower副本在ISR中。
1. 该远程Follower副本LEO值落后于Leader副本LEO值的时间不超过Broker端参数replica.lag.time.max.ms的值。如果使用默认值的话就是不超过10秒。
乍一看这两个条件好像是一回事因为目前某个副本能否进入ISR就是靠第2个条件判断的。但有些时候会发生这样的情况即Follower副本已经“追上”了Leader的进度却不在ISR中比如某个刚刚重启回来的副本。如果Kafka只判断第1个条件的话就可能出现某些副本具备了“进入ISR”的资格但却尚未进入到ISR中的情况。此时分区高水位值就可能超过ISR中副本LEO而高水位 &gt; LEO的情形是不被允许的。
下面我们分别从Leader副本和Follower副本两个维度来总结一下高水位和LEO的更新机制。
**Leader副本**
处理生产者请求的逻辑如下:
1. 写入消息到本地磁盘。
<li>更新分区高水位值。<br>
i. 获取Leader副本所在Broker端保存的所有远程副本LEO值LEO-1LEO-2……LEO-n<br>
ii. 获取Leader副本高水位值currentHW。<br>
iii. 更新 currentHW = max{currentHW, minLEO-1, LEO-2, ……LEO-n}。</li>
处理Follower副本拉取消息的逻辑如下
1. 读取磁盘(或页缓存)中的消息数据。
1. 使用Follower副本发送请求中的位移值更新远程副本LEO值。
1. 更新分区高水位值(具体步骤与处理生产者请求的步骤相同)。
**Follower副本**
从Leader拉取消息的处理逻辑如下
1. 写入消息到本地磁盘。
1. 更新LEO值。
<li>更新高水位值。<br>
i. 获取Leader发送的高水位值currentHW。<br>
ii. 获取步骤2中更新过的LEO值currentLEO。<br>
iii. 更新高水位为min(currentHW, currentLEO)。</li>
## 副本同步机制解析
搞清楚了这些值的更新机制之后我来举一个实际的例子说明一下Kafka副本同步的全流程。该例子使用一个单分区且有两个副本的主题。
当生产者发送一条消息时Leader和Follower副本对应的高水位是怎么被更新的呢我给出了一些图片我们一一来看。
首先是初始状态。下面这张图中的remote LEO就是刚才的远程副本的LEO值。在初始状态时所有值都是0。
<img src="https://static001.geekbang.org/resource/image/1e/36/1ee643ce819a503f72df3d9b4ab04536.jpg" alt="">
当生产者给主题分区发送一条消息后,状态变更为:
<img src="https://static001.geekbang.org/resource/image/73/0b/7317242d7068dbf618866d5974a2d80b.jpg" alt="">
此时Leader副本成功将消息写入了本地磁盘故LEO值被更新为1。
Follower再次尝试从Leader拉取消息。和之前不同的是这次有消息可以拉取了因此状态进一步变更为
<img src="https://static001.geekbang.org/resource/image/91/0d/910e114abe40f1f9e4a13f6e6083320d.jpg" alt="">
这时Follower副本也成功地更新LEO为1。此时Leader和Follower副本的LEO都是1但各自的高水位依然是0还没有被更新。**它们需要在下一轮的拉取中被更新**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/80/cb/8066e72733f14d2732a054ed56e373cb.jpg" alt="">
在新一轮的拉取请求中由于位移值是0的消息已经拉取成功因此Follower副本这次请求拉取的是位移值=1的消息。Leader副本接收到此请求后更新远程副本LEO为1然后更新Leader高水位为1。做完这些之后它会将当前已更新过的高水位值1发送给Follower副本。Follower副本接收到以后也将自己的高水位值更新成1。至此一次完整的消息同步周期就结束了。事实上Kafka就是利用这样的机制实现了Leader和Follower副本之间的同步。
## Leader Epoch登场
故事讲到这里似乎很完美依托于高水位Kafka既界定了消息的对外可见性又实现了异步的副本同步机制。不过我们还是要思考一下这里面存在的问题。
从刚才的分析中我们知道Follower副本的高水位更新需要一轮额外的拉取请求才能实现。如果把上面那个例子扩展到多个Follower副本情况可能更糟也许需要多轮拉取请求。也就是说Leader副本高水位更新和Follower副本高水位更新在时间上是存在错配的。这种错配是很多“数据丢失”或“数据不一致”问题的根源。基于此社区在0.11版本正式引入了Leader Epoch概念来规避因高水位更新错配导致的各种不一致问题。
所谓Leader Epoch我们大致可以认为是Leader版本。它由两部分数据组成。
1. Epoch。一个单调增加的版本号。每当副本领导权发生变更时都会增加该版本号。小版本号的Leader被认为是过期Leader不能再行使Leader权力。
1. 起始位移Start Offset。Leader副本在该Epoch值上写入的首条消息的位移。
我举个例子来说明一下Leader Epoch。假设现在有两个Leader Epoch&lt;0, 0&gt;&lt;1, 120&gt;那么第一个Leader Epoch表示版本号是0这个版本的Leader从位移0开始保存消息一共保存了120条消息。之后Leader发生了变更版本号增加到1新版本的起始位移是120。
Kafka Broker会在内存中为每个分区都缓存Leader Epoch数据同时它还会定期地将这些信息持久化到一个checkpoint文件中。当Leader副本写入消息到磁盘时Broker会尝试更新这部分缓存。如果该Leader是首次写入消息那么Broker会向缓存中增加一个Leader Epoch条目否则就不做更新。这样每次有Leader变更时新的Leader副本会查询这部分缓存取出对应的Leader Epoch的起始位移以避免数据丢失和不一致的情况。
接下来我们来看一个实际的例子它展示的是Leader Epoch是如何防止数据丢失的。请先看下图。
<img src="https://static001.geekbang.org/resource/image/4d/f5/4d97a873fc1bfaf89b5cc8259838f0f5.jpg" alt="">
我稍微解释一下单纯依赖高水位是怎么造成数据丢失的。开始时副本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机制来规避这种数据丢失。我依然用图的方式来说明。
<img src="https://static001.geekbang.org/resource/image/3a/8c/3a2e1131e8244233c076de906c174f8c.jpg" alt="">
场景和之前大致是类似的只不过引用Leader Epoch机制后Follower副本B重启回来后需要向A发送一个特殊的请求去获取Leader的LEO值。在这个例子中该值为2。当获知到Leader LEO=2后B发现该LEO值不比它自己的LEO值小而且缓存中也没有保存任何起始位移值 &gt; 2的Epoch条目因此B无需执行任何日志截断操作。这是对高水位机制的一个明显改进即副本是否执行日志截断不再依赖于高水位进行判断。
现在副本A宕机了B成为Leader。同样地当A重启回来后执行与B相同的逻辑判断发现也不用执行日志截断至此位移值为1的那条消息在两个副本中均得到保留。后面当生产者程序向B写入新消息时副本B所在的Broker缓存中会生成新的Leader Epoch条目[Epoch=1, Offset=2]。之后副本B会使用这个条目帮助判断后续是否执行日志截断操作。这样通过Leader Epoch机制Kafka完美地规避了这种数据丢失场景。
## 小结
今天我向你详细地介绍了Kafka的高水位机制以及Leader Epoch机制。高水位在界定Kafka消息对外可见性以及实现副本机制等方面起到了非常重要的作用但其设计上的缺陷给Kafka留下了很多数据丢失或数据不一致的潜在风险。为此社区引入了Leader Epoch机制尝试规避掉这类风险。事实证明它的效果不错在0.11版本之后关于副本数据不一致性方面的Bug的确减少了很多。如果你想深入学习Kafka的内部原理今天的这些内容是非常值得你好好琢磨并熟练掌握的。
<img src="https://static001.geekbang.org/resource/image/98/3f/989d13e4bc4f44618a10b5b7bd6f523f.jpg" alt="">
## 开放讨论
在讲述高水位时我是拿2个副本举的例子。不过你应该很容易地扩展到多个副本。现在请你尝试用3个副本来说明一下副本同步全流程以及分区高水位被更新的过程。
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。