4、设计
4.1、背景及目的
为了实现一个处理实时数据流的平台。能够支持大量数据的实时聚合计算,能够优雅的周期性的从大数据块中进行离线同步,是一个分布式、低延迟的消息队列,支持分区、备份。
4.2、持久化相关
(1)、不要惧怕磁盘操作
kafka的持久化操作重度依赖与文件系统。通常的认识是磁盘很慢,事实上磁盘比你想象中的快也比你想象中的慢。合理的设计可以使磁盘的IO和网络IO一样快。
事实上如果我们使用一个支持JBOT的7200转 SATA RAID-5的磁盘进行测试,持续读写的速度能达到600MB/s,但是随机读写的速度仅有100k/s,线性读写是最可预测的操作,所以操作系统在这方面已经做了大量的优化。现代操作系统提供了read-ahead和write-hebind技术用于块数据的读取,小的分组数据的物理写入,具体可以参考(https://queue.acm.org/detail.cfm?id=1563874)
为了弥补随机读写的不足,现代操作系统通常是积极的使用空闲内存用作磁盘缓存,如果不使用direct IO的话都是走的这么一层统一的缓存,不管读还是写都需要。(这个内存的回收代价很小,对应用程序是不可感知的)所以即使一份缓存数据在应用程序里面已经有了,但是可能在OS pagecache中也存了一份重复的数据。
对于jvm来说,内存使用方面有两个特点:
1、放到jvm内存中的数据通常会膨胀至少2倍;
2、java堆数据的回收繁琐并且效率不是很高;
基于这些特点来说我们选择使用direct IO。这样有以下几个好处:
1、使用操作系统自身的pagecache比我们自己用任何数据结构维护的数据都占内存少,并且不会增加应用程序的GC压力
2、与应用程序耦合性低,即使应用程序挂掉,缓存还是热数据,不会丢失不需要重新加载
3、缓存和磁盘数据的一致性由操作系统保证,解决了一个大难题。如果是线性读写都有相应的优化操作
与其自己维护缓存,在达到一定量级的时候慌慌张张写入磁盘,不如直接反转将所有数据都直接写入文件系统,由操作系统进行OS pagecache和disk之间的操作控制。
(2)、持续读写能力
对于一些别的消息系统来说通常是使用一个队列进行实现的,然后提供索引以实现随机查询。通常索引结构是BTree,能够支持事务和非事务语义,并且有不错的查询速度O(log N)。但是如果将查询操作放到磁盘上时,它就不会是一个线性的增长,当数据膨胀两倍时,查询时间肯定不只膨胀两倍,主要原因在于:
1、磁盘每次寻址都需要固定时间,并且磁盘并行度有限,并行度为1;
2、存储系统由缓存和磁盘组成,缓存大小可变范围有限,当数据持续膨胀时,查询性能是急剧降低的。
针对以上的特点进行考虑,kafka没有采用这样的设计,而是采用了日志的解决方案,单纯的对文件进行追加操作,所有的写操作都是O(1),并且读不会阻塞写,这样有明显的性能优势,当性能和数据解耦之后意味着我们可以提供几乎无限的存储空间,甚至可以作为一个海量数据存储系统来使用。这中特性也是其他消息队列不具有的。
4.3、效率
在提升效率方面,kafka做了大量优化。因为效率是保证多租户的基础,基础的服务不能因为应用程序的一些变化而受到影响,尤其是一个对于一个支持大量应用程序的集中式服务。而通常影响效率的操作有两种:很多小数据量的IO操作和过多的字节拷贝。
小数据量操作在客户端和服务端,服务端和服务端交互时都会有,为了解决这个问题,kafka定义了一个 message set 用于批量的操作消息。这个简单的操作巨大的提升了效率,批处理产生更大的网络IO包,更大的连续读写块,将更多的随机读写转换成线性读写。
字节拷贝在小数据量的时候不是问题,但是在负载大的时候也有很大影响。字节拷贝是指我们从网络或者磁盘读取数据时,需要先读入系统内核空间中,然后再从内核空间拷贝到用户空间,然后写出时也是先从用户空间拷贝到内核空间再执行写出操作。但是就消息系统这个场景来看我们其实只是搬运消息,从网络IO获取然后通过磁盘IO做持久化存储,不需要做加工于是可以完全避免字节拷贝。为了避免字节拷贝,kafka定义了一套在producer、broker、consumer上通用的二进制消息格式。
注意:JVM上的direct IO只支持unix和linux系统,在windows上其实是没有实现zero-copy的。
端到端的批数据压缩处理,在有些场景下网络带宽是瓶颈,为了有效改善这一问题,kafka消息可以进行压缩处理,目前支持的压缩算法包括GZIP、Snappy、LZ4、ZStandard compression protocols。
4.4、生产者
(1)、负载均衡
producer发送消息到broker的时候直接发送到相应partition的leader,中间不会有任何的路由层。所有的broker节点都能应答元数据请求,主要是客户端请求的topic的分区、分区对应的节点、分区的leader相关的信息。
客户端控制着消息发送到topic对应的哪个分区,可以在客户端进行负载均衡配置,可以使用默认的负载均衡规则也可以进行自定义。
(2)、异步发送
批处理是提升效率的一个显著操作,kafka客户端会先将要发送的数据写入缓存,等满足相应的条件之后再统一写到一次请求里面进行批量提交,这个条件默认为(64k大小或者10ms)。这个可以在配置文件中进行修改。
4.5、消费者
kafka的消费着可以对任意topic的任意位置进行消费,只需要一个offset就可以精确定位到要消费的消息的位置。
(1)推 or 拉
消费消息时是由comsumer去pull或者由broker去push是两种不同的方式。在这个方面,kafka和传统的消息队列一样,由producer推消息到broker,然后由consumer自行到broker拉取消息。如果由broker推消息到consumer会有很多问题,对于多种异构的consumer需要由broker来做兼容;如果消息的消费速率大于生效速率,consemer会无感知的闲置;pull模式有懒执行或者懒拉取的意义在其中,可以减轻broker负载。所以kafka采用了传统的pull机制。
但是pull机制会有一个频繁轮询的问题,kafka通过设置comsumer的轮询时间来避免这个问题。
(2)、消费位置保存
如何追踪保存消费位置是消息队列的关键点。大多数消息队列都将其保存到了broker上面,当消费者进行消费时可以立即记录或者根据消费者返回的消费状态进行记录,根据具体场景进行配置。对于大多数扩展性差的消息系统来说,可以知道被消费的消息,然后进行删除,能保持一个较小的数据量。
kafka中的每个分区对每个消费者都只存储一个offset,消耗很少,于是kafka会做定期的checkpoint保存消费的offset。同时消费者也可以对以前的消息进行多次消费。
4.6、消息语义
Kafka提供了三种语义实现:
1、至少一次消费
2、至多一次消费
3、正好一次消费
主要需要解决两类问题,发送消息时的数据保证和消费消息的数据保证。很多消息系统声称能做到正好一次消费,但是并没有把失败重试的情况考虑进去。而kafka中有一个提交到log的概念,当一条消息被所有该分区的所有保持同步的副本同步之后,才会记录为提交状态。
(1)、数据生产
在0.11.0.0版本之前,producer向kafka集群发送一条消息后没有收到任何返回的commit信息,那么只能重发,这就保证了至少一次的语义,因为可能会在log里面写入两条相同的记录。从0.11.0.0版本开始,kafka支持幂等性的消息生产,为每个producer分配一个全局id,producer生产每条消息时也会产生一个序列号id,用这两个id的组合可以进行去重从而保证幂等性。同样从0.11.0.0版本开始,kafka提供了事务主题能力,可以将写入多个主题的数据放入到一个事务中,要么同时成功,要么同时失败。
当然不是所有场景都需要这么强的数据质量保证,我们可以根据业务场景对持久化级别进行配置,可以配置producer完全异步提交,或者等到leader写入成功即完成提交,或者等到所有在同步备份写入成功再提交。
对于消费者来说,每一个comsumer都保存着自己的offset,如果没有发生事故那么会一直正常的消费运行,但是如果消费者宕机就会丢失其消费的offset,根据offset的更新策略可能会有以下两种情况:
1、读数据后,先更新offset,然后做消息处理。当消费者在更新完offset,做消息锤之前宕机,那么等消费者重启之后,实际上会少消费一些数据。(只多一次消费语义)
2、读数据后,先做消息处理,然后再更新offset。当消费者给再处理完消息之后,更新offset之前宕机,那么重启之后会多消费一些消息。(至少一次语义)
(2)、数据消费
那么如果保证正好一次消费呢。举个例子,当我们使用kafka streaming application消费一个topic的数据,然后写入到另一个topic的时候,我们可以使用到上面提到的事务特性,将处理的消息结果和offset使用一个事务写到不同的topic中,当发生中断时,offset会回滚到之前的值,处理结果的数据会其他消费者不可见,当然这取决于配置的隔离级别,如果这个结果topic配置的消费者是read_committed属性的话就只能读区到成功提交的数据。
当要将消息写出到一个其他系统时,就需要去手动消费位置和实际消费结果的一致性,比较古典的一种做法是使用两阶段提交保证一个事务的特性,但问题在于很多存储系统是不支持两阶段提交的。现在有另一种更简单通用的解决方式,让consumer在要输出数据的系统中同时保存offset。kafka connect向hdfs中存储数据就是采取的这种方式,能保证数据和offse同时成功或失败,对于有这样强exact-one需求的系统来说我们可以使用相同套路来实现。
Kafka stream支持只消费一次的语义,能够使用事务性的生产者/消费者来在不同的topic中进行数据的转换于存储。如果需要使用其他系统则需要额外的协调者,kafka自带的offset属性让这种操作变得可以实现,同理可以kafka connector的实现。
kafka connect怎么保证exact-one语义的呢?kafka connect只是搭起了一个处理的框架。这里我们主要看sink task是怎么操作的,主要有以下几个方法:
1、put()方法用于获取数据,进行一定的清理转换后存储到缓存中。
2、flush()方法一般用于offset提交过程中,当满足批处理要求的时候将缓存中的数据以原子性的操作进行写出,保证处理数据和offset的同时成功或失败。hdfs中可以使用wal来保证
4.7、备份
(1)、基础
kafka可以对通过配置servers的数据量实现对不同的topic进行不同的备份。当集群中有节点宕机时能够实现自动的失败处理。
其他的消息系统也提供了一些备份相关的特性,但是看上去只是一种附加功能,不是重度使用的那种。配置不是动态的,吞吐量收到影响,需要手动进行繁琐的配置。而kafka是默认就开启了备份功能,不需要备份的topic的备份数量为1。
所有的备份构成topic 的一个完整分区,在没有失败的情况下,每一个分区都会有一个leader和多个follwer,总数加起来为备份数。分区的所有读写任务都由leader完成。通常分区数比broker数量多,并且leade会均匀的分布在broker上。follwer的log决定于leader的log,以保证有相同的消息和offset。
follower像一个正常的消费者一样去消费leader的消息,所有的follower都可以从leader中进行批量拉取。正如所有的分布式系统处理宕机一样,kafka需要知道哪个节点还活着,这里包含下面两个点:
1、活着的节点必须保持和zookeeper的心跳
2、活着的节点必须在备份leader的日志时不被甩开太远
一般使用 in sync来描述一个节点是否可用,一个分区的leader会维护所有 in sync的follower。如果一个follower挂了则剔除,通过配置的最大滞后时间来判断一个节点是否还 in sync。
我们能在提交消息时定义当所有的in sync的备份都都添加了这条消息到自己log后返回提交成功的消息,当然也只有提交成功的消息才能被consumer消费,所以我们不必担心leader宕机之后会丢失消息。producer也可以选择是否等待消息提交,决定于实际的应用场景,可以选择leader写入成功之后就返回,那么即使没有被所有 in sync的备份同步也可以被consumer消费。kafka只能保证被提交的消息不会丢失
(2)、Quorums、ISRs、State Machines
kafka最重要的就是备份日志,是所有分布式数据系统的基石。统一topic的备份日志需要保持所有消息的顺序一致,最简单最快的实现方法就是以发送顺序,只要leader还活着,所有的follower就只能到leader那里去拷贝消息。
当然如果leader不宕机就不需要follower,当leader宕机的时候我们需要从follower中选择一个新leader,但是follower可能消息比较滞后,那么算法久需要保证,如果一条消息提交了,新的leader中也必须包含这条消息,所以当你提交消息需要的follower多时,在选新leader时就有更多的选择。
Quorums意思是 如果你配置了需要确认的数量和至少比较的数量,并且这两个数量之间有重合。
一个通用的解决方案是是投票,获得大多数投票的成为新leader。当我们有2f+1个副本数时,收到超过f+1票的即成为新的leader,只要宕机的数量不超过f都能保证消息不丢失因为在剩下的f+1个副本中至少会有一个副本包含所有的消息。新leader必须时一个拥有最完整日志的备份,但还有很多细节的问题需要算法解决,例如当leader宕机时怎么保证日志的一致性,当我们新增或删除服务器的时候怎么保证日志的一致性。多数人投票有一个优点,其延迟时间只取决于最快的节点。这个家族中有很多实现,比如zookeeper的zab,raft。其中kafka的实现时来自微软的pacificA。
多数人投票的缺点就是当你没有足够的备份的时候就不能进行宕机处理,要容忍3个备份的报错就需要有5个备份,要容忍1个备份的报错则需要3个备份,就我们经验来说,只容忍单本分错误是不够的,那么当我们有5个备份时对同一份数据需要写5次,存储量时5倍并且吞吐量只有1/5,这对于zookeeper、hdfs的namenode 这样的元数据配置来说没有问题,但是并不适用于数据本身的存储。
kafka在选择quorum set的时候做了轻微的改变,没有使用大多数投票而是自己动态维护了所有 in sync 的备份集合ISR(in sync replication),并且在这里面进行leade的选取。只有当所有的ISR都备份成功之后才算写入成功,所有分区的ISR配置都会持久化到zookeeper中,这是kafka的一个重要的特性,使用这样的策略之后的f+1个备份就是容忍f个备份的错误。对于大多数场景来说,我们认为这样的做法是合理的,大多数投票和ISR这两种方式提交一条消息都需要等待相同数量的备份的应答。大多数投票的优点就是不用等待最慢的节点,于是我们做了改善,可以由客户端自己设置在提交消息时是否阻塞,是否需要多个备份都应答。
另一个重要的设计是kafka宕机节点重启时并不要求其数据是完整的,备份算法常依赖于稳定存储的存在,如果没有潜在的一致性冲突的话就不会丢失数据。这涉及到了两个基本问题:
1、硬盘损坏是我们实际数据系统中 最常见的问题之一,通常无法保证数据的完整性
2、即使硬盘没有损坏,我们也不希望每一次写入都适用fsync,这样会是写入的效率下降2~3个数量级。Kafka 中的协议保证当有节点要重新加入ISR之前必须自己将所有的数据重新同步一遍
(3)、Unclear leader election:what if they all die
kafka只有在至少有一个备份in sync的时候才能保证数据不丢失。然而一个实际的系统是需要作出反应的,这里有两个方式你可以选择:
1、等待ISR恢复并且从中选择一个leader
2、选择第一个恢复的备份作为leader,重新开始工作
需要在这两个选择中做一个平衡,从0.11.0.0版本开始默认选择第一个策略,但是也可以在配置文件中进行配置。
(4)、Availability and durability Guarantees
当写数据到kafka时,producer可以选择是否等待消息确认:
1、ack=-1 当所有in sync的节点写入成功之后才提交成功
2、ack=1只要leader写入成功则提交成功
3、ack=0 不需要等待直接默认成功
当然也可以进行topic级别的单独配置:
1、禁用ISR以外的节点作为leader
2、指定最小的ISR数量,只有当reply的数量大于minisize的时候才认为一条消息写入成功。
(5)、replica management
kafka集群管理者所有的topic和topic所有的分区和所有分区的备份,我们尝试使用负载均衡的方式避免数据分布不均衡,将所有的leader也进行负载均衡。
对选举过程进行调优也很重要,因为这是处于一个服务不可用的时间窗口中,leader选举的一个简单实现就是对每一个失败的partition都选择一台broker作为controller,由controller去推测宕机等级并执行选举。而kafka则采取的是批量处理,对大量的partition统一使用一台broker来作为controller处理,如果controller宕机了就从剩下的中再选一台。
4.8、日志压缩
日志压缩保证了kafka总能保留每个topic中至少每个key的最后一条消息,通常有如下场景。当应用崩溃后重新恢复状态,重新加载缓存到应用中。下面详细说明:
当目前为止我们只描述了一种简单的数据处理方式,当其超过一定时间活着超过一定大小之后就进行简单的丢弃,这对于基于时间的日志来说是适用的,但是如果是对一个基于key的重要的数据流或者可变(mutable)的数据来说(比如对数据库表的变更)。
log compaction能够保存每个key的最后一条消息,这种方式能够保证我们的日志中至少有一份描述所有key的最新状态的完整的快照,此功能经常用在下面的使用场景中:
1、数据库的变更日志。当你的存储系统中有变更操作时,将这些写入缓存只需要最新的日志,但是如果想重新加载所有缓存的话则需要一个完整的数据集。
2、事件检索,这是一种应用程序的设计风格,将查询处理和程序设计放在一起,用事件日志作为应用的基本存储。
3、用作高可用日志记录,可供其他节点读取。
在每一个用例中,当服务崩溃我们都需要对所有缓存进行一次完整的加载。实现日志压缩的想法十分简单,如果我们把每一次更新操作都完整记录,我们可以返回到任意时间状态下的系统状态,但是这种存储方式时不现实的,即使是对一个稳定的数据集其日志信息也是无限增长的。这种简单丢弃更新的日志也会限制大小,而且并不能退回到任意状态。
日志压缩这种机制是一种更细粒度的更新机制,基于key的日志保存了最后的更新状态,相对于给予时间和大小的日志压缩方式粒度更细。
每一个topic都可以单独设置其日志的保留策略,kafka是用来描述和组织线性读写数据的,即使是不会重复消费消息的场景中也很实用。
(1)、log compaction basics
下图描述了kafka中log的offset存储方式:
日志头部和传统的kafka日志一致,都适用了连续的、密集的索引来存储消息。日志压缩增加了一另一种方式来处理日志尾部数据,上图显示的是一个压缩后的尾部数据视图。需要注意的是,尾部数据的index仍然保留的是它原始的index,即使日志已经压缩,所有offset在log里仍然保持着有效位置。访问已删除的offset时返回的是有效的最小的offset 38.
压缩仍然允许删除,带有null payload的key会被视为从log中删除,并且该key的所有历史消息都会被删除,但是是在一段时间后清除以释放空间,清楚之后就不再保留删除标记。
日志压缩会周期性的在后台运行,拷贝不同的日志段,并且不会阻塞实际读写,但是需要配置一个合理的io配置,避免和producer、consumer争抢资源。其合并过程如下图所示
(2)、log compaction grarantees
1、所有的头部消费者都能看见topic实时写入的每一条数据,并且每条数据都会有一个连续的offset。可以通过配置min.compaction.lag.ms来保证日志从写入到压缩至少保留多少时间,提供了保留日志的一个基本边界。
2、消息的顺序会总是保存,日志压缩不会改变其顺序,只是会删除其中的部分
3、一条消息的offset不会改变,是日志中的永久标记。
4、所有的consumer从头开始消费日志时都能至少看见所有key的最后状态,并且有删除标记的消息也能被访问,只要在被标记的delete.retention.ms时间内(默认为24h)。
(3)、log compaction details
日志压缩由log cleaner来执行,其底层是一个负责拷贝日志segment的线程池,工作流程如下:
1、先选择 ratio = head / tail 中,ratio最大的log
2、先创建一个简单的总结,描述每一个key的最后一个offset是多少
3、从头开始拷贝日志,把tail部分出现的重复的key的消息删除,拷贝完成之后会切换为压缩后的日志工作,所以磁盘必须有足够的空余大小放下多余的segment
4、日志头的summary是一个hash 表,用24个字节来存储一个entry,8g的缓冲可以大概可以支持压缩366g的日志数据。
(4)、configuring the log cleaner
log cleaner 默认情况下是启用的。你可以为特殊的topic开始如下配置:
1、log.cleanup.policy=compact ,可以在创建topic时指定,也可以通过alter命令进行指定
2、log.cleaner.min.compaction.lag.ms 可以用来指定日志在被压缩之前至少保留的时间。如果这个没有设置这个时间,除了当前正在进行写入的segment外其他的segment都会被压缩。
4.9、限流
Kafka集群能够对其发请求的客户端进行限流处理,两种类型的客户端限流会被应用到broker上:
1、以byte-rate为单位的网络带宽限流(从0.9版本开始)
2、请求率限额定义了cpu利用率限制,网络和IO线程的百分比
(1)、why are quotas necessary
consumer和producer在其进行consume或者produce的过程中很有可能产生大量的数据传输和访问请求,沾满了broker的资源,造成网络拥堵,造成其他客户端无法正常访问。使用限流就可以防止这种问题发生,尤其是在大型的多租户集群上尤为重要,一小部分的行为恶劣的客户端机器可以降低整个集群服务的体验。事实上,当我们启动kafka作为一个服务的时候可以强制使用API去执行限流。
(2)、client groups
kafka客户端的标示是用户主体,它代表安全集群中通过认证的用户。在集群中,由broker使用配置的PricipalBuilder来对未被识别的客户端进行处理。client_id是由客户端应用程序选择的有意名字的逻辑分组,由(user,client_id)组成的二元组定义了一组拥有相同用户主体和client_id的安全逻辑分组。
可以对user,client_id或者(user, client_id)组成的二元组进行限流配置。对于一个给定的连接来说,配置最具体的限流规则会生效。
(3)、quota configuration
可以对user,client_id或者(user, client_id)组成的二元组进行限流配置,单独配置的限流参数会覆盖默认的限流参数。这种机制类似于单个topic的特殊配置会覆盖默认配置一样,user和(user,client-id)限流配置会被持久化到zookeeper的 /config/users目录下,client-id限流会写到/config/clients目录下。这些配置会被broker读取并且立即生效,这样对与限流的改变不需要我们重启整个集群,每个group都可以用相同的机制动态更新。限流配置的优先级如下所示:
1、/config/users//clients/<client_id>
2、/config/users//clients/default
3、/config/users/
4、/config/users//clients/<client_id>
5、/config/users//clients/
6、/config/users/
7、/config/clients/<client_id>
8、/config/clients
broker属性(quota.producer.default , quota.consumer.default)同样也能用来设置group的网络带宽限流,但是会在之后的版本中被移除,默认的client-id限流会被写入zookeeper中。
(4)、network bandwidth quotas
网络带宽限流是针对一组客户端定义的byte传输速率。默认的每个group都会收到由集群配置的 bytes / sec 的配置。这个配额是在每个代理的基础上定义的。在对客户机进行节流之前,每组客户机可以为每个broker publish / fetch 最大 n 字节/秒。
(5)、request rate quotas
请求速率配额定义为ckient可以在配额窗口内对每个broker的请求处理程序I/O线程和网络线程使用的时间百分比。一个限流的n%代表的是一个线程的n%,所以io线程加上网络线程之和会超过100% ,每一个group配置的n%中包括了网络和IO在一个时间窗口中能占用的总的资源,因为分配的IO线程和网络线程是基于不同broker的cpu核心数的,所以请求率限额也代表了cpu的限流配置。
(6)、enforcment
默认情况下,每个client都会收到来自集群的限流配置,这种限流配置都是基于broker的,我们认为按每台broker去定义限流比定义每台客户端要好,因为对client进行限流需要将配置在broker之间共享,比机遇broker作限流更困难。
那么broker是怎么执行限流的呢?broker会计算这个客户端在其限流条件之下需要延迟的时间,并且立即执行这个延迟。对于fetch请求,不会返回任何数据,broker会对这个客户端保持缄默,知道到达延迟时间。客户端接受到没有消息的返回后也会保持沉默,不会在延迟时间内再向broker发送任何消息,于是被限流客户端会被有力地双向限制住。即使是老版本的没有实现客户端限制的也会被broker限制住,也只有在延迟时间之后才会接收到有数据的消息。
流量限制和线程限制通常是在多个小的时间窗口上进行限制的)(30个1s的时间窗口),为了快速的推测和执行限流。如果在一个大的时间窗口上执行可能会造成大量的数据堆积和长时间的延迟后大量数据的爆发。