转载『为什么 Kafka 如此地快』

原文:medium.com/swlh/why-kafka-is-so-fast-bde0d987cd03 by Emil Koutanov,译文:strikefreedom.top/archives/why-kafka-is-so-fast by 潘少

探究是哪些精妙的设计决策使得 Kafka 成为了现如今的性能强者。

软件体系结构在过去的几年间发生了巨大的变化。单体应用程序或甚至几个粗粒度的服务共享一个公共数据存储的理念,在全世界的软件从业者的头脑中早已不复存在了。自主微服务、事件驱动架构和职责分离 (CQRS) 模式是构建以业务为中心的现代应用程序的主要工具。除此之外,设备连接物联网、移动和可穿戴设备的普及,正在对系统在接近实时的情况下必须处理的事件数量造成越来越大的压力。

我们首先要接受一个共识:术语『快』是一个多义的、复杂的甚至是模糊不清的词。延迟、吞吐量和抖动,这些指标会影响人们对这个术语的理解。它还具有内在的上下文关系:行业和应用领域本身就设置了关于性能的规范和期望。某个东西是否『快』很大程度上取决于一个人的参照系。

Apache Kafka 以延迟和抖动为代价对吞吐量进行了优化,同时保留了其他必须的功能特性,比如持久化、严格的日志记录顺序和至少交付一次的语义。当有人说 "Kafka 很快",并且假定他们至少是有资格说这话的,那么我们可以认为他们指的是 Kafka 在短时间内安全地积累和分发大量日志记录的能力。

从历史上看,Kafka 诞生于 LinkedIn 的业务需求:高效地移动大量的消息,每小时的数据量达数 TB 。因为时间的可变性,单个消息的传播延迟被认为是次要的。毕竟,LinkedIn 不是从事高频交易的金融机构,也不是需要在确定的时限内完成指定操作的工业控制系统。Kafka 可用于实现近实时(或称为软实时)的系统。

注意:对于不熟悉这个术语的人,这里必须说明一下,实时并不等同于快速,它仅仅意味着 "可预测"。具体点说,实时意味着完成一个指定操作所需的硬性时间上限,或称为截止时间。如果系统作为一个整体不能每次都满足这个时限(内完成操作),它就不能被归类为实时。能够在具有小概率超时容错性的时限范围内完成操作的系统被称为近实时系统。就吞吐量而言,实时系统通常比近实时或非实时的系统要慢。

Kafka 的高性能主要得益于两个要素,这两个要素需要分开来讨论。第一个与客户端 (Client) 和 代理 (Broker) 实现上的底层效率有关。第二个则来自于流数据处理的机会性并行。

Broker 性能

日志结构的持久性

Kafka 利用了一种分段式的、只追加 (Append-Only) 的日志,基本上把自身的读写操作限制为顺序 I/O,也就使得它在各种存储介质上能有很快的速度。一直以来,有一种广泛的误解认为磁盘很慢。实际上,存储介质 (特别是旋转式的机械硬盘) 的性能很大程度依赖于访问模式。在一个 7200 转/分钟的 SATA 机械硬盘上,随机 I/O 的性能比顺序 I/O 低了大概 3 到 4 个数量级。此外,一般来说现代的操作系统都会提供预读和延迟写技术:以大数据块的倍数预先载入数据,以及合并多个小的逻辑写操作成一个大的物理写操作。正因为如此,顺序 I/O 和随机 I/O 之间的性能差距在 flash 和其他固态非易失性存储介质中仍然很明显,尽管它远没有旋转式的存储介质那么明显。

日志记录批处理

顺序 I/O 在大多数的存储介质上都非常快,几乎可以和网络 I/O 的峰值性能相媲美。在实践中,这意味着一个设计良好的日志结构的持久层将可以紧随网络流量的速度。事实上,Kafka 的瓶颈通常是网络而非磁盘。因此,除了由操作系统提供的底层批处理能力之外,Kafka 的 Clients 和 Brokers 会把多条读写的日志记录合并成一个批次,然后才通过网络发送出去。日志记录的批处理通过使用更大的包以及提高带宽效率来摊薄网络往返的开销。

批量压缩

当启用压缩功能时,批处理的影响尤为明显,因为压缩效率通常会随着数据量大小的增加而变得更高。特别是当使用 JSON 等基于文本的数据格式时,压缩效果会非常显著,压缩比通常能达到 5 到 7 倍。此外,日志记录批处理在很大程度上是作为 Client 侧的操作完成的,此举把负载转移到 Client 上,不仅对网络带宽效率、而且对 Brokers 的磁盘 I/O 利用率也有很大的提升。

廉价的 Consumers

与传统 MQ 风格的 Brokers 在消费点删除消息 (导致随机 I/O 损耗) 不同,Kafka 不会在消息被消费后删除消息 —— 相反,它独立地跟踪每个 Consumer Group 级别的偏移量。偏移量本身的进度被发布到一个名为 __consumer_offsets 的内部 Kafka Topic 上了。同样的,因为是只追加 (Append-Only) 的操作,所以这个过程非常快。这个 Topic 的内容会在后台被进一步缩减 (利用了 Kafka 的压缩特性) ,只为任意给定的 Consumer Group 保留最后的已知偏移量。

将此模型与更传统的消息 Brokers 进行比较,后者通常提供几种不同的消息分布拓扑。一方面,是一个消息队列 —— 一种提供点对点消息传递而不具备点对多点功能的持久化传输机制。另一方面,一个发布-订阅主题允许点对多点消息传输,但是这样带来的代价是牺牲持久性。在传统 MQ 中实现持久的点对多点消息传递模型需要为每个有状态的 Consumer 维护一个专有的消息队列。这将同时产生读和写操作的扩增。一方面,发布者被迫往多个队列写数据。另一种情况是,扇出中继可能从一个队列里消费日志记录并将其写入其他几个队列,但这只会延迟读写扩增到来的时间,治标不治本。另一方面,一些 Consumers 在 Broker 上产生负载 —— 混合了读和写 I/O,既有顺序的,也有随机的。

Kafka 里的 Consumers 是 "廉价的",只要他们不修改日志文件 (只有 Producer 或者是 Kafka 的内部进程有权限修改)。这意味着大量的 Consumers 可以并发地读取同一个 Topic,而不会压垮集群。不过,新增一个 Consumer 仍然需要一些成本,但是它主要是顺序读操作,顺序写操作占的比率很低。因此,一个单一的 Topic 在不同的 Consumer 群组中被共享是相当正常的。

非强制刷新缓冲写操作

另一个助力 Kafka 高性能、同时也是一个值得更进一步去探究的底层原因:Kafka 在确认写成功 ACK 之前的磁盘写操作不会真正调用 fsync 命令;通常只需要确保日志记录被写入到 I/O Buffer 里就可以给 Client 回复 ACK 信号。这是一个鲜为人知却至关重要的事实:事实上,这正是让 Kafka 能表现得如同一个内存型消息队列的原因 —— 因为 Kafka 是一个基于磁盘的内存型消息队列 (受缓冲区/页面缓存大小的限制)。

另一方面,这种形式的写入是不安全的,因为副本的写失败可能会导致数据丢失,即使日志记录似乎已经被确认成功。换句话说,与关系型数据库不同,确认一个写操作成功并不等同于持久化成功。真正使得 Kafka 具备持久化能力的是运行多个同步的副本的设计;即便有一个副本写失败了,其他的副本 (假设有多个) 仍然可以保持可用状态,前提是写失败是不相关的 (例如,多个副本由于一个共同的上游故障而同时写失败)。因此,不使用 fsync 的 I/O 非阻塞方法和冗余同步副本的结合,使得 Kafka 同时具备了高吞吐量、持久性和可用性。

Client 侧的优化

大多数数据库、队列和其他形式的持久化中间件都是围绕重量级 Server (或 Server 集群) 和轻量级 (极度简单的) Client —— 通过知名的有线协议与 Server(s) 通信这一组合模式来设计的。 Client 的实现通常被认为要比服务端简单得多。在这种模式下,服务端将承担大部分的负载,而 Client 仅仅是充当应用程序代码和 Server 之间的接口。

Kafka 对 Client 采取了一种独具一格的设计理念。在将日志记录发送到服务端之前, Client 需要先完成大量的工作。这其中包括日志记录在累加器中的分段,散列日志记录的键值以到达正确的分区 (Partition) 索引,对日志记录进行校验和计算以及压缩日志记录批次。 Client 能够感知集群元数据,并定期刷新该元数据,以同步 Broker 拓扑结构的任何变更。这也让 Client 可以做一些低层次的转发决策;生产者 Clients 不会盲目地向集群发送一条日志记录并依靠集群将其转发到适当的 Broker 节点,而是直接将写操作转发到分区主节点。类似地, 消费者 Clients 在搜寻日志记录时能够做出明智的决策, 它们可能会去访问 (在地理意义上) 距离发出『读取查询』更近的副本数据。(该特性是 Kafka 最近添加的,从 2.4.0 版本开始提供。) 零拷贝

导致应用程序效率低下的一个典型根源是缓冲区之间的字节数据拷贝。Kafka 使用由 Producer、Broker 和 Consumer 多方共享的二进制消息格式,因此数据块即便是处于压缩状态也可以在不被修改的情况下在端到端之间流动。虽然消除通信各方之间的结构化差异是非常重要的一步,但它本身并不能避免数据的拷贝。

Kafka 通过利用 Java 的 NIO 框架,尤其是 java.nio.channels.FileChannel 里的 transferTo 这个方法,解决了前面提到的在 Linux 等类 UNIX 系统上的数据拷贝问题。此方法能够在不借助作为传输中介的应用程序的情况下,将字节数据从源通道直接传输到接收通道。要了解 NIO 的带来的改进,请考虑传统方式下作为两个单独的操作:源通道中的数据被读入字节缓冲区,接着写入接收通道:

File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

通过图表来说明,这个过程可以被描述如下:

尽管上面的过程看起来已经足够简单,但是在内部仍需要 4 次用户态和内核态的上下文切换来完成拷贝操作,而且需要拷贝 4 次数据才能完成这个操作。下面的示意图概述了每一个步骤中的上下文切换。

让我们来更详细地看一下细节:

初始的 read() 调用导致了一次用户态到内核态的上下文切换。DMA (Direct Memory Access 直接内存访问) 引擎读取文件,并将其内容复制到内核地址空间中的缓冲区中。这个缓冲区和上面的代码片段里使用的并非同一个。在从 read() 返回之前,内核缓冲区的数据会被拷贝到用户态的缓冲区。此时,我们的程序可以读取文件的内容。接下来的 send() 方法会切换回内核态,拷贝用户态的缓冲区数据到内核地址空间 —— 这一次是拷贝到一个关联着目标套接字的不同缓冲区。在后台,DMA 引擎会接手这一操作,异步地把数据从内核缓冲区拷贝到协议堆栈。send() 方法在返回之前不等待此操作。send() 调用返回,切换回用户态。

尽管模式切换的效率很低,而且需要进行额外的拷贝,但在许多情况下,中间内核缓冲区的性能实际上可以进一步提高。比如它可以作为一个预读缓存,异步预载入数据块,从而可以在应用程序前端运行请求。但是,当请求的数据量极大地超过内核缓冲区大小时,内核缓冲区就会成为性能瓶颈。它不会直接拷贝数据,而是迫使系统在用户态和内核态之间摇摆,直到所有数据都被传输完成。

相比之下,零拷贝方式能在单个操作中处理完成。前面示例中的代码片段现在能重写为一行程序:

fileDesc.transferTo(offset, len, socket);

零拷贝方式可以用下图来说明:

在这种模式下,上下文的切换次数被缩减至一次。具体来说,transferTo() 方法指示数据块设备通过 DMA 引擎将数据读入读缓冲区,然后这个缓冲区的数据拷贝到另一个内核缓冲区中,分阶段写入套接字。最后,DMA 将套接字缓冲区的数据拷贝到 NIC 缓冲区中。

最终结果,我们已经把拷贝的次数从 4 降到了 3,而且其中只有一次拷贝占用了 CPU 资源。我们也已经把上下文切换的次数从 4 降到了 2。

这是一个巨大的提升,不过还没有实现完全 "零拷贝"。不过我们可以通过利用 Linux 内核 2.4 或更高版本以及支持 gather 操作的网卡来做进一步的优化从而实现真正的 "零拷贝"。下面的示意图可以说明:

调用 transferTo() 方法会致使设备通过 DMA 引擎将数据读入内核读缓冲区,就像前面的例子那样。然而,通过 gather 操作,读缓冲区和套接字缓冲区之间的数据拷贝将不复存在。相反地,NIC 被赋予一个指向读缓冲区的指针,连同偏移量和长度,所有数据都将通过 DMA 抽取干净并拷贝到 NIC 缓冲区。在这个过程中,在缓冲区间拷贝数据将无需占用任何 CPU 资源。

传统的方式和零拷贝方式在 MB 字节到 GB 字节的文件大小范围内的性能对比显示,零拷贝方式相较于传统方式的性能提升幅度在 2 到 3 倍。但更令人惊叹的是,Kafka 仅仅是在一个纯 JVM 虚拟机下、没有使用本地库或 JNI 代码,就实现了这一点。

规避 GC

对通道 (Channel)、本地缓冲区 (Native Buffer) 和页面缓存 (Page Cache) 的大量使用还有一个额外的好处 —— 即减少垃圾收集器 (GC) 的负载。举个例子,在一台 32 GB 内存的机器上运行 Kafka 会产生 28-30 GB 的可用页面缓存,这完全超出了 GC 的作用范围。其实最终吞吐量的差异很小 —— 只有几个百分点 —— 因为经过正确的参数调优之后的 GC 的吞吐量可能相当高,特别是在处理寿命较短的对象时。真正的收益是抖动的减少;通过规避 GC,Brokers 不太可能出现那种导致日志记录端到端传播延迟增大、从而影响 Client 的暂停。

有一说一,与当时构想出 Kafka 的难度相比,现在规避 GC 已经不是什么问题了。像 Shenandoah 和 ZGC 这样的现代垃圾回收器可以扩展到巨大的、TB 级的堆,并且有可调的最坏情况下的暂停时间,可以把该时间优化到个位数毫秒级别。目前,基于 JVM 的应用程序在使用基于堆的大型缓存之后的性能优于堆外设计的情况并不少见。

流数据并行

日志结构 I/O 的效率是影响性能的一个关键因素,主要影响写操作;Kafka 在对 Topic 结构和 Consumer 群组的并行处理是其读性能的基础。这种组合产生了非常高的端到端消息传递总体吞吐量。并发性根深蒂固地存在于 Kafka 的分区方案和 Consumer Groups 的操作中,这是 Kafka 中一种有效的负载均衡机制 —— 把数据分区 (Partition) 近似均匀地分配给组内的各个 Consumer 实例。将此与更传统的 MQ 进行比较:在 RabbitMQ 的等效设置中,多个并发的 Consumers 可能以轮询的方式从队列读取数据,然而这样做,就会失去消息消费的顺序性。

分区机制也使得 Kafka Brokers 可以水平扩展。每个分区都有一个专门的 Leader;因此,任何重要的主题 Topic (具有多个分区) 都可以利用整个 Broker 集群进行写操作,这是 Kafka 和消息队列之间的另一个区别;后者利用集群来获得可用性,而 Kafka 将真正地在 Brokers 之间负载均衡,以获得可用性、持久性和吞吐量。

生产者在发布日志记录之时指定分区,假设你正在发布消息到一个有多个分区的 Topic 上。(也可能有单一分区的 Topic, 这种情况下将不成问题。) 这可以通过直接指定分区索引来完成,或者间接通过日志记录的键值来完成,该键值能被确定性地哈希到一个一致的 (即每次都相同) 分区索引。拥有相同哈希值的日志记录将会被存储到同一个分区中。假设一个 Topic 有多个分区,那些不同哈希值的日志记录将很可能最后被存储到不同的分区里。但是,由于哈希碰撞的缘故,不同哈希值的日志记录也可能最后被存储到相同的分区里。这是哈希的本质,如果你理解哈希表的原理,那应该是显而易见的。

日志记录的实际处理是由一个在 (可选的) Consumer Group 中的 Consumer 操作完成。Kafka 确保一个分区最多只能分配给它的 Consumer Group 中的一个 Consumer 。(我们说 "最多" 是因为考虑到一种全部 Consumer 都离线的情况。) 当第一个 Consumer Group 里的 Consumer 订阅了 Topic,它将消费这个 Topic 下的所有分区的数据。当第二个 Consumer 紧随其后加入订阅时,它将大致获得这个 Topic 的一半分区,减轻第一个 Consumer 先前负荷的一半。这使得你能够并行处理事件流,并根据需要增加 Consumer (理想情况下,使用自动伸缩机制),前提是你已经对事件流进行了合理的分区。

日志记录吞吐量的控制一般通过以下两种方式来达成:

Topic 的分区方案。应该对 Topics 进行分区,以最大限度地增加独立子事件流的数量。换句话说,日志记录的顺序应该只保留在绝对必要的地方。如果任意两个日志记录在某种意义上没有合理的关联,那它们就不应该被绑定到同一个分区。这暗示你要使用不同的键值,因为 Kafka 将使用日志记录的键值作为一个散列源来派生其一致的分区映射。

一个组里的 Consumers 数量。你可以增加 Consumer Group 里的 Consumer 数量来均衡入站的日志记录的负载,这个数量的上限是 Topic 的分区数量。(如果你愿意的话,你当然可以增加更多的 Consumers ,不过分区计数将会设置一个上限来确保每一个活跃的 Consumer 至少被指派到一个分区,多出来的 Consumers 将会一直保持在一个空闲的状态。) 请注意, Consumer 可以是进程或线程。依据 Consumer 执行的工作负载类型,你可以在线程池中使用多个独立的 Consumer 线程或进程记录。

如果你之前一直想知道 Kafka 是否很快、它是如何拥有其现如今公认的高性能标签,或者它是否可以满足你的使用场景,那么相信你现在应该有了所需的答案。

为了让事情足够清楚,必须说明 Kafka 并不是最快的 (也就是说,具有最大吞吐量能力的) 消息传递中间件,还有其他具有更大吞吐量的平台 —— 有些是基于软件的 —— 有些是在硬件中实现的。Apache Pulsar 是一项极具前景的技术,它具备可扩展性,在提供相同的消息顺序性和持久性保证的同时,还能实现更好的吞吐量-延迟效果。使用 Kafka 的根本原因是,它作为一个完整的生态系统仍然是无与伦比的。它展示了卓越的性能,同时提供了一个丰富和成熟而且还在不断进化的环境,尽管 Kafka 的规模已经相当庞大了,但仍以一种令人羡慕的速度在成长。

Kafka 的设计者和维护者们在创造一个以性能导向为核心的解决方案这方面做得非常出色。它的大多数设计/理念元素都是早期就构思完成、几乎没有什么是事后才想到的,也没有什么是附加的。从把工作负载分摊到 Client 到 Broker 上的日志结构持久性,批处理、压缩、零拷贝 I/O 和流数据级并行 —— Kafka 向几乎所有其他面向消息的中间件 (商业的或开源的) 发起了挑战。而且最令人叹为观止的是,它做到这些事情的同时竟然没有牺牲掉持久性、日志记录顺序性和至少交付一次的语义等特性。

Kafka 不是最简单的消息传输平台,所以有很多东西可以学习。一个人必须先掌握整体/部分顺序性、主题 (Topic)、分区 (Partition)、 消费者 (Consumer) 和 消费者组 (Consumer Group) 等基本概念之后,才有可能轻松地设计和构建高性能的事件驱动系统。虽然学习曲线是陡峭的,但最终的结果肯定是值得的。如果你热衷于服用《黑客帝国》中的 "红色药丸",可以阅读 Introduction to Event Streaming with Kafka and Kafdrop。

这篇文章是否对你有所裨益?我很想听到你的反馈,所以别藏着掖着了。如果你对 Kafka、Kubernetes、微服务或者事件流处理,甚至于说仅仅只是想提问题,欢迎在 Twitter 上关注我。我同时还是开源项目 Kafdrop 的维护者以及《Effective Kafka》一书的作者。