查看原文
其他

面试官:为什么 Kafka 如此之快?

Emil Koutanov zhisheng 2022-07-29

作者 | Emil Koutanov

译者 | 弯月

以下为译文:

最近几年,软件体系结构领域发生了巨大的变化。单体应用,乃至共享一个通用数据存储的多个服务的概念已从软件从业者的世界消失了。微服务、事件驱动的体系结构以及CQRS成了构建以业务为中心的现代化应用程序的主要工具。而在这之上,则是物联网、移动设备、可穿戴设备等设备连接的激增,系统必须实时处理的事件数量的压力也增大了。

首先,我们必须承认“快速”这个词涉及的方面很广泛,很复杂,而且比较模糊。延迟、吞吐量和抖动等都是人们衡量“快速”的决定性指标。快速本身也要考虑上下文关系,各个行业和应用程序领域都有特定的规范和期望值。快慢的判断很大程度上取决于相应的参照系。

Apache Kafka针对吞吐量进行了优化,但牺牲了延迟和抖动,同时保留了其他所需的品质,比如持久性、严格的记录顺序以及“至少一次”的交付语义。当有人说“Kafka很快”,并假设它们具备一定的能力时,你可以认为他们指的是Kafka在短时间内安全地积累和分发大量记录的能力。

究其历史,Kafka的诞生是因为LinkedIn的需求,因为他们需要有效地移动大量的消息,每小时的数据总量达数TB。一条消息的传播延迟倒是次要的。毕竟,LinkedIn不是从事高频交易的金融机构,也不是有明确截止期限的工业控制系统。我们可以利用Kafka实现近乎实时(又名软实时)的系统。 

注意:有些人可能对实时这个术语不太熟悉,“实时”并不意味着“快速”,它的意思是“可预测”。具体而言,实时意味着完成动作所需花费的时间有硬性上线,即截止期限。如果系统整体每次都无法满足截止期限,则不能称其为实时系统。能够在一定概率公差范围内完成操作的系统称为“近乎实时”。单从吞吐量看来,一般实时系统都比近乎实时或非实时系统慢。

为了提高速度,Kafka做了两方面的努力,下面我们来分别讨论。第一个关系到客户端与代理之间的低效;第二个源于流处理的机会并行性。


代理的性能


日志结构的持久性

Kafka利用分段式追加日志,将大部分读写都限制为顺序I / O,这种方式在各种存储介质上的读写速度都非常快。人们普遍认为磁盘的读写速度很慢,但实际上存储介质(尤其是旋转介质)的性能很大程度上取决于访问模式。常见的7,200 RPM SATA磁盘上的随机I / O的性能要比顺序I / O慢3~4个数量级。此外,现代操作系统提供了预读和延迟写入技术,可以预先取出大块的数据,并将较小的逻辑写入组合成较大的物理写入。因此,即使在闪存和其他形式的固态非易失性介质中,随机I/O和顺序I/O的差异仍然很明显,尽管与旋转介质相比,这种差异性已经很小了。

记录批处理

在大多数媒体类型上,顺序I / O的速度非常快,可与网络I / O的峰值性能相媲美。在实践中,这意味着精心设计的日志结构持久层能够跟上网络流量的速度。实际上,Kafka的瓶颈通常不在于磁盘,而是网络。因此,除了操作系统提供的低级批处理外,Kafka客户和代理还会将读写的多个记录打包成批次,然后再通过网络发送。记录的批处理通过使用更大的数据包,以及提高带宽效率来分摊网络往返的开销。

批量压缩

启用压缩后,批处理的影响将更为明显,因为随着数据量的增加,压缩会更加有效。尤其是当使用基于文本的格式(如JSON)时,压缩的效果会非常明显,压缩率通常会到5~7倍之间。此外,记录的批处理大部分是在客户端完成的,它将负载转移到客户端上,不仅可以减轻网络带宽的压力,而且对代理的磁盘I / O利用率也有积极的影响。

廉价的消费者

传统的MQ风格的代理程序会在消费消息的时候删除消息(会导致随机I / O的性能下降),Kafka与之不同,它不会在使用过后删除消息,它会按照每个消费者组单独跟踪偏移量。偏移量的进度本身发布在Kafka的内部主题__consumer_offsets上。同样,由于这是一个仅追加的操作,所以速度非常快。在后台,这个主题的内容将进一步减少(使用Kafka的压缩功能),仅保留消费者组的最新已知偏移量。

我们来比较一下该模型与更传统的消息代理(这些代理通常都会提供多种不同的消息分发拓扑)。一方面是消息队列(一种持久的传输,用于点对点消息传递,没有点对多点的功能。)另一方面,pub-sub主题允许点对多点消息传递,但牺牲了持久性。在传统的MQ中实现持久的点对多点消息传递模型需要为每个有状态的消费者维护一个专用的消息队列。这会放大读写量。一方面,发布者不得不写入多个队列。或者,扇出中继可能会从一个队列中消费记录,并写入几个其他队列,但这只是把读写放大的点推迟了而已。另一方面,多个消费者会在代理上产生负载,这些负载既包含顺序I / O的读写,也包含随机I / O的读写。

只要Kafka中的消费者不更改日志文件(仅允许生产者或内部Kafka进程更改日志文件),它们就很“廉价”。这意味着大量的消费者可以同时读取同一主题,而不会占用过多的集群。虽然添加消费者还是需要付出一些代价,但是大多都是顺序读取,加上极少量的顺序写入。因此,多个消费者生态系统共享一个主题是很正常的。

未刷新的缓冲写入

Kafka高性能的另一个根本原因(也是值得进一步探讨的原因)在于,在确认写入之前,Kafka在写入磁盘时实际上并不会调用fsync。ACK唯一的要求就是记录已被写入I / O缓冲区。这一点鲜为人知,但至关重要,正是因为这一点,Kafka的操作就像是一个内存队列一样,因为Kafka的目标就是由磁盘支持的内存队列(规模由缓冲区/页面缓存的大小决定)。

另一方面,这种写入的形式是不安全的,因为即使看似记录已被确认,副本出问题也可能导致数据丢失。换句话说,与关系数据库不同,仅承认写入并不意味着持久性。保证Kafka持久的原因在于它运行了多个同步副本。即使其中一个出现问题,其他副本也将继续运行,当然前提是其他副本没有受影响(有时,某个常见的上游故障可能会导致多个副本同时出问题)。因此,无fsync的I / O非阻塞方法与冗余的同步副本的结合保证了Kafka的高吞吐量、持久性和可用性。

客户端优化

大多数数据库、队列以及其他形式的持久性中间件的设计理念中,都有一个全能的服务器(或服务器集群),加上多个瘦客户端,两者之间通过常见的通信协议通信。通常我们认为,客户端的实现难度远低于服务器端。因此,服务器承担了大部分负载,而客户端仅充当应用程序代码和服务器之间的接口。

Kafka的客户端设计采用了不同的方法。在记录到达服务器之前,客户端需要执行大量操作,包括将记录暂存到收集器中,对记录的键进行哈希处理以获得正确的分区索引,对记录进行校验以及对批次进行压缩。客户端掌握了集群的元数据,并会定期刷新这些元数据,以了解代理拓扑的变化。因此,客户端可以决定底层的转发,生产者客户端不会将记录盲目地发给集群,并依赖集群将记录转发到合适的代理节点,而是直接将写入转发给分区的主服务器。同样,消费者客户在选择记录源时也可以做一些智能处理,例如在发送读取查询时,选择地理位置更接近的副本。(此功能是Kafka的新功能,自2.4.0版开始提供。)

零复制

最常见的低效处理来自缓冲区之间的字节数据复制。Kafka的生产者、代理和消费者之间共享了同样的二进制消息格式,因此数据块即使经过压缩,在端与端之间流动时也无需进行任何修改。尽管消除通信双方的结构差异是很重要的一步,但它本身并不能避免数据复制。

为了在Linux和UNIX系统上解决了此问题,Kafka使用了Java的NIO框架,特别是java.nio.channels.FileChannel的方法transferTo()。我们可以通过这种方法,将字节从源通道传输到接收器通道,而无需将应用程序作为传输中介。为了说明NIO的不同之处,我们可以考虑一下传统的方法:将源通道读取到字节缓冲区中,然后作为两个单独的操作写入到接收器通道中:

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

流程图大致如下:

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

详细来说:

  • 首先,read()需要将上下文从用户模式切换到内核模式。读取文件,然后通过DMA引擎(直接内存访问)将文件内容复制到内核地址空间的缓冲区中。这与上述代码中使用的缓冲区不同。

  • 在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直接读取。在任何时候,CPU都不需要复制缓冲区。

传统方式与零复制方式(文件大小范围从几兆字节到千兆字节)的比较结果表明。零复制的性能提高了2~3倍。但更令人惊讶的是,Kafka仅使用了普通的JVM就实现了该功能,没有用到任何原生库或JNI代码。

避免垃圾收集

大量使用通道、原生缓冲区和页面缓存还有另一个好处:减少了垃圾收集器(garbage collector,GC)的负载。例如,在拥有32GB内存的计算机上运行Kafka,就有28~30GB的页面缓存可用,这完全超出了GC的范围。吞吐量的差异很小(在几个百分点左右),因为经过正确地微调后,GC的吞吐量可以达到很高,尤其是在处理短期对象时。真正的收益在于抖动的减少:避免使用GC,代理可以减少可能会影响到客户端的暂停,延长记录端到端传播的延迟。

平心而论,对于Kafka来说,与最初的设想时相比,如今避免使用GC已不再是一个问题。Shenandoah和ZGC等现代GC可以扩展到数TB,并且其最坏情况下的暂停时间是可调节的,最低可以调节到几毫秒。如今,对于基于JVM的应用程序来说,使用大型基于堆的缓存的效果远胜于不采用堆的设计。


流并行


日志结构I / O的效率是影响性能的关键方面,主要影响在于写入。Kafka对主题结构以及消费者生态系统中并行性的处理是其读取性能的基础。这种组合产生了很高的端到端消息传递吞吐量。并发根植于它的分区方案与用户组的操作中,它实际上是Kafka的负载平衡机制:在组内的各个用户实例之间均匀地分配分区配额。比较一下传统的MQ:在同等的RabbitMQ设置中,多个并发消费者以循环方式从队列中读取,但这种做法丢掉了消息排序的概念。

分区机制还为Kafka代理带来了水平可伸缩性。每个分区都有专门的主节点,因此,任何重大主题(具有多个分区)都可以利用代理的整个集群执行写操作。这是Kafka和消息队列之间的又一个区别,后者利用集群来提高可用性,而Kafka可以平衡代理之间的负载,并提高可用性、持久性和吞吐量。

如果你打算发布拥有多个分区的主题,那么生产者需要在发布记录时指定分区。(只有一个分区的主题没有这种问题。)实现方法有两种:直接的方式(指定分区索引)和间接的方式(通过记录键的方式,该键可以通过哈希生成唯一的分区索引)。拥有同样哈希的记录会占据同一个分区。假设一个主题具有多个分区,则具有不同键的记录可能也会位于不同的分区中。但是,由于哈希冲突,具有不同哈希值的记录也可能会在同一个分区中。这就是哈希的本质。如果你了解哈希表的运作方式,就会发现这正是哈希表的原理。

实际的记录处理由消费者负责,在一个消费者组(可选)内进行操作。Kafka可以保证分区最多只能指定给一个消费者组内的一个消费者。(我们说“最多”是考虑到可能所有消费者都离线的情况。)当组中的第一个消费者订阅该主题时,它会收到该主题上的所有分区。当第二个消费者加入时,它会收到大约一半的分区,从而降低了第一个消费者的大约一半的负担。这样,只要你的事件流中有足够多的分区,就能并行处理事件流,根据需要添加消费者(最好使用自动伸缩机制)。

控制记录的吞吐量可以通过两个途径完成:

  1. 主题分区架构。主题应该按照独立事件子流的最大数量分区。换句话说,记录的顺序只有在绝对必要的时候才需要保证。如果任何两个记录都不相关,那么不应该被绑定到同一个分区。这就需要使用不同的键,因为Kafka使用记录的键作为哈希的来源,以保证分区映射的一致性。

  2. 组内的消费者数量。你可以增加消费者数量来匹配输入记录的负载,最大等于主题中的分区数量。(你甚至可以拥有多个消费者,但能够获得至少一个分区的活跃消费者数量的上限就是分区数量,其余的消费者只能处于闲置状态。)注意消费者可以是进程或线程。根据消费者的负载类型,你可以采用多个独立的消费者线程,或者在线程池中处理记录。

如果你想知道为什么Kafka这么快,它的性能特性是怎样实现的,或者怎样才能伸缩你的集群,那么看完这篇文章,你就应该得到答案了。

更明确地说,Kafka并不是最快的(即并不是吞吐量最大的)消息中间件,有些平台的吞吐量更大,其中有软件实现的也有硬件实现的。Kafka对于吞吐量与延迟之间的平衡处理也算不上最好的,Apache Pulsar的吞吐量平衡性更好,还能提供顺序一致性和可靠性保证。选择Kafka的原因作为整个生态系统的原因是,从整体上来说它还没有对手。它展示了优异的性能,同时还提供了庞大、成熟且一直在进步的社区。

Kafka的设计者和维护者设计了一个非常优秀的、以性能为主的方案。其设计几乎没有任何返工或补丁的迹象。不论是将工作量交个客户端,还是代理的日志式架构,甚至是批处理、压缩、零复制I/O和流式并行,Kafka几乎打败了所有面向消息的中间件,不论是商业的还是开源的。更精彩的是,这些实现没有在持久性、记录顺序、“至少一次”传输语义等质量方面做出任何妥协。

作为消息平台,Kafka并不简单,需要学习许多知识才能掌握。你必须理解全序和偏序、主题、分区、消费者、消费者组等概念,才能毫无障碍地设计并构建一个高性能的事件驱动系统。尽管学习曲线陡峭,但结果非常值得。

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

end







公众号(zhisheng)里回复 面经、ClickHouse、ES、Flink、 Spring、Java、Kafka、监控 等关键字可以查看更多关键字对应的文章。

点个赞+在看,少个 bug 👇

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存