在过去的几年里,软件架构领域发生了巨大的变化。人们不再认为所有的系统都应该共享一个数据库。

图片来自 Pexels

微服务、事件驱动架构和 CQRS(命令查询的责任分离 Command Query Responsibility Segregation)是构建当代业务应用程序的主要工具。

除此以外,物联网、移动设备和可穿戴设备的普及,进一步对系统的近实时能力提出了挑战。

首先让我们对“快”这个词达成共识,这个词是多方面的、复杂的、高度模糊的。一种解释是把”延迟、吞吐量和抖动“作为对“快”的衡量指标。

还有,比如工业应用领域,行业本身设置了对于“快”的规范和期望。所以,“快”在很大程度上取决于你的参照体系是什么。

Apache Kafka 以牺牲延迟和抖动为代价优化了吞吐量,但并没有牺牲,比如持久性、严格的记录有序性和至少一次的分发语义。

当有人说“Kafka 速度很快”,并假设他们至少有一定的能力时,你可以认为他们指的是 Kafka 在短时间内分发大量记录的能力。

Kafka 诞生于 LinkedIn,当时 LinkedIn 需要高效地传递大量信息,相当于每小时传输数 TB 的数据量。

在当时,消息传播的延迟被认为是可以接受的。毕竟,LinkedIn 不是一家从事高频交易的金融机构,也不是一个在确定期限内运行的工业控制系统。Kafka 可用于近实时系统。

注意:“实时”并不意味着“快”,它的意思是“可预测的”。具体来说,实时意味着完成一个动作具有时间限制,也就是最后期限。

如果一个系统不能满足这个要求,它就不能被归类为”实时系统“。能够容忍一定范围内延迟的系统被称为“近实时”系统。从吞吐量的角度来说,实时系统通常比近实时或非实时系统要慢。

Kafka 在速度上有两个重要的方面,需要单独讨论:

  • 与客户端与服务端之间的低效率实现有关。

  • 源自于流处理的并行性。

服务端优化

日志的存储

Kafka 利用分段、追加日志的方式,在很大程度上将读写限制为顺序 I/O(sequential I/O),这在大多数的存储介质上都很快。人们普遍错误地认为硬盘很慢。

然而,存储介质的性能,很大程度上依赖于数据被访问的模式。同样在一块普通的 7200 RPM SATA 硬盘上,随机 I/O(random I/O)与顺序 I/O 相比,随机 I/O 的性能要比顺序 I/O 慢 3 到 4 个数量级。

此外,现代的操作系统提供了预先读和延迟写的技术,这些技术可以以块为单位,预先读取大量数据,并将较小的逻辑写操作合并成较大的物理写操作。

因此,顺序 I/O 和随机 I/O 之间的性能差异在闪存和其他固态非易失性介质中仍然很明显,不过它们在旋转存储,比如固态硬盘中的性能差异就没有那么明显。

记录的批处理

顺序 I/O 在大多数存储介质上都非常快,可以与网络 I/O 的最高性能相媲美。在实践中,这意味着一个设计良好的日志持久化层能跟上网络的读写速度。事实上,Kafka 的性能瓶颈通常并不在硬盘上,而是网络。

因此,除了操作系统提供的批处理外,Kafka 的客户端和服务端会在一个批处理中积累多个记录——包括读写记录,然后在通过网络发送出去。

记录的批处理可以缓解网络往返的开销,使用更大的数据包,提高带宽的效率。

批量压缩

当启用压缩时,对批处理的影响特别明显,因为随着数据大小的增加,压缩通常会变得更有效。

特别是在使用基于文本的格式时,比如 JSON,压缩的效果会非常明显,压缩比通常在 5x 到 7x 之间。

此外,记录的批处理主要作为一个客户端操作,负载在传递的过程中,不仅对网络带宽有积极影响,而且对服务端的磁盘 I/O 利用率也有积极影响。

便宜的消费者

不同于传统的消息队列模型,当消息被消费时会删除消息(会导致随机 I/O),Kafka 不会在消息被消费后删除它们——相反,它会独立地跟踪每个消费者组的偏移量。

可以参考 Kafka 的内部主题 __consumer_offsets 了解更多。同样,由于只是追加操作,所以速度很快。消息的大小在后台被进一步减少(使用 Kafka 的压缩特性),只保留任何给定消费者组的最后已知偏移量。

将此模型与传统的消息模型进行对比,后者通常提供几种不同的消息分发拓扑。

一种是消息队列——用于点对点消息传递的持久化传输,没有点对多点功能。

另一种是发布订阅主题允许点对多点消息通信,但这样做的代价是持久性。在传统消息队列模型中实现持久化的点对多点消息通信模型需要为每个有状态的使用者维护专用消息队列。

这将放大读写的消耗。消息生产者被迫将消息写入多个消息队列中。另外一种选择是使用扇出中继,扇出中继可以消费来自一个队列中的记录,并将记录写入其他多个队列中,但这只会将延迟放大点。

并且,一些消费者正在服务端上生成负载——读和写 I/O 的混合,既有顺序的,也有随机的。

Kafka 中的消费者是“便宜的”,只要他们不改变日志文件(只有生产者或 Kafka 的内部进程被允许这样做)。

这意味着大量消费者可以并发地从同一主题读取数据,而不会使集群崩溃。添加一个消费者仍然有一些成本,但主要是顺序读取夹杂很少的顺序写入。

因此,在一个多样化的消费者系统中,看到一个主题被共享是相当正常的。

未刷新的缓冲写操作

Kafka 性能的另一个基本原因是,一个值得进一步研究的原因:Kafka 在确认写操作之前并没有调用 fsync。ACK 的唯一要求是记录已经写入 I/O 缓冲区。

这是一个鲜为人知的事实,但却是一个至关重要的事实。实际上,这就是 Kafka 的执行方式,就好像它是一个内存队列一样——Kafka 实际上是一个由磁盘支持的内存队列(受缓冲区/页面缓存大小的限制)。

但是,这种形式的写入是不安全的,因为副本的出错可能导致数据丢失,即使记录似乎已经被 ACK。

换句话说,与关系型数据库不同,仅写入缓冲区并不意味着持久性。保证 Kafka 持久性的是运行几个同步的副本。

即使其中一个出错了,其他的(假设不止一个)将继续运行——假设出错的原因不会导致其他的副本也出错。

因此,无 fsync 的非阻塞 I/O 方法和冗余的同步副本组合为 Kafka 提供了高吞吐、持久性和可用性。

客户端优化

大多数数据库、队列和其他形式的持久性中间件都是围绕全能服务器(或服务器集群)和瘦客户端的概念设计的。

客户端的实现通常被认为比服务器端简单得多。服务器会处理大部分的负载,而客户端仅充当服务端的门面。

Kafka 采用了不同的客户端设计方法。在记录到达服务器之前,会在客户端上执行大量的工作。

这包括对累加器中的记录进行分段、对记录键进行散列以得到正确的分区索引、对记录进行校验以及对记录批处理进行压缩。

客户端知道集群元数据,并定期刷新元数据以跟上服务端拓扑的更改。这让客户端更准确的做出转发决策。

不同于盲目地将记录发送到集群并依靠后者将其转发到适当的节点,生产者客户端可以直接将写请求转发到分区主机。

类似地,消费者客户端能够在获取记录时做出更明智的决定,比如在发出读查询时,可以使用在地理上更接近消费者客户端的副本。(该特性是从 Kafka 的 2.4.0 版本开始提供。)

零拷贝

一种典型的低效方式是在缓冲之间复制字节数据。Kafka 使用由生产者、消费者、服务端三方共享的二进制消息格式,这样即使数据块被压缩了,也可以不加修改地传递数据。

虽然消除通信方之间的数据结构差异是重要的一步,但它本身并不能避免数据的复制。

Kafka 使用 Java 的 NIO 框架,特别是 java.nio.channels.FileChannel 的 transferTo() 方法,在 Linux 和 UNIX 系统上解决了这个问题。

此方法允许字节从源通道传输到接收通道,而不需要将应用程序作为传输中介。

了解 NIO 的不同之处,请思考传统的方法会怎么做,将源通道读入字节缓冲区,然后作为两个独立的操作写入接收器通道:

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

可以用下图来表示:

虽然这副图看起来很简单,但是在内部,复制操作需要在用户态和内核态之间进行四次上下文切换,并且在操作完成之前要复制四次数据。

下图概述了每次步骤的上下文切换:

详细说明:

  • 初始的 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被赋予一个指向读缓冲区的指针,连同偏移量和长度。在任何情况下,CPU 都不涉及复制缓冲区。

文件大小从几 MB 到 1GB 的范围内,传统拷贝和零拷贝相比,结果显示零拷贝的性能提高了两到三倍。

但更令人印象深刻的是,Kafka 使用纯 JVM 实现了这一点,没有本地库或 JNI 代码。

避免垃圾回收

大量使用通道、缓冲区和页面缓存还有一个额外的好处——减少垃圾收集器的工作负载。

例如,在 32 GB RAM 的机器上运行 Kafka 将产生 28-30 GB 的页面缓存可用空间,完全超出了垃圾收集器的范围。

吞吐量的差异非常小(大约几个百分点),但是经过正确调优的垃圾收集器的吞吐量可能非常高,特别是在处理短生存期对象时。真正的收益在于减少抖动。

通过避免垃圾回收,服务端不太可能遇到因垃圾回收引起的程序暂停,从而影响客户端,加大记录的通信延迟。

与初期的 Kafka 相比,现在避免垃圾回收已经不是什么问题了。像 Shenandoah 和 ZGC 这样的现代垃圾收集器可以扩展到巨大的、多 TB 级的堆,在最坏的情况下,并且可以自动调整垃圾收集的暂停时间,降到几毫秒。

现在,可以看见大量的基于 Java 虚拟机的应用程序使用堆缓存,而不是堆外缓存。

流处理的并行性

日志的 I/O 效率是性能的一个重要方面,主要的性能影响在于写。Kafka 对主题结构和消费生态系统中的并行性处理是其读性能的基础。

这种组合产生了整体非常高的端到端消息吞吐量。将并发性深入到分区方案和使用者组的操作中,这实际上是 Kafka 中的一种负载均衡机制——将分区平均地分配到各个消费者中。

将此与传统的消息队列进行比较:在 RabbitMQ 的设置中,多个并发的消费者可以以轮询的方式从队列中读取数据,但这样做会丧失消息的有序性。

分区机制有利于 Kafka 服务端的水平扩展。每个分区都有一个专门的领导者。因此,任何重要的多分区的主题都可以利用整个服务端集群进行写操作。

这是 Kafka 和传统消息队列的另一个区别。当后者利用集群来提高可用性时,Kafka 通过负载均衡来提高可用性、持久性和吞吐量。

发布具有多个分区的主题时,生产者指定发布记录时的分区。(可能有一个单分区主题,那就不是问题了)

可以通过指定分区索引直接完成,或通过记录键间接完成,记录键通过计算散列值确定分区索引。具有相同散列值的记录共享相同的分区。

假设一个主题有多个分区,那么具有不同键的记录可能会出现在不同的分区中。

然而,由于散列冲突,具有不同散列值的记录也可能最终出现在同一个分区中。这就是散列的本质。如果你理解了散列表的工作方式,一切都很自然了。

记录的实际处理由消费者完成,在一个可选的消费者组中完成。Kafka 保证一个分区最多只能分配给消费者组中的一个消费者。(为什么用”最多“,当所有消费者都离线时,那就是 0 个消费者了)

当组中的第一个消费者订阅主题时,它将接收该主题上的所有分区。当第二个消费者订阅主题时,它将接收到大约一半的分区,从而减轻第一个消费者的负载。

根据需要添加消费者(理想情况下,使用自动伸缩机制),这使你能够并行地处理事件流,前提是你已经对事件流进行了分区。

以两种方式控制记录的吞吐量:

①主题分区方案。应该对主题进行分区,最大化事件流的数量。换句话说,只有在绝对需要时才提供记录的顺序。

如果任何两个记录不存在关联,它们就不应该被绑定到同一个分区。这意味着要使用不同的键,因为 Kafka 使用记录键的散列值作为分区映射的根据。

②组中消费者的数量。你可以增加消费者的数量来均衡入站记录的负载,消费者的数量最多可以增加到和分区数量一样多。(你可以增加更多的消费者,但每个分区最多只能有一个的活动消费者,剩下的消费者将处于闲置状态)

请注意,你可以提供一个线程池,根据消费者执行工作负载的不同,消费者可以是一个进程或一个线程。

如果你想知道 Kafka 为什么这么快,它是如何做到的,以及它是否适合你,我想你现在已经有了答案了。

为了更清楚地说明问题,Kafka 不是最快的消息中间件,吞吐量也不是最大的。有其他平台能够提供更高的吞吐量——有些是基于软件的,有些是基于硬件的。

很难同时做到吞吐量大且延迟低,Apache Pulsar[1] 是一个有前途的技术,可扩展,更好的吞吐量-延迟配置文件,同时提供顺序性和持久性。

采用 Kafka 的理由是,作为一个完整的生态系统,它在整体上仍然是无与伦比的。

它展示了出色的性能,同时提供了一个丰富和成熟的环境,Kafka 仍在以令人羡慕的速度增长。

Kafka 的设计者和维护者在设计一个以性能为核心的解决方案时做了大量的工作。它的设计元素中很少有让人觉得是事后才想到的,或者是补全的。

从将工作负载转移到客户端,到服务端日志的持久性、批处理、压缩、零拷贝 I/O 和并行流处理——Kafka 向任何其他消息中间件厂商发起挑战,无论是商业的还是开源的。

最令人印象深刻的是,它做到了这一点,却没有牺牲持久性、记录有序性和至少一次分发的语义。

Kafka 不是最简单的消息中间件平台,还有许多需要改进的地方。在设计和构建高性能事件驱动系统之前,必须掌握总体和部分的顺序、主题、分区、消费者和消费者组的概念。

虽然知识曲线很陡峭,但值得你花时间去学习。如果你知道这个谚语“red pill”(red pill,指为了达到对某种事物的深度探索或追求,选择去思考,不放弃,继续走下去,哪怕这条路多难走),请阅读“介绍 Kafka 和 Kafdrop 中的事件流 Introduction to Event Streaming with Kafka and Kafdrop[2]”。(作者:钟涛编译来源:分布式实验室)

相关链接:

  • https://pulsar.apache.org/

  • https://medium.com/swlh/introduction-to-event-streaming-with-kafka-and-kafdrop-22afdb4b380a