一、说明
不得不说,官网和源码确实是我们学习技术最直接的地方,下面我们就来根据官网学习下Kafka的设计思想,官网地址:https://kafka.apache.org/documentation/#design
二、Kafka需要具备哪些功能
1、需要作为统一平台处理一个公司所有的实时数据
2、需要具有高吞吐量来支持大量的流事件,例如:实时日志聚合
3、需要优雅的处理大量数据积压,以便能够支持来自离线系统的定期数据加载,这也意味着系统必须处理低延迟交付以处理更传统的消息传递用例
4、需要支持分区、分布式
5、必须能够在机器故障的情况下保证容错
根据以上的功能要求,Kafka具有许多独特的设计,更类似于数据库日志而不是传统的消息传递系统。下面让我们来逐个看下这些独特的设计。
三、持久化
1、pagecache
文件通常会放在磁盘中,而cpu的速度和磁盘的访问速度相差很大,因此操作系统需要先将数据加载到内存中,才能被cpu访问。这部分内存就被称为:pagecache
它是操作系统内核的一部分内存,并将最近读取或写入的文件数据缓存在内存中,以提高文件访问的性能。当用户访问磁盘时,会先看pagecache中是否存在,如果存在直接返回,否则再从磁盘加载数据到pagecache,然后返回给用户。相应的,当用户写入磁盘时,也是先写入pagecache(当然如果是直接IO会绕过pagecache直接写入磁盘),并由操作系统定期刷新到磁盘。
它只是一种缓存机制,并不能保证数据的持久性和一致性,数据最终还是会写到磁盘上。pagecache中的数据可以认为是磁盘中部分数据在内存中的副本。
2、不要害怕文件系统
Kafka在很大程度上依赖于文件系统来存储和缓存消息。人们普遍认为“磁盘很慢”,也让人们怀疑持久化结构能否提供有竞争力的性能。事实上,磁盘比人们预期的要慢得多,也比人们预期的要快得多,这取决于它们的使用方式;设计得当的磁盘结构通常可以和网络一样快。
关于磁盘性能的关键事实是:在过去的十年里,硬盘驱动器的吞吐量一直与磁盘查找的延迟不同。因此,JBOD上线性写入的性能:配置六个7200rpm SATA RAID-5阵列约为600MB/秒,但随机写入的性能仅约为100k/秒,相差超过6000倍。因为线性读写由操作系统进行了大量优化。现代操作系统提供预读和后写技术,以大块倍数预取数据将较小的逻辑写入分组为较大的物理写入。在某些情况下,顺序磁盘访问可以比随机内存访问更快!
可参考:The Pathologies of Big Data - ACM Queue
现代操作系统越来越积极地使用主内存进行磁盘缓存。当内存被回收时,现代操作系统会很乐意将所有空闲内存转移到磁盘缓存,而性能损失很小。所有磁盘读取和写入都将通过这个统一的缓存。如果不使用直接I/O,就不能轻易关闭此功能,因此即使进程维护数据的进程内缓存,这些数据也可能会在操作系统pagecache中复制,有效地将所有内容存储两次。
此外,我们是在JVM之上构建的,使用Java内存的人都知道两个常识:
1、对象的内存开销非常高,通常会使存储的数据大小翻倍(或更糟)
2、随着堆内数据的增加,Java垃圾回收机制变得越来越繁琐和缓慢
由于这些因素,使用文件系统并依赖pagecache比维护内存缓存或其他结构要好——通过自动访问所有空闲内存,我们至少可以将可用缓存增加一倍,并且通过存储紧凑的字节结构而不是单个对象,可能会再次增加一倍。这样做将导致32GB机器上的缓存高达28-30GB,而不会受到GC惩罚。此外,即使重新启动服务,该缓存也会保持可用,而进程内缓存需要在内存中重建(对于10GB缓存可能需要10分钟),否则它将需要从完全冷的缓存开始(这可能意味着糟糕的初始性能)。这也大大简化了代码,因为维护缓存和文件系统之间一致性的所有逻辑现在都在操作系统中,这往往比一次性进程内尝试更有效、更正确。如果您的磁盘使用倾向于线性读取,那么预读有效地在每次磁盘读取时用有用的数据预先填充此缓存。
这是一种以pagecache为中心的设计风格:当空间用完时,我们不会在内存中尽可能多地维护并将其全部刷新到文件系统中,而是将其颠倒过来。所有数据都立即写入文件系统上的持久日志,而不必刷新到磁盘。实际上,这只是意味着它被转移到内核的pagecache中。
3、恒定时间
消息传递系统中使用的持久数据结构通常是每个消费者的队列,带有关联的BTree或其他通用随机访问数据结构,以维护有关消息的元信息。BTrees是可用的最通用的数据结构,可以支持消息传递系统中各种各样的事务性和非事务性语义。不过,它们确实有相当高的成本:Btree操作是O(log N)。通常O(log N)被认为基本上等同于常数时间,但磁盘操作并非如此。磁盘寻道以10 ms的速度出现,每个磁盘一次只能进行一次寻道,因此并行性是有限的。因此,即使是少量的磁盘寻道也会导致非常高的开销。由于存储系统将非常快的缓存操作与非常慢的物理磁盘操作混合在一起,因此当数据随着固定缓存的增加而增加,观察到的树结构性能通常是超线性的——也就是说,当数据加倍会让事情变得更糟,而不是慢两倍。
直观地说,持久队列可以建立在简单的读取和附加到文件上,这是日志记录解决方案的常见情况。这种结构的优点是所有操作都是O(1),读取不会屏蔽写入或相互屏蔽。这具有明显的性能优势,因为性能与数据大小完全分离——一台服务器现在可以充分利用许多廉价、低转速的1+TB SATA驱动器。尽管它们的寻道性能很差,但这些驱动器对于大型读取和写入具有可接受的性能,价格为1/3,容量为3倍。
能够访问几乎无限的磁盘空间而不受任何性能损失意味着我们可以提供一些通常在消息传递系统中找不到的功能。例如,在Kafka中,我们可以将消息保留相对较长的时间(例如一周),而不是试图在消息被消耗后立即删除它们。正如我们将描述的那样,这为消费者带来了很大的灵活性。
四、效率
1、IO优化
Kafka主要用在处理大量的网络活动数据,每个页面浏览总次数可能会产生数十次写入。此外,我们假设发布的每条消息至少被一个消费者(通常是许多消费者)阅读,因此需要努力降低消费成本
第三章已经阐述了解决磁盘低性能的方法。那么造成效率低下的就剩下两个原因了:
1、太多的小输入/输出操作
2、过多的字节复制
小输入/输出问题发生在客户端和服务器之间以及服务器自己的持久操作中。因此Kafka的协议是围绕一个“消息集”抽象构建的,即:
1、网络请求将消息分组在一起,并分摊网络往返的开销,而不是一次发送一个消息
2、服务器反过来一次性将消息块附加到其日志中
3、消费者一次获取一个大的线性块
这种简单的最优化产生数量级的加速。批处理导致更大的网络数据包、更大的顺序磁盘操作、连续的记忆块等等,所有这些都允许Kafka将随机消息写入的突发流转化为流向消费者的线性写入。
另一个低效率是字节复制。由于数据在生产者、broker、消费者之间只传输无需修改。因此可以通过Linux中的sendfile系统调用完成解决。
我们先看看数据从文件到Socket的传统操作是怎么样的:
1、操作系统从磁盘读取数据到内核空间的pagecache
2、应用程序将数据从内核空间读入用户空间缓冲区
3、应用程序将数据写回内核空间到Spcket缓冲区
4、操作系统将数据从Spcket缓冲区复制到网卡/网络适配器缓冲区,然后通过网络发送
这显然是低效的,有四个副本和两个系统调用。使用sendfile,通过允许操作系统将数据从页面缓存直接发送到网络来避免这种重新复制。所以在这个优化的路径中,只需要将最终副本复制到网卡/网络适配器缓冲区。
Kafka中常见的使用是一个topic有多个消费者消费,因此,我们希望在零拷贝的基础上,将数据复制到pagecache中一次,并在每次消费时重复使用,而不是存储在内存中,每次读取再复制到用户空间。这允许消息以接近网络连接限制的速率被消费。
pagecache和sendfile的这种组合意味着在消费者只需要关注Kafka即可,它将看不到磁盘上的任何读取活动,因为它们将完全从缓存中提供数据。
注意:Kafka目前不支持内核SSL_sendfile。因此,当启用SSL时不使用sendfile
2、带宽
在某些情况下,瓶颈实际上不是CPU或磁盘,而是网络带宽。对于需要通过网络在数据中心之间发送消息的数据管道来说尤其如此。这时候就需要对数据进行压缩。
a、一条消息压缩一次
b、同一类型消息统一压缩一次
对比看来,肯定b效率更高,因为大部分冗余是由于同一类型消息之间的重复造成的(例如JSON中的字段名或web日志中的用户代理或常见字符串值)
Kafka以高效的批处理格式支持b。一批消息可以组合在一起,压缩,并以这种形式发送到服务器。broker解压批处理以验证它。例如,它验证批处理中的记录数与批处理头状态相同。然后,这批消息以压缩形式写入磁盘。批处理将在日志中保持压缩,也将以压缩形式传输给消费者。消费者解压缩它接收到的任何压缩数据。
Kafka支持GZIP、Snappy、LZ4和ZStandard压缩协议
五、生产者
生产者将数据直接发送到作为分区leader的broker,而无需任何中间路由层。
生产者默认随机向各个分区打消息来实现负载平衡,也可以通过一些语义分区函数来完成。比如用户指定key,并使用hash来分配打到哪个分区,或者也可以自定义分区器。
批处理是效率的主要驱动因素之一,为了启用批处理,Kafka生产者将尝试在内存中积累数据,并在单个请求中发送更大的批处理。批处理可以配置为积累不超过固定数量的消息,并且等待时间不超过一些固定的延迟限制(例如64k或10毫秒)。这允许积累更多的消息来发送,并且在服务器上几乎没有更大的I/O操作。这种缓冲是可配置的,并提供了一种机制来权衡少量额外的延迟以获得更好的吞吐量。
六、消费者
消费者通过向引导其想要消费的分区的brokers发出“fetch”请求来工作。消费者在每次请求时在日志中指定其偏移量(offset),并从该位置接收回一块日志。因此,消费者对这个位置有很大的控制权,如果需要,可以对历史数据进行重新消费。
1、push vs pull
我们首先考虑的一个问题是,消费者应该从brokers提取数据,还是brokers应该将数据推送给消费者。在这方面,Kafka遵循了一种更传统的设计,大多数消息传递系统都采用这种设计,数据从生产者推送到brokers,由消费者从brokers中提取。一些以日志记录为中心的系统,如Scribe和Apache Flume,遵循一种非常不同的基于推送的路径,将数据推送到下游。这两种方法都有利弊。然而,基于推送的系统难以处理不同的消费者,因为brokers控制着数据传输的速率。目标通常是让消费者能够以最大可能的速度消费;不幸的是,在推送系统中,这意味着当消费者的消费率低于生产率时,消费者往往会不知所措(本质上是拒绝服务攻击)。基于拉动的系统具有更好的特性,即消费者只是落后,并在可能的时候赶上。这可以通过某种退避协议来缓解,消费者可以通过该协议表示自己不堪重负,但让传输速率充分利用(但永远不要过度利用)消费者比看起来要棘手。以前以这种方式构建系统的尝试使我们采用了更传统的拉取模型。
基于拉取的系统的另一个优点是,它适合对发送给消费者的数据进行积极的批处理。基于推送的系统必须选择立即发送请求或积累更多数据,然后在不知道下游消费者是否能够立即处理的情况下稍后发送。如果调整为低延迟,这将导致一次发送一条消息,但传输最终仍会被缓冲,这是浪费。基于拉取的设计解决了这个问题,因为消费者总是在日志中的当前位置之后(或达到某个可配置的最大大小)拉取所有可用消息。因此,在不引入不必要的延迟的情况下,可以获得最佳的批处理。
基于拉取的系统的缺陷是,如果brokers没有数据,消费者可能最终会在一个紧密的循环中轮询,实际上是忙于等待数据到达。为了避免这种情况,我们在拉取请求中设置了参数,允许消费者请求在“长轮询”中阻塞,等待数据到达(并且可以选择等待,直到给定数量的字节可用以确保大传输大小)。
2、消费者定位
令人惊讶的是,跟踪已消费的内容是消息传递系统的关键性能点之一。
Kafka是这样做的:消费者从broker拉取数据后,broker将其标记为已发送,而不是已消费,broker需要等待消费者的特定确认来将消息标记为已消费。这种策略解决了丢失消息的问题,但也产生了新的问题。
1、消费者处理完消息,马上就要向broker发送确认消息时,失败了,会导致重复消费
2、消费者还没处理完消息,就向broker发送了确认消息,结果消息处理失败了,导致数据丢失
这就需要消费者将处理消息的最后逻辑和向borker发送确认消息这两件事放在同一个事务中来解决
维护offset还有一个好处:如果消费者代码有一个bug,并且在一些消息被消费后被发现,
一旦bug被修复,消费者就可以重新消费这些消息
3、离线数据加载
Kafka还允许消费者定期消费,将数据定期批量加载到Hadoop或离线数仓中。每个节点/主题/分区组合对应一个映射任务,从而在加载过程中实现完全并行。Hadoop提供了任务管理,失败的任务可以重新启动,而不会有重复数据的危险
4、静态成员
静态成员资格旨在提高基于组再平衡协议构建的流应用程序、消费者组和其他应用程序的可用性。再平衡协议依赖于组协调器将实体ID分配给组成员。这些生成的ID是短暂的,当成员重新启动并重新加入时会发生变化。对于基于消费者的应用程序,这种“动态成员身份”可能会导致在代码部署、配置更新和定期重启等管理操作期间将大量任务重新分配给不同的实例。对于大型状态应用程序,混洗任务在处理之前需要很长时间来恢复其本地状态,并导致应用程序部分或完全不可用。基于这一观察,Kafka的组管理协议允许组成员提供持久的实体ID。基于这些ID,组成员身份保持不变,因此不会触发重新平衡。
如果你想使用这个特性,需要以下操作:
1、将broker集群和客户端应用程序升级到2.3或更高版本,并确保升级后的代理使用
2、为一个组下的每个消费者实例设置配置ConsumerConfig#GROUP_INSTANCE_ID_CONFIG为唯一值
3、对于Kafka Streams应用程序,为每个KafkaStreams实例设置一个唯一的ConsumerConfig#GROUP_INSTANCE_ID_CONFIG就足够了,独立于实例使用的线程数
七、总结
结合以上设计理念我们可以得出以下视图:(下载后就会变清晰哟)
版权归原作者 隔着天花板看星星 所有, 如有侵权,请联系我们删除。