【译】Kafka Design

407 阅读52分钟

原文:docs.confluent.io/platform/cu…

动机

为了将 Kafka 设计为一个能够处理大型公司实时数据流的统一平台,我们必须要考虑很多因素。

首先,它必须具有高吞吐量,以支持大容量事件流,例如实时的日志聚合。

其次,它需要处理好大量的积压数据,以便能够支持离线系统的定期数据加载。

这也意味着它可能要低延迟的处理一些传统的消息用例。

我们想支持对这些feeds的分区、分布式、以及实时处理,以满足新的或者相关的feeds,这激励我们创建了分区模型和消费者模型。

为了实现这些用途,我们的设计加入了许多独特的元素,使得它更像一个数据库日志而非传统的消息系统。接下来我们简要介绍一下这些设计元素。

持久化

不要害怕文件系统

Kafka 严重依赖文件系统来存储和缓存消息。大家普遍认为“磁盘很慢”,这导致人们怀疑持久化结构是否可以提供具有竞争力的性能。事实上,磁盘比人们预期的要慢得多,也快得多,这取决于怎么使用它们。 一个设计合理的磁盘结构甚至可以和网络一样快。

在过去十年中,关于磁盘性能的实际情况是,硬盘驱动器的吞吐量与磁盘寻道的延迟有所不同。在一个具有 6 个 7200 rpm SATA RAID-5 阵列的 JBOD 配置上,线性写入的性能约为 600MB/秒,但是随机写入的性能仅为 100k/秒左右,差别超过 6000 倍。这些线性读取和写入是所有使用模式中最可预测的,并且由操作系统进行了大量优化。现代操作系统提供了预读和后写技术,这些技术会预先读取多个block,并将较小的逻辑写入合并为较大的物理写入。关于这个问题的进一步讨论可以在这篇 ACM 队列文章 中找到;实际上,他们发现在某些情况下顺序磁盘访问比随机内存访问更快!

为了弥补这种性能差异,现代操作系统在使用主内存进行磁盘缓存方面变得越来越积极。当内存被回收时,现代操作系统很乐意损失一点性能,将所有空闲内存转移到磁盘缓存中。所有的读写操作都会通过这个缓存区,如果不使用直接 I/O,则无法轻易关闭该功能。即使一个进程维护着数据的进程内缓冲,这个数据也很有可能在操作系统的页缓存中又被复制了一份,从而有效的将所有内容存储了两次。

此外,我们是在 JVM 之上构建的,任何了解过 Java 内存使用的人都知道两件事:

  1. 对象的内存开销非常高,经常会比存储的数据量多一倍(甚至更糟)。

  2. 随着堆内数据的增加,Java 垃圾回收会变得越来越繁琐和缓慢。

由于这些因素,使用文件系统或者依赖于页缓存要优于维护内存缓存或者其他结构——我们通过自动访问所有空闲内存来使可用内存翻倍,并且通过存储一个压缩的字节结构来代替单个对象的存储,来使可用内存再次翻倍。这样做,可使一个 32GB 的机器产生高达 28-30GB 的缓存,而不会造成垃圾回收。此外,即使服务重启,这些缓存依然是热缓存,而进程内的缓存需要在内存中重建(对于 10GB 缓存可能需要 10 分钟),否则它将以完全冷缓存的方式启动(这很可能意味着非常糟糕的初始化性能)。这也极大的简化了代码,因为维护缓存和文件系统一致性的所有逻辑都在操作系统中了,这往往比一次性的在进程内尝试更有效、更正确。如果你的磁盘使用更偏向线性读,那么预先读实际上是在每次磁盘读取时,使用有用的数据预先填充此缓存。

这表明了一种非常简单的设计:与其在内存中维护尽可能多的数据,并且还要在空间不足时将它们都刷新到文件系统中,我们不如将其反转。所有的数据都直接写到文件系统的持久化日志中,而不用再刷新到磁盘。实际上,这只是意味着它被传输到了内核的页缓存中。

这种以页缓存为中心的设计风格在一篇关于 Varnish 设计的文章中有所描述(进行适度的傲慢)

固定时间就足够了

消息系统中的持久化数据结构,经常是一个关联的 BTree 或者随机访问数据结构的预消费队列,来维护消息的元数据。BTree 是非常有用的数据结构,它使得在消息系统中支持事务或者非事务性语义成为可能。不过,BTree 确实带来了非常高的成本:操作复杂度是 O(log N)。通常认为 O(log N) 本质上等同于常数时间,但对于磁盘操作而言并非如此。磁盘寻道的时间为 10ms,每个磁盘一次只能进行一次寻道,所以并行性会受到限制。因此,即使少量的磁盘寻道也会导致非常高的开销。由于存储系统将非常快的缓存操作和非常慢的物理磁盘操作结合在一起,观察到随着固定缓存的数据增加,树形结构的性能通常是超线性的——数据加倍会比速度减倍,让事情更糟糕。

直观来看,一个持久化队列可以建立在简单读和文件追加上,这也是常见的日志解决方案。这种结构的优点是所有操作的复杂度是 O(1),并且读写不会相互阻塞。因为性能与数据大小是完全解耦的,这具有非常明显的性能优势——一台服务器可以充分利用大量廉价的、低转速的 1+TB SATA 驱动器。尽管它们寻道性能很差,但这些驱动器在接受大量读写时的性能是可接受的,并且价格仅 1/3,容量却增加了3倍。

没有任何性能损失的无限虚拟磁盘空间,意味着我们可以提供一些通常在消息系统中找不到的功能。例如,在 Kafka中,我们会将消息保留相对较长的一段时间(比如一周),而不是尝试在消息被消费后就立即删除。正如我们将要描述的,这为消费者提供了极大的灵活性。

效率

我们为提高效率做过很多努力,主要的案例之一就是处理网络活跃数据,该数据量非常大:每个页面视图就可能会生成十几次写入。此外,我们假定发布的每个消息至少被一个消费者(往往是多个)订阅,因此我们努力让消费者尽可能易于使用。

我们还发现,从构建和运行一系列类似系统的经验上来看,效率对于多租户运行至关重要。如果下游基础服务很容易因为应用程序的使用出现小波动而成为瓶颈,那么这种小的变化将会经常导致问题。通过非常快的速度,我们可以确保应用程序在基础设施之前就会因负载过重而崩溃。尤其当尝试在一个中央集群中,运行成百上千个应用程序的集中式服务时,这是非常重要的,因为使用模式几乎每天都在变化。

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

小 I/O 操作的问题会同时发生在客户端、服务端以及服务端自身的持久化操作中。

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

这个简单的优化产生了数量级的速度。批处理会导致更大的网络包,更大的顺序磁盘操作,和连续的内存块等,所有这些都允许 Kafka 将突发的随机消息写入流转化为流向消费者的线性写入。

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

Broker 维护的消息日志只是一个文件目录,每个文件都是由被生产者和消费者以相同的格式写入磁盘的一系列消息集填充的。维护这种通用格式可以优化最重要的操作:持久日志块的网络传输。现代 Unix 操作系统提供了一种高度优化的代码路径,用于将数据从 pagecache 传输到 socket;在 Linux 中,是通过 sendfile 系统调用完成的。

为了理解 sendfile 的影响,重要的是理解从文件到 socket 传输数据的通用数据路径:

  1. 操作系统从磁盘读取数据到内核空间的页缓存
  2. 应用程序从内核空间读取数据到用户空间缓冲区
  3. 应用程序将数据从内核空间写回到套接字缓冲区
  4. 操作系统将数据从套接字缓冲区复制到 NIC 缓冲区,然后在这里通过网络发送数据

这显然非常低效,这里有四次复制和两次系统调用。Sendfile通过允许操作系统直接将数据发送到页缓存,从而避免了这种重新复制。因此,在这个优化的路径中,只有最终复制到 NIC 缓冲区的操作是必须的。

一个常见的使用案例是,一个主题有多个消费者。使用上述的零复制的优化,数据只需要被复制到页缓存一次,并在每次消费时重用,而不是存储在内存中,每次读取都要复制到用户空间。这允许以接近网络连接限制的速率消费消息。

页缓存和 sendifle 的这种组合意味着在 Kafka 集群中,当消费者大部分时间都在执行时,你将看不到磁盘上的读取活动,因为它们将完全从缓存中提供数据。

有关Java中支持的 sendfile 和 零拷贝的更多背景信息,可以参考这篇文章

端到端的批量压缩

在某些情况下,网络带宽才是瓶颈,而不是CPU和磁盘。对于数据中心之间需要通过广域网传输消息的数据管道来说,这一点又为适用。当然,用户总是可以在不需要 Kafka 提供任何支持的情况下一次性压缩消息,但这可能会导致非常差的压缩率,因为大部分的冗余是由于相同类型的消息之间的重复(例如Json中的字段名称或者 Web 日志的用户代理或者常见字符值)。高效压缩要求将多个消息压缩在一起而不是每个消息单独压缩。

Kakfa 用一种高效的批量格式化处理支持这个特性。一批消息可以被压缩在一起,并以这种格式发送到服务器。这批消息将会以压缩的形式写入并在日志中以压缩的形式保留,将来也只会被消费者解压。

Kafka 支持 GZIP,Snappy,LZ4 和 ZStandard 压缩协议。关于压缩的更多细节可以在这里找到。

生产者

负载均衡

生产者直接将消息发送到作为分区 leader 的 broker,而不需要任何路由层介入。为了帮助生产者做到这一点,所有的 Kafka 节点都可以回应一个元数据请求,包含哪些服务器是存活的,在任何给定的时间里一个主题的分区的 leader 在哪里,并且允许生产者适当的引导请求。

客户端控制消息发送到哪个分区。这可以随机的进行,实现一种随机的负载均衡,也可以通过一些语义分区功能来完成。我们开放了语义分区的接口,允许用户对分区指定一个键,并通过这个键来散列到一个分区。(如果需要,还有一个选项可以覆盖分区功能)。例如,如果这个键被指定为用户id,那么一个特定用户的所有数据都将被发送到同一个分区。这反过来将会允许消费者对它们的消费做出区域性假设,这种分区方式被明确的设计为允许消费者进行区域敏感处理。

异步发送

批处理是效率最重要的驱动力之一,为了实现批处理,Kakfa 生产者将尝试在内存中积累数据,然后在一次请求中发送更大的批处理。批处理可以被配置为积累不超过固定大小的消息,并且等待时间不超过固定的延迟时间(例如64k或者10ms)。这将允许积累更多的字节数被发送,并在服务器上进行少量较大的I/O操作。这种缓冲是可配置的,它提供了一种机制以少量额外的延迟来换取更好的吞吐量。

关于配置和生产者api的细节,可以在这个文档的其它地方找到。

消费者

Kafka 消费者的工作方式是从它想消费的分区 leader 的 broker 上 “获取”请求。消费者在每次请求中指定了它在日志中的偏移量,并接收从该位置开始的一块日志。因此,消费者对这个位置拥有很大的控制权,并且如果需要可以进行回放对数据重新消费。

推与拉

我们最初考虑的一个问题是:消费者应该从 broker 拉取数据,还是 broker 应该推送数据给消费者。在这方面,Kafka遵循了一种更传统的设计,这也是大多数消息系统所共有的,即生产者推送数据到 broker,然后消费者从 broker 拉取数据。一些日志中心系统,例如 Scribe 和 Apache Flume,遵循一种非常特殊的基于推送的路径,数据推送到下游。这两种方法都有优缺点。然而,一个基于推送的系统很难处理不同的消费者,因为 broker 控制着消息传输的速率。目标通常是让消费者以最大可能的速率进行消费。不幸的是,在一个推模式的系统中,这意味着当消费者的消费速度落后于生产速度时,消费者往往会被淹没(本质上是一种拒绝服务的攻击)。基于拉模式的系统拥有更好的特性,在这种情况下,消费者只是落后,但是当它能追上时就可以追上来。这可以通过某种补偿协议来缓和,消费者可以表示它已经不堪重负了,但是如果想让传输速率充分利用(但是绝不是过度利用)消费者,这比看起来更棘手。以前在这种方式下对构建系统的尝试,导致我们将采用更传统的拉模式。

拉系统的另一个优点是,它可以将大批量的数据发送给消费者。基于推模式的系统,必须选择立即发送一个请求,或者积攒更多的数据,然后在不知道下游消费者是否能够及时处理的情况下发送。如果调整为低延迟的,这将会导致每次都是发送单一的消息,但是传输最终还是会被缓冲,这是一种浪费。基于拉模式的设计解决了这个问题,消费者总是拉取在日志中当前位置之后的所有可用的消息(或者达到一些可配置的最大数据量)。因此,我们可以得到最佳的批量处理,而不会引入不必要的延迟。

一个原始的基于拉模式系统的缺点是,如果 broker 没有数据,消费者将会在一个紧密的循环中进行轮训,有效地忙于等待数据的到来。为了避免这种情况,我们需要在拉取请求中设置一些参数,允许消费者的请求在一个“长轮询”中阻塞等待直到有数据到来(也可以选择等待一定数量的字节,以确保有较大的传输量)。

你可能会想到其它只有拉模式或者端到端的设计。生产者将在本地写入一个本地日志,brokers 将会在消费者向他们拉取的时候到本地日志中拉取。一个类似“存储-转发”类型的生产者经常被提出。这很吸引人,但是我们觉得这不太适合我们的目标,因为我们的使用案例拥有成千上万的生产者。我们在大规模运行持久化数据系统的经验使我们感到,在系统中涉及成千上万的磁盘,跨越很多应用,实际上并不会让事情变得更可靠,反而运行起来将会是一场噩梦。在实践中,我们发现我们可以大规模的运行在一个健壮的服务等级协议管道中,而不需要对生产者进行持久化。

消费者定位

出人意料的是,持续追踪已消费的内容是消息系统的关键性能点之一。

大多数消息系统保存了关于 broker 上哪些消息已经被消费了的元数据信息。也就是说,当一个消息被传递给消费者时,broker 要么马上在本地记录这个情况,要么等待消费者的确认。这是一个相当直觉地选择,并且实际上在一个单机服务器中,这个状态还可以去哪里是不清晰的。由于在许多消息系统中用于存储的数据结构扩展性很差,这也是一个实用的选择——因为 broker 知道哪些是被消费过的,可以马上进行删除,从而保持数据量很小。

或许不明显的是,使 broker 和 消费者就哪些内容已经被消费了达成一致,并不是一个无关紧要的问题。如果 broker 每次在网络上发送完消息后,就将其记录为已消费,那么如果消费者对消息处理失败了(也就是说因为它崩溃了或者请求超时或其它原因),则消息就会丢失。为了解决这个问题,许多消息系统增加了一个确认功能,这意味着当消息被发送后,消息只是被标记为了发送,而不是已消费。Broker 会等待消费者的确认,然后再将消息标记为已消费。这种策略修复了丢失消息的问题,但是也产生了新问题。首先,如果消费者处理了消息,但是在回复确认前失败了,那么消息将会被重复消费。第二个问题是围绕性能的,现在 broker 必须对每一条消息保持多种状态(首先要锁住它,这样它就不会再被发送出去了,然后将它永久的标记为已消费,这样它就可以被删除了)。必须要处理的比较棘手的问题是,那些已经发送了但是从未被确认过的消息。

Kafka 处理这些的方式不同。我们的 topic 被划分到一组完全有序的分区中,每一个分区在任何时候都将会被每个订阅的消费组内的一个消费者所消费。这意味着,在每个分区中消费者的位置仅仅只是一个整数,即下一个要消费的消息的偏移量。这使得关于已经被消费的消息的状态非常小,对每个分区来说仅仅只是一个数字。这个状态可以被定期的检控。这使得等价的消息确认非常便宜。

这个决定有一个附加的好处。消费者可以谨慎的倒退到一个旧的偏移量并重新消费数据。这违反了队列的公共契约,但是对于许多消费者来说,这变成了一项不可或缺的功能。例如,如果消费者的代码中有错误,并且在一些消息已经被消费后才发现,那么一旦错误被修复,消费者就可以重新消费这些消息。

离线数据加载

可扩展的持久化允许只定期消费的消费者成为可能,例如定期的批量加载数据到一个离线系统,如 Haddop 或者关系型数据仓库。

在 Hadoop 的情况下,我们通过将负载分散到独立的集合任务中实现数据加载的并行化,将每个节点、主题、分区组合对应一个任务,从而允许在加载时完全并行化。Hadoop 提供了任务管理,失败的任务可以重启而没有重复数据的危险——它们只是从原始位置开始重新启动。

静态成员

静态成员旨在提高流应用、消费者组和其它建立在组再平衡协议之上的应用的可用性。再平衡协议依赖于组协调器为组成员分配实体ID。这些生成的ID是短暂存在的,当成员重启或者重新加入时将会改变。对于基于消费者的应用来说,这个“动态成员”会导致在管理操作中,例如代码部署、配置更新或者定期重启的管理操作中,会有很大比例的任务被重新分配给不同的实例。对于大型状态应用程序,打乱的任务需要很长时间才能恢复它们的本地状态,这会导致应用程序部分的或者全部不可用。受这个观察的启发,Kafka 的组管理协议允许组成员提供持久的实例ID。基于这些ID的组成员保持不变,因此不会触发重平衡。

如果你想使用静态成员:

  • 将 broker 集群和客户端应用程序升级到 2.3 或者更高的版本,并且同时也要确认升级后的 broker 也使用 2.3 或者更高版本的 inter.broker.protocol.version。
  • 一个组中的每个消费者实例,将 ConsumerConfig#GROUP_INSTANCE_ID_CONFIG 设置为唯一值。
  • 对于 Kafka Streams 应用来说,为每个 KafkaStreams 实例设置一个唯一的 ConsumerConfig#GROUP_INSTANCE_ID_CONFIG 就足够了,与实例中使用的线程数无关。

如果你的 broker 是 2.3 之前的版本,但是你在客户端设置了 ConsumerConfig#GROUP_INSTANCE_ID_CONFIG ,那么应用程序将会检测出 broker 版本并且抛出 UnsupportedException。如果你不小心对不同的实例设置了重复的ID,broker 端的防护机制将会通过触发 org.apache.kafka.common.errors.FencedInstanceIdException 来通知你重复的客户端立即进行关闭。

想要了解更多,请查看博文 Apache Kafka 对云端的再平衡协议:静态成员 和 KIP-345

消息传递语义

现在我们已经对生产者和消费者如何工作有了一定了解,让我们讨论一下 Kafka 在生产者和消费者之间提供的语义保证。显然,有很多种可能的消息传递保障可以被提供:

  • 最多一次——消息可能丢失,但是永远不会重复发送。
  • 至少一次——消息永远不会丢失,但是可能会重复发送。
  • 只有一次——这是人们真正想要的,每个消息只传递一次,并且只有一次。

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

许多系统声称提供 "完全一次 "的交付语义,但重要的是要读懂其中的细节,这些声称大多是有误导性的(即它们没有说明,当消费者或生产者可能失败的情况,或者有多个消费者进程的情况,或者写入磁盘的数据可能丢失的情况)。

Kafka 的语义是直截了当的。当发布一条消息时,我们有一个概念,即该消息被 "提交 "到日志中。一旦发布的消息被提交,只要复制该消息的分区的一个 broker 仍然 "活着",它就不会丢失。下一节将更详细地描述提交的消息、活着的分区的定义,以及我们试图处理的故障类型。现在让我们假设一个完美的、无损的 broker ,并尝试理解对生产者和消费者的保证。如果一个生产者试图发布一个消息并经历了一个网络错误,它不能确定这个错误是发生在消息提交之前还是之后。这类似于向一个有自动生成的键的数据库表插入的语义。

在 0.11.0.0 之前,如果生产者没有收到消息被提交的响应指示,它几乎没有选择,只能重新发送消息。这提供了至少一次的交付语义,因为如果最初的请求已经成功了,消息可能会在重发过程中再次被写入日志。从 0.11.0.0 开始,Kafka生产者也支持幂等的交付选项,保证重发但不会导致日志中出现重复的条目。 为了实现这一点,broker 为每个生产者分配了一个 ID,并使用生产者与每条消息一起发送的序列号来删除重复的消息。同样从 0.11.0.0 开始,生产者支持使用类似事务的语义向多个主题分区发送消息的能力:即要么所有的消息都被成功写入,要么都不成功。这个的主要用例是 Kafka 主题之间的仅仅只做一次处理(如下所述)。

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

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

  • 它可以读取消息,然后在日志中保存它们的位置,最后处理这些消息。在这种情况下,消费者进程有可能在保存其位置之后,但在保存其消息处理的输出之前就崩溃了。在这种情况下,接手处理的进程将从保存的位置开始,即使在该位置之前的一些消息还没有被处理过。这相当于 "最多一次 “的语义,因为在消费者失败的情况下,消息可能不会被处理。
  • 它可以读取消息,处理消息,并最终保存其位置。 在这种情况下,消费者进程有可能在处理完消息后,但在保存其位置前崩溃。 在这种情况下,当新进程接管时,它收到的前几条消息已经被处理了。这对应于消费者失败情况下的 "至少一次 "语义。在许多情况下,消息有一个主键,所以更新是幂等的(收到两次相同的消息只是用它的另一个副本覆盖了一个记录)。

那么,究竟什么是一次语义(即你真正想要的东西)?当从一个 Kafka 主题消费并生产到另一个主题时(例如在 Kafka Streams 应用中),我们可以利用上面提到的 0.11.0.0 中新的事务性生产者功能。消费者的位置以消息的形式存储在一个主题中,所以我们可以在同一个事务中把偏移量写到Kafka 中,而输出主题接收处理后的数据。如果事务被中止了,消费者的位置将恢复到旧值,输出主题上产生的数据对其他消费者不可见,这取决于他们的 "隔离级别"。在默认的 “读未提交 "隔离级别中,所有的消息对消费者来说都是可见的,即使它们是被中止的事务的一部分,但是在 “读已提交 “的隔离级别中,消费者将只返回来自已提交的事务的消息(以及任何不属于事务的消息)。

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

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

复制

Kafka 在可配置的服务器数量上为每个主题的分区复制日志(你可以根据每个主题来设置这个复制因子)。这样,当集群中的一个服务器发生故障时,就可以自动地将故障转移到这些副本上,这样消息在发生故障时仍然可用。

其它消息系统也提供了一些与复制相关的功能,但是在我们看来(纯个人观点),这似乎是一个附加的东西,没有被大量使用,而且有很大的缺点:复制不活跃、吞吐量受到严重影响,需要繁琐的手动配置等等。Kafka 在默认情况下是和复制一起使用的——事实上,我们实现了复制因子是1的未复制主题。

复制的单位是主题分区。在非故障条件下,Kafka 中的每个分区都有一个领导者和零个或多个追随者。包括领导者在内的复制的总数构成了复制因子。所有的读和写都会到分区的领导者那里。通常情况下,分区的数量比 broker 多得多,领导者在 broker 之间均匀地分布。跟随者的日志与领导者的日志相同--都有相同的偏移量和相同顺序的消息(当然,在任何时候,领导者可能在其日志的末尾有一些尚未复制的消息)。

跟随者从领导者那里消费消息,就像普通的 Kafka 消费者一样,并将它们应用到自己的日志中。让追随者从领导者那里获取有一个很好的特性,那就是允许追随者自然地把他们应用到日志中的日志条目批处理在一起。

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

  1. 节点必须能够保持与 ZooKeeper 的会话(通过 ZooKeeper 的心跳机制)。
  2. 如果它是一个跟随者,它必须复制发生在领导者上的写操作,并且不落下 "太远"。

我们把满足这两个条件的节点称为 "同步",以避免 "活着 "或 "失败 "的模糊性。领导者跟踪 "同步 "节点的集合。如果一个跟随者死亡、被卡住或落后了,领导者将会把它从同步复制的列表中删除。对卡住和落后的副本的判断由 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 ZabRaftViewstamped 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对数据丢失的保证是以至少有一个副本保持同步为前提的。如果复制一个分区的所有节点都死了,这个保证就不再成立了。

然而,一个实际的系统需要在所有副本都死亡时做一些合理的事情。如果你不幸发生了这种情况,必须考虑会发生什么。有两种行为可以实现:

  1. 等待 ISR 中的一个副本复活,并选择这个副本作为领导者(希望它仍有所有的数据)。
  2. 选择第一个恢复的副本(不一定在 ISR 中)作为领导者。

这是在可用性和一致性之间的一个简单权衡。如果我们在等待 ISR 中的副本,那么只要这些副本被停机,我们就一直不可用。如果这些副本被破坏了,或者它们的数据丢失了,那么我们就会永久停机。另一方面,如果一个不同步的副本复活了,并且我们允许它成为领导者,那么它的日志就会成为实际数据的来源,尽管它不能保证拥有每一次提交的消息。从 0.11.0.0 版本开始,Kafka 默认选择第一种策略,倾向于等待一个一致的副本。这种行为可以通过配置属性 unclean.leader.election.enable 来改变,以支持正常运行时间优于一致性的用例。

这种困境并不是 Kafka 特有的。它存在于任何基于法定人数的方案中。例如,在一个多数表决方案中,如果大多数服务器遭遇永久性故障,那么你必须选择是失去100%的数据,还是违反一致性,将现有服务器上的数据作为新的真实数据的来源。

可用性和耐用性保证

当写入 Kafka 时,生产者可以选择是否等待消息被0、1或所有(-1)副本确认。请注意,"所有副本的确认 "并不能保证所有分配的副本都收到了消息。默认情况下,当 acks=all 时,一旦所有当前同步的副本收到消息,就会发生确认。例如,如果一个主题只配置了两个副本,并且其中一个失败了(即只剩下一个同步的副本),那么指定 acks=all 的写入将成功。然而,如果剩下的副本也发生故障,这些写入可能会丢失。虽然这确保了分区的最大可用性,但对于一些喜欢耐用性而不是可用性的用户来说,这种行为可能是不可取的。因此,我们提供了两个主题级别的配置,可以用来选择消息的耐久性而不是可用性。

  1. 禁用不干净的领导者选举——如果所有的副本都不可用,那么分区将保持不可用,直到大多数最近的领导者再次变得可用。这实际上是宁可选择不可用也不愿意选择信息丢失的风险。请看前面章节关于不清洁领导者选举的说明。

  2. 指定一个最小 ISR 的数量——只有当 ISR 的数量超过最小值时,分区才会接受写入,以防止消息被写入单个副本,而这个副本随后变得不可用。这个设置只有在生产者使用 acks=all 并保证消息将被至少这么多同步的副本确认时才会生效。这个设置在一致性和可用性之间提供了一个权衡。最小 ISR 大小的更高设置保证了更好的一致性,因为消息被保证写入更多的副本,从而降低了消息丢失的概率。然而,它降低了可用性,因为如果同步副本的数量低于最小阈值,分区将不可写入。

副本管理

以上关于复制日志的讨论实际上只涵盖了一个日志,也就是一个主题分区。然而一个 Kafka 集群将管理成百上千的这些分区。我们尝试以轮询的方式在集群中平衡分区,以避免将高容量主题的所有分区集中在少数节点上。同样,我们也试图平衡领导者,以便每个节点都是按比例分享其分区的领导。

优化领导者的选举过程也很重要,因为这是不可用的关键窗口。一个不成熟的领导者选举的实现最终会在这个节点发生故障时为该节点托管的所有分区进行选举。相反,我们选举其中一个 broker 作为 "控制器"。这个控制器在 broker 层面检测故障,并负责改变故障 broker 中所有受影响分区的领导者。其结果是,我们能够批处理许多所需的领导者变更通知,这使得选举过程对于大量分区的选举来说更便宜也更快。如果控制器失败了,幸存的 broker 中的一个将成为新的控制器。

日志压缩

日志压缩确保 Kafka 将始终保留单个主题分区的数据日志中每个消息键的至少最后一个已知值。它解决了一些用例和场景,如在应用崩溃或系统故障后的状态恢复,或在运行维护期间应用重启后重新加载缓存。让我们更详细地探讨这些用例,然后描述压缩的工作原理。

到目前为止,我们只描述了比较简单的数据保留方法,即在一段固定的时间后或当日志达到某种预定的大小时,旧的日志数据会被丢弃。这对于临时事件数据很有效,比如日志,每条记录都是单独存在。然而,一类重要的数据流类别是对关键的、易变的数据的更改日志(例如,对数据库表的更改)。

让我们讨论一个这样的数据流的具体例子。

Important

压缩的主题必须有带键的记录,以实现记录的保留。 Kafka 中的压缩并不保证在任何时候都只有一条具有相同键的记录。有可能存在多个具有相同键的记录,包括墓碑,因为压缩时间是不确定的。只有当主题分区满足某些少数条件时才会进行压缩,比如脏污染率、记录处于非活动段文件中等等。

假如我们有一个包含用户电子邮件地址的主题;每当一个用户更新他们的电子邮件地址时,我们用他们的用户 ID 作为主键向这个主题发送一条消息。现在假设我们在某个时间段内为一个 id 为 123 的用户发送以下消息,每条消息都对应于电子邮件地址的变化(其他 id 的消息被省略)。

123 => bill@microsoft.com
        .
        .
        .
123 => bill@gatesfoundation.org
        .
        .
        .
123 => bill@gmail.com

日志压缩给了我们一个更细化的保留机制,这样我们就能保证至少保留每个主键的最后一次更新(例如 bill@gmail.com)。通过这样做,我们保证日志包含了每个键的最终值的完整快照,而不仅仅是最近改变的键。这意味着下游的消费者可以从这个主题上恢复他们自己的状态,而我们不需要保留所有变化的完整日志。

让我们先看一下这一点很有用的几个用例,然后我们再看看如何使用它。

  1. 数据库变更订阅。通常有必要将一个数据集放在多个数据系统中,而这些系统中往往有一个是某种类型的数据库(要么是 RDBMS,要么可能是新式的键值存储)。例如,你可能有一个数据库、一个缓存、一个搜索集群和一个 Hadoop 集群。数据库的每一个变化都需要反映在缓存、搜索集群中,并最终反映在 Hadoop 中。在一个人只处理实时更新的情况下,你只需要最近的日志。但如果你想能够重新加载缓存或恢复一个失败的搜索节点,你可能需要一个完整的数据集。

  2. 事件源。这是一种应用程序的设计风格,它将查询处理与应用程序放在一起,并使用变日志作为应用程序的主要存储。

  3. 用于高可用性的日志。一个进行本地计算的进程可以通过记录它对基本地状态的改变来实现容错,这样如果它失败了,另一个进程可以重新加载这些改变并继续运行。这方面的一个具体例子是在一个流查询系统中处理计数、聚合和其他类似“分组”的处理。Samza,一个实时流处理框架,正是为了这个目的而使用这个功能

在每一种情况下,我们主要需要处理实时的变化,但偶尔,当一台机器崩溃或数据需要重新加载或重新处理时,我们需要做一个完整的加载。日志压缩允许从统一支持主题中提供这两种使用情况。在这篇博客中,对日志的这种使用方式有更详细的描述。

一般的想法是简单的。如果我们有无限的日志保留,并且我们记录了上述情况下的每一个变化,那么我们就会捕捉到系统从最初开始的每个时间段的状态。使用这个完整的日志,我们可以通过回放日志中的前 N 条记录来恢复到任何时间点。这种假设的完整日志对于多次更新一条记录的系统来说并不实用,因为即使是稳定的数据集,日志也会无限制地增长。简单的日志保留机制会扔掉旧的更新,但日志不再是恢复当前状态的方法,现在从日志的开头恢复不再能重现当前状态,因为旧的更新可能根本就没有被捕获。

日志压缩是一种机制,提供更细粒度的每条记录的保留,而不是更粗粒度的基于时间的保留。我们的想法是有选择地删除哪些有相同逐渐的最近更新的记录。这样以来,日志就能保证每个键至少有最后的状态。

这种保留策略可以按主题设置,因此一个集群可以有一些主题,其保留是通过大小或时间来执行的,而其他主题的保留是通过压缩来执行的。

这一功能的灵感来自于 LinkedIn 最古老、最成功的基础设施之一 —— 名为 Databus 的数据库变更日志缓存服务。与大多数日志结构的存储系统不同,Kafka 是为订阅而建立的,并为快速线性读写组织数据。与 Databus 不同的是,Kafka 作为一个真实源存储,所以即使在上游数据源无法重放的情况下,它也很有用。

日志压缩基础

下面是一张高层次的图片,它显示了 Kafka 日志的逻辑结构和每个消息的偏移量。

image.png

日志的头部与传统的 Kafka 日志相同。它有密集的、连续的偏移,并保留了所有的消息。日志压缩增加了一个处理日志尾部的选项。上面的图片显示了一个有压缩尾部的日志。请注意,日志尾部的信息保留了它们第一次被写入时分配的原始偏移量 —— 这一点从未改变。还要注意的是,所有的偏移量仍然是日志中的有效位置,即使该偏移量的消息已经被压缩掉了;在这种情况下,这个位置与日志中出现的下一个最高偏移量是没有区别的。例如,在上图中,偏移量 36、37 和 38 都是相等的位置,从这些偏移量中的任何一个开始读,都会返回一个以 38 开始的信息集。

压缩也允许删除。一个嗲有键和空有效负载的消息将被视为从日志中删除。这个删除标记将导致任何带有该键的先前消息被删除(就像任何带有该键的新消息一样),但删除标记是特殊的,因为它们本身将在一段时间后从日志中清理出来以释放空间。在上图中,不再保留删除信息的时间点被标记为“删除保留点”。

压缩实在后台通过定期重新复制日志段来完成的。清理工作不会阻碍读取,并且可以被节制在不超过可配置的 I/O 吞吐量的范围内,以避免影响生产者和消费者。压缩日志段的实际过程看起来像这样:

image.png

日志压缩能提供什么保障?

日志压缩保证了以下几点:

  1. 任何停留在日志头部的消费者都会看到每个被写入的消息;这些消息会有顺序的偏移。该主题的 min.compaction.lag.ms 可以用来保证在一条消息被写入后必须经过的最小时间长度,然后才能被压缩。也就是说,它为每条信息在(未压缩的)头部停留的时间提供了一个下限。该主题的 max.compaction.lag.ms 可以用来保证从消息被写入到该消息有资格被压缩的最大延迟。

  2. 消息的顺序总是被保持。压缩永远不会重新排列消息,只是删除一些。

  3. 一个消息的偏移量永远不会改变。它是日志中一个位置的永久标识符。

  4. 任何从日志开始的消费者将至少看到所有记录的最终状态,并按照它们被写入的顺序。此外,只要消费者在小于主题 delete.retention.ms 设置的时间段(默认是 24 小时)内到达日志的头部,就会看到所有被删除的记录的删除标记。换句话说:由于删除标记的删除是与读取同时发生的,如果消费者滞后超过 delete.retention.ms ,就有可能错过删除标记。

日志压缩细节

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

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

日志清理器配置

默认情况下,日志清理器是被启用的。这将启动清理器的线程池。要在一个特定的主题上弃用日志清理,请添加日志特定属性:

log.cleanup.policy=compact

log.cleanup.policy 属性是在 broker 的 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 指标。

更多清理器配置在 Kafka Broker Configurations 中有所描述。

docs.confluent.io/platform/cu…