复制
Kafka 在可配置的服务器数量上为每个主题的分区复制日志(你可以根据每个主题来设置这个复制因子)。这样,当集群中的一个服务器发生故障时,就可以自动地将故障转移到这些副本上,这样消息在发生故障时仍然可用。
其它消息系统也提供了一些与复制相关的功能,但是在我们看来(纯个人观点),这似乎是一个附加的东西,没有被大量使用,而且有很大的缺点:复制不活跃、吞吐量受到严重影响,需要繁琐的手动配置等等。Kafka 在默认情况下是和复制一起使用的——事实上,我们实现了复制因子是1的未复制主题。
复制的单位是主题分区。在非故障条件下,Kafka 中的每个分区都有一个领导者和零个或多个追随者。包括领导者在内的复制的总数构成了复制因子。所有的读和写都会到分区的领导者那里。通常情况下,分区的数量比 broker 多得多,领导者在 broker 之间均匀地分布。跟随者的日志与领导者的日志相同--都有相同的偏移量和相同顺序的消息(当然,在任何时候,领导者可能在其日志的末尾有一些尚未复制的消息)。
跟随者从领导者那里消费消息,就像普通的 Kafka 消费者一样,并将它们应用到自己的日志中。让追随者从领导者那里获取有一个很好的特性,那就是允许追随者自然地把他们应用到日志中的日志条目批处理在一起。
与大多数分布式系统一样,自动处理故障需要对节点 "活着 "的含义有一个精确的定义。对于Kafka节点的有效性有两个条件:
- 节点必须能够保持与 ZooKeeper 的会话(通过 ZooKeeper 的心跳机制)。
- 如果它是一个跟随者,它必须复制发生在领导者上的写操作,并且不落下 "太远"。
我们把满足这两个条件的节点称为 "同步",以避免 "活着 "或 "失败 "的模糊性。领导者跟踪 "同步 "节点的集合。如果一个跟随者死亡、被卡住或落后了,领导者将会把它从同步复制的列表中删除。对卡住和落后的副本的判断由 replica.lag.time.max.ms 配置控制。
在分布式系统术语中,我们只尝试处理节点突然停止工作,然后恢复的 "失败/恢复 "模型(可能不知道他们已经死亡)。Kafka 不处理所谓的 "拜占庭 "故障,即节点产生任意的或恶意的响应(也许是由于错误或犯规)。
现在,我们可以更精确地定义,当该分区的所有同步副本都将消息应用于他们的日志时,该消息就被视为已提交。只有提交的消息才会被发送给消费者。这意味着消费者不需要担心,如果领导者失败可能会看到丢失的消息。另一方面,生产者可以选择是否等待消息提交,这取决于他们对延迟和耐久性之间的权衡。这种偏好是由生产者使用的acks 设置控制的。请注意,主题有一个关于同步复制的 "最小数量 "的设置,当生产者要求确认一条消息已经被写入 ISR 时,会对其进行检查。如果生产者要求一个不那么严格的确认,那么即使同步复制的数量低于最小值(例如,它可以低到只有领导者),该消息也可以被提交和消费。
Kafka 提供的保证是,只要至少有一个同步的副本活着,那么提交的消息就不会丢失,在任何时候都是如此。
Kafka 在经过短暂的故障转移期后,在节点故障的情况下仍然可用,但在网络分区的情况下可能无法保持可用。
复制日志:法定人数、ISR 和状态机
Kafka 分区的核心是一个复制的日志。复制日志是分布式数据系统中最基本的原语之一,有很多实现方法。复制的日志可以被其他系统用作以状态机风格实现其他分布式系统的原语。
复制日志模拟了就一系列数值的顺序达成共识的过程(一般将日志条目编号为0,1,2,......)。有很多方法可以实现这一点,但最简单和最快的是由一个领导者来选择提供给它的数值的顺序。只要领导者还活着,所有的追随者都只需要复制领导者选择的值和排序。
当然,如果领导者不失败,我们就不需要追随者了! 当领导者确实死亡时,我们需要从追随者中选择一个新的领导者。但是追随者本身可能会落后或崩溃,所以我们必须确保我们选择的是一个最新的追随者。日志复制算法必须提供的基本保证是,如果我们告诉客户端一个消息已经提交,而领导者失败了,我们选出的新领导者也必须拥有该消息。这就产生了一个权衡:如果领导者等待更多的追随者来确认一条消息,然后再宣布它的承诺,那么就会有更多潜在的候选领导者。
如果你选择了所需的确认数和必须比较的日志数,以选出一个领导者,从而保证有重叠,那么这被称为 Quorum。
解决这个问题的一个常见方法是,在提交决策和领导者选举中都使用多数投票。这不是 Kafka 所做的,但我们还是来探讨一下,以了解其中的利弊。假设我们有 2f+1 个副本。如果 f+1 个副本必须在领导者宣布提交之前收到消息,并且如果我们通过从至少 f+1 个副本中选出具有最完整日志的追随者来选举新的领导者,那么,只要不超过 f 次失败,领导者就能保证拥有所有提交的消息。这是因为在任何 f+1 个副本中,至少有一个副本包含所有提交的信息。该副本的日志将是最完整的,因此将被选为新的领导者。还有许多剩余的细节是每个算法必须处理的(比如精确定义什么可以使日志更完整,在领导者故障期间确保日志的一致性,或改变副本集中的服务器集),但我们现在将忽略这些。
这种多数投票的方法有一个非常好的特性:延迟只取决于最快的服务器。也就是说,如果复制因子为3,延迟是由较快的跟随者而不是较慢的跟随者决定的。
这个家族中有丰富的算法,包括 ZooKeeper’s Zab、Raft 和 Viewstamped Replication。我们所知道的与Kafka的实际实现最相似的学术出版物是微软的 PacificA。
多数投票的缺点是,不需要太多的失败就会让你没有可选的领导者。容忍一个故障需要三份数据副本,而容忍两个故障需要五份数据副本。根据我们的经验,只有足够的冗余来容忍单个故障对于实际的系统是不够的,但是每写5次,要求5倍的磁盘空间,吞吐量是1/5,这对于大批量的数据问题来说是不太现实的。这可能就是为什么仲裁算法在共享集群配置中更常见,例如 ZooKeeper,但在主数据存储中却不太常见。例如,在 HDFS 中,namenode 的高可用性特性是建立在基于多数投票的日志上的,但这种更昂贵的方法并不用于数据本身。
Kafka 采取了一种稍微不同的方法来选择其法定人数集。Kafka 不采用多数投票的方式,而是动态地维护一组同步复制集(ISR)。只有这个集合的成员才有资格被选为领导者。对 Kafka 分区的写入,在所有同步复制集都收到该写入之前,不会被视为提交。当 ISR 集发生变化时,都会被持久化到 ZooKeeper 中。正因为如此,ISR 中的任何副本都有资格被选为领导者。这对于 Kafka 的使用模式来说是一个重要的因素,因为 Kafka 有很多分区,确保领导的平衡很重要。有了这个 ISR 模型和 f+1 个副本,一个 Kafka 主题可以容忍 f 个故障而不丢失已提交的消息。
对于我们希望处理的大多数用例,我们认为这种权衡是合理的。在实践中,为了容忍 f 个故障,多数投票和 ISR 方法都会在提交消息之前等待相同数量的副本确认(例如,为了度过一个故障,多数的法定人数需要三个副本和一个确认,ISR 方法需要两个副本和一个确认)。在没有最慢的服务器的情况下提交的能力是多数投票方法的一个优势。然而,我们认为通过允许客户端选择是否在消息提交时进行阻塞,可以改善这一问题,而且由于所需的复制因子较低而带来的额外吞吐量和磁盘空间是值得的。
另一个重要的设计区别是,Kafka 并不要求崩溃的节点在恢复时所有的数据都是完整的。在这一领域,复制算法依赖于 "稳定存储 "的存在,这种存储在任何故障恢复的场景中都不会丢失,而不会出现潜在的违反一致性的情况,这一点并不罕见。这种假设有两个主要的问题。首先,磁盘错误是我们在持久性数据系统的实际操作中观察到的最常见的问题,而且它们往往会使数据不完整。其次,即使这不是一个问题,我们也不希望在每次写入时都要求使用 fsync 来保证一致性,因为这会使性能降低 2 到 3 个数量级。我们允许副本重新加入 ISR 的协议确保在重新加入之前,它必须再次完全重新同步,即使它在崩溃时丢失了未刷新的数据。
不公平的领导者选举:如果他们都死了怎么办?
请注意,Kafka对数据丢失的保证是以至少有一个副本保持同步为前提的。如果复制一个分区的所有节点都死了,这个保证就不再成立了。
然而,一个实际的系统需要在所有副本都死亡时做一些合理的事情。如果你不幸发生了这种情况,必须考虑会发生什么。有两种行为可以实现:
- 等待 ISR 中的一个副本复活,并选择这个副本作为领导者(希望它仍有所有的数据)。
- 选择第一个恢复的副本(不一定在 ISR 中)作为领导者。
这是在可用性和一致性之间的一个简单权衡。如果我们在等待 ISR 中的副本,那么只要这些副本被停机,我们就一直不可用。如果这些副本被破坏了,或者它们的数据丢失了,那么我们就会永久停机。另一方面,如果一个不同步的副本复活了,并且我们允许它成为领导者,那么它的日志就会成为实际数据的来源,尽管它不能保证拥有每一次提交的消息。从 0.11.0.0 版本开始,Kafka 默认选择第一种策略,倾向于等待一个一致的副本。这种行为可以通过配置属性 unclean.leader.election.enable 来改变,以支持正常运行时间优于一致性的用例。
这种困境并不是 Kafka 特有的。它存在于任何基于法定人数的方案中。例如,在一个多数表决方案中,如果大多数服务器遭遇永久性故障,那么你必须选择是失去100%的数据,还是违反一致性,将现有服务器上的数据作为新的真实数据的来源。
可用性和耐用性保证
当写入 Kafka 时,生产者可以选择是否等待消息被0、1或所有(-1)副本确认。请注意,"所有副本的确认 "并不能保证所有分配的副本都收到了消息。默认情况下,当 acks=all 时,一旦所有当前同步的副本收到消息,就会发生确认。例如,如果一个主题只配置了两个副本,并且其中一个失败了(即只剩下一个同步的副本),那么指定 acks=all 的写入将成功。然而,如果剩下的副本也发生故障,这些写入可能会丢失。虽然这确保了分区的最大可用性,但对于一些喜欢耐用性而不是可用性的用户来说,这种行为可能是不可取的。因此,我们提供了两个主题级别的配置,可以用来选择消息的耐久性而不是可用性。
-
禁用不干净的领导者选举——如果所有的副本都不可用,那么分区将保持不可用,直到大多数最近的领导者再次变得可用。这实际上是宁可选择不可用也不愿意选择信息丢失的风险。请看前面章节关于不清洁领导者选举的说明。
-
指定一个最小 ISR 的数量——只有当 ISR 的数量超过最小值时,分区才会接受写入,以防止消息被写入单个副本,而这个副本随后变得不可用。这个设置只有在生产者使用 acks=all 并保证消息将被至少这么多同步的副本确认时才会生效。这个设置在一致性和可用性之间提供了一个权衡。最小 ISR 大小的更高设置保证了更好的一致性,因为消息被保证写入更多的副本,从而降低了消息丢失的概率。然而,它降低了可用性,因为如果同步副本的数量低于最小阈值,分区将不可写入。
副本管理
以上关于复制日志的讨论实际上只涵盖了一个日志,也就是一个主题分区。然而一个 Kafka 集群将管理成百上千的这些分区。我们尝试以轮询的方式在集群中平衡分区,以避免将高容量主题的所有分区集中在少数节点上。同样,我们也试图平衡领导者,以便每个节点都是按比例分享其分区的领导。
优化领导者的选举过程也很重要,因为这是不可用的关键窗口。一个不成熟的领导者选举的实现最终会在这个节点发生故障时为该节点托管的所有分区进行选举。相反,我们选举其中一个 broker 作为 "控制器"。这个控制器在 broker 层面检测故障,并负责改变故障 broker 中所有受影响分区的领导者。其结果是,我们能够批处理许多所需的领导者变更通知,这使得选举过程对于大量分区的选举来说更便宜也更快。如果控制器失败了,幸存的 broker 中的一个将成为新的控制器。