Kafka设计原理总结
前言
好长时间没有更新过博客了,这一段时间工作和私人生活的时间挤压,也很少有时间可以比较详细的梳理一篇文章。梳理一篇文章让遇到相同问题或学习的同学理解我认为是很重要的,随便搞一搞会误人子弟的(我尽量不误人子弟哈,捂脸),哈哈。
在自己的学习过程中,每次深入学习一个技术时也都会将思路以及核心点按自己理解的方式总结笔记,但是发现这样的总结可能不适合以博客的形式发出来,详细的写一篇文章的成本非常高,为了加快自己的学习效率,所总结的笔记更多的是记录自己理解时疑点或是比较精华的地方,记录形式也会稍有粗糙。后面有时间我会尽量将总结的内容详细整理后发出来,大家一起相互学习,互相进步 ~
本片文章适合对kafka基础有一定了解的同学参考、学习。
场景分析
观察者模式应该都不陌生,看过zk源码应该都知道,里面大量使用了 阻塞队列+线程池的方式来实现观察者模式,来做异步处理。这种方式只能解决单进程下的异步处理,假设是两个服务之间采用异步的方式来处理任务,单进程队列就无法处理了。那么就需要一个中间层来存储消息分发消息。
应用场景
Kafka是一款分布式消息发布与订阅系统,特点是高性能、高吞吐量。
Kafka最开始的应用场景就是针对流数据、运营数据等做日志收集,用户画像、日志收集监控。适用于大数据的场景。
架构设计
一个典型的 Kafka 体系架构包括若干 Producer(可以是服务器日志,业务数据,页面前端产生的 page view 等等),若干 broker(Kafka 支持水平扩展,一般 broker 数量越多,集群吞吐率越高),若干 Consumer (Group),以及一个 Zookeeper 集群。Kafka 通过 Zookeeper 管理集群配置,选举 leader,以及在 consumer group 发生变化时进行 rebalance。Producer 使用 push(推) 模式将消息发布到 broker,Consumer 使用 pull(拉) 模式从 broker 订阅并消费消息。
分区(Partition)
针对一个主题划分出多个区域部署在多台节点中,每个分区都是一个独立,都保存着一个topic的部分数据,这样的做法为了提升处理性能以及存储容量,并可以线性拓展提高吞吐量。
在很多场景都存在这种思想,网络通信TCP数据包、数据库分库分表等等。
消费者集群可以按策略均匀的订阅不同的分区,提高处理性能。
每一个分区只能有一个消费者消费(同一个消费组下),这样减少了并发争抢带来的开销。如果consumer比partition多,那么多余的consumer是浪费的。
分区的建议:
1、consumer group下的consumer的数量不要大于partition
上面提到了,kafka的设计是在同一个partition上是不允许并发的。consumer比partition多则浪费。
2、建议partition的数量是consumer group下的consumer数量的整数倍
当partition比consumer多时,一个consumer可能会消费多个partition,如果不是整数倍,则会有的consumer消费的partition多,有的少,消费不均匀。
Rebalance
每一个消费者都会订阅属于自己负责的topic分区,但是如果出现Broker宕机或者是消费者宕机,某些topic分区的数据就没有消费者来消费了,造成消息丢失的错觉。那么Rebalance就是一种动态来分配分区与消息者之间关系的策略。有以下几种情况会触发Rebalance:
- 当消费组消费者数量发生变化(宕机、扩缩容等)
- topic的分区数量发生变化
那么当出现以上情况的任意一种时,kafka就会重新分配每个consumer和partition之间的关系。Rebalance制定了一些分区策略,让消费组下的所有consumer达成一致,让每个消费者均衡的订阅不同的分区。
- 范围分区
1、对partition和consumer个数相除、取模划分消费的范围。
公式:
n = partition个数 / consumer 个数
m = partition个数 % consumer 个数
前m个consumer分配n+1,后面的消费者分配n个分区。
example:
partition:0,1,2,3,4,5,6,7,8,9
consumer:0,1,2
consumer0:0,1,2,3
consumer1:4,5,6
consumer2:7,8,9
- 轮询分区
1、将partition和consumer分别通过hashcode进行排序,然后consumer组按顺序依次轮询一人消费一个分区,直到没有分区。
example:
partition:0,1,2,3,4,5,6,7
consumer:0,1,2
consumer0:0,3,6
consumer1:1,4,6
consumer2:2,5
- 粘性分区
1、分区的分配尽可能的均匀
2、分区的分配尽可能和上次分配保持相同
Rebalance策略总结:范围分区、轮询分区两个策略相对分配策略比较简单,但当触发Rebalance时可能每个消费者Rebalance后的与之前的分区映射很可能有很大的差别,或不均匀的情况。粘性分区可以进可能的保持这两点,但分配计算策略要复杂很多,相应的Rebalance计算时间也会更长些。
Coordinator
上面介绍了Rebalance策略对partition和consumer之间关系的概念,但这个概念最终的如何做的,如何交互的。这时就引入了Coordinator。
核心分为三个阶段:
find
consumer group下所有节点选择kafka cluster中负载最小的节点发送请求,这个kafka node会根据consumer group id对_consumer_offsets分区数量进行取模找到存储当前consumer group offset的kafka node信息返回给consumer,consumer以它作为当前分组的coordinator,并向coordinator建立连接。
__consumer_offsets是记录consumer消费partition的消费位置的主题(默认有50个分区)
join
1、consumer发送当前自己的信息,比如自己的分区策略、订阅的topic信息、consumer groupid等等。
2、coordinator根据consumer group下的所有consumer上报的分区策略,来确定分区策略(哪个策略多就选哪个)。
3、coordinator选举一个consumer作为当前consumer group的leader节点,将选好的分区策略和同consumer group的其他consumer信息告诉leader(步骤1的response),由leader来进行具体的策略执行。
1、正常情况下同一个consumer group下consumer分区策略都是一样,我们不会部署一个应用集群,每台机器还单独选择不同的kafka 分区策略。
2、将计算分区的逻辑下发给客户端做,减少kafka cluster的计算资源。
sync
1、当连接建立成功后,所有consumer会想coordinator发送同步请求,只有leader的sync请求会将计算好的分区规则发送给coordinator,coordinator会等待leader的sync请求,收到leader的分区规则后,将规则response所有consumer分区的规则。
分区副本(Replication)
每个分区都是一个独立,都保存着一个topic的部分数据,那么如果其中分区的机器发生宕机,将丢失部分数据。分区副本就是来保证每个分区的高可用,避免数据丢失。
副本分区主要采用leader/follower类似主从的方式,leader副本处理所有请求,follower同步leader数据。
Kafka副本选举机制不采用Quorum多数通过的策略是因为在大多数投票中,多数节点挂掉会让你不能选举leader,要冗余单点故障需要三份数据(三个节点),并且要冗余两个故障需要五份的数据。根据我们的经验,在一个系统中,仅仅靠冗余来避免单点故障是不够的,但是每写5次,对磁盘空间需求是5倍, 吞吐量下降到 1/5,这对于处理海量数据问题是不切实际的。quorum算法更适用于做一些集群配置等存储,并不适合大数据原始数据的存储。kafka采用一种自研的ISR的做法。
分区副本数量不能大于broker数量,每个台broker上同一个分区副本只能存在一份。因为放多份也没什么意义,挂掉是整个broker都挂掉了。
ISR
ISR是一个分区副本集合,这个集合里存放的副本是与leader副本的延迟”差不多“的副本,与Quorum机制不同的是ISR是一个可以灵活调整的高可用机制。
follower副本会同步leader副本的所有数据,那么这个同步过程肯定是有延迟的,这个延迟每个follower副本肯定都是不一样的,中间掺杂网络问题、宕机的副本重新恢复了等等。
ISR集合维护是与leader节点保持密切联系(或者说性能比较好)的follower副本,每次提交数据只用和这些性能较好的高度一致的副本(ISR集合中的副本)完成同步就可以认为这条数据已经提交了成功了,然后给到producer响应。在一致性和性能上做了权衡,也可以根据配置来适配业务。
加入ISR集合条件
ISR的数据会保存在zookeeper中由leader副本负责维护,满足ISR集合的条件:
1、副本节点必须与zookeeper保持连接。
2、副本节点的最后一条offset值与leader副本节点的offset值的相差不能超过一个时间阈值,如果follower副本在此阈值时间内一直赶不上leader副本,则被踢出ISR集合。
生产者确认机制
这块主要就是开发给业务人员来根据自身的业务来权衡一致性和性能的策略
0 代表不需要等待broker确认
效率最高,但风险最大,生产者都不知道消息是否发送到了broker
1 代表只需要kafka cluster中leader副本确认
延迟较小,但也容易出现leader副本确认后宕机消息没有同步到follower副本的情况
-1 代表需要ISR集合中所有的节点确认
效率最低,但安全性最高。但也不能完全避免数据丢失,ISR集合极端情况也有可能缩小到只有一个副本。
所有副本不工作情况
当partition下的所有分区都挂掉时的处理策略,在可用性或一致性之间的衡量。
1、只等待ISR集合中有副本活过来选他作为leader
保证消息的一致性,但不可用的时间会相对拉长,如果ISR集合一直没有副本活过来则一直不可用
2、任意一个副本活过来选他作为leader
复活的副本可以是不在ISR集合中的副本,但他不一定包含了全部已经commit了的消息。
LEO & HW
在副本同步中,LEO和HW用来记录数据同步处理过程的状态。
LEO(log end offset):表示消息日志的下一条offset位置(是当前末尾日志的下一个即将被消费的位置)
HW(hight water):表示当前位置之前的消息都是可以消费的
表示已经被ISR集合中所有副本同步过的值,可以理解为这些值是都已经备份过的,代表数据已经提交成功了的。
例如下图:
leader副本收到了生产者发来的6条消息,followerB同步了5条,followerC同步了4条。那么leader的HW等于2,证明只有0-2的数据已经被所有follower(根据配置的策略定的)备份过了。
副本同步
同步流程
同步流程:
1、follower副本与leader副本建立一个长轮训模型(pull)建立时会发送自己的下一个的消费位置LEO。
2、当leader副本收到一条消息时,leader副本保持消息并更新LEO,响应follower副本请求,将消息和HW response给follower副本,follower副本保持消息并更新LEO。
3、follower处理完response后,会再次向leader副本发起fetch(长轮训),同样告诉leader副本自己的LEO信息,leader将会更新remote LEO值(leader记录follower副本LEO的属性),根据remote LEO取所有follower副本(ISR集合中)发送的LEO值的最小值更新leader的HW值。
Kafka使用HW值来决定副本备份的进度,而HW值的更新通常需要额外一轮FETCH RPC才能完成
日志截断
如果发生当leader副本收到producer提交消息后,消息没有完全同步(有可能同步了一部分,不同follower之间同步的进度也不一致)到follower副本时出现宕机后又恢复,follower与leader副本的数据如何保持一致。如下图:
1、leader收到了6条数据,followerB少同步了5,followerC少同步了4、5.
2、当leader出现宕机时,followerB担任leader
3、当老的leader恢复时变成followerA,会将日志截断到HW时的位置,将LEO指向HW。然后向新leader进行FETCH数据。
老的leader恢复时必须要放弃之前提交消息,如果不进行日志截断,那么新leaderB如果收到又一个producer的消息那么他5这个位置和老leader5这个位置就产生数据不一致了。所以将LEO恢复到HW位置,因为只有HW位置之前的数据都是所有副本已备份并且认同的,3、4、5数据并没有与所有副本(ISR集合)确认,需要抛弃这些数据然后重新和新的leader进行同步。
如果ISR副本同步策略等于-1,那么证明其实kafka server还并没有响应producer告诉他这条消息发生成功了,那么这时如果leader宕机producer那边收到异常情况就会尝试重新发送消息(kafka默认保障At least once策略,可能会出现重复消息)。
follower副本如果重启是一样的,同样也会截断到follower的HW位置。因为不知道在重启过过程中,自己之前备份的数据是否最终被“提交了”,或者经过了多轮leader选举,leader都换了不知道多少人了,那HW之后位置的消息谁知道还是不是一致的了。
数据丢失风险
了解完了上面副本同步流程,LEO&HW本身是一个好的设计,但是只按上面的设计会存在数据丢失的风险,核心就点在于在第二轮fetch时follower的HW才可以被更新(是一个异步延迟更新),一旦出现崩溃就会被作为日志截断的依据,导致HW过期。如下图:
如上图描述,producer端已经收到消息确认的通知了,但经过这样的极端情况,最终导致已经确认的消息丢失。
数据不一致风险
如上图描述,前三步骤和数据丢失情况一致,在老leader没有恢复之前,新leader又收到了生产者发来的一条消息。当老leader恢复时变成follower节点,发生自己的HW和LEO相等,就不用日志截断了。这样就发生了同一个offset位置的数据不一致情况。
Leader Epoch
核心问题在于依据HW截断做日志截断的依据,而且HW的同步是异步的,任何异常崩溃都可能导致HW是一个过期的值。在kafka0.11.x版本引入了leader epoch的概念来规避此问题。leader epoch由一对二元组组成(epoch,startOffset)。Kafka Broker 会在内存中为每个分区都缓存 Leader Epoch 数据,同时它还会定期地将这些信息持久化到一个 checkpoint 文件中。当 Leader 副本写入消息到磁盘时,Broker 会尝试更新这部分缓存。如果该 Leader 是首次写入消息,那么 Broker 会向缓存中增加一个 Leader Epoch 条目,否则就不做更新。
epoch区别leader的朝代,当leader更换时epoch会+1
startOffset代表当前朝代的leader时从哪个offset位置开始的
follower当重启后并不会直接进行日志截断,先向现任leader发起OffsetsForLeaderEpochRequest请求携带follower副本当前的epoch。有如下几种情况:
- leader收到了请求
- 如果follower的epoch与leader相等,leader返回当前LEO,follower leo不会大于leader leo所以不会发生截断,继续后续的fetch数据同步流程。
- 如果follower的epoch与leader不等,leader根据follower的epoch+1去本地epoch文件找到对应的startOffset返回给follower,follower会根据leader返回的startOffset来判断,如果自己当前的LEO大于则截断,小于不会发生截断,继续后续的fetch数据同步流程。
- leader挂了收不到请求
- 那么follower会成为leader更新epoch+startOffset,并不会发生截断。老leader复活后与新leader会走上面epoch不一致时的流程。
对应刚刚上面的场景,如下图:
消息存储
kafka采用日志的存储格式并将消息持久化到磁盘中,在每台broker下都能看到不同的分区下存储的消息信息。
磁盘给大家的印象都是比较慢的,如何支撑kafka的高吞量的呢,kafka官网也给出了答案:传送门 。大概总结下就是,磁盘并不是想象中的那么慢,而是看如何去使用。借助磁盘顺序读写、以及操作系统页缓存从而达到高性能高吞吐。
kafka采用LogSegment的思想来对日志进行分段存储,一个LogSegment中存在一个日志文件和一个索引文件,日志文件是用来存储消息的,索引文件的保持消息的索引用来提高查找速度的。
为什么要采用LogSegment分段的思想来构建,单体文件会随着消息的增加而无线扩张,形成一个巨型文件,对于消息的查找、清理、以及维护都会带来更大的成本。LogSegment的思想就是将一个巨型文件按规则拆分成多个大小均匀的小文件,来提高磁盘的利用率。
消息文件
kafka通过文件名+offset的方式来进行日志文件的分段,每一个文件名代表开始存储的offset。例如:
LogSegment1保持是消息范围 (0,19282)
LogSegment2保持是消息范围 (19283,48473)
LogSegment3保持是消息范围 (19283,48473)
LogSegment的增长是取决于当前offset的LogSegment的大小,如果LogSegment的文件大小超过阈值(log.segment.bytes默认1GB)就会向前新增一个LogSegment段。
索引文件
.index文件是用来加速定位消息的物理位置的,kafka中索引属于稀疏索引,它们不会保存每一条记录的索引而且一个范围或者说是N个记录的索引区间。存储的格式为:
offset:10331 position: 3293
offset:20331 position: 4270
offset:32331 position: 6389
第一条记录对应offset范围(0,10331)
第二条记录对应offset范围(10332,20331)
第三条记录对应offset范围(20332,32331)
offset为消息的偏移量,position为当前offset消息在日志文件中的物理偏移量地址。
index中数据的增长速率根据log.index.interval.bytes(默认4kb)来决定,也就是日志文件记录多少kb大小的消息后记录一条索引
如何查找消息流程
1、根据offset值采用二分查找法,快速找到Logegment段对应的index文件。
2、在索引文件中根据offset找到对应范围的offset值映射的position地址
3、打开日志文件,从获得position地址按顺序向前查找,比较offset值最终找到offset对应的消息。
日志清理策略
kafka会定期的对日志清理,因为消费过的日志本质上已经没有用了,不可能然消息一直占用磁盘空间。kafka提供了二中清理策略:清除单位是LogSegment
1、根据时间清理,默认会清除7天之前的日志文件包括索引文件。
2、根据所有LogSegment大小清理,超过一定阈值后(默认为不限制大小),会删除旧的数据(根据总大小和阈值计算)。
日志压缩
日志压缩这个是一种日志去重策略,可以有效的减少日志文件的大小,根据日志的key进行去重,一个key只保留最新的一条记录。
例如:修改名称的场景,一个用户可能修改了多次名称,只有最后一次的名称是有效的,前面的都是不生效没用的。只保留最后一次的名称即可。
高可靠保障
任何消息中间件都不能保证百分之百可靠,只能说无限接近百分之百。那么在kafka中有哪些保障策略我大概梳理了一下:
存储层面
分区副本的概念,创建多个分区来保障数据存储的可靠性以及吞吐量,但同样也会带来性能开销。
生产者层面
ack机制,生产者发生消息到broker,broker要响应producer消息提交成功。但是也要保证消息到了broker并完成了多个副本的同步,这里可能会造成性能的牺牲。kafka这里开放了策略让开发者进行选择:生产者确认机制
消费者层面
消费者可以根据场景选择自动批量提交以及手动提交等方式来进行消费确认机制。
消息传输层面
1、At most once: 消息可能会丢,但绝不会重复传输
2、At least once:消息绝不会丢,但可能会重复传输
producer端:
在消息传输过程中,所有消息中间件都会面临一个问题,当producer消息发出去后,由于网络问题导致通信的中断,producer无法知道自己的数据是否已经提交,那么就面临如果不care,那么可能造成消息丢失,如果retry多次这条消息,那么就有可能出现同样的消息发送了多次,导致消费者可能消费多次出现重复消费的情况。
consumer端:
当 consumer 读完消息之后先 commit 再处理消息,在这种模式下,如果 consumer 在 commit 后还没来得及处理消息就 crash 了,下次重新开始工作后就无法读到刚刚已提交而未处理的消息,这就对应于 at most once 了。
读完消息先处理再 commit。这种模式下,如果处理完了消息在 commit 之前 consumer crash 了,下次重新开始工作时还会处理刚刚未 commit 的消息,实际上该消息已经被处理过了,这就对应于 at least once。
在各个商业级的消息中间件领域也都是宁愿重复消费,也不能导致消息丢失的场景发生,kafka也是一样。由开发者来根据自己业务保证消费端的幂等性。
如果必须保证业务消息去重,kafka提出了全局消息唯一id的概念,保证发送端幂等性,消费端引入第三方缓存,根据消息全局唯一id来存储,保证consumer crash时消息会被持久化。这也是kafka提出的一些思路,但这样必然大大增加的系统复杂度。
总结
作为一款主流的分布式中间件,核心要保证三大要素,水平扩容、高可用、高性能,针对这三个要素我们简单总结下kafka中的一些值得学习的设计思想:
水平扩容
采用partition的思想将topic按需拆分均匀的分布在各个节点中,提升处理性能以及存储容量,并可以进行水平扩容。解决了存储问题同时也加强了消息处理性能。
高可用
采用分区副本机制,来保证topic中的每个分区消息不会出现单点问题,副本的同步这个机制又延伸到 ISR 机制、LEO&HW机制,这些都是一些很好的设计思想很值得学习。
ISR机制与Quorum机制两种都是在数据备份场景中很好的设计思想
高性能
-
消息存储与检索,采用LogSegment思想将大文件拆分、稀疏索引的设计。
-
大量利用磁盘顺序IO,操作系统层面 page cache(页缓存)、IO 0拷贝机制大幅提高IO处理性能。
-
offset消费偏移量代替消息消费状态,降低存储效率、以及磁盘随机IO带来的性能开销。详细描述:传送门
-
一个consumer对应一个分区(同一个topic下),点对点订阅,不用处理并发消费问题。
以上这些设计都是相对比较突出的好的设计思想点,在学习的过程中也遇到了很多比较巧妙的小设计值得我们学习,有兴趣的同学可以继续探索哈。