为什么 Kafka 这么快?

Kafka 的很多设计理念都比较前卫,从将工作负担分担到了客户端,从 broker 的日志持久化、批处理、压缩、零拷贝 I/O 和并行流——Kafka 对其他开源的和商业的消息中间件都带来了巨大的挑战。

作者 | Emil Koutanov

译者 | 明明如月,责编 | 郭芮

出品 | CSDN(ID:CSDNnews)

以下为译文:

在过去的几年中,软件体系结构发生了巨大的变化。单体应用程序或者甚至几个粗粒度服务共享一个公共数据存储的情况已经一去不复返,微服务治理、事件驱动架构和 CQRS(命令查询职责分离模式) 已经成为构建当代以业务为中心的应用程序的主要工具。更糟糕的是随着物联网(IoT)、移动设备、可穿戴设备等设备连接的普及,系统要能够几近实时处理事务。

我们首先要承认“快速”这个术语是多面的、复杂的、高度模糊的。延迟、吞吐量、抖动是影响这个术语解释的几个重要指标。它还具有固有的上下文关系:行业和应用程序领域自己设定了围绕性能的规范和标准——是否快在很大程度上取决于和什么参照物作对比。

Apache Kafka 以延迟和抖动为代价对吞吐量进行了优化,同时保留了其他重要特性,如持久化、顺序性和至少一次的交付的语义。当有人说“Kafka 很快” ,他们指的是 Kafka 有在短时间内安全地堆积和处理大量记录的能力。

从发展历史来看,Kafka 的诞生是因为 LinkedIn 需要高效地传输大量的信息(每小时的数据量达到了几百万兆字节)。单个信息传播延迟相对次要,毕竟 LinkedIn 不是一个从事高频交易的金融机构,也不是一个在确定的期限内运行的工业控制系统。Kafka 可以用于实现接近实时(或称为软实时)的系统。

注意: 对于那些不熟悉“实时”这个词的人来说,“实时”并不意味着“快速” ,而是意味着“可预测”。 具体来说,实时意味着对完成一项行动所花费的时间有一个确定的上限,也就是所谓的最后期限。如果系统作为一个整体不能每次都能在这个最后期限内完成,它就不能被归类为实时。能够在概率容差范围内运行的系统被标记为“接近实时”。就吞吐量而言,实时系统通常比近实时或非实时系统慢。

Kafka 速度快有两个重要方面,需要分别进行讨论。第一个与客户端和 broker 实现的低消耗有关;第二个源于流处理的并行性。

broker 性能

日志结构的持久化

Kafka 使用了一个分段的、仅追加的日志,在很大程度上将自己限制为连续的读写 I/O,这在各种各样的存储媒介上速度都很快。人们普遍误以为磁盘很慢;  然而,存储介质 (特别是旋转介质) 的性能在很大程度上依赖于访问模式。在一个典型的 7,200 转/分 SATA 磁盘上,随机  I/O 的性能要比顺序 I/O  慢 3 到 4 个数量级。此外,现代操作系统提供了预读和延迟写技术,可以以大块倍数预取数据,并将较小的逻辑写入分组到大的物理写入中。正因为如此,顺序 I/O和随机 I/O 之间的区别造成的性能差异在闪存和其他形式的固态非易失性介质中仍然很明显,尽管它远不如旋转介质那么引人关注。

记录批处理

在大多数媒体类型上,顺序 I/O 的速度快得惊人,堪比网络 I/O 的最高性能。在实践中,这意味着设计良好的日志结构持久层能够跟上网络流量。事实上,很多时候,Kafka 的瓶颈并不是磁盘,而是网络。因此,除了操作系统提供的低级批处理之外,Kafka 的客户端和 broker 还会在通过网络发送数据之前,在一个批处理中累积多条记录 (包括读和写)。记录的批处理分摊了网络往返的开销,使用了更大的数据包从而提高了带宽利用率。

批量压缩

当启用压缩时,批处理的影响特别明显,因为随着数据大小的增加,压缩通常会变得更有效。特别是当使用诸如 JSON 这样的基于文本的格式时,压缩的效果可能非常明显,压缩比通常在 5  倍到 7 倍。此外,记录批处理主要是由客户端操作完成的,它将负载转移到客户端。这不仅有利于提高网络带宽利用率,而且也有助于提高 broker 的磁盘 I/O 效率。

廉价的消费者

和消费后删除消息(将会产生随机 I/O)的传统 MQ 风格不同,Kafka 不会在消费后立即删除消息。Kafka 会在每个消费组级别跟踪消费的偏移。偏移量(offset)分区存储在 Kafka 自动创建的 __consumer_offsets  topic 中。另外,这是一个只追加的操作,所以很快。这个主题(topic)的内容所占空间也会在后台压缩 (使用 Kafka 的压缩特性) ,只保留任意给定消费者组的最后已知偏移量。

将此模型与更传统的消息代理进行比较,后者通常提供几种截然不同的消息分发拓扑。一方面是消息队列ー一种持久的点对点消息传输,没有单点对多点的能力。另一方面,发布-订阅 topic 允许点对多点消息传递,但这样做是以牺牲持久性为代价的。在传统 MQ 中实现持久的点到多点消息传递模型需要为每个有状态消费者维护一个专用消息队列。这就产生了读和写放大。一方面,发布者被迫写入多个队列。或者,借助中继节点可以使用一个队列中的记录并向其他几个队列写入,但这只是推迟放大点。另一方面,一些消费者正在 broker上生成负载,它们是连续和随机的读写 I/O 混合体。

Kafka 中的消费者是“廉价的” ,只要他们不改变日志文件 (只有生产者或 Kafka 内部进程才允许这样做)。这意味着大量消费者可以同时订阅同一主题,而不会影响到集群。其实添加消费者仍然需要一些成本,但主要是顺序读取、顺序写入的速率会降低。因此,多个消费者共享一个主题的现象非常普遍。

未刷新的缓冲区写入

Kafka 性能的另一个基本原因,也是一个值得进一步探讨的原因: Kafka 在确认写入之前并没有在写入磁盘时实际调用 fsync; 对 ACK 的唯一要求是记录已被写入 I/O 缓冲区。这是一个鲜为人知的事实,但却是一个至关重要的事实: 事实上,正是这个处理技巧让 Kafka 的表现看起来像是一个内存中的队列ー实际上 Kafka 是一个有磁盘支持的内存队列 (受缓冲区 / pagecache 大小的限制)。

另一方面,这种写的方式并不安全,因为副本的失败可能导致数据丢失,即使记录似乎已经得到承认。换句话说,和关系数据库不同,写确认并不意味着持久化的完成。使 Kafka 持久运行的是几个同步的副本; 即使其中一个失败,其他副本 (假设有多个副本) 仍然可以运行ーー前提是这个失败是不相关的 (即多个副本由于一个常见的上游失败而同时失败)。因此,结合使用无需 fsync 的非阻塞 I/O 方法和冗余的同步副本,Kafka 得到了高吞吐量、可靠性和可用性的结合。

客户端优化

大多数数据库、队列和其他形式的持久化中间件都是围绕万能服务器 (或服务器集群) 的概念设计的,而且还有通过一个众所周知的有线协议与服务器进行通信的瘦客户端。客户端实现通常会比服务器简单得多。因此,服务器将吸收大部分负载ー客户端仅仅充当应用程序代码和服务器之间的接口。

Kafka 在客户设计上采用了不同的方法。它会在记录到达服务器之前,在客户端上执行了大量的工作。这包括在累加器中暂存记录、散列记录键以得到正确的分区索引、校验和记录批处理的压缩。客户端知道集群元数据,并定期刷新该元数据,以跟上 broker 拓扑的任何更改。这使得客户端可以做出底层的转发决策,而不是盲目地将记录发送到集群并依赖集群将其转发到适当的 broker 节点,生产者客户端将直接转发写到分区主节点。类似地,消费者客户端在寻找记录时能够做出明智的决策,在发出读查询时可能使用地理位置离客户端更近的副本。(这个特性是 Kafka 最近添加的,可以在2.4.0版本中使用。)

零拷贝

效率低下的一个典型原因是在缓冲区之间复制字节数据。Kafka 使用一种由生产者、broker 和消费者共享的二进制消息格式,这样数据块即使被压缩,也可以不经修改地端到端传输。虽然消除通信各方之间的结构性差异非常重要,但它本身并不能避免数据的复制。

Kafka 通过使用 Java 的 NIO 框架 (特别是 java.nio.channels.FileChanneltransferTo () 方法 ) 在 Linux 和 UNIX 系统上解决了这个问题。此方法允许将字节从源信道传输到接收信道,而不涉及作为传输中介的应用程序。为了体会 NIO 的优势,我们来看看传统的方法,源信道被读入一个字节缓冲区,然后作为两个独立的操作写入一个接收信道:

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

上述描述可以通过下图来表示:

普通拷贝

尽管这看起来很简单,但在内部,复制操作需要在用户态和内核态之间进行四次上下文切换,并且在操作完成之前要复制数据四次。下图概述了每个步骤的上下文切换。

上下文切换

接下来进行详细介绍:

  • 最初的 read() 造成了用户态到内核态的切换。当文件被读取后,它的内容将被 DMA(直接内存存取)拷贝到内核地址空间的缓冲区中。注意,这个缓冲区和代码里使用的缓冲区是不同的。

  • read() 函数返回之前,内容将从内核缓冲区拷贝到用户缓冲区中。此时,我们的应用程序就可以读取这个文件的内容了。

  • 后来的 send() 函数调用从用户态又切换到内核态,即将用户空间缓冲区的数据拷贝到内核地址空间,此时将拷贝到目标 socket 相关的缓冲区中。在这背后, DMA 将接管这个操作,异步将数据从内核缓冲区拷贝到协议栈中。这里的 send() 函数并不会等待这个过程结束才返回。

  • send() 返回,又从内核态切换到用户态。

尽管上下文切换效率低下,而且还需要额外的数据拷贝,但在许多情况下,它实际上中间内核缓存可以提高性能。它可以充当预读缓存,异步预取块,从而可以在应用程序中预先运行请求。但是,当请求的数据量大大超过内核缓冲区大小时,内核缓冲区就成为性能瓶颈。和直接数据不同,所有数据被传输完毕之前,需要系统在用户态和内核态之间不断切换。

相比之下,零拷贝方法是在一个操作中处理的。前面示例中的代码片段可以重写为一行程序:

fileDesc.transferTo(offset, len, socket);

零拷贝方法如下所示:

零拷贝

在该模型下,上下文切换的次数变为一次。具体地说,transferTo () 方法指示块设备通过 DMA 引擎将数据读入读缓冲区。然后,这个缓冲区将另一个内核缓冲区拷贝到套接字中。最后,通过 DMA 将套接字缓冲区复制到 NIC (网络接口控制器) 缓冲区。

切换

因此,我们已经将拷贝的次数从四次减少到三次,而且其中只有一个拷贝涉及 CPU。我们还将上下文切换的次数从 4次减少到了 2 次。

这是一个巨大的改进,但还有改进空间。在运行 Linux 内核 2.4 及以后版本时,在支持 gather 操作的网络接口卡上,后者可以作为进一步的优化来实现。这一点如下图所示:

优化

如上图所示,调用 transferTo () 方法会让设备通过 DMA 引擎将数据读入内核读缓冲区中。但是,使用 gather 操作时,读缓冲区和套接字缓冲区之间不存在拷贝。相反,NIC 将得到一个指向读缓冲区的指针,以及由 DMA 清空的偏移量和长度。这种模式下,CPU 不会参与到缓冲区拷贝中。

比较传统的和零拷贝的文件大小 (从几兆字节到一千兆字节) ,发现零拷贝的性能提高了两到三倍。但更令人折服的是 Kafka 没有使用本地 (native) 库函数或 JNI 代码,而仅使用纯 JVM 就实现了这一点。

避免 GC

频繁使用通道、native 缓冲区和页面缓存还有一个好处: 减少垃圾收集器(GC) 的负担。例如,在一台有32 GB RAM 的机器上运行 Kafka 将导致 28–30 GB 可用于页面缓存,完全超出了 GC 的范围。吞吐量方面的差异很小ー在几个百分点的区间内ー因为经过正确调优,特别在处理生命周期较短的对象时 GC 的吞吐量可能相当高。真正的好处是减少抖动; 通过避免 GC,broker 不太可能经历可能影响客户端的暂停,从而降低了记录的端到端传播延迟。

公平地说,与 Kafka 诞生时相比,现在降低 GC 次数已经不是什么大问题了。像 Shenandoah 和 ZGC 这样的现代垃圾回收器可以扩展到巨大的、数兆兆字节的堆,并且可以设置最坏情况下的暂停时间,甚至可以精确到毫秒级别。如今,使用大型基于堆的缓存的 JVM 的应用程序胜过堆外设计的情况也并不少见。

流并行性

日志结构的  I/O 效率是性能的一个关键,尤其对于写操作来说。Kafka 在主题结构和消费者生态系统中对并行性的处理是其读取性能的基础。这种组合产生非常高的总体端到端消息吞吐量。并发性植根于 Kafka 的分区方案和消费者组的操作中,这是 Kafka 内部的一种有效的负载平衡机制,即在消费者组内的实例之间大致均匀地分配分区任务。与更传统的 MQ 相比,在等效的 RabbitMQ 设置中,多个并发消费者可能以循环的方式从一个队列读取数据,但是这样做就无法保证消费的顺序性。

分区机制还允许 Kafka  broker 的水平可伸缩。每个分区都有一个专门的 leader;因此,任何重要的主题(具有多个分区)都可以利用 broker 的整个集群进行写操作。这是 Kafka 和消息队列之间的另一个区别;当消息队列利用集群获得可用性时,Kafka 通过跨 broker 负载均衡来获得可用性、稳定性和吞吐量。

加入你需要发布一个带有多个分区( partition )的主题,生产者在发布记录时可以指定分区。(可能有一个单分区主题,在这种情况下,并没啥问题) 这可以直接通过指定一个分区索引来实现,也可以通过一个记录键来间接实现,记录键可以确定地散列到一个一致的 (即每次都相同) 分区索引。相同散列的记录就可以保证占用相同的分区。假设一个主题有多个分区,那么使用不同密钥的记录很可能会出现在不同的分区中。但是,由于散列冲突,具有不同散列的记录也可能最终出现在同一分区中。这就是散列的本质,这和散列表的原理是一样的。

记录的实际处理是由消费者完成的,在一个(可选的)使用者组中进行操作。Kafka 保证一个分区最多只能分配给其消费者组中的一个消费者。(我们说“最多”是为了涵盖所有消费者离线时的情况。) 当组中的第一个使用者订阅该主题时,它将接收该主题的所有分区。当第二个使用者随后加入时,它将获得大约一半的分区,从而减轻了第一个使用者先前负载的一半。这使你能够并行处理事件流,根据需要添加消费者(理想情况下,使用自动伸缩机制) ,前提是你已经对事件流进行了充分的分区。

吞吐量的控制通过两种方式实现:

  • 主题分区模型。应该对主题进行分区,以实现最大化独立事件子流的数量。换句话说,记录顺序只能在绝对必要的情况下才保留。如果任何两个记录之间没有因果关系,那么它们就不应该绑定到同一个分区上。这意味着它们可以使用不同的键,因为 Kafka 将使用一个记录的键作为散列源会映射到一致的分区中。

  • 组中消费者的数量。你可以增加消费者的数量,以便分担消息消费的负担。消费者数最多可达主题中分区的数量。(如果你愿意,您可以拥有更多的消费者,但是分区计数将对至少获得一个分区分配的活动消费者的数量设置一个上限; 剩余消费者将保持空闲状态。) 注意,消费者可以是进程也可以是线程。根据消费者执行的工作负载类型,你可以在线程池中使用多个单独的消费者线程或进程来消费记录。

如果你想知道 Kafka 是不是很快,它是如何实现高性能的,或者是否可以用到你的系统中,相信看到这里你应该已经有了答案。

很明显,Kafka 并不是最快的(换句话说,并不是吞吐量最大的)消息中间件。还有一些基于软件或硬件实现的具有更高吞吐量的其他中间件。Apache Pulsar 吞吐量和延迟率折中最好的方案,但是它非常有前途,具有可拓展性,能够在保证顺序性和稳定性的同时获得更大的吞吐量和更小的延迟。采用 Kafka 的主要原因是,它具有一个完整的无以伦比的生态系统。它的表现非常出色,提供了非常丰富和成熟的环境。尽管规模强大,但是 Kafka 仍然以令人羡慕的速度增长。

Kafka 的设计人员和维护人员在设计以性能为导向的方案上做的非常棒,它的很多设计理念都比较前卫。从将工作负担分担到了客户端。从 broker 的日志持久化、批处理、压缩、零拷贝 I/O 和并行流,Kafka 对其他开源的和商业的消息中间件都带来了巨大的挑战。最重要的是 Kafka 在实现这些功能的同时并没有牺牲稳定性、记录顺序和至少一次交付的语义等特性。

Kafka 并不是简单的消息分发平台,还有很多东西需要学习。想要优雅地设计和构建高性能的事件驱动系统,必须掌握整体顺序和部分顺序性、主题、分区、消费者和消费者组的概念。虽然知识曲线有点陡峭,但结果肯定是值得的。如果你热衷于服用众所周知的‘红色药丸’ ,请阅读通过 Kafka 和 Kafdrop 来介绍事件流。

原文:https://medium.com/swlh/why-kafka-is-so-fast-bde0d987cd03

作者:Emil Koutanov,软件架构师、工程师,一个父亲。同时也是一个狂热的作家、博主和小说作家。译者:明明如月,知名互联网公司 Java 高级开发工程师,CSDN 博客专家。

本文为 CSDN 翻译,转载请注明来源出处。

【End】

CSDNx巨杉大学联合认证学习,免费开放!“分布式数据库集训营”帮助您开始学习分布式数据库、分布式架构知识,现在加入活动,完成课程还将专属礼品快来参加吧~

推荐阅读 

啥?不让一块芯片流向华为?

结束 Java、Python 之战,如何学习所有编程语言?

2020年涨薪26-30%,能实现吗?18%数据科学家是这么期待的

隐身术?登顶 GitHub Top1:200 行 JS 代码让画面人物瞬间消失!

RabbitMQ VS Kafka:消息队列与流处理平台之争

被盗巨鲸用户可能遭到了持续性攻击

你点的每一个在看,我认真当成了喜欢

猛戳“阅读原文”,了解详情

发布了1733 篇原创文章 · 获赞 4万+ · 访问量 1560万+

猜你喜欢

转载自blog.csdn.net/csdnnews/article/details/104471147