kafka 消息队列探索

162 阅读14分钟

一、kafka简介

Kafka最初是由Linkedin公司开发的,是一个分布式的、可扩展的、容错的、支持分区的(Partition)、多副本的(replica)、基于Zookeeper框架的发布-订阅消息系统,Kafka适合离线和在线消息消费。它是分布式应用系统中的重要组件之一,也被广泛应用于大数据处理。Kafka是用Scala语言开发,它的Java版本称为Jafka。Linkedin于2010年将该系统贡献给了Apache基金会并成为顶级开源项目之一。

为什么要学习kafka:

kafka作为一款大家熟知的消息队列,我们从jmq或则阿里的rocketMq都可以看到kafka的影子,所以学习kafka能更容易理解其他消息队列的设计

二、kafka设计

Kafka 将消息以 topic 为单位进行归纳,发布消息的程序称为 Producer,消费消息的程序称为 Consumer。它是以集群的方式运行,可以由一个或多个服务组成,每个服务叫做一个 Broker,Producer 通过网络将消息发送到 kafka 集群,集群向消费者提供消息,broker 在中间起到一个代理保存消息的中转站。kafka 一个主题可以跨多个分区,某个分区只属于一个主题(分区更像分散开内容不重复的队列),kafka的分区可以分布在不同的服务器broker上(某个分区只能在一个broker上)kafka 同一个分区保存的不同副本消息一致(同一个分区的副本保存在不同的broker上容灾能力),leader和follower(副本之间是“一主多从”的关系)存在于不同的broker中,以此来保证容灾能力,当leader副本出现故障时,从follower副本中重新选举新的leader副本对外提供服务,每个分区在每个消费集群中只能有一个消费者进行订阅消费,一个消费者(开启多个线程同时拉取不同分区的消息)可以消费多个分区

2.1 Kafka 中重要的组件

Producer: 消息生产者,发布消息到Kafka集群的终端或服务

Broker:一个 Kafka 节点就是一个 Broker,多个Broker可组成一个Kafka 集群。

如果某个 Topic 下有 n 个Partition 且集群有 n 个Broker,那么每个 Broker会存储该 Topic 下的一个 Partition 如果某个 Topic 下有 n 个Partition 且集群中有 m+n 个Broker,那么只有 n 个Broker会存储该Topic下的一个 Partition 如果某个 Topic 下有 n 个Partition 且集群中的Broker数量小于 n,那么一个 Broker 会存储该 Topic 下的一个或多个 Partition,这种情况尽量避免,会导致集群数据不均衡

Topic:消息主题,每条发布到Kafka集群的消息都会归集于此,Kafka是面向Topic 的

Partition:Partition 是Topic在物理上的分区,一个Topic可以分为多个Partition,每个Partition是一个有序的不可变的记录序列。单一主题中的分区有序,但无法保证主题中所有分区的消息有序,Kafka 每个分区是逻辑概念,在物理上实际按大小被分成多个 Segment。

Consumer:从Kafka集群中消费消息的终端或服务

Consumer Group:每个Consumer都属于一个Consumer Group,每条消息只能被Consumer Group中的一个Consumer消费,但可以被多个Consumer Group消费。

Replica:Partition 的副本,用来保障Partition的高可用性。

Controller Kafka 集群中的其中一个服务器,用来进行Leader election以及各种 Failover 操作。

Zookeeper:Kafka 通过Zookeeper来存储集群中的 meta 消息

2.2 生产者发送流程

2.3 消费者流程

2.3.1 partition分配策略

Kafka 客户端提供了3 种分区分配策略:RangeAssignor、RoundRobinAssignor 和 StickyAssignor,前两种分配方案相对简单一些StickyAssignor 分配方案相对复杂一些。

RangeAssignor:这种分配方式明显的问题就是随着消费者订阅的Topic的数量的增加,不均衡的问题会越来越严重。

RoundRobinAssignor:分区分配策略是将 Consumer Group 内订阅的所有 Topic 的 Partition 及所有 Consumer 进行排序后按照顺序尽量均衡的一个一个进行分配。如果 Consumer Group 内,每个 Consumer 订阅都订阅了相同的Topic,那么分配结果是均衡的。如果订阅 Topic 是不同的,那么分配结果是不保证“尽量均衡”的,因为某些 Consumer 可能不参与一些 Topic 的分配

StickyAssignor:Topic Partition 的分配要尽量均衡。 Rebalance(重分配,后面会详细分析) 发生时,尽量与上一次分配结果保持一致。

2.3.2 消费者组再平衡

Consumer Group 状态机制

Rebalance 一旦发生,必定会涉及到 Consumer Group 的状态流转,此时 Kafka 为我们设计了一套完整的状态机机制,来帮助 Broker Coordinator 完成整个重平衡流程。了解整个状态流转过程可以帮助我们深入理解 Consumer Group 的设计原理。

5种状态,定义分别如下:

Empty 状态: Empty 状态表示当前组内无成员, 但是可能存在 Consumer Group 已提交的位移数据,且未过期,这种状态只能响应 JoinGroup 请求。

Dead 状态: Dead 状态表示组内已经没有任何成员的状态,组内的元数据已经被 Broker Coordinator 移除,这种状态响应各种请求都是一个Response:UNKNOWN_MEMBER_ID。

PreparingRebalance 状态: PreparingRebalance 状态表示准备开始新的 Rebalance, 等待组内所有成员重新加入组内。

CompletingRebalance 状态CompletingRebalance 状态表示组内成员都已经加入成功,正在等待分配方案,旧版本中叫“AwaitingSync”。

Stable 状态Stable 状态表示 Rebalance 已经完成, 组内 Consumer 可以开始消费了。

2.3.3 reblance流程分析

Rebalance 主要分为两个步骤:加入组(对应JoinGroup请求)和等待 Leader Consumer 分配方案(SyncGroup 请求)

三、 kafka 高性能分析

3.1 顺序写

磁盘完成一次磁盘 IO,需要经过寻道、旋转和数据传输三个步骤:

寻道时间:Tseek 是指将读写磁头移动至正确的磁道上所需要的时间。寻道时间越短,I/O 操作越快,目前磁盘的平均寻道时间一般在 3-15ms。

旋转延迟:Trotation 是指盘片旋转将请求数据所在的扇区移动到读写磁盘下方所需要的时间。旋转延迟取决于磁盘转速,通常用磁盘旋转一周所需时间的 1/2 表示。比如:7200rpm 的磁盘平均旋转延迟大约为 60*1000/7200/2 = 4.17ms,而转速为 15000rpm 的磁盘其平均旋转延迟为 2ms。

数据传输时间:Ttransfer 是指完成传输所请求的数据所需要的时间,它取决于数据传输率,其值等于数据大小除以数据传输率。目前 IDE/ATA 能达到 133MB/s,SATA II 可达到 300MB/s 的接口数据传输率,数据传输时间通常远小于前两部分消耗时间。简单计算时可忽略。

因此,如果在写磁盘的时候省去寻道、旋转可以极大地提高磁盘读写的性能。

Kafka 采用顺序写文件的方式来提高磁盘写入性能。顺序写文件,基本减少了磁盘寻道和旋转的次数。磁头再也不用在磁道上乱舞了,而是一路向前飞速前行。

Kafka 中每个分区是一个有序的,不可变的消息序列,新的消息不断追加到 Partition 的末尾,在 Kafka 中 Partition 只是一个逻辑概念,Kafka 将 Partition 划分为多个 Segment,每个 Segment 对应一个物理文件,Kafka 对 segment 文件追加写,这就是顺序写文件。

参考链接

3.2 零拷贝网络和磁盘

3.2.1 传统的 IO 模型

从图中可以看出一次IO需要四次拷贝

3.2.2 java NIO

NIO模型上可以看出拷贝从四次较少到三次,并且拷贝时用户态内核态的上下文切换次数从四次减少至两次

3.2.3 PageCahce

producer 生产消息到 Broker 时,Broker 会使用 pwrite() 系统调用【对应到 Java NIO 的 FileChannel.write() API】按偏移量写入数据,此时数据都会先写入page cache。consumer 消费消息时,Broker 使用 sendfile() 系统调用【对应 FileChannel.transferTo() API】,零拷贝地将数据从 page cache 传输到 broker 的 Socket buffer,再通过网络传输。leader 与 follower 之间的同步,与上面 consumer 消费数据的过程是同理的。page cache中的数据会随着内核中 flusher 线程的调度以及对 sync()/fsync() 的调用写回到磁盘,就算进程崩溃,也不用担心数据丢失。另外,如果 consumer 要消费的消息不在page cache里,才会去磁盘读取,并且会顺便预读出一些相邻的块放入 page cache,以方便下一次读取。

因此如果 Kafka producer 的生产速率与 consumer 的消费速率相差不大,那么就能几乎只靠对 broker page cache 的读写完成整个生产 - 消费过程,磁盘访问非常少。

image.png

3.3 批量与压缩

Kafka Producer 向 Broker 发送消息不是一条消息一条消息的发送。Producer 有两个重要的参数:batch.size和linger.ms。这两个参数就和 Producer 的批量发送有关。

Producer、Broker 和 Consumer 使用相同的压缩算法,在 producer 向 Broker 写入数据,Consumer 向 Broker 读取数据时甚至可以不用解压缩,最终在 Consumer Poll 到消息时才解压,这样节省了大量的网络和磁盘开销。

3.4 kafka索引设计

kafka并不是每个message都构建索引,大约4kb进行构建索引,Kafka采用稀疏索引的,查找消息时,首先根据文件名找到所在的索引文件,然后二分法遍历索引文件里找到离目标消息最近的索引,再顺序遍历消息文件找到目标消息。一次寻址的时间复杂度为O(log2n)+O(m),其中n为索引文件中的索引个数,m为索引的稀疏程度。可以看到,寻址过程还是需要一定时间。一旦找到消息后位置后,就可以批量顺序读取,不必每条消息都要进行一次寻址。

JMQ采用定长稠密索引设计,每个索引固定长度。定长设计的好处是,直接根据索引序号就可以计算出索引在文件中的位置:索引位置 = 索引序号 * 索引长度

segment file 组成:由 2 大部分组成,分别为 index file 和 data file,此 2 个文件一一对应,成对出现,后缀”.index”和“.log”分别表示为 segment 索引文件、数据文件。

segment 文件命名规则:partion 全局的第一个 segment 从 0 开始,后续每个 segment 文件名为上一个 segment 文件最后一条消息的 offset 值。数值最大为 64 位 long 大小,19 位数字字符长度,没有数字用 0 填充。

index 采用稀疏索引,这样每个 index 文件大小有限,Kafka 采用mmap的方式,直接将 index 文件映射到内存,这样对 index 的操作就不需要操作磁盘 IO。mmap的 Java 实现对应 MappedByteBuffer 。

”Kafka 充分利用二分法来查找对应 offset 的消息位置:

1.按照二分法找到小于 offset 的 segment 的.log 和.index

2.用目标 offset 减去index文件名中的 offset 得到消息在这个 segment 中的偏移量。

3.再次用二分法在 index 文件中找到对应的索引。

4.到 log 文件中,顺序查找,直到找到 offset 对应的消息。

3.5 kafka网络模型

Kafka 自己实现了网络模型做 RPC。底层基于 Java NIO,采用和 Netty 一样的 Reactor 线程模型。

Reacotr 模型主要分为三个角色

Reactor:把 IO 事件分配给对应的 handler 处理

Acceptor:处理客户端连接事件

Handler:处理非阻塞的任务

kafka基于reactor模型实现了多路复用和处理线程池,设计如下:

其中包含了一个Acceptor线程,用于处理新的连接,Acceptor 有 N 个 Processor 线程 select 和 read socket 请求,N 个 Handler 线程处理请求并响应,即处理业务逻辑。

I/O 多路复用可以通过把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。它的最大优势是系统开销小,并且不需要创建新的进程或者线程,降低了系统的资源开销。

3.6 分区并发

Kafka 的 Topic 可以分成多个 Partition,每个 Paritition 类似于一个队列,保证数据有序。同一个 Group 下的不同 Consumer 并发消费 Paritition,分区实际上是调优 Kafka 并行度的最小单元,因此,可以说,每增加一个 Paritition 就增加了一个消费并发。

Kafka 具有优秀的分区分配算法——StickyAssignor,可以保证分区的分配尽量地均衡,且每一次重分配的结果尽量与上一次分配结果保持一致。这样,整个集群的分区尽量地均衡,各个 Broker 和 Consumer 的处理不至于出现太大的倾斜。

四、kafka选举

4.1 kafka在zookeeper上保存元数据信息

/brokers/ids/[id] 记录集群中的broker id

/brokers/topics/[topic]/partitions 记录了topic所有分区分配信息以及AR集合

/brokers/topics/[topic]/partitions/[partition_id]/state记录了某partition的leader副本所在brokerId,leader_epoch, ISR集合,zk 版本信息

/controller_epoch 记录了当前Controller Leader的年代信息

/controller 记录了当前Controller Leader的id,也用于Controller Leader的选择

/admin/reassign_partitions 记录了需要进行副本重新分配的分区

/admin/preferred_replica_election:记录了需要进行"优先副本"选举的分区,优先副本在创建分区的时候第一个副本

/admin/delete_topics 记录删除的topic

/isr_change_notification 记录一段时间内ISR列表变化的分区信息

4.2 kafka选举流程

4.3 ZooKeeper的使用弊端

Kafka目前强依赖于ZooKeeper:ZooKeeper为Kafka提供了元数据的管理,例如一些Broker的信息、主题数据、分区数据等等,还有一些选举、扩容等机制也都依赖ZooKeeper。

4.3.1 运维复杂度

运维Kafka的同时需要保证一个高可用的Zookeeper集群,增加了运维和故障排查的复杂度。

4.3.2 性能差

•在一些大公司,Kafka集群比较大,分区数很多的时候,ZooKeeper存储的元数据就会很多,达到一定级别后监听延迟增加,影响kafka性能。

•重新选举时,老的Controller需要关闭监听、事件处理线程和定时任务。分区数非常多时,这个过程非常耗时,而且这个过程中Kafka集群是不能工作的。

•Zookeeper节点如果频繁发生Full Gc,与客户端的会话将超时,由于无法响应客户端的心跳请求,从而与会话相关联的临时节点也会被删除。

所以Kafka 2.8版本上支持内部的quorum服务来替换ZooKeeper的工作。

五、缺点

•无法弹性扩容:对partition的读写都在partition leader所在的broker,如果该broker压力过大,也无法通过新增broker来解决问题;

•扩容成本高:集群中新增的broker只会处理新topic,如果要分担老topic-partition的压力,需要手动迁移partition,这时会占用大量集群带宽;

•消费者新加入和退出会造成整个消费组rebalance:导致数据重复消费,影响消费速度,增加e2e延迟;

•partition过多会使得性能显著下降:ZK压力大,broker上partition过多让磁盘顺序写几乎退化成随机写。

•监控不完善,需要安装插件

•需要配合zookeeper进行元数据管理

•消费失败没有重试机制,要自己实现消息重试的功能

六、kafka实践

参见: docker-compose搭建kafka集群及spring-boot连接