kafka 的设计理念

141 阅读6分钟

本文主要讲解了kafka的生产者,服务端和消费者之间的设计。

1. 生产者

如上图所示,生产者主要包括三个模块:

  • KafkaProducer主线程负责创建消息,并通过拦截器、序列化器和分区器进行处理,之后压缩消息并存入RecordAccumulator缓存。
  • RecordAccumulator为每个分区维护一个队列,用于暂存待发送的消息集合。
  • Sender子线程在满足条件时被激活,从缓存中取出消息,根据元数据信息将其发送至对应的Broker,完成消息的实际传输。

发送流程如下:

  • 初始化KafkaProducer:加载默认及用户配置参数,启动必要的网络线程。
  • 拦截器处理:执行预定义的拦截器逻辑,对即将发送的消息进行初步处理,并封装为ProducerRecord对象。
  • 序列化:调用序列化器(Serializer)将消息的键和值转换为字节格式。
  • 分区选择:通过分区器(Partitioner)确定消息应发送到的主题分区。
  • 获取元数据:从Kafka Broker集群中获取最新的集群元数据信息,确保目标分区准确无误。
  • 缓存消息:将序列化后的消息放入RecordAccumulator中。首先查找对应分区的队列(Deque),若不存在则创建新队列并添加消息。
  • 触发发送条件:当满足发送阈值(如消息大小达到batch.size或等待时间超过linger.ms)时,激活Sender线程。使用NetWorkClient将消息批次转换成请求格式,并按Broker ID组织待发送的数据列表。
  • 建立连接并发送:与相关Broker建立网络连接,发送对应Broker的所有待发送消息列表。

上述生产者的架构中,有以下特点:

  • 在发送消息时,RecordAccumulator里面的batch会暂时存储一批要发送的消息,这批消息的上限是 batch.size,如果在 linger.ms 后依然没有足够的消息,也会激活sender线程进行发送。(批量处理
  • batch的内存是通过buffer pool 去申请的, BufferPool 的大小默认为 32M,内部内存区域分为两块:固定大小内存块集合 free 和非池化缓存 nonPooledAvailableMemory(不是固定大小的)。(缓存
  • 异步,消息的生产和消息的发送不会相互阻塞

2. 服务端

上图来源于公众号“华仔聊技术”

整个服务端架构可以分为四个层次:

  1. Acceptor 线程构成的连接创建层,负责创建和客户端的连接;
  2. Processor 线程类构成的网络事件处理层, 负责数据的读写;
  3. 由 RequestChannel 构成的请求和响应的缓冲层
  4. 由 KafkaRequestHandler 和 KafkaApis 构成的真正的业务处理层

前两层可以理解为一个多reactor多线程模型,它是一种基于事件驱动的异步编程。

第三层是一个缓存层,用于暂存请求。

最后才是一个业务处理层,用于处理kafka的请求。

对于服务端而言,请求分为控制面请求和数据面请求。

  • 控制面请求指的是用于控制服务端或协调同步类的请求,比如,关闭broker节点,follower partition所在的节点向leader partition所在的节点拉取消息。
  • 数据面请求是指真正业务数据相关的请求,如生产者生产消息时向broker发送消息的请求,消费者拉取消息时向broker拉取消息的请求等

kafka的请求是写进上述的日志文件里,日志文件是顺序写,日志会根据大小进行分片,还会通过mmap和sendfile进行零拷贝,每个log文件都会索引,索引文件采用绝对偏移量+相对偏移量的方式进行存储的,使用相对偏移量可以减少空间的使用。除此之外,页面缓存,提高读取效率。

每个.log都是一个绝对索引,从下图可以看出来,Kafka 的数据是基于「主题 + 分区 + 副本 + 分段 + 索引」的结构

上图来源于公众号“华仔聊技术”

3. 消费者

一个 Consumer Group 中有多个 Consumer,每个消息者负责一部分分区的消费。

对于每个消费者的分区分配有三种策略:

  1. RangeAssignor
    将分区按范围分配,确保每个消费者拥有连续的分区。比如消费者1分区负责所有topic的1~2分区
  2. RoundRobinAssignor
    将分区轮流分配给消费者,确保负载均匀。适合分区数多于消费者数,能够平衡每个消费者的负载。
  3. StickyAssignor
    优先保持现有分配,减少分区迁移。适合动态变化的消费者组,能减少因消费者加入或离开而导致的频繁分区迁移。

因为消费者(加入或者退出)和分区数的变化,可能会导致消费组的重新分配。

消费组有以下5种状态:

以下是每种Kafka消费者组状态的简短描述,每种状态控制在30字左右:

  • Empty:组内无成员,但存在未过期的已提交位移数据,仅响应JoinGroup请求。
  • Dead :组内无任何成员,元数据已被移除,所有请求返回UNKNOWN_MEMBER_ID
  • PreparingRebalance :准备开始新的Rebalance,等待所有成员重新加入组内,暂停消息消费。
  • CompletingRebalance:成员已成功加入,等待分配方案完成,旧版本中称为“AwaitingSync”
  • Stable :Rebalance已完成,组内消费者可以正常消费消息,系统进入稳定状态。

上图来源于公众号“华仔聊技术”

消费组的重平衡主要分下面四步。

  • 消费者向集群中任一broker发送获取GroupCoordinator的请求FindCoordinatorRequest,服务端返回GroupCoordinator信息包括GroupCoordinator所在的broker的node_id、host和port信息。(寻找组协调器)
  • 成功找到消费组对应的GroupCoordinator后,消费者进入加入消费组的阶段。消费者会向GroupCoordinator发送JoinGroupRequest请求,GroupCoordinator返回响应告诉消费者是否加入消费组成功了,并选出leader consumer,同时把分区策略和其他消费者的订阅信息发送给leader consumer。(加入消费者组,指定leader consumer)
  • leader consumer根据GroupCoordinator提供的分区策略和所有消费者的订阅信息确定分区消费方案并发送给GroupCoordinator,然后GroupCoordinator根据收到的分区消费方案把每个消费者要消费的分区信息发送给每个消费者。(下发消息方案)
  • 消费者收到分区消费方案后,向GroupCoordinator所在的broker发送心跳维持从属关系。(维持心跳)

消费者offset提交:手动提交和自动提交

手动提交分为两类:

  • 先提交offset,再处理消息,能保证至多消费一次,有可能丢失消息
  • 先处理消息,再提交offset,能保证至少消费一次,有可能重复消费

offset 是存储在__consumer_offsets内部主题里。

每条消息的key,value 如下所示:

上图来源于公众号“华仔聊技术”

消费者拉取消息也一样,都是先拉到一个缓冲区,再从缓存区拉取消费