我们设计 kafka的目的是为了提供了一个处理所有实时数据汇总的统一平台。为了实现这个目标,我们不得不考虑各种各样的用例。
首先,它应该是一个可以以高吞吐量的方式来处理像实时日志收集一样的高容量事件流。
其次,能优雅处理离线系统周期性加载数据而导致的大批量数据积压。
另外,它能够处理低延迟的处理传统的点对点消息传递。
我们希望能支持分区,分布式,实时处理汇总的数据,传递。这促成了我们的分区和消费者模型。
在流信息被传递给其它数据系统时,我们知道系统必须能保证在机器出错故障时具有容错能力。
为了支持上面这些想法,我们设计了一些相对于传统消息系统更像数据库日志的独特组件。在下面的章节中,我们会概述其中一部分设计。
kafka 强依赖文件系统来存储和缓存消息。一个普遍的看法是“磁盘很慢”,这也使人们怀疑一个提供持久化功能的系统在性能上有没有竞争力。事实上,磁盘说慢非常慢,说快也很快。只要正确使用,正确设计磁盘结构,会让磁盘跟网络一样快。
关于磁盘性能的关键事实是,硬盘的吞吐量与过去十年的磁盘搜索的延迟已经不同了。
因此,一个配置了6块 six 7200转每分钟的 SATA RAID-5 磁盘阵列,线性写可以达到600MB/秒。但是相同配置随机写只有100k/秒。整整相差了6000多倍。线性读写是所有使用方式中最容易想到的。并且也是被操作系统做了很大优化的方式。一个现代操作系统,会提供预读和延迟写技术来处理数据。预取即一次性取很多数据块。延迟写即把一组小的逻辑写整合成一次大的物理写。这也说明,顺序访问磁盘会比随机访问要快很多。
为了弥补这种性能差异,现代操作系统越来越积极的使用主内存进行磁盘缓存。如果不使用直接磁盘io,这个特性很难关闭。因此,即使一个进程维护了一个进程内的数据缓存,那么数据很可能被复制到os pageCache中,所有数据都会被有效存储两次。
此外,我们是建立在jvm之上。研究过java内存的都了解以下两点:
因为这些特性,使用文件系统以及依赖pageCache更优与直接使用内存(虽然pageCache也在内存中,但不像jvm直接使用内存,而是通过pageCache间接使用:不知道理解是否正确)或其它结构。我们通过自动连接到空闲内存,至少让可用内存翻倍(有空闲内存就使用,其它程序申请就释放,不固定占有--相同jvm head)。
通过存储压缩过字节数据比存储对象,我们可能还能让可用内存翻倍。这就使在一个32G内存的机器上缓存28-30G也不会触发GC。另外,即使服务重启了,缓存仍然存在。相反,进程内的缓存会全部丢失(重新加载需要花费很长时间)--(注意:是服务重启,并不是机器重器。pageCache是在内存中,由os管理,与进程无直接关系,因此不会丢失)。 这也大大简化了保持缓存和OS的文件系统的一致性逻辑的代码,这比使用一次性的进程内缓存(one-off in-process)更有效也更正确。如果你的磁盘支持线性读取,从每个磁盘读取的有效数据都会有效的预先放入缓存。
这意味一个非常简单的设计:当内存空间不足的时候,我们会吧数据全部持久化到文件系统。 所有数据立即写持久化日志到文件系统(os层面,非磁盘,此处批指的是pageCache ),而不是直接刷新到磁盘。这也意味着把数据转移到内核的pageCache。
这种以pagecache为中心的设计风格请参考这个文章。
消息系统中持久化数据结构的设计通常是维护者一个和消费队列有关的BTrees或者其它能够随机存取结构的元数据信息。BTrees是一个很好的结构,可以用在事务型与非事务型的语义中。尽管B树的操作需要O(logN),但它们的成本相当高。通常O(log N)被认为基本上等同于常量时间,但是对于磁盘操作来说这不这样的。磁盘寻址一次需要10ms,并且一次只能寻一个,因此并行化是受限的。
因此,即使是少量磁盘寻址也会有非常高的开销。由于存储系统将非常快的缓存操作与非常缓慢的物理磁盘操作混合在一起,我们注意到在缓存固定的情况下数据的增加使得树结构的性能的变化是超线性的,比如,数据增加2倍,对性能的影响却超过2倍。
直观上讲,一个持久化的队列可以构建在对一个文件的简单的读和追加上,就像一般情况下的日志解决方案。这个结构的优点是所有的操作都是O(1),磁盘读、写也不会互相阻塞。由于性能和数据大小完全无关,所以这个结构有显著的优势-一个服务器现在可以充分利用一些廉价,低转速的1TB+ SATA硬盘。虽然它们的寻址性能很差,但这些磁盘在大块数据读写方面的性能还是可以接受的。这些磁盘普遍只有SAS磁盘价格的1/3和3倍以上的容量。
在没有任何性能损失的情况下 拥有访问几乎无限的磁盘空间的能力意味着我们可以提供传统消息传递系统中很少看到的一些功能:例如,在传统消息中间件系统中往往会在消息一旦被获取后立即尝试删除该消息数据,而Kafka能够为消息数据保留一个相对来说很长的时间(如一周)。仅这一个特性,就为消息消费端提供了大量的灵活性。
我们在效率上投入了大量努力。我们最主要的一个用例就是处理网站活动数据。网站活动数据容量非常大:每个页面浏览都可能产生几个写操作。另外,每条消息都至少有一个(通常很多)消费者消息。因此,我们努力让消息消费的代价变的尽可能小。
我们还发现,从构建和运行一系列类似系统的经验来看,效率是实现多用户操作的关键,如果下游基础设施服务由于应用程序使用不当而容易成为瓶颈,那么这些小地方往往会产生问题。由于速度非常快,这有助于确保应用程序在基础设施的负载能力之下尽量提高负载。当在一个统一的集群中支持运行数十个或数百个应用的统一服务时这点尤其重要,因为使用模式几乎没在变化。
我们在上一节讨论了磁盘效率。 一旦消除了较差的磁盘访问模式,在这种类型的系统中有两个常见的原因会导致低效:太多的小I / O操作和过多的字节复制。
客户端和服务器之间以及服务器自身的持久性操作都会发生小I / O问题。
为了消除上述情况,我们创建了一个“消息集”的抽象概念,即消息按自然分组组合在一起。这种机制允许网络请示可以一次把多个消息分组打包在一起发送,这样可以分摊一次请求只发一条消息而带来的网络开销。服务器可以一次性把打包在一起的消息追加到日志中。消费者也可以整包消费消息。
这个简单的优化让速度提升了一个数量级。批量操作导致大的网络数据包,大的磁盘线性操作,以及连序的内存块等等。所有这些,都让kafka能把随机的消息流以线性的方式写入,并发送给消费者。
另外一影响性能的是字节复制。在低消息率(消息量小)的情况下,这不是问题。但是在高负载的情况下,这影响是显著的。为了消除这个,我们采用了一个标准的二进制消息格式。生产者、消费者、代理都遵守这个标准(因此数据包在传递过程中不需要修改)。
broker维护的消息日志本身就是一个文件目录,每个文件都保存一系列消息集合,这些消息集合以生产者和消费者都可以使用的通用格式写入磁盘(传统的mq可能使用不同的格式:如生产者使用字符串,代理使用二进制,消费者又使用字符串)。保持这种通用格式可以优化最重要的操作:持久化的日志块的网络传输。 现代unix 操作系统,为从pagecache向socker传输数据提供了一个高度优化的编程方式。 在Linux中,这是通过“ sendfile system call”完成的。
为了理解sendFile的影响,需要了解数据从文件到socket的传输路径:
四个副本,两次系统调用,这显然是低效。通过使用sendfile可以消除重复复制。它是允许os直接把数据从pagecache复制到network,可以避免重新复制。所以在这个优化的路径中,只需要最终拷贝到NIC缓冲区。
我们希望针对一个主题多个消费者的情况能有一个通用的用例。使用上面零复制优化方案,数据从磁盘到pagecache,只复制一次。多次消费共用同一个pagecache的数据。而不像那种把数据放到内存的做法,每次读都会把数据从内核中复制出去。这让消息的消费速率基本接近网络传输的最高限制。
把pagecache和sendfile结合起使用,让kafka集群,你会发现基本没有读取磁盘的操作,因此数据完全从缓存中读取(基本没有读操作,并不代表没有写操作。此处有一个前提:生产者和消费者同时在线,生产者生产的数据放入pagecache中,还没有被回收时,就被消费者消费,因此基本不需要再从磁盘读到pagecache)。
有关sendfile和Java中零拷贝支持的更多背景信息,请参阅本文。
在某些情况下,瓶颈实际上不是CPU或磁盘,而是网络带宽。对于需要通过广域网在数据中心之间发送消息的数据管道尤其明显。当然,用户可以一次压缩一条消息(完全不需要kafka的支持即可)。但是这可能导致很低的压缩率。因为更多的冗余是由于有很多相同类型的消息的重复引起的(例如,JSON中的字段名称或web日志中的user agents或相同的字符串值)。如果想提高压缩率,应该多条相同类型的消息一起批量压缩。
Kafk以高效的批处理格式支持这一点。 一批消息可以压缩在一起并以这种形式发送到服务器。 这批消息将以压缩格式写入,并在日志中保持压缩,只会由消费者解压缩。(0.11开始的)
kafka支持 GZIP, Snappy,LZ4压缩协议。
生产者将数据直接发送给分区leader所在的broker,而不需要任何中间路由层。为了帮助生产者做到这一点,在Kafka集群中的每个节点都会向Producer提供metadata元数据的查询服务,帮助producer发现和定位自己需要使用的活跃分区是哪一个,然后producer便可以把处理请求直接发往该topic的活跃分区,不需再经由什么中间的路由层次。
客户端控制它将消息发布到哪个分区。这种控制算法可是随机的(随机负载均衡),也可以通过自定义分区函数完成。我们通过允许用户指定一个键进行分区,并使用它来散列到一个分区(如果需要的话,也可以选择重写分区函数)。例如,如果选择的密钥是用户ID,那么给定用户的所有数据都将被发送到同一个分区。这反过来将允许消费者利用分区选择他们要消费的数据,这种分区方式明确地设计为允许消费者进行位置敏感的处理。
批量发送是效率的主要因素。为了批量操作,kafka生产者通过在内存中缓存数据,并一次请求发送较大的批次。批量操作可以通过配置一个消息数量上限和一个延迟时间(64k或10ms)来控制批量操作。这样就允许一次请求可以发送更多消息,让服务器使用更少量但是更大的i/o操作。缓存是可配置的,为牺牲少量延迟但是提供更高吞吐量的追求提供了一个可选机制。这种缓冲是可配置的,并提供了一种机制来牺牲少量额外的延迟以获得更好的吞吐量。
Kafka消费者通过向经销商发出“fetch”请求来引导他们想要消费的分区。消费者在每次请求中指定它在消息日志中的偏移量(从哪个位置开始消费),并从该位置接收一个日志块。因此,消费者可以控制偏移量位置,并在需要时重新消费已消费过的数据。
一个我们考虑的问题是,消费者是应该从代理拉取数据,还是代理把数据推给消费者。
在这方面,kafka选择了一个大多数消息系统者会使用,但更传统的方式:生产者主动把数据推送到代理,消费者主动从代理拉取数据。一些以日志为中心的系统,例如: Scribe和Apache Flume,是基于推送的方式把数据推到下流系统。这两种方式各有利弊,但是基于推的系统,因为代理控制了数据传输速率,所以很难处理各种不同类型的消费者。一般来说,我们的目标是为了让消费者尽可能以最大的效率消息消息。但是,基于推的系统,如果消费端消费速率跟不上生产速率,就会导致消费端压力过大而过载。基于拉的系统有更好的特性,消费者可以落后生产者,只是在消费者有能力的情况下追赶生产者即可。这种方式可以使用一些退出协议减轻过载的消费服务器,但是仍然可以充分利用传输速率。因此,我们选择使用拉模型,但是消费者还是可以非常聪明的得到一个最大的(而非过载)传输效率。因此,我们选择使用拉模型。
基于拉的系统还有另外一个优点:消费者可以主动批量操作数据。一个基于推的系统,在不知道消费者是否会立即处理的情况下,必须选择是立即推送还是缓存更多数据后批量推送。如果需要低延迟,就必须一次一条消息的方式发送,这太浪费了。基于拉的设计修复了这个缺陷,因为消费者可以从日志的当前位置拉取后面的所有消息(可以配置最大拉取数量)。人们可以获得最佳批次而不会引起不必要的延迟。
一个简单的基于拉的系统有一个不足之处就是:如果代理没有数据了,消费者可能仍然循环拉取数据。为了消除这个,我们在请求的提供了参数,让以通过long poll的方式来阻塞一个请求来等待数据到达(并且可以选择等待直到数据达到给定数量的字节,可用以确保传输大的数据量)。
你可以想象其他可能的设计,只有拉,端到端。生产者在本地写一个本地化地的日志,broker和消费者一起从这个日志中拉取数据。经常提出一个类似的“存储和转发”生产者。这是很有意思的,但我们觉得不是很适合我们有成千上万的生产者的目标用例。我们在大规模运行持久数据系统的经验使我们感到,有许多应用程序使用的涉及数以千计的磁盘的系统实际上不会使事情变得更加可靠,而会是一场噩梦。在实践中,我们发现我们可以大规模地运行带有强大SLA的流水线,而不需要生产者持久性。
让人惊讶的是,跟踪哪些消息被消费了,是影响一个消息系统性能的一个关键点。
大多消息系统都会在代理上保存哪些消息被消费的元数据。也就是说,消息一旦分发给消费者,代理就要立即在本地记录下,或者等消费者确认。这是一个相当直观的选择,实际上对于单机器的服务器来说,它不清楚这个状态还可以存哪里。由于许多消息系统存储的消息规模并不大,因此这也是一个务实的选择—-broker知道哪些消息已经消费了,并可以立即删除,因此可以保持一个很小量的数据存储。
让broker和消费者就消息是否已经消费达成协议并不是一个小问题。如果消息每次通过网络分发出去,broker就必须马上记录下来,而消费者处理消息失败了(如:超时了,或崩溃了),那么消息可能就丢失了。为了解决这个问题,许多消息系统增加了一个确认回执的功能。也就是说,一条消息发出后,消息会被标记为“发送”状态而不是”已消费”状态。broker等待消费者回执确认后,再把消息标记为消费。这个策略解决了丢失信息的问题,但是却产生了新的问题。首先,如果消费者处理完消息,在发送回执的时候失败了,那么消息可能会被消费两次。第二个问题是性能问题,现在,broker必须保持每个消息的多个状态(首先锁定它,以便不会重复发送,然后将其标记为已消费,可被删除)。 棘手的问题必须得到处理,比如如何处理被发送但未被确认的消息。
kafka使用不同的处理方式。
我们的主题被分成一组完全有序的分区,每一个分区同一时间只能被订阅消费组中的一个消费者消费。这就意味着一个消费者的位置在每个分区上只用一个integer字段就可标记下一条消息的偏移量。这使得消息被消费的状态维护非常小,每个分区只有一个数字。状态可以定期checkpoint,这使消息确认代价非常小。
这样做有一个好处,消费者可以故意倒回到旧的偏移量并重新使用数据。这违反了队列的通用协议。但是对于许多消费者来说却是一个必不可少的特征。例如,如果消费者代码有一个bug,在消息被消费以后才发与,那么在bug修复以后可以重新消费消息。
可扩展的持久化特性使这些消费者成为可能。像:定期批量加载数据到离线系统的消费者(hadooop或关系型数据仓库)。
在hadoop并行加载数据的情况下我们通过拆分map-task,每个节点/主题/分区都允许并行加载。hadoop提供task管理,任务失败时,可以重新从原来的位置加载(不用担心失败,因为有副本)。
现在我们对生产者和消费者如何工作有一些了解,让我们来讨论一下Kafka在生产者和消费者之间提供的语义保证。 显然,可以提供多种可能的消息传递保证:
这分解成两个问题:发布消息的持久性保证和消费消息时的保证。
许多系统声称提供“恰好一次”的传送语义,但重要的是要阅读细则,这些声明大多是误导性的(即它们不说明消费者或生产者可能失败的情况,存在多个消费者进程的情况,或写入磁盘的数据可能丢失的情况)。
kafka的语义是直截了当的。发布消息时,我们将消息 “commit”到日志中。一旦发布的消息被提交,只要复制写入该消息的分区的broker保持“alive”状态,它就不会丢失。提交的消息的定义,活动分区以及我们试图处理哪些类型的失败将在下一节中更详细地描述。现在让我们假设一个完美的,无损的broker,并了解生产者和消费者是如何保证的。如果生产者尝试发布消息并且遇到网络错误,则不能确定消息是在commit之前还是之后发生的。 这与使用自动生成的键插入到数据库表中的语义相似。这与使用自增主键插入到数据库表中的语义相似。
在0.11.0.0之前,如果一个生产者没有收到一个表示消息已经提交的响应,那么除了重新发送消息之外别无选择。这提供了至少一次传送语义,因为如果原始请求实际上已经成功,则在重新发送后消息将再次写入日志。自0.11.0.0开始,Kafka生产者也支持一个幂等递送选项,保证重新发送不会在日志中生成重复数据。为了达到这个目的,broker为每个生产者分配一个ID,生产者发送的每个消息都有一个序列号,使用id和序列号保证不会有重复消息。也从0.11.0.0开始,生产者支持使用类似事务的语义将消息发送到多个主题分区的能力:即,所有的消息都被成功写入或者全部失败。这主要用户在kafka topic之间进行“精确一次”处理:
并不是所有的用例都需要这样的强力保证,对于对延迟敏感的使用情况,我们允许生产者指定它想要的耐久性(durability)级别。 如果生产者指定它想要等待提交的消息,则可以采用10ms的量级。 然而,制作者也可以指定它想要完全异步地执行发送,或者它只想等到领导者(但不一定是跟随者)有消息。比如设置等待10ms以确认消息发送出去了、或者是按照异步模式发送,或者设置为一直等待到leader分区已经收到消息为止。
从消费者角度看Kafka的消息分发机制,在Kafka集群中,所有的副本都有相同offsets的相同日志数据。Consumer控制自己消费日志的offset。如果consumer端从不会发生故障,那只需把offsets保存保内存中就可以了。但是如果消费者出现了故障,我们希望这个topic的分区数据被另外一个消费进程处理,那么新的处理进程就需要从一个合适的位置来处理。
假设消费者读取一些消息 -它有几个选择来处理消息并更新offests:
那么怎么实现”exactly-once”呢。当从一个Kafka主题中消费并产生到另一个主题(如Kafka Streams应用程序中)时,我们可以利用上面提到的0.11.0.0中的新的事务性生产者功能。消费者的位置作为消息存储在主题中,所以我们可以在与接收处理数据的输出主题的同一个事务中将offset写入kafka,如果事务被中止,则消费者价格offset位置恢复到老的值,并根据“隔离级别”,输出主题上产生的数据不会被其他消费者看到。在默认的“read_uncommitted”隔离级别中,消费者即使是中止事务,所有消息都是可见的,但是在“read_committed”中,消费者只会从已提交的事务中读取消息。
写入外部系统时,需要协调消费者的位置与实际作为输出存储的位置。实现这一目标的经典做法就是消费者位置的存储和消费者数据的存储引入两阶段提交。但是,这还有更简单的方法,通过让消费者将其offset存储在与其输出相同的位置来实现。 这样做更好,因为消费者可能想要写入的输出系统可能有许多不支持两阶段提交。 例如,一个Kafka Connect连接器,它将HDFS中的数据和它所读取的数据的偏移一起填入数据,以保证数据和偏移量都被更新,或者都失败。 对于许多其他需要这些更强的语义的数据系统,对于许多消息没有主键去重的同样需要更强的语义的数据系统,我们也用类似的模式处理。
所以,我们可以在kafka streams中有效的支持精确一次的语义,并且在Kafka主题之间传输和处理数据时,事务性生产者/消费者通常可提供准确一次的语义。其他目的系统的精确一次通常需要这些系统的支持,但是Kafka提供了实现这种可行的补偿(参见Kafka Connect)。
Kafka通过可配置服务器数量复制每个主题分区的日志(您可以在每个主题上设置不同复制因子)。 这样,当集群中的服务器出现故障时,可以自动故障转移到这些副本,以便在发生故障时保持可用状态。
其他消息传递系统提供了一些与复制有关的功能,但是在我们(完全有偏见的)看来,这似乎是一个不太常用的东西,而且有很大的缺点:从属节点处于不活动状态,吞吐量受到严重影响,繁琐的手动配置等等。Kafka是默认使用复制的,实际上当复制因子是1时,我们将未复制的topic当做已复制的topic。
副本的最小单位主题分区。在没有失败的情况下,每个kafka分区有一个leader和0个或更多followers。副本总数(包括leader)组成了副本因子。所有的读写都由leader的分区上处理。通常,有比broker多很多的分区,leader均匀的分布在所有broker上。followers上的副本日志与leader保持完全一样:都有相同的偏移量,消息拥有相同的顺序(当然,在任何时间,因为延迟leader日志未尾有几条还没有复制的消息)。
followers像一个普通的消费者一样从leader消费消息,把消息追加他们日志(不是复制文件的方式创建副本,而是假装成消费者从leader消费)。使用这种方式可以使follower很自然的将日志条目(log entries)组织成一个批量。
与大多数分布式系统一样,自动处理故障需要精确定义节点怎么样才能算“alive”。 对于kafka节点的alive需要满足两个条件
在分布式系统术语中,我们只尝试处理 “失败/恢复”模型,节点突然停止工作,然后恢复(可能不知道已经死亡)。kafka不处理“拜占庭”式错误。这些节点产生随意或恶意的反应(可能是因为bug或者违规操作)
一条消息只有当所有同步副本把消息都追加到它们的分区日志中才称为“已提交”。只有“已提交”的消息才会发送给消费者。这意味消费者不需要担心它们看到消息会因为leader挂掉而消失。另一方面,生产者可以选择是等待消息处于“已提交”状态还是不需要等待。这取决于为了低延迟还是持久化之间的权衡。这是通过生产者的”acks”配置来控制。
请注意,主题有一个“minimum number”的同步副本的设置,当生产者请求确认消息已写入所有的同步副本集时,将检查这些副本。如果生产者请求较不严格的确认,则即使同步复本的数量低于最小值(minimum number)(例如,它可以与领导者一样低),也可以提交和消费该消息。
kafka提供的保证是,在任何时间只要至少有一个同步副本存在,已经提交的消息就不会丢失。
在短暂的故障切换期后,kafka将保持可用状态,但在出现网络分区的情况下可能无法保持可用状态。
kafka分区的核心是日志复制。 复制日志是分布式数据系统中最基本的原理之一,实现它的方法很多。日志复制机制同样地在需要保存状态的分布式系统中有大量应用。
一个复制日志模型需要保持一系列值的顺序一致(通常是对日志条目编号0,1,2,…)。 有很多方法可以实现这一点,但最简单和最快的方法是选择提供给它排序的值leader。 只要leader还活着,所有的follower只需要复制数据并且根据leader的选择排序。
当然,如果leader没有失效,我们是不是需要followers的。但是,当leader挂了时,我们需要从followers中选一个新的leader。不过,followers本身也可能落后太多,或者崩溃。因此我们必须保证我们选择一个最新的follower。一个日志副本的基本保证的算法是:如果我们告诉客户端消息已经提交了,此时leader失效了,那么选出的新leader必须包含那条消息。这就需要一个折衷,如果一条消息在被标明“己提交”之前等待更多的follower确认,那么它在失效后,会有更多的follower可以做为新的leader,这会造成吞吐量降。
如果你选择需要确认的数量并且必须比较日志数量去选择leader,这样保证有重叠,则称为仲裁。
最常见的做法是投票,得票最多的成为领导。但这不是kafka采用的方式,但是我们可以来了解一下这种方式。假设我们有2f + 1个副本(包括leader和follower),如果f+1个副本必须在leader声明commit之前接收到一条消息,并且如果我们通过从至少f+1个副本中选出有最完整日志记录的来成为新的leader,失败不超过f,leader才能保证拥有所有提交的消息。这是因为在任何f+1副本中,必须至少有一个副本包含所有提交的消息。 这个副本的日志将是最完整的,因此将被选为新的leader。每个算法都必须处理许多其他细节(例如,精确定义了什么使得日志更加完整,确保了领导失败期间的日志一致性或更改副本集中的服务器集),但是现在我们将忽略这些细节(例如,精确定义了什么使得日志更加完整,确保leader失败期间或者更改副本服务器列表的日志一致性),但是现在我们将忽略这些细节。
这种方式有个很大的优势,系统的延迟只取决于最快服务器,也就是说,如果复制因子是3,则等待由最快的slave决定而不是慢的那个。
实际上,Leader Election算法非常多,比如ZooKeeper的Zab, Raft和Viewstamped Replication。而Kafka所使用的Leader Election算法更像微软的PacificA算法。
投票的缺点是,为了保证Leader选举的正常进行,它所能容忍的fail的follower个数比较少。 要容忍一个故障需要三份数据,而要容忍两次故障则需要五份数据。根据我们的经验,只有足够的冗余来容忍单个故障对一个实际系统来说是不够的,但是每写五次,磁盘空间需要增加的5倍并且吞吐量变成1/5,对于大量的数据问题就不是很实用。这可能是为什么仲裁算法更常用于共享群集配置(如ZooKeeper)的原因,但是对于主数据存储则不太常见。 例如,在HDFS中,namenode的高可用性功能是根据选举实现的,但是这种代价昂贵的方法不适用于数据本身。
kafka采取了一种稍微不同的方法来选择参加选举的人数。kafka不是多数投票,而是动态地维护一组和leader有联系的同步副本(ISR)。只有ISR的成员才有资格当选leader。 在所有的同步副本都收到写入前,写入Kafka分区的消息不会被视为提交。当ISR变化时会被持久化到ZooKeeper中。正因为如此,ISR中的任何副本都有资格当选leader。 对于kafka的使用模型来说,这是一个重要的因素,有很多分区,确保领导平衡很重要。有了这个ISR模型和f + 1副本,一个Kafka主题可以容忍f故障,而不会丢失确认后的消息。
kafka采取了一种稍微不同的方法来选择参加选举的人数。kafka不是多数投票,而是动态地维护一组和leader有联系的同步副本(ISR)。只有ISR的成员才有资格当选leader。 在所有的同步副本都收到写入前,写入Kafka分区的消息不会被视为提交。当ISR变化时会被持久化到ZooKeeper中。正因为如此,ISR中的任何副本都有资格当选leader。 对于kafka的使用模型来说,这是一个重要的因素,有很多分区,确保领导平衡很重要。有了这个ISR模型和f + 1副本,一个Kafka主题可以容忍f故障,而不会丢失承诺的消息。
在大多数使用场景中,这种模式是非常有利的。事实上,为了容忍f个Replica的失败,大多数投票和ISR在commit前需要等待确认的副本数量是一样的(例如,在有一个失败时仍然可用,大多数投票选举需要三个副本和一次确认,ISR方法要求两个副本和一次确认),延迟取决于最快服务器是大多数投票选举的一个优点。然而,我们认为可以通过允许客户端选择提交消息是否阻塞来改善,并且需要较少的复制因子而增加吞吐量和减少磁盘空间是值得的。
另一个重要的设计区别是,Kafka不要求崩溃的节点恢复所有的数据。在这个空间中,复制算法依赖于“稳定存储”的存在并不罕见,“稳定存储”在任何没有违反一致性的故障恢复场景中都不会丢失,这个假设有两个基本的问题。首先,磁盘错误是我们在持久化数据系统的实际操作中观察到的最常见的问题,这通常不会使数据再丢失。其次,即使这不是个问题,我们也不希望在每次写入时都要求使用fsync来保证一致,因为这可能会使性能降低两到三个数量级。我们允许副本重新加入ISR的协议可以确保在重新加入之前,即使丢失的是未刷新的数据,它也必须重新全部同步。
kafka保证数据不丢失的前提是:至少有一个副本是同步的。如果所有节点的分区副本都失效了,就无法保证数据不丢失。
如果真发生了这种事情,那么有两个方案:
这就需要在可用性和一致性当中作出一个简单的折衷。如果一定要等待ISR中的Replica“活”过来,那不可用的时间就可能会相对较长。而且如果ISR中的所有Replica都无法“活”过来了,或者数据都丢失了,这个Partition将永远不可用。如果选择第一个“活”过来的Replica作为Leader,而这个Replica不是ISR中的Replica,那即使它并不保证已经包含了所有已commit的消息,它也会成为Leader而作为consumer的数据源(前文有说明,所有读写都由Leader完成)。Kafka使用了第二种方式。根据Kafka的文档,可以使用配置属性 unclean.leader.election.enable 来禁用此行为,从而根据不同的使用场景选择高可用性还是强一致性。
当向kafka写入消息时,生产者可以选择是否等待消息被commited的确认(0,1,all(-1))。注意,all(-1),不保证所有的副本都收到消息了。默认情况下,acks=all,只要所有当前的同步副本收到消息,确认就会发生(为了性能,一般只选指定数量的副本做为同步副本,只要这些副本确认了就可以保证消息不丢失)。例如,如果一个主题配置了只有两个副本,一个失败(即只有一个同步副本保留),那么指定acks = all的写入将会成功。 但是,如果剩余副本也失败,这些写入可能会丢失。尽管这确保了分区的最大可用性,但是对于偏好持久性而不是可用性的一些用户,这种行为可能是不希望的。 因此,我们提供了两个主题级配置,可用于优先考虑消息的持久性:
1. 禁用unclean的领导人选举-如果所有的副本变得不可用,那么分区将保持不可用,直到最近的领导者再次可用。这有效地提高数据丢失的风险。 见上一节。
2. 指定最小的ISR大小 - 分区只有在ISR的大小超过制定最小值时才接受写入操作,以防止仅写入单个副本的消息丢失,当ISR大小小于指定值时将不可用。 这个设置只有在生产者使用acks = all的情况下才会生效,并保证消息至少被这么多(某个最小值)的副本确认。 此设置提供了一致性和可用性之间的折中。 对于最小ISR大小的更高设置保证了更好的一致性,因为信息被保证写入更多的副本,这减少了丢失的可能性。 但是,这会降低可用性,因为如果同步副本的数量低于最小阈值,则分区将无法写入。
以上关于复制日志的讨论确实只涉及单个日志,即一个主题分区。然而,一个Kafka集群将管理数百或数千个这样的分区。我们尝试以循环方式平衡集群内的分区,以避免高容量主题的所有分区集中在少量节点上。同样,我们试图平衡leader,使每个节点按照其分区比例份额被选举为leader。
优化leader的选举过程也是非常重要的,因为这是不可用的关键窗口。leader选举的一个简单的实现是,在一个节点失败时,最终在所有分区节点的每个分区上进行选举。相反,我们选择其中一个beoker作为“controller”。该controller检测broker级别的故障,并负责更改故障broker中所有受影响的分区的leader。这使得我们可以批量发起变更通知,这让选举程序面对大量分区时,变得代价更小也更快。如果controller失效了,余下的broker中的一个将成为新的controller.
日志压缩可确保Kafka始终至少为单个主题分区的数据日志中的每个message key保留最后一个已知值。它的使用场景如在应用程序崩溃或系统故障之后恢复状态,或者在运行维护期间重新启动应用程序后重新加载缓存。 让我们更详细地介绍这些用例,然后描述压缩是如何工作的。
到目前为止,我们只描述了更简单的数据保留方法,在一段固定的时间之后或日志达到某个预定的大小后旧的日志数据被丢弃。这适用于时间事件数据,例如记录每个消息到独立的地方。 然而,重要的一类数据流是根据主键进行更改的日志,可变数据(例如对数据库表的更改)。
我们来讨论一个这样的流数据的具体例子。 假设我们有一个包含用户邮箱地址的主题, 每当用户更新他们的电子邮件地址时,我们都会使用他们的用户ID作为主键向此主题发送消息。 现在说我们在一段时间内为id为123的用户发送以下消息,每个消息对应于电子邮件地址的改变(其他id的消息被省略):
123 => bill@microsoft.com
.
.
123 => bill@gatesfoundation.org
.
.
123 => bill@gmail.com
日志压缩为我们提供了更为细化的保留机制,因此我们保证至少保留每个主键的最新更新(例如[email protected])。通过这样做,我们保证日志包含每个key的最终值的完整快照,而不仅仅是最近更改的key。 这意味着下游消费者可以从这个主题中恢复自己的状态,而不必保留所有更改的完整日志。
我们先看几个有用的用例,然后看看如何使用它。
在这些情况下,主要需要处理变化的实时流入,但是偶尔当机器崩溃或需要重新加载或重新处理数据时需要全部加载。日志压缩允许将这两个用例从相同的支持主题中提取出来。本博客文章更详细地介绍了这种日志的使用方式。
在这些情况下,主要需要处理变化的实时流入,但是偶尔当机器崩溃或需要重新加载或重新处理数据时需要全部加载。日志压缩允许将这两个用例从相同的支持主题中提取出来。如果我们可以保留无限的日志,并且记录了上述情况下的每一个变化,那么我们就可以在每次从第一次开始时就捕获系统的状态。 使用这个完整的日志,我们可以通过重放日志中的前N个记录来恢复到任何时间点。对于更新单个记录多次的系统,这个假设的完整日志不是很实用,因为即使对于稳定的数据集,日志也将无限制地增长,简单的丢弃旧数据的日志保留机制将可以限制占用空间,但是日志不能恢复当前状态 - 限制从头回访日志可能不会重新得到当前状态,因为旧的数据可能已经删除。
日志压缩是提供更细粒度的基于每条记录保留的机制,而不是更粗粒度的基于时间的保留。这个想法是有选择地删除最近更新的有相同主键的记录。 这样,日志保证至少有每个key的最后一个状态。
此保留策略可以按每个主题进行设置,因此单个群集可以有一些主题,保留机制是按大小或时间强制执行的,其他主题是通过压缩保留保留的主题。
下图显示Kafka日志每个消息的偏移量的逻辑结构。
日志的头部与传统的Kafka日志相同。它具有密集的连续偏移并保留所有消息。日志压缩添加了一个处理日志尾部的选项。上面的图片显示了一个压缩后tail的日志。 请注意,日志尾部的消息保留了第一次写入时指定的原始偏移量 - 这些消息从不改变。 还要注意的是,即使具有该偏移量的消息已被压缩,所有偏移仍然保留在日志中的有效位置;在这种情况下,这个位置与日志中出现的下一个最高偏移无法区分。 例如,在上面的图片中,偏移量36,37和38都是等同的位置,并且从这些偏移量的任何一个开始的读取将返回从38开始的消息集合。
压缩也允许删除。具有key和空有效负载的消息将被视为从日志中删除。这个删除标记会导致任何先前的消息被删除(如同任何带有该key的新消息一样),但是删除标记是特殊的,因为在一段时间之后它们自己将被清除出日志以释放空间。删除不再保留的时间点被标记为上图中的“delete retention poin”。
压缩是通过定期重新复制日志段在后台完成的。 清除不会阻塞读取,并且可以被限制使用不超过可配置数量的I /O吞吐量,以避免影响生产者和消费者。 压缩日志段的实际过程如下所示:
日志压缩提供以下保证:
日志压缩是由日志清理器处理的,日志清理器是后台线程池,用于重新记录日志段文件,删除其日志头部出现的记录。
每个压缩机线程的工作原理如下:
日志清理器默认是启用的。 这将启动清理线程池。 要在特定主题上启用日志清理,可以添加特定于日志的属性:
log.cleanup.policy=compact
这可以在主题创建时或使用alter topic命令完成。
日志清理器可以配置保留日志的未压缩“头”的最小量。 这是通过设置压缩时间滞后来实现的。
log.cleaner.min.compaction.lag.ms
这可以用来防止在最小消息时间内更新的消息被压缩。 如果未设置,则除了最后一个分段(即,当前正在写入的那个分段)之外,所有日志段都有资格进行压缩,即使所有消息都比最小压缩时间滞后更早,活动段也不会被压缩。
Kafka有对请求执行配额以控制客户端使用broker的资源的能力。Kafka brokers可以为每组共享配额的客户端执行两种类型的客户配额:
1. 网络带宽配额定义了字节率阈值(从0.9开始)
2. 请求速率配额将CPU利用率阈值定义为网络和I / O线程的百分比(自0.11开始)
对于处理非常大容量数据的生产者或消费者,很可能会独占broker资源,并导致网络饱和以及对别的客户端和borkers产生DOS攻击。有了配额,就可解决这个问题。在大的多租户集群中,一系列小的恶意操作都可能降低用户的体验。
Kafka客户端的身份是代表安全集群中经过身份验证的用户的用户主体。在支持未经身份验证的客户端的集群中,用户主体是代理使用可配置的PrincipalBuilder选择的未经身份验证的用户的分组。 Client-id是由客户端应用程序选择的具有有意义名称的客户端的逻辑分组。 tuple(user,client-id)定义了共享用户组(user group)和client id的安全逻辑组的客户。
可以为(user, client-id),用户和客户端组定义配额配置。 可以在任何需要更高(或更低)配额的配额级别上覆盖默认配额, 该机制类似于每个主题日志配置覆盖。 用户和(user, client-id)配额覆盖写入ZooKeeper的/ config / users目录,客户端配额覆盖写在/ config / clients下。 这些覆盖被所有broker读取,并立即生效。 这使我们可以更改配额,而无需重新启动整个群集。每个组的默认配额也可以使用相同的机制动态更新。
配额配置的优先顺序是:
1. /config/users//clients/
2. /config/users//clients/
3. /config/users/
4. /config/users//clients/
5. /config/users//clients/
6. /config/users/
7. /config/clients/
8. /config/clients/
broker属性(quota.producer.default,quota.consumer.default)也可用于为客户端组设置网络带宽配额的默认值。这些属性已被弃用,将在以后的版本中删除。 客户端ID的默认配额可以在Zookeeper中设置,类似于其他配额覆盖和默认设置。
网络带宽配额为每组共享配额的客户端定义字节速率阈值。 默认情况下,每个唯一的客户端组都会收到由群集配置的固定配额(以bytes/sec为单位)。 这个配额是以每个broker为基础定义的。 客户端被限制之前,每个客户端组可以发布/获取每个代理的最大X字节/秒。
请求率限额为客户端定义可以在请求处理程序的I / O线程和配额窗口内每个代理的网络线程上使用的时间百分比。 n%的配额代表一个线程的n%,所以配额超出了((num.io.threads + num.network.threads)* 100)%的总容量。 每个客户端组在受到限制之前,可以在配额窗口中的所有I / O和网络线程中使用最高达n%的总百分比。 由于为I / O和网络线程分配的线程数通常基于代理主机上可用的内核数量,因此请求速率限额表示可由共享配额的每个客户端组使用的CPU总百分比。
默认情况,每个唯一的clientId会收来自集群配置的配额(bytes/sec,通过quota.producer.default, quota.consumer.default配置)。配置是基于broker,每个client只能发布或拉取以最大速率范围内。我们决定基于broker定义配额,要比配置到client上要好。因为那样实现起来太难了。
当检测到一个超额行为,broker会怎么做?
在我们的解决方案中,broker不会向客端报错,而是直接降低客端速度。它计算违规的client需要延迟的时间,并延迟response.这种方法对客户端是透明的。