(四)Kafka0.8.2官方文档中文版系列-kafka设计
4 Design(设计、构思):
4.1 Motivation(Kafka设计动机):
我们设计Kafka的目标是能够成为一个统一的平台,能够作为处理大公司(就是大数据量,复杂的业务场景)所拥有的实时数据流。为此,我们必须考虑一些使用场景,比如说:
- 它必须具有高吞吐量以支持高容量事件流,例如实时日志聚合。
- 它需要优雅地处理大型数据积压,以便能够支持离线系统的定期数据加载。这也意味着系统必须处理低延迟交付以处理更传统的消息传递用例。
- 我们希望支持对这些消息流的进行分区、分布式、实时处理,以创建新的派生信息流。这激发了我们的分区和消费者模式。
- 最后,在将流馈送到其他数据系统以提供服务的情况下,系统必须能够在出现机器故障时保证容错。
支持这些用途,需要使用到有许多独特元素的设计,它可能更类似于数据库日志,而不是传统的消息传递系统。我们将在以下部分概述设计的一些元素。
4.2 Persistence(持久化)
Don't fear the filesystem!(不要担心文件系统)
Kafka在很大程度上依赖于文件系统来存储和缓存消息。 人们普遍认为“磁盘速度慢”,这让人们怀疑文件系统能否提供有竞争力的性能。 事实上,根据使用方式的不同,磁盘速度既可能比人们预期的要慢得多,也有可能快得多,依赖于你怎么使用它; 并且设计合理的磁盘结构通常可以与网络一样快。
有一个重要的事实是,硬盘驱动器的吞吐量与过去十年的磁盘搜索的延迟有所不同。因此,在具有6个7200rpm SATA RAID-5阵列的JBOD配置上,线性写的性能大约为600MB/sec,但随机写的性能仅为100k/sec,差异为6000X。对所有的使用模式,这些线性读写是可预测的,并且操作系统进行了大量优化。现代操作系统提供了预读和写后技术,可以以预取多个大块数据,并将更小的逻辑写分组到大型的物理写中(写入合并)。关于这个问题的进一步讨论可以在这篇ACM队列文章中找到;他们实际上发现,在某些情况下,顺序磁盘访问可以比随机存储器访问更快。
为了弥补这种性能差异,现代操作系统在使用主内存进行磁盘缓存方面变得越来越积极。现代的操作系统会很高兴地将所有空闲内存转移到磁盘缓存中,当内存被回收时,性能损失很小。所有的磁盘读写都将经过这个统一的缓存。如果不使用直接I/O,这个特性不容易被关闭,因此即使一个进程维护数据的进程内缓存,这个数据很可能会被复制到在OS pagecache中,有效地将所有东西存储两次。
此外,我们正在JVM之上构建,任何花时间使用Java内存的人都知道两件事:
- 对象的内存开销非常高,通常会使存储的数据的大小增加一倍(甚至更多)。
- 随着堆内数据的增加,Java垃圾收集变得越来越复杂和缓慢。
由于这些因素使用文件系统和依赖pagecache优于维护一个内存中的缓存或其他结构,通过自动访问所有可用内存,我们至少有两倍可用缓存,存储一个紧凑的字节结构而不是单个对象,可能再翻一番。这样做将导致在32GB的机器上缓存28-30GB,而没有GC所带来的问题。此外,即使重新启动服务,这个缓存也将保持温暖(缓存还有效),而进程内缓存将需要在内存中重新构建(10GB缓存可能需要10分钟),否则它将需要从一个完全冷缓存开始(这可能意味着糟糕的初始性能)。但这也极大地简化了代码,因为所有维护缓存和文件系统之间一致性的逻辑现在都在操作系统中,操作系统比一次性的进程内尝试更有效、更正确。如果您的磁盘使用更倾向于线性读操作,那么提前读操作将有效地在每个磁盘读操作上使用有用的数据预先填充此缓存。
这表明一个非常简单的设计:当我们用完空间时,不要在内存中尽可能多地维护并将其全部冲到文件系统中,我们将其反转。 所有数据立即写入文件系统上的持久日志,而不必刷新到磁盘。 实际上,这只意味着它被转移到内核的pagecache中。
Constant Time Suffices(满足常量时间)
消息传递系统中使用的持久化数据结构通常是具有相关联的BTree或其他通用随机访问数据结构的来存储,以维护关于消息的元数据。BTrees是可用的最通用的数据结构,并且可以在消息传递系统中支持各种事务和非事务语义。但它们确实具有相当高的成本:Btree操作是O(log N)。通常认为O(log N)基本上等于常量时间,但对于磁盘操作则不然。磁盘搜索时间为10毫秒,每个磁盘一次只能进行一次搜索,因此并行性有限。因此,即使少数磁盘搜索也会导致非常高的开销。由于存储系统将非常快速的高速缓存操作与非常慢的物理磁盘操作混合在一起,因此随着数据随固定高速缓存的增加,树结构的观察性能通常是超线性的 - 即将数据加倍速度下降不只是两倍。
直观地,可以在简单读取上构建持久队列,并将其附加到文件,这与日志记录解决方案的情况一样。该结构的优点是所有操作都是O(1)并且读取不会阻止写入或相互阻塞。这具有明显的性能优势,因为性能完全与数据大小分离 , 一台服务器现在可以充分利用一些廉价,低转速的1 + TB SATA驱动器。虽然它们的搜索性能很差,但这些驱动器在大量读写时具有可接受的性能,价格是其三分之一和但是容量的三倍。
在没有任何性能损失的情况下访问几乎无限的磁盘空间意味着我们可以提供消息传递系统中通常不具备的一些功能。例如,在Kafka中,我们可以将消息数据保留一个相当长的时间(比如说一周),而不是只要数据被消费过了,就尝试去删除它。这将会带来极大的便利,正如我们将要描述的那样。
4.3 Efficiency(效率)
我们在效率方面投入了大量精力。我们的主要用例之一是处理网络活动数据,这是非常高的数据量:每个页面视图可能会生成数十个写入。此外,我们假设每个发布的消息由至少一个消费者(通常很多)读取,因此我们努力使消费成本尽可能廉价。
从以往建立和运行许多类似系统的经验中,我们发现效率是多用户操作的关键。如果由于下游服务的一个小缺陷而成为下游服务的瓶颈,即便是改动这样的小问题也会产生一些问题。为了更快速,我们可以确保在下游服务设施构建之前,让应用程序能够在负载下工作。当尝试在集中集群上同时运行数十或数百个应用程序的集中服务时,这一点尤为重要,因为使用模式的变化几乎每天都会发生。
我们在上一节中讨论了磁盘效率。一旦消除了不良的磁盘访问模式,这种类型的系统还有有两个常见的低效率原因:太多的小I / O操作和过多的字节复制。
1)小I / O问题发生在客户端和服务器之间以及服务器自己的持久化操作中。
为了避免这种情况,我们的协议是围绕“消息集”的抽象而构建的,该抽象自然地将消息组合在一起。这允许网络请求将消息组合在一起从而分摊网络往返的开销,而不是一次发送单个消息。服务器依次将消息块添加到其日志中,并且消费者一次获取大的线性块。
这种简单的优化可以使性能有数量级的提升。批处理会导致更大的网络数据包,更大的顺序磁盘操作,连续的内存块等等,所有这些都允许Kafka将突发的随机消息写入流转换为流向消费者的线性写入。
2)另一个低效率是字节复制。在低消息速率下,这不是问题,但在负载下,这个影响是显着的。为了避免这种情况,我们采用标准化的二进制消息格式,由生产者,代理和消费者共享(因此数据块可以在不修改的情况下进行传输)。
(brokers)代理维护的消息日志本身只是一个文件目录,每个文件都由一系列消息集填充,并以相同格式写入磁盘,这些消息集由生产者和消费者使用。维护这种通用格式可以优化最重要的操作:持久日志块的网络传输。现代unix操作系统提供高度优化的代码路径,用于将数据从pagecache直接传输到socket; 在Linux中,这是通过sendfile系统调用完成的。
要了解sendfile(零拷贝)的影响,理解将数据从文件传输到套接字(socket)的公共数据路径是非常重要的:
- 操作系统将磁盘中的数据读入内核空间的pagecache
- 应用程序将内核空间中的数据读入用户空间缓冲区
- 应用程序将数据写回内核空间中的套接字缓冲区
- 操作系统将数据从套接字缓冲区复制到NIC缓冲区,并通过网络发送
这显然是低效的,有四个副本和两个系统调用。使用sendfile(零拷贝),允许操作系统将数据从pagecache直接发送到网络,从而避免了这种重新复制。因此,在此优化路径中,只需要将内核空间pagecache的数据复制到NIC缓冲区。
一个普遍的场景就是一个topic上面有多个消费者。使用上面的零拷贝优化,数据被精确复制到pagecache中一次,可以重复被多个消费者使用,而不是存储在内存中,并在每次读取时复制到内核空间。这将允许以接近网络连接限制的速率消费消息。
pagecache和sendfile的这种组合意味着在消费者大部分读写操作会被上面的方式捕获到,您将看不到磁盘上的任何读取活动,因为它们将完全从缓存提供数据。
有关Java中sendfile和零拷贝支持的更多背景信息,请参阅此文章。
(Sendfile 函数在两个文件描写叙述符之间直接传递数据(全然在内核中操作,传送),从而避免了内核缓冲区数据和用户缓冲区数据之间的拷贝,操作效率非常高,被称之为零拷贝。)
End-to-end Batch Compression(端到端的批量压缩)
在一些场景,系统的瓶颈不是CPU和磁盘读写性能而是网络带宽。对于需要通过广域网在数据中心之间发送消息的数据管道尤其如此。当然,用户可以对每条消息进行压缩,而无需Kafka的任何支持,但这会导致非常差的压缩率,因为冗余的大部分是由于相同类型的消息之间的重复(例如,JSON中的字段名称)或Web日志中的用户代理或常见字符串值)。高效压缩的做法是,需要一起压缩多个消息,而不是单独压缩每个消息。
Kafka通过允许递归消息集来支持这一点。可以将一批消息压缩在一起压缩并以此形式发送到服务器。这批消息将以压缩形式写入,并将在日志中保持压缩状态,并且只能由消费者解压缩。
Kafka支持GZIP和Snappy压缩协议。有关压缩的更多详细信息,请参见此处。
4.4 Producer(生产者)
Load balancing(负载均衡)
生产者将数据直接发送到分区领导者,而不需要任何中间路由层。为了实现producer可以将数据直接发送给broker(partition的服务代理),kafka的所有节点都可以对一个请求做出响应,告知producer那个服务器还活着,broker可以在任何时候将producer的请求做出合适的指向。
客户端控制它的消息发布到那个分区。这可以随机完成,通过实现一种随机负载平衡,或者可以通过一些语义分区功能来完成。我们提供了一种语义分区,基于用户提供的一个特定键并且使用该键的hash值对消息进行分区(如果需要的话,可以重新实现分区的方法)。例如,如果选择的key是用户ID,则给定用户的所有数据将被发送到同一分区。这反过来将允许消费者对其消费做出地点假设。这种分区方式明确设计为允许在消费者中进行对位置敏感的处理。
Asynchronous send(异步发送)
批处理是效率的重要驱动因素之一,为了支持批处理,Kafka生产者将尝试在内存中累积数据,并在单个请求中发送更大批量数据。批处理可以配置为累积不超过固定数量的消息,并且等待不超过一些固定的延迟限制(例如64k或10ms)。这允许生产者累积更多字节以发送,并且在服务器上几乎没有更大的I / O操作。这种缓冲是可配置的,并提供了一种机制来权衡少量的额外延迟以获得更好的吞吐量。
4.5 Consumer(消费者)
Kafka消费者通过向它想要消费的分区的broker发出“获取”请求来进行消费。消费者在每个请求的日志中指定其偏移量,并从该位置开始接收大量的日志。因此,消费者可以对该位置进行重要控制,并且如果需要,可以将其倒回以重新消费数据。
Push vs. Pull(推送PK拉取)
我们初步考虑的一个问题是,消费者从brokers处拉取数据,还是brokers将消息推送给消费者。在这方面,Kafka遵循更传统的设计,借鉴了大多数消息传递系统,其中数据从生产者推送到代理并由消费者从代理者拉取。一些以日志为中心的系统,例如Scribe和Apache Flume遵循一个非常不同的基于推送的路径,数据被推向下游。两种处理方法都有利弊。然而,基于推送的系统难以与不同的消费者打交道,因为broke需要控制数据传输的速率。目标通常是消费者能够以最大可能的速度消费; 不幸的是,在基于推送系统中,这意味着当消费者的消费率低于生产者的生产速率(实质上,是拒绝服务攻击)时,消费者往往不堪重负。基于消费者自行拉取的系统具有更好的特性,消费者在消费速率上可以落后于生产者的生产速率,并在可能的情况下赶上。这可以通过某种后移协议来缓解,消费者可以通过该协议表明它已经不堪重负,但是,让消费者充分利用转换速率(但从未过度利用),这比看起来更棘手。基于之前以这种方式构建系统的尝试,我们采用了这种传统的拉取模型。
基于拉式系统的另一个优点是它有助于对发送给消费者的数据,进行更好的批处理。基于推送的系统必须选择立即发送请求或累积更多数据,然而它并不知道下游消费者是否能够立即处理它。如果调优为低延迟,这将导致每次只发送一条消息,将导致传输被缓冲(消息堆积),这是浪费。基于拉式的设计解决了这个问题,因为消费者总是在日志中的当前位置(或达到配置的最大值)之后拉出所有可用消息。因此,我们可以在不引入不必要的延迟的情况下获得最佳的批处理。
一个不成熟的基于拉取的系统的缺点是,如果broker没有数据,消费者可能会在紧密的循环中结束轮询,实际上忙于等待数据到达。为了避免这种情况,我们在pull请求中有参数允许消费者请求在“长轮询”中阻塞,等待数据到达(可选地等待直到给定数量的字节可用,以确保传输内容有一个合适的大小)。
你可以想象其他可能的设计只有拉取,端到端。生产者将日志写在本地,brokers从生产者拉取,然后消费者从broker拉取数据。类似于经常被提出的“存储转发”生产者类型。这很有趣,很迷人,但我们觉得不太适合我们有数千个生产者的目标用例。我们在大规模运行持久性数据系统的经验使我们感到,在许多应用程序中涉及系统中的数千个磁盘实际上并不会使事情变得更可靠,并且操作将会成为噩梦。实际上,我们发现,无需生产者进行持久化操作,可以大规模运行具有强大SLA(Service Level Agreement)的管道。
Consumer Position
出人意料的是,实时掌握已经被消费的消息,是一个消息系统的关键性能点之一。
大多数消息系统都会保留已被消费的消息的元数据在broker(代理)上。也就是说,当消息被分发给消费者时,broker要么立即在本地记录该事实,要么等待来自消费者的确认。这是一个相当直观的选择,实际上对于单个机器服务器来说,掌握消息的状态是不容易的,不是很容易就能跟踪到消息的状态。由于在许多消息传递系统中用于存储的数据结构规模不大,这也是一个务实的选择——因为代理知道消耗了什么,它可以立即删除它,一直保持数据量在一个较小的状态。
此外,让broker和消费者就消费的内容达成一致也是一个很重要的问题。如果每条消息被消费后,broker记录下被消费的状态,并立马通过网络广播出去,但是消费者无法处理的消息(比如,因为它崩溃或请求超时或其他),那么该消息将会丢失。为了解决这个问题,许多消息系统添加了一个确认功能,这意味着消息只被标记为发送时未被消费的发送; 代理等待来自消费者的特定确认以将消息记录记为已消费。此策略解决了丢失消息的问题,但产生了新的问题。首先,如果消费者处理消息但在发送确认之前失败,则消息将被消耗两次。第二个问题则是性能问题,这种情况下broker必须保留关于每条消息的多个状态(首先锁定它以便不再发出第二次,然后将其标记为永久消耗,以便可以将其删除)。必须处理棘手的问题,例如如何处理已发送但一直不能确认的消息。
kafka对此的处理方式不同。我们的主题被分为一组完全有序的分区,每个分区在任何给定时间由一个消费者使用。这意味着每个分区中的消费者位置只是一个整数,即消耗的下一个消息的偏移量。这使得状态消耗非常小,每个分区只有一个数字。此状态可以定期检查。这使得消息确认的变得非常简单廉价。
这个决定有一个附带好处。消费者可以故意回退到旧的偏移量重新使用数据。这违反了队列的通用合同,但结果却成为许多消费者的基本特征。例如,如果消费者代码有错误并且在消费了某些消息后被发现,则消费者可以在修复错误后重新使用这些消息。
Offline Data Load(离线数据加载)
可伸缩持久性允许用户消费者定期消费数据,例如批量数据加载,周期性地将数据加载到离线系统(如Hadoop或关系数据仓库)中。
在hadoop的情况下,通过划分独立的map任务来并行的加载数据,一个map任务可以加载一个节点、topic、partition或者他们的组合。Hadoop提供了任务管理机制,失败的任务可以重新开始执行不会存在重复数据的情况,他们只是从原来的位置重新开始执行。