Files
KnowStreaming/study-kafka/4-design.md
2023-02-22 14:38:17 +08:00

28 KiB
Raw Blame History

title, order, toc
title order toc
4.Kafka架构设计 4 menu

4.1 持久性

Kafka 严重依赖文件系统来存储和缓存消息,人们普遍认为“磁盘很慢”, 所以这会使用怀疑用文件系统做持久结构能否提供具有竞争力的性能。

事实上, 磁盘比人们预期的要慢得多,也快得多,这取决于它们的使用方式。一个设计合理的磁盘结构通常可以和网络一样快。

关于磁盘性能的关键事实是,在过去十年中,硬盘驱动器的吞吐量一直不同于磁盘寻道的延迟。因此,在具有六个 7200rpm SATA RAID-5 阵列组成的磁盘簇的线性(顺序)写入速度能达到 600MB/秒,但随机写入的性能仅为约 100k/秒——相差超过 6000 倍。这些线性读写是所有使用模式中最可预测的,并且由操作系统进行了大量优化。

现代操作系统提供 预读(提前将一个比较大的磁盘块读入内存)和后写(将很多小的逻辑写操作合并起来组成一个大的物理写操作)技术 。

顺序磁盘访问可能比随机内存访问更快!

为了弥补这种性能差异,现代操作系统越来越积极地使用主内存进行磁盘缓存。现代操作系统很乐意将所有空闲内存转移到磁盘缓存中,而在回收内存时几乎没有性能损失。所有的磁盘读写都会经过这个统一的缓存。如果不使用 Direct I/O则无法轻松关闭此功能因此即使进程维护数据的进程内缓存此数据也可能会在 OS 页面缓存中复制,从而有效地将所有内容存储两次。

此外, Kafka 是在 JVM 上构建的, 大家都知道 Java 内存有以下问题

  1. 对象的内存开销非常高,通常会使存储的数据大小翻倍(或更糟)。
  2. 随着堆内数据的增加Java 垃圾收集变得越来越繁琐和缓慢。

由于这些因素, 使用文件系统和依赖页面缓存优于维护内存中缓存或其他结构

并且这种方式,即使 Kafka 服务重新启动, 页面缓存也是处于活跃状态, 而进程内的缓存需要再内存中重建(缓存预热),否则它需要从完全冷的缓存开始(意味着糟糕的初始化性能)

这也大大简化了代码,因为维护缓存和文件系统之间一致性的所有逻辑现在都在操作系统中,这往往比一次性的进程内尝试更有效、更正确。如果您的磁盘使用倾向于线性读取,那么预读实际上是在每次磁盘读取时使用有用数据预先填充此缓存。

这表明了一种非常简单的设计:与其在内存中维护尽可能多的内容,并在空间不足时将其全部刷新到文件系统中,不如将其反转。所有数据都会立即写入文件系统上的持久日志,而不必刷新到磁盘。实际上这只是意味着它被转移到内核的页面缓存中。

Kafka 在设计时采用了文件追加的方式来写入消息, 即只能在日志文件的尾部追加新的消息,并且也不允许修改写入的消息, 这种方式属于典型的顺序写盘的操作,所以就算 kafka 使用磁盘作为存储介质,它所能承载的吞吐量也不容小觑。

4.2 效率

我们在上一节中讨论了磁盘效率。一旦消除了不良的磁盘访问模式,这种系统效率低下的常见原因有两个: 太多的小型 I/O 操作和过多的字节复制。

太多的小型 I/O 操作

小的 I/O 问题既发生在客户端和服务器之间,也发生在服务器自己的持久操作中。

为了避免这种情况,我们的协议是围绕“消息集”抽象构建的,该抽象自然地将消息组合在一起。这允许网络请求将消息组合在一起并分摊网络往返的开销,而不是一次发送单个消息。服务器依次将消息块一次性附加到其日志中,而消费者一次获取大的线性块

这种简单的优化会产生数量级的加速。

字节复制

另一个低效率是字节复制。在低消息率下,这不是问题,但在负载下影响很大。为了避免这种情况,我们采用了由生产者、代理和消费者共享的标准化二进制消息格式(因此数据块可以在它们之间传输而无需修改)。

Broker 维护的消息日志本身只是一个文件目录,每个文件都由一系列消息集填充,这些消息集以生产者和消费者使用的相同格式写入磁盘。保持这种通用格式可以优化最重要的操作:持久日志块的网络传输

现代 Unix 操作系统提供了一个高度优化的代码路径,用于将数据从页面缓存传输到套接字;在 Linux 中,这是通过sendfile 系统调用完成的。也就是 零拷贝

端到端批量压缩

在某些情况下,瓶颈实际上不是 CPU 或磁盘,而是网络带宽。对于需要通过广域网在数据中心之间发送消息的数据管道来说尤其如此。

当然,用户总是可以一次压缩一条消息,而不需要 Kafka 的任何支持,但这会导致压缩率非常低,因为大部分冗余是由于相同类型的消息之间的重复(例如,在 Web 日志中的 JSON 或用户代理或通用字符串值)。有效的压缩需要将多条消息一起压缩,而不是单独压缩每条消息。

Kafka 通过高效的批处理格式支持这一点。一批消息可以聚集在一起压缩并以这种形式发送到服务器。这批消息会以压缩的形式写入,并且会在日志中保持压缩状态,只会被消费者解压。

Kafka 支持 GZIP、Snappy、LZ4 和 ZStandard 压缩协议。可以在此处找到有关压缩的更多详细信息。

4.3 生产者

负载均衡

生产者直接将数据发送到作为分区 Leader 的 Broker 上,无需任务中间路由层。

生产者可以通过更新元数据信息 来获取当前哪些 Broker 是在线的。

生产者客户端控制消息具体发布到哪个分区, 实现一种随机负载均衡。

具体请看Kafka 中生产消息时的三种分区分配策略

异步发送

批处理是效率的主要驱动力之一为了启用批处理Kafka 生产者将尝试在内存中累积数据并在单个请求中发送更大的批处理。批处理可以配置为累积不超过固定数量的消息,并且等待时间不超过某个固定的延迟限制(例如 64k 或 10 ms。这允许在服务器上累积更多要发送的字节并减少更大的 I/O 操作。

这种缓冲是可配置的,并提供了一种机制来权衡少量额外的延迟以获得更好的吞吐量。

关于这一部分请看:图解 Kafka Producer 消息缓存模型

4.4 消费者

Kafka 消费者通过向引导它想要消费的分区的代理发出“fetch”请求来工作。

消费者在每个请求中指定其在日志中的偏移量,并从该位置开始接收回一大块日志。

因此,消费者对这个位置有很大的控制权,并且可以在需要时可以回溯消息。

推与拉

我们考虑的第一个问题是消费者是否应该从 Broker 那里提取数据,或者 Broker 应该将数据推送给消费者。

Kafka 是将数据推送到 Broker, 并由消费者从 Broker 拉取数据。

基于拉的系统的一个缺点是,如果 Broker 没有数据, 消费组可能最终会在一个紧密的循环中轮询,实际上是忙于等待数据到达,为了避免这种情况,我们在拉取请求中设置了参数,允许消费者请求在“长轮询”中阻塞,等待数据到达(并且可选地等待给定数量的字节可用以确保大传输大小)。

消费者定位

跟踪已消费的内容是消息传递系统的关键性能点之一 。

大多数消息传递系统保留有关在代理上消费了哪些消息的元数据。也就是说,当消息被分发给消费者时,代理要么立即在本地记录该事实,要么等待消费者的确认。这是一个相当直观的选择,实际上对于单机服务器来说,这种状态还能去哪里还不清楚。由于在许多消息传递系统中用于存储的数据结构扩展性很差,这也是一个务实的选择——因为代理知道消耗了什么,它可以立即删除它,从而保持数据大小较小。

可能不明显的是,让代理和消费者就消费的内容达成一致并不是一个微不足道的问题。如果代理在每次通过网络分发消息时立即将消息记录为已消费,那么如果消费者未能处理该消息(例如因为它崩溃或请求超时或其他原因),则该消息将丢失。为了解决这个问题,许多消息传递系统添加了确认功能,这意味着消息在发送时只标记为已发送而不是消费;代理等待来自消费者的特定确认以将消息记录为已消费. 这种策略解决了丢失消息的问题,但会产生新的问题。首先,如果消费者处理消息但在发送确认之前失败,则消息将被消费两次。第二个问题是关于性能的,现在代理必须为每条消息保留多个状态(首先将其锁定以使其不会再次发出,然后将其标记为永久消耗以便可以将其删除)。必须处理棘手的问题,例如如何处理已发送但从未确认的消息。

卡夫卡以不同的方式处理这个问题。我们的主题被划分为一组完全有序的分区,每个分区在任何给定时间由每个订阅消费者组中的一个消费者消费。这意味着消费者在每个分区中的位置只是一个整数,即下一条要消费的消息的偏移量。这使得关于已消费什么的状态非常小,每个分区只有一个数字。这种状态可以定期检查点。这使得消息确认的等价物非常便宜。

这个决定有一个附带好处。消费者可以故意回退到旧的偏移量并重新使用数据。这违反了队列的通用合同,但事实证明它是许多消费者的基本特征。例如,如果消费者代码有错误,并且在消费了一些消息后发现,那么一旦错误被修复,消费者可以重新消费这些消息。

离线数据加载

可扩展的持久性允许消费者只定期消费,例如批量数据加载,定期将数据批量加载到离线系统,如 Hadoop 或关系数据仓库。 在 Hadoop 的情况下,我们通过将负载拆分到各个地图任务上来并行化数据加载,每个地图任务一个用于每个节点/主题/分区组合从而允许加载中的完全并行性。Hadoop 提供任务管理,失败的任务可以重新启动,而不会出现重复数据的危险——它们只需从原始位置重新启动。

4.5 消息传递语义

我们讨论一下 Kafka 在生产者和消费者之间提供的语义保证。显然,可以提供多种可能的消息传递保证:

  1. 最多一次——消息可能会丢失,但永远不会重新传递。
  2. 至少一次——消息永远不会丢失,但可以重新传递。
  3. 恰好一次——这就是人们真正想要的,每条消息都只传递一次。

值得注意的是,这分为两个问题:发布消息的持久性保证和消费消息时的保证。

许多系统声称提供“恰好一次”的交付语义,但重要的是阅读细则,这些声明中的大多数都是误导性的(即它们不会转化为消费者或生产者可能失败的情况,存在多个消费者进程,或写入磁盘的数据可能丢失的情况)。

Kafka 的语义是直截了当的。当发布一条消息时,我们有一个消息被“提交”到日志的概念。一旦发布的消息被提交,只要复制此消息写入的分区的代理保持“活动”状态,它就不会丢失。提交消息的定义、活动分区以及我们尝试处理的故障类型的描述将在下一节中更详细地描述。现在让我们假设一个完美的无损代理,并尝试了解对生产者和消费者的保证。如果生产者尝试发布消息并遇到网络错误,则无法确定此错误是在消息提交之前还是之后发生的。

在 0.11.0.0 之前,如果生产者没有收到表明消息已提交的响应,它别无选择,只能重新发送消息。这提供了至少一次传递语义,因为如果原始请求实际上已经成功,则在重新发送期间消息可能会再次写入日志。从 0.11.0.0 开始Kafka 生产者还支持幂等交付选项,以保证重新发送不会导致日志中出现重复条目 ​​。为此,代理为每个生产者分配一个 ID并使用生产者与每条消息一起发送的序列号对消息进行重复数据删除。同样从 0.11.0.0 开始,生产者支持使用类似事务的语义将消息发送到多个主题分区的能力:即 要么所有消息都已成功写入,要么都没有。主要用例是 Kafka 主题之间的一次性处理(如下所述)。

并非所有用例都需要如此强大的保证。对于对延迟敏感的用途,我们允许生产者指定其所需的持久性级别。如果生产者指定它想要等待提交的消息,这可能需要 10 毫秒的时间。然而,生产者也可以指定它想要完全异步地执行发送,或者它只想等到领导者(但不一定是追随者)收到消息。

现在让我们从消费者的角度来描述语义。所有副本都具有完全相同的日志和相同的偏移量。消费者控制其在此日志中的位置。如果消费者从未崩溃,它可以只将这个位置存储在内存中,但是如果消费者失败并且我们希望这个主题分区被另一个进程接管,那么新进程将需要选择一个合适的位置来开始处理。假设消费者读取了一些消息——它有几个选项来处理消息和更新它的位置。

它可以读取消息,然后将其位置保存在日志中,最后处理消息。在这种情况下,消费者进程可能会在保存其位置之后但在保存其消息处理的输出之前崩溃。在这种情况下,接管处理的进程将从保存的位置开始,即使该位置之前的一些消息尚未处理。这对应于“最多一次”语义,因为在消费者失败的情况下,消息可能不会被处理。 它可以读取消息,处理消息,最后保存它的位置。在这种情况下,消费者进程可能会在处理消息之后但在保存其位置之前崩溃。在这种情况下,当新进程接管它收到的前几条消息时,它已经被处理了。这对应于消费者失败情况下的“至少一次”语义。在许多情况下,消息有一个主键,因此更新是幂等的(两次接收相同的消息只会用另一个自身的副本覆盖记录)。 那么恰好一次语义(即你真正想要的东西)呢?从 Kafka 主题消费并生产到另一个主题时(如在 Kafka Streams 中) 应用程序),我们可以利用上面提到的 0.11.0.0 中新的事务生产者功能。消费者的位置作为消息存储在主题中,因此我们可以在与接收处理数据的输出主题相同的事务中将偏移量写入 Kafka。如果交易中止消费者的位置将恢复到其旧值并且输出主题的生成数据将不会对其他消费者可见具体取决于他们的“隔离级别”。在默认的“read_uncommitted”隔离级别中所有消息对消费者都是可见的即使它们是中止事务的一部分但在“read_committed”中消费者将只返回来自已提交事务的消息以及任何不属于的消息一笔交易

写入外部系统时,限制在于需要将消费者的位置与实际存储为输出的内容相协调。实现这一点的经典方法是在消费者位置的存储和消费者输出的存储之间引入两阶段提交。但这可以通过让消费者将其偏移量与其输出存储在同一位置来更简单和更普遍地处理。这更好,因为消费者可能想要写入的许多输出系统不支持两阶段提交。作为一个例子,考虑一个 Kafka Connect 连接器在 HDFS 中填充数据以及它读取的数据的偏移量,以保证数据和偏移量都被更新或两者都不更新。对于许多其他需要这些更强语义并且消息没有允许重复数据删除的主键的数据系统,我们遵循类似的模式。

因此Kafka 在 Kafka Streams 中有效地支持一次性交付,并且在 Kafka 主题之间传输和处理数据时,通常可以使用事务性生产者/消费者来提供一次性交付。其他目标系统的 Exactly-once 交付通常需要与此类系统合作,但 Kafka 提供了偏移量,这使得实现这一点变得可行(另请参见 Kafka Connect。否则Kafka 默认保证至少一次交付,并允许用户通过在处理一批消息之前禁用生产者的重试并在消费者中提交偏移量来实现最多一次交付

4.6 复制

Kafka 中可以配置副本数, 这些副本分为 Leader 副本和 Follower 副本, Follower 副本会从 Leader 副本中同步数据。 Leader 副本只有一个,Follower 有 0 个或者多个。

这将允许集群在服务器发生故障的时候自动故障转移到这些副本中,提升集群高可用性。

Follower 副本有和 Leader 副本相同的偏移量和相同顺序的消息(当然可能 Follower 还没有来得及同步完全,会少一点数据)

Leader 负责提供读写能力, Follower 只负责复制备份。

与大多数自动处理故障的分布式系统一样,需要对节点“活着”的含义有一个精确的定义。对于 Kafka 节点的活跃度有两个条件

一个节点必须能够维持它与 ZooKeeper 的会话(通过 ZooKeeper 的心跳机制) 如果它是追随者,它必须复制发生在领导者上的写入,并且不能落后“太远” 我们将满足这两个条件的节点称为“同步”,以避免“活着”或“失败”的模糊性。领导者跟踪一组“同步”节点。如果追随者死亡、卡住或落后,领导者会将其从同步副本列表中删除。卡住和滞后副本的确定由 replica.lag.time.max.ms 配置控制。 在分布式系统术语中,我们只尝试处理“故障/恢复”故障模型其中节点突然停止工作然后恢复可能不知道它们已经死亡。Kafka 不处理所谓的“拜占庭”故障,即节点产生任意或恶意响应(可能是由于错误或犯规)。

我们现在可以更精确地定义,当该分区的所有同步副本都将消息应用于其日志时,该消息被视为已提交。只有提交的消息才会发送给消费者。这意味着消费者不必担心如果领导者失败,可能会看到可能丢失的消息。另一方面,生产者可以选择等待或不提交消息,这取决于他们对延迟和持久性之间权衡的偏好。此首选项由生产者使用的 acks 设置控制。请注意,主题具有同步副本的“最小数量”设置,当生产者请求确认消息已写入完整的同步副本集时,会检查该设置。

Kafka 提供的保证是,只要始终有至少一个同步副本处于活动状态,提交的消息就不会丢失。

在短暂的故障转移期后如果存在节点故障Kafka 将保持可用,但在存在网络分区时可能无法保持可用。

Leader 选举、ISR 收缩机制

Leader 选举请看:Kafka Leader 选举流程和选举策略

在这里插入图片描述

ISR 伸缩机制:ISR 伸缩机制

在这里插入图片描述

4.7 日志紧缩

日志紧缩确保 Kafka 将始终为单个主题分区的数据日志中的每个消息 key 至少保留最后一个已知值

日志紧缩基础

这是一张高级图片,显示了 Kafka 日志的逻辑结构以及每条消息的偏移量。

在这里插入图片描述

日志的头部与传统的 Kafka 日志相同。它具有密集的顺序偏移并保留所有消息。日志压缩添加了处理日志尾部的选项。上图显示了带有压实尾部的原木。请注意,日志尾部的消息保留了第一次写入时分配的原始偏移量——永远不会改变。另请注意,所有偏移量在日志中仍然是有效位置,即使具有该偏移量的消息已被压缩;在这种情况下,该位置与日志中出现的下一个最高偏移量无法区分。例如,在上图中,偏移量 36、37 和 38 都是等效位置,从这些偏移量中的任何一个开始读取都会返回以 38 开头的消息集。

压缩还允许删除。带有键和空负载的消息将被视为从日志中删除。这样的记录有时被称为墓碑。此删除标记将导致删除具有该键的任何先前消息(与具有该键的任何新消息一样),但删除标记的特殊之处在于它们本身将在一段时间后从日志中清除以释放空间. 删除不再保留的时间点在上图中标记为“删除保留点”。

压缩是通过定期重新复制日志段在后台完成的。清理不会阻塞读取,并且可以限制为使用不超过可配置量的 I/O 吞吐量,以避免影响生产者和消费者。压缩日志段的实际过程如下所示:

在这里插入图片描述

日志紧缩提供什么保证

日志压缩保证以下内容:

  1. 任何关注日志头部的消费者都会看到写入的每条消息;这些消息将具有顺序偏移量。主题min.compaction.lag.ms可用于保证在消息写入之后必须经过的最短时间长度才能被压缩。即,它提供了每条消息将在(未压缩的)头部中保留多长时间的下限。主题max.compaction.lag.ms可用于保证消息写入时间和消息符合压缩条件的时间之间的最大延迟。
  2. 消息的顺序始终保持不变。压缩永远不会重新排序消息,只需删除一些。
  3. 消息的偏移量永远不会改变。它是日志中某个位置的永久标识符。
  4. 从日志开头开始的任何消费者都将按照写入顺序至少看到所有记录的最终状态。此外,如果消费者在小于主题delete.retention.ms设置的时间段内到达日志头部(默认为 24 小时),则会看到已删除记录的所有删除标记。换句话说:由于删除标记的删除与读取同时发生,如果消费者滞后超过 delete.retention.ms.

日志紧缩细信息

日志压缩由日志清理器处理,这是一个后台线程池,用于重新复制日志段文件,删除其键出现在日志头部的记录。每个 compactor 线程的工作方式如下:

  1. 它选择日志头与日志尾比率最高的日志
  2. 它为日志头部中的每个键创建一个最后偏移量的简洁摘要
  3. 它从头到尾重新复制日志,删除日志中稍后出现的键。新的、干净的段会立即交换到日志中,因此所需的额外磁盘空间只是一个额外的日志段(不是日志的完整副本)。
  4. 日志头的摘要本质上只是一个空间紧凑的哈希表。每个条目正好使用 24 个字节。因此,使用 8GB 的 清理缓冲区,一次清理迭代可以清理大约 366GB 的日志头(假设有 1k 条消息)。

配置日志清理器

默认情况下启用日志清理器。这将启动清洁线程池。要对特定主题启用日志清理,请添加特定于日志的属性


 log.cleanup.policy=compact

log.cleanup.policy属性是在代理文件中定义的代理配置设置server.properties;它会影响集群中没有配置覆盖的所有主题,如此处 所述。日志清理器可以配置为保留最少量的未紧缩日志“头”。这是通过设置压缩时间延迟来实现的

  log.cleaner.min.compaction.lag.ms

这可用于防止比最小消息年龄更新的消息受到压缩。如果未设置,则所有日志段都符合压缩条件,除了最后一个段,即当前正在写入的段。即使活动段的所有消息都早于最小压缩时间延迟,活动段也不会被压缩。可以配置日志清理器以确保最大延迟,在此之后,未压缩的日志“头”符合日志压缩的条件。

 log.cleaner.max.compaction.lag.ms

这可用于防止具有低生产率的原木在无限制的持续时间内保持不适合压实。如果未设置,则不会压缩不超过 min.cleanable.dirty.ratio 的日志。请注意,此压缩截止日期不是硬性保证,因为它仍然受日志清理线程的可用性和实际压缩时间的影响。您将需要监控 uncleanable-partitions-count、max-clean-time-secs 和 max-compaction-delay-secs 指标。

4.8 配额

Kafka 集群能够对请求实施配额以控制客户端使用的 Broker 资源。Kafka Broker 可以为共享配额的每组客户端强制执行两种类型的客户端配额:

  1. 网络带宽配额定义字节速率阈值(自 0.9 起)
  2. 请求速率配额将 CPU 利用率阈值定义为网络和 I/O 线程的百分比(从 0.11 开始)

为什么需要配额?

生产者和消费者有可能生产/消费非常大量的数据或以非常高的速率生成请求,从而垄断 Broker 资源,导致网络饱和,并且通常 DOS 其他客户端和代理本身。拥有配额可以防止这些问题,并且在大型多租户集群中更为重要,其中一小部分行为不良的客户端可能会降低行为良好的客户端的用户体验。事实上,当将 Kafka 作为服务运行时,这甚至可以根据商定的合同强制执行 API 限制。

配额配置

可以为(用户、客户端 ID、用户和客户端 ID 组定义配额配置。可以在任何需要更高(甚至更低)配额的配额级别覆盖默认配额。该机制类似于每个主题的日志配置覆盖。用户和(用户,客户端 ID配额覆盖写入 ZooKeeper 下/config/users 和客户端 ID 配额覆盖写入/config/clients 下。这些覆盖被所有代理读取并立即生效。这让我们无需滚动重启整个集群即可更改配额。有关详细信息,请参见此处。每个组的默认配额也可以使用相同的机制动态更新。

配额配置的优先顺序为:

  1. /config/users//clients/
  2. /config/users/<用户>/clients/<默认>
  3. /config/users/<用户>
  4. /config/users/<默认值>/clients/
  5. /config/users/<默认值>/clients/<默认值>
  6. /config/users/<默认值>
  7. /config/clients/
  8. /config/clients/<默认>

Broker 属性quota.producer.default、quota.consumer.default也可用于为客户端 ID 组设置网络带宽配额的默认值。这些属性已被弃用,并将在以后的版本中删除。可以在 Zookeeper 中设置 client-id 的默认配额,类似于其他配额覆盖和默认值。

网络带宽配额

网络带宽配额定义为共享配额的每组客户端的字节速率阈值。默认情况下,每个唯一的客户端组都会收到集群配置的以字节/秒为单位的固定配额。此配额是根据每个经纪人定义的。在限制客户端之前,每组客户端最多可以发布/获取每个代理 X 字节/秒。

请求速率配额

请求速率配额定义为客户端可以在配额窗口内使用请求处理程序 I/O 线程和每个代理的网络线程的时间百分比。n%的配额表示 一个线程的 n% ,因此配额超出了((num.io.threads + num.network.threads) * 100)%的总容量。每组客户可以使用的总百分比高达 n%在被限制之前跨配额窗口中的所有 I/O 和网络线程。由于分配给 I/O 和网络线程的线程数通常基于代理主机上可用的内核数,因此请求率配额表示共享配额的每组客户端可能使用的 CPU 的总百分比。