背景
Kafka2.8 之后,移除了Zookeeper,而使用了自己研发的Kafka Raft。
为什么移除Zookeeper?
原来Zookeeper在Kafka中承担了Controller选举、Broker注册、TopicPartition注册和选举、Consumer/Producer元数据管理和负载均衡等。
即承担了各种元数据的保存和各种选举。
而Zookeeper并“不快”,集群规模大了之后,很容易成为集群的性能瓶颈。
Kafka作为一个消息中间件,还依赖额外的一个协调系统,而不能实现自我管理,说不过去~无法做到开箱即用,还得要求使用者掌握Zookeeper的调优知识。
到了2.8版本,Kafka移除了Zookeeper,使用自己的Kafka Raft(KRaft),这名字,一眼就能看出来是基于Raft算法实现的。
Raft算法
Raft是一种共识算法,即在分布式系统中,所有节点对同一份数据的认知能够达成一致。
算法主要做两件事情:
分解问题,将复杂的分布式共识问题拆分为: 领导选举、日志复制、安全性。
压缩状态空间,相对于Paxos算法而言施加了更合理的限制,减少因为系统状态过多而产生的不确定性。
复制状态机
在共识算法中,所有服务器节点都会包含一个有限状态自动机,名为复制状态机(replicated state machine)。
每个节点都维护着一个复制日志(replicated logs)的队列,复制状态机会按序输入并执行该队列中的请求,执行状态转换并输出结果。
可见,如果能保证各个节点中日志的一致性,那么所有节点状态机的状态转换和输出也就都一致。
基本流程为:
- 某个节点的共识模块(包含共识算法的实现)从客户端接收请求。
- 该共识模块将请求写入自身的日志队列,并与其他节点的共识模块交流,保证每个节点日志都相同。
- 复制状态机处理日志中的请求。
- 将输出结果返回给客户端。
领导选举
节点状态与转移规则
在Raft集群中,任意节点同一时刻只能处于领导者(leader)、跟随者(follower)、候选者(candidate)三种状态之一。下图示出节点状态的转移规则。
所有节点,一开始角色都是follower,当发现没有leader的时候,就会把自己的角色切换为candidate,发起选举。
得到半数节点投票的,会成为leader。
如果follower或者当前leader发现变更了leader,就会主动退出follow状态。
当leader故障或断开连接,follower就会重新切换为candidate,发起新一轮的选举。
只有leader节点能管理日志的复制,即leader接受客户端的请求,再复制到follower节点。
领导任期
上图中,蓝色表示选举时间段,绿色表示选举出的领导者在位的时间段,这两者合起来即称作一个任期(term),其计数值是自增的。
任期的值就可以在逻辑上充当时间戳,每个节点都会保存一份自己所见的最新任期值,称为currentTerm。
选举流程
如果一个或多个follower节点在选举超时(election timeout)内没有收到leader节点的心跳(一个名为AppendEntries的RPC消息,本意是做日志复制用途,但此时不携带日志数据),就会发起选举流程:
- 增加本地的currentTerm值;
- 将自己切换到候选状态;
- 给自己投一票;
- 给其他节点发送名为RequestVote的RPC消息,请求投票;
- 等待其他节点的消息。
一个任期内,每个节点只能投一票,并且先到先得,也就是会把票投给RequestVote消息第一个到达的那个节点。
选举可能有三种结果:
- 收到多数节点的投票,赢得选举,成为领导节点;
- 收到其他当选节点发来的AppendEntries消息,转换回跟随节点;
- 选举超时后没收到多数票,也没有其他节点当选,就保持候选状态重新选举。
获得超过多数选票的节点当选leader节点,需要立即给其他节点发送AppendEntries消息,告知其他节点,已经选举完毕,避免触发新的一轮选举。
日志复制
日志格式
领导节点选举出来后,集群就可以开始处理客户端请求了。前面已经说过,每个节点都维护着一个复制日志的队列,它们的格式如下图所示:
可见,日志由一个个按序排列的entry组成。每个entry内包含有请求的数据,还有该entry产生时的领导任期值。在论文中,每个节点上的日志队列用一个数组log[]表示。
复制流程
客户端发来请求时,领导节点首先将其加入自己的日志队列,再并行地发送AppendEntries RPC消息给所有跟随节点。
领导节点收到来自多数跟随者的回复之后,就认为该请求可以提交了(见图中的commited entries)。
然后,领导节点将请求应用(apply)到复制状态机,并通知跟随节点也这样做。这两步做完后,就不会再回滚。
这种从提交到应用的方式与最基础的一致性协议——两阶段提交(2PC)有些相似,但Raft只需要多数节点的确认,并不需要全部节点都可用。
只保证最终一致性!
安全性
安全性是施加在领导选举、日志复制两个解决方案上的约束,用于保证在异常情况下Raft算法仍然有效,不能破坏一致性,也不能返回错误的结果。所有分布式算法都应保障安全性,在其基础上再保证活性(liveness)。
Raft协议的安全性保障有5种,分别是:选举安全性(election safety)、领导者只追加(leader append-only)、日志匹配(log matching)、领导者完全性(leader completeness)、状态机安全性(state machine safety) 。
选举安全性
选举安全性是指每个任期内只允许选出最多一个领导。如果集群中有多于一个领导,就发生了脑裂(split brain)。根据“领导选举”一节中的描述,Raft能够保证选举安全,因为:
- 在同一任期内,每个节点只能投一票;
- 只有获得多数票的节点才能成为领导。
领导者只追加
在讲解日志复制时,我们可以明显地看出,客户端发出的请求都是插入领导者日志队列的尾部,没有修改或删除的操作。
这样可以使领导者的行为尽量简单化,使之没有任何不确定的行为,同时也作为下一节要说的日志匹配的基础。
日志匹配
如果两个节点的日志队列中,两个entry具有相同的下标和任期值,那么:
- 它们携带的客户端请求相同;
- 它们之前的所有entry也都相同。
第一点自然由上一节的“领导者只追加”特性来保证,而第二点则由AppendEntries RPC消息的一个简单机制来保证:每条AppendEntries都会包含最新entry之前那个entry的下标与任期值,如果跟随节点在对应下标找不到对应任期的日志,就会拒绝接受并告知领导节点。
有了日志匹配特性,就可以解决日志复制中那个遗留问题了。假设由于节点崩溃,跟随节点的日志出现了多种异常情况,如下图:
图中是6种可能的情况。比如a和b是丢失了entry,c和d是有多余的未提交entry,e和f则是既有丢失又有冗余。
这时领导节点就会找到两个日志队列中最近一条匹配的日志点,将该点之后跟随节点的所有日志都删除,然后将自己的这部分日志复制给它。
例如对于上图中的情况e来说,最近一条匹配的日志下标为5,那么5之后的所有entry都会被删除,被替换成领导者的日志。
领导者完全性
领导者完全性是指,如果有一条日志在某个任期被提交了,那么它一定会出现在所有任期更大的领导者日志里。这也是由两点来决定的:
- 日志提交的定义:该条日志被成功复制到多数节点才算是提交;
- 选举投票的附加限制:只有当节点A的日志比节点B的日志更新时,B才可能会投票给A。也就是说,如果一个候选节点没有包含所有被提交的日志,那么它一定不会被选举为领导。
根据这两个描述,每次选举出的领导节点一定包含有最新的日志,因此只存在跟随节点从领导节点更新日志的情况,而不会反过来,这也使得一致性逻辑更加简化,并且为下面的状态机安全性提供保证。
状态机安全性
状态机安全性是说,如果一个节点已经向其复制状态机应用了一条日志中的请求,那么对于其他节点的同一下标的日志,不能应用不同的请求。这句话就很拗口了,因此我们来看一种意外的情况。
- 在时刻a,节点S1是领导者,第2个任期的日志只复制给了S2就崩溃了。
- 在时刻b,S5被选举为领导者,第3个任期的日志还没来得及复制,也崩溃了。
- 在时刻c,S1又被选举为领导者,继续复制日志,将任期2的日志给了S3。此时该日志复制给了多数节点,但还未提交。
- 在时刻d,S1又崩溃,并且S5重新被选举为领导者,将任期3的日志复制给S1~S4。
这里就有问题了,在时刻c的日志与新领导者的日志发生了冲突,此时状态机是不安全的。
(抄的,没懂为什么算是发生冲突,不是没提交么,没提交,怎么就算冲突了。这算冲突,时刻b就不冲突么?难道大多数同步了,就认为是会提交的,不应该丢?)
为了解决该问题,Raft不允许leader在当选后提交“前任”的日志,而是通过日志匹配原则,在处理“现任”日志时将之前的日志一同提交。
具体方法是:在领导者任期开始时,立刻提交一条空的日志
所以上图中时刻c的情况不会发生,而是像时刻e一样先提交任期4的日志,连带提交任期2的日志。就算此时S1再崩溃,S5也不会重新被选举了。
Kafka Raft的实现
Quorum节点状态机
在KRaft协议下,节点可以处于以下4种状态之一。
- Candidate(候选者)——主动发起选举。
- Leader(领导者)——在选举过程中获得多数票。
- Follower(跟随者)——已经投票给Candidate,或者正在从Leader复制日志。
- Observer(观察者)——没有投票权的Follower,与ZK中的Observer含义相同。
消息定义
经典Raft协议只定义了两种RPC消息,RequestVote(让其他节点给自己投票)和AppendEntries(leader节点给follower节点发送的心跳),并且都是以推的方式。
在KRaft协议下,以拉的交互模式(使用内部topic来实现元数据同步,topic的日志消费采用拉取的方式),定义的RPC消息有:
- Vote,投票消息。
- BeginQuorumEpoch,新Leader当选时发送,告知其他节点当前的Leader信息。
- EndQuorumEpoch,当前Leader退位时发送,触发重新选举,用于graceful shutdown。
- Fetch,复制Leader日志,由Follower/Observer发送。经典Raft协议中的AppendEntries消息是Leader将日志推给Follower,而KRaft协议中则是靠Fetch消息从Leader拉取日志。同时Fetch也可以作为Follower对Leader的活性探测。
为什么要采用拉取的方式?
- 可以将一致性检查的工作放在Leader节点,拉取的时候校验,Follower/Observer请求中携带当前日志的LEO。Leader会匹配对应的Leader节点任期,如果匹配,就返回要同步的数据;不匹配,则告知Follower/Observer截断日志。
- 可以更快速地启动一个全新的Follower,直接从offset 0开始复制日志,Leader不需要记录Follower同步到哪里。
- 方便淘汰过期的Follower,过期的Follower跟Leader同步数据,Leader从配置上知道是无效的Follower,就可以告知。
- 推送模式下,当Leader节点无法给Follower发送心跳的时候,才会主动退位;在拉取模式下,当Leader无法提交数据的时候,就可以退位了。
拉取方式的缺点:
- 处理僵尸Leader和Fetch的延迟比较大。多数Follower向Leader去Fetch日志超时后,才认为Leader挂了;定时Fetch日志,肯定没有直接Leader推来的快。
领导选举
当满足以下三个条件之一时,Quorum中的某个节点就会触发选举:
- Follower向Leader发送Fetch请求后,在超时阈值quorum.fetch.timeout.ms之后仍然没有得到Fetch响应,表示Leader疑似失败。
- 从当前Leader收到了EndQuorumEpoch请求,表示Leader已退位。
- Candidate状态下,在超时阈值
quorum.election.timeout.ms
之后仍然没有收到多数票,也没有Candidate赢得选举,表示此次选举作废,重新进行选举。
元数据日志复制
与维护Consumer offset的方式类似,脱离ZK之后的Kafka集群将元数据视为日志,保存在一个内置的Topic中,且该Topic只有一个Partition。
为什么只有一个Partition?
Raft的日志队列,是需要有序的,先进先出。Kafka的日志,每个分区是一个逻辑上的文件,顺序写入,只有一个Partition的时候,所有消息都是顺序的。
元数据日志的消息格式与普通消息没有太大不同,但必须携带Leader的纪元值(即之前的Controller epoch):
Record => Offset | LeaderEpoch | ControlType | Key | Value | Timestamp
Follower以拉模式复制Leader日志,就相当于以Consumer角色消费元数据Topic,符合Kafka原生的语义。
KRaft里,怎么判断哪些元数据是已经提交的?
Kafka里,有一个概念HW高水位,所有Follower都同步了的日志下标,就是这个partition的HW,表示日志不会丢失。HW之前的日志,代表已提交。
HW-LEO之间的数据,则是未提交的数据。
状态机安全性保证
在安全性方面,KRaft与传统Raft的选举安全性、领导者只追加、日志匹配和领导者完全性保证都是几乎相同的。下面只简单看看状态机安全性是如何保证的,仍然举论文中的极端例子:
- 在时刻a,节点S1是Leader,epoch=2的日志只复制给了S2就崩溃了;
- 在时刻b,S5被选举为Leader,epoch=3的日志还没来得及复制,也崩溃了;
- 在时刻c,S1又被选举为Leader,继续复制日志,将epoch=2的日志给了S3。此时该日志复制给了多数节点,但还未提交;
- 在时刻d,S1又崩溃,并且S5重新被选举为领导者,将epoch=3的日志复制给S0~S4。
此时日志与新Leader S5的日志发生了冲突,如果按上图中d1的方式处理,消息2就会丢失。
传统Raft协议的处理方式是:在Leader任期开始时,立刻提交一条空的日志,所以上图中时刻c的情况不会发生,而是如同d2一样先提交epoch=4的日志,连带提交epoch=2的日志。
与传统Raft不同,KRaft附加了一个较强的约束:当新的Leader被选举出来,但还没有成功提交属于它的epoch的日志时,不会向前推进HW。
为什么要增加这么个约束?
个人认为,普通Kafka的Consumer,只能拉取到HW之前的消息,如果消息同步到了ISR集合中的所有Follower节点,HW就会增加,Consumer就会消费到。
但是在KRaft中,加上这个约束,即使上图中时刻c的情况发生了,消息2也被视为没有成功提交(HW未改变),所以按照d1方式处理是安全的,因为数据并不会被Consumer读取到。
相关配置
quorum.voters,可以投票的broker的名单,{broker-id}@{broker-host):{broker-port}。
quorum.fetch.timeout.ms,Follower/Observer拉取日志的超时时间。
quorum.election.timeout.ms,选举的超时时间,规定时间内,没有节点获得多数选票,就需要发起新的投票。
quorum.election.backoff.max.ms,选举超时后,最多等待的毫秒数,然后重新发起投票。
quorum.request.timeout.ms,请求的超时时间。
quorum.retry.backoff.ms,请求超时之后的初始间隔时间,过了这个时间之后,再重试
quorum.retry.backoff.max.ms,请求超时后,最大的重试间隔时间
broker.id,节点id