动机
我们设计Kafka能够作为处理大公司可能拥有的所有实时数据馈送的统一平台。为此,我们必须考虑相当广泛的用例。
它必须拥有高吞吐量以支技大规模流式事件,比如实时日志聚合。
它需要优雅的处理大量数据积压,以便能够支持来自离线系统的定期数据加载。
它同时也意味着该系统应该处理低延迟分发,以处理更多传统的消息用例。
我们想要支持对这些反馈进行分区、分布式、实时实处理,以创建新的派生的反馈。这激发了我们的分区和消费者模型。
最终,在流被输入到其它数据系统以提供服务的情况,我们知道该系统应该能够在机器故障的情况下保证容错。
为了支持这些用途使我们设计了一系统独特的元素,它更类似于数据库日志,而不是传统的消息传递系统。我们将在以下几节中概述设计的一些元素。
持久性
不要当心文件系统!
Kafka严重依赖文件系统来保存和缓存消息。这里有个普遍的人为“磁盘很慢”,它使人们怀疑一个持久性结构是否可以提供有竞争力的性能。实际上,磁盘的速的比人们预期的要慢得多也快得多,这取决于人们怎样去使用它;正确设计的磁盘结构通常可以和网络一样快。
关键的事实是硬盘的吞吐量与过去10年硬盘的搜索延迟有所不同。导致的结果是,在具有6个7200rpm SATA RAID-5阵列JBOD配置上线性写入的性能大概是600MB/秒,而随机写入的性能大约是100K/秒-相差超过6000X。这些线性的读取和写入是所有用例模式下最可预测的,操作系统对它们进行了大量的优化。现代操作系统提供预读和在后写的技术,以大块多次预取数据并将小的写逻辑进行分组,组成一个大的物理写。关于这个问题更详细的讨论请参考 ACM Queue article; 它实际上发现了:按顺序文章在某些情况下比随机的内存访问更快。
为了弥补这种性能的差异,现代操作系统越来越积极的使用主内存进行磁盘缓存。现代操作系统会很高兴地将所有空闲内存转移到磁盘缓存中,而在回收内存时,性能损失很少。向的和磁盘读和写都将通这个统一的缓存。在不使用直接I/O时这种特性不容易被关掉,所以,如果一个过程维护着一个进程内的数据缓存,这份数据很可能也被服务到了操作系统缓存,从而有效的将所有内容存储两次。
此外,我们在JVM上构建,任何使用过JAVA内存的人都知道两件事情:
- 对象的内存开销非常高,通常使存储的数据加位(或更糟)
- Java的垃圾回收在堆数据增长时会变得繁琐和慢
由于这些因素使用文件系统并依赖pagecache优于维护一个内存内缓或者其它结构-通过自动访问所有空闲内存我们至少可以使可用内存加倍,并且如果我们存储的是压缩的字节结构而不是单个对像还可以再加位。这样做会在32GB机器上产生高达28-30GB的缓存,而不会产生GC惩罚。些外,即使服务被重启这些缓也存将保持温暖,而进程中的缓存将需要在内存中重新构建(如查10GB的缓存可能需要10分钟)或者它需要从一个完全冷的缓存启动(这意味着可怕的初始化性能)。由于所有维持缓存与文件系统的逻辑现在都在操作系统中,这同时也简化了代码。与一次性的过程中尝试相比,这更有效,更正确。如果您的磁盘使用有利于线性读取,则预读有效地预先填充此缓存,并在每个磁盘读取时使用有用的数据。
这建议了一种非常简单的设计:与其在内存中尽可能多地维护,并在空间耗尽时惊慌地将其全部清空到文件系统中,还不如将其反转。所有数据都立即写入文件系统上的持久日志,而不必刷新到磁盘。实际上,这只意味着它被转移到内核的pagecache中。
恒定的时间
消息系统中使用的持久数据结构通常是每个消息者队列具有相关联的BTree或者其它通用随机访问的数据结构,以维护消息关于消息的元数据。BTree是最通用的数据结构,使得它可能支持消息系弘中各种各样的事务和非事务语义。不过,它确实带来了相当高的成本:BTree的操作为O(lonN).通常O(logN)被认为基本等于常量时间,但是对于硬盘操作则不是这样的。 硬盘扫描为10ms a pop,并且每块硬盘在同时间内只能扫描一次,所以并行是有限的。于是即使少数硬盘搜索也需要非常高的代价。当存储系统混合了非常快的缓存操作和非常慢的物理硬盘操作,所以当数据随着缓存而增加时,树结构的观察性能通常是超线性的。将数据翻倍将比速度翻倍糟糕得多。
直观地说,持久性队列可以构建在简单的读取和附加到文件上,这与常见的日志解决方案一样。这种结构的优点是所有操作都是O(1)并且读取不会阻塞写操作或相互阻塞。
这有非常明显的性能优势,因为性能完全与数据大小解耦-一台服务器现在可以充分利用一些廉价,低转速的1 + TB SATA驱动器。虽然它们的搜索性能很差,但这些驱动器在大量读写时具有可接受的性能,价格是其三分之一和容量的三倍。
在没有任何性能损失的情况下访问几乎无限的磁盘空间意味着我们可以提供消息传递系统中通常没有的一些特性。例如,在Kafka中,我们可以将消息保留相对较长的时间(比如一周),而不是试图在消息被使用后立即删除它们。正如我们将要描述的,这为消费者带来了极大的灵活性。
效率
我们在效率方面作出了重大的努力。我们的一个重要的用例是页面活动数据,它的规模非常大:每个页面可能产生许多的写入。此外,我们假设每个被发布的消息至少被一个消费消费(通常有许许多个),因此我们尽可能使消费变得便宜。
我们同时发现,根据构建和运行一些类似的系统的经验,性能是多租户运营的关键。如果下游的基础设施服务通过该应用程序在可以容易的成瓶颈,如此小的变更通常会创造问题。通过非常快的速度,我们帮助确保应用程序在基础设施之前会在负载下翻过来。当试图在一个集中式集群上运行一个支持数十或数百个应用程序的集中式服务时,这一点尤其重要,因为使用模式的变化几乎每天都在发生。
在上一节中我们讨论了硬盘的性能。一旦低硬盘访问模式被淘汰,在这类系统中有两个常见的低效原因:太多小的I/O操作和过多的字节拷贝。
小的I/O问题在客户端和端之间以及服务器自身的持久性操作中。
为了避免这些,我们的协议是围绕着“消息集”抽象构建的,该抽象自然地将消息分组在一起。这允许网络请求将消息分组并分摊网络往返的开销,而不是一次只发送单个消息。反过来,服务器一次性的将消息块添加到其日志中,并且消费者一次获取大量的连续的消息块。
这个简单的优化产生了数量级的加速。批处理会导致更大的网络数据包,更大顺序硬盘操作,连续的内存块等等,所有这些允许Kafka将突然发的随机消息写入流转换为线性的写入流,并将其传递给消费者。
另一个低效原因是字节复制。在低消息速率时这不是问题,但是在负责下,影响是显著的。为了避免这些,我们采用标准化的二进制消息格式,该模式被生产者、代理以及消费者共享(因此数据块可以在不修改的情况在它们之间传递)
代理维护的消息日志本身就是一个文件目录,每个文件目录由一系统的消息集填充,这些消息集以生产者和消费者使用的相同格式 写入硬盘。维护这种通用格式可以优化最重要的操作:持久化日志块的网络传输。现在Unix操作系统为将数据从pagecache传输到socket提供了高度优化的代码路径;在Linux中,这是通过sendfile系统完成的。
为了理解sendfile的影响,理解将数据从文件传输据到socket的公共数据路径非常重要:
- 操作系统从硬盘读取数据到内核空间中的pagecache。
- 应用程序从内核空间读取数据到用户空间的缓冲区。
- 应和程序将数据写回到内核空间到socket缓冲区
- 操作系统从socket缓冲区复制数据到NIC缓冲区,并通过网络发送
这很明显非常低效,这里有四个复本和两个系统调用。使用sendfile,通过允许操作系直接发送pagecache的数据到网络来避免重复复制。因此在此优化路径中,只需要将最终副本复制到NIC缓冲区。
我们期望一个常见的用例是一个主题上的多个消费者,使用上面的零复制优化,数据只被复制一次到pagecache,并在每次使用时重用,而不是存储在内存中,并在每次读取时都将其复制到用户空间。这允许以接近网络连接极限的速度使用消息。
这种pagecache和sendfile的组合意味着,在Kafka集群上,用户主要集中在这里,无论如何,您都不会看到磁盘上的读取活动,因为它们将完全从缓存中提供数据。
端到端批量压缩
在某些情况下,瓶颈实际上不是CPU或者硬盘而是网络带宽。对于需要将消息通过广域网在数据中心之间发送的的数据管道来说,尤其如此。当然,你可以总是在不需要Kafka任何支持的时候压缩消息,但是这可能导致非常低的压缩率,有效的压缩需要将多个消息压缩在一起,而不是单独压缩每个消息。
Kafka以高效的批量格式支持这一点。一个批次的消息可以聚集在一起压缩,并且发送到服务器。这个批次的消息将以压缩形式写入,并且在日志中也维持压缩状态,只有消费者解压缩。