mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
107
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/23 | Kafka副本机制详解.md
Normal file
107
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/23 | Kafka副本机制详解.md
Normal 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 Replicas(ISR)
|
||||
|
||||
我们刚刚反复说过,追随者副本不提供服务,只是定期地异步拉取领导者副本中的数据而已。既然是异步的,就存在着不可能与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副本不同步而导致的数据不一致的情形?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
105
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/24 | 请求是怎么被处理的?.md
Normal file
105
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/24 | 请求是怎么被处理的?.md
Normal 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(() -> {
|
||||
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并不是一件困难的事情。
|
||||
|
||||
## 开放讨论
|
||||
|
||||
坦白来讲,我对社区否定优先级队列方案是有一点不甘心的。如果是你的话,你觉得应该如何规避优先级队列方案中队列已满的问题呢?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
121
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/25 | 消费者组重平衡全流程解析.md
Normal file
121
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/25 | 消费者组重平衡全流程解析.md
Normal 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操作。请思考一下,针对这个问题,我们该如何改进这个过程?我们是否能允许部分消费者在重平衡过程中继续消费,以提升消费者端的可用性以及吞吐量?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
115
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/26 | 你一定不能错过的Kafka控制器.md
Normal file
115
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/26 | 你一定不能错过的Kafka控制器.md
Normal 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的依赖,你觉得可能的方向是什么?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
169
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/27 | 关于高水位和Leader Epoch的讨论.md
Normal file
169
极客时间专栏/Kafka核心技术与实战/深入Kafka内核/27 | 关于高水位和Leader Epoch的讨论.md
Normal 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事务,因为事务机制会影响消费者所能看到的消息的范围,它不只是简单依赖高水位来判断。它依靠一个名为LSO(Log 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,而高水位 > LEO的情形是不被允许的。
|
||||
|
||||
下面,我们分别从Leader副本和Follower副本两个维度,来总结一下高水位和LEO的更新机制。
|
||||
|
||||
**Leader副本**
|
||||
|
||||
处理生产者请求的逻辑如下:
|
||||
|
||||
1. 写入消息到本地磁盘。
|
||||
<li>更新分区高水位值。<br>
|
||||
i. 获取Leader副本所在Broker端保存的所有远程副本LEO值(LEO-1,LEO-2,……,LEO-n)。<br>
|
||||
ii. 获取Leader副本高水位值:currentHW。<br>
|
||||
iii. 更新 currentHW = max{currentHW, min(LEO-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<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的起始位移,以避免数据丢失和不一致的情况。
|
||||
|
||||
接下来,我们来看一个实际的例子,它展示的是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值小,而且缓存中也没有保存任何起始位移值 > 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个副本来说明一下副本同步全流程,以及分区高水位被更新的过程。
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
Reference in New Issue
Block a user