阅读 208

消息在kafka中的历程(万字长文,谨慎阅读)

本文主要描述一个消息从生产者生产投递到kafka中,再到消费者拉取消息进行消费的详细过程,对于kafka的基础概念本文限于篇幅不再详细介绍;文中使用的是一台包含三个Kafka服务的本地集群

PS:如果对kafka的使用没啥问题,可以直接跳过启动阶段,只需要知道kafka集群中有三台broker即可。

启动阶段

启动服务

三个Kafka服务的server.properties配置分别为:

broker.id=0
listeners=PLAINTEXT://localhost:9092
log.dirs=D:\kafka_2.12-2.3.0\kafka-logs
zookeeper.connect=localhost:2181

broker.id=1
listeners=PLAINTEXT://localhost:9093
log.dirs=D:\kafka_2.12-2.3.0-1\kafka-logs
zookeeper.connect=localhost:2181

broker.id=2
listeners=PLAINTEXT://localhost:9094
log.dirs=D:\kafka_2.12-2.3.0-2\kafka-logs
zookeeper.connect=localhost:2181
复制代码

然后通过Kafka在windows下提供启动脚本kafka-server-start.bat,依次按照相应的配置文件进行Kafka的启动(注意在此之前要保证zookeeper已经在localhost:2181上开启服务):

PS D:\kafka_2.12-2.3.0-1> bin/windows/kafka-server-start.bat config/server.properties
复制代码

此时可以通过zkCli命令打开zk的客服端,并查询对应的broker是否已经启动:

[zk: localhost:2181(CONNECTED) 9] ls /brokers/ids
[0, 1, 2]
复制代码

创建主题:

服务启动完成后,我们通过windows环境下的kafka-topics.bat脚本的create选项进行topic-partition主题的创建,主题拥有三个分区、每个分区有三个副本;

PS D:\kafka_2.12-2.3.0> bin/windows/kafka-topics.bat --zookeeper localhost:2181 
--create --topic topic-partition --partitions 3 --replication-factor 3
Created topic topic-partition.
复制代码

接着我们通过kafka-topics.batdescribe选项查看当前主题的信息:

PS D:\kafka_2.12-2.3.0> bin/windows/kafka-topics.bat --zookeeper localhost:2181
--describe --topic topic-partition     
Topic:topic-partition   PartitionCount:3        ReplicationFactor:3     Configs:
        Topic: topic-partition  Partition: 0    Leader: 0    Replicas: 0,2,1 Isr: 0,2,1
        Topic: topic-partition  Partition: 1    Leader: 1    Replicas: 1,0,2 Isr: 1,0,2
        Topic: topic-partition  Partition: 2    Leader: 2    Replicas: 2,1,0 Isr: 2,1,0
复制代码

分区的AR(All Replicas) 表示集群中存在该分区副本的所有broker,如对于主题topic-partition的分区0来讲,该分区的AR的brokerId依次为[021];

分区的ISR(In-Sync Replicas)表示集群中存在与该分区leader副本保持同步分区的所有broker,ledaer节点也处于ISR中(详细的ISR加入、淘汰机制、leader副本的选举会在下面讲,这里先提一下概念);

我们可以通过在zk客服端命令查看zk的/brokers/topics目录下对应的topic信息:

[zk: localhost:2181(CONNECTED) 8] ls /brokers/topics
[__consumer_offsets, test, topic-partition]
复制代码

宏观图

按照topic_partition主题存在3个分区、副本因子为3的定义,先画出了kafka中消息的宏观流向,先有个大概印象即可~下面的小节会将这个画面一步步具体起来。

image.png

生产者生产、发送消息阶段

生产消息

生产消息即生产ProducerRecord对象:

public class ProducerRecord<K, V> {
    private final String topic; // 主题
    private final Integer partition; // 分区
    private final Headers headers; // 消息头部
    private final K key; // 消息键
    private final V value; // 消息值
    private final Long timestamp; // 消息的时间戳
    // 省略构造器
}
复制代码

ProducerRecord对象存储在内存的JVM进程空间中,而在网络传输时则需要将对象进行序列化编码为特定的字节序列,因此生产完消息后首先需要进行序列化以便下一步进行发送;

序列化分为三个大方向:

  • 语言特定格式,如JavaSerializable等语言内置的序列化方式;
  • 文本格式,如XMLJSON
  • 二进制编码格式,如ThriftProtocol Buffers

发送消息

确定消息发往的broker

我们可以通过kafka-console-producer.bat脚本连接到对应的broker并进行消息的投递:

PS D:\kafka_2.12-2.3.0-1> bin/windows/kafka-console-producer.bat --broker-list localhost:9092 --topic topic-partition
>`Hello, Nice to meet you`
复制代码

因此生产者投递一条消息只需要知道 指定的主题broker-list(bootstrap.server)即可;

但是我们知道topic-partition主题有三个分区,并且每个分区的Leader副本处于不同的broker节点上;并且我们可以在消息中增加Key来指定要投递的分区,而通过指定broker-list连接的节点上并不一定存在该分区的Leader副本;

所以此时存在两种解决方案:

  • broker-list对应的broker处理,其负责消息的传递到对应的broker节点上;
  • 由生产者客户端向kafka集群请求kafka集群对应主题的元数据(Metadata),接着生产者客户端通过元数据将消息投递给对应分区Leader副本所在的broker节点-(元数据包括主题的分区数、分区副本数、目标分区Leader副本所在的broker节点的Ip:Port);

Kafka选择了第二种,由客户端承担获取具体broker的大部分压力;

客户端通过向客户端已知负载最低的broker节点发送MetadataRequest请求元数据获取分区Leader副本对应的broker后再将ProducerRecord填充为如下的ProducerRequest格式投递给对应的broker,其中蓝色方框表示了该条消息的主题、分区、消息内容 record_set(ProducerRecord),其他的参数会在下面依次讲到;

image.png

PS:kafka定义了不同的Request协议用于不同场景,如获取元数据的MetadataRequest、用于生产者投递消息的ProducerRequest

池化消息发送

上一节中,我们分析完一条生产者消息经过找到对应的broker地址、包装为对应的Request对象这两个过程发送到broker中;

但是在同一个生产者客户端可能会存在多个线程同时生产发往各个主题、各个分区的消息,此时我们可以有两种方案:

  • 在每个线程中生产完对应的消息后自行发送;
  • 将问题抽象为生产者-消费者问题,通过线程间的协作来组织代码;

第一种方案,时效性好,但比较浪费资源,每次发送时无论数据量的大小都直接发送,不能有效复用连接传送更多的数据包;并且因为生产与发送消息这两个步骤耦合在一起,不利于程序的可扩展性;

第二种方案可以更好的利用网络资源,多个生产线程通过将发往各自主题、各自分区的消息放在不同的队列中,当消息达到一定量级后交由发送线程一起发送,这样便可以减少建立连接带来的消耗;

kakfa选择了第二种,produce线程每条生产的消息为ProducerRecord,在放入消息累加器池中前,我们需要对生产的消息按照分区进行归类(通过分区器计算分区),这样Send线程在取消息时可以在一个与broker的连接中将同一个分区的包发送出去,减少了频繁建连的开销;

计算分区后,produce线程将消息放入消息累加器对应分区的Deque中,多个消息组成的固定大小的消息集为ProducerBatch,而ProducerBatch也是消息发送的基本单位,其大小可以通过batch.size进行控制,特别地,如果一条ProducerRecord的大小超过该值,则该消息对应的ProducerBatch大小为该条消息的大小:

image.png

接着发送线程从消息累加器中获取不同的主题、不同分区的消息,并将取出的ProducerBatch根据ProducerBatch的分区、kafka集群元数据获取该消息应该发送到的broker,并将ProducerBatch封装为对应的ProducerRequest,并将其放入到按照不同目的broker划分的队列后交给selector进行依次发送:

image.png

此时发送线程有三种选择:发后即忘;发后异步回调;发后同步观察状态;

而对于后面两种,发送线程需要维护一个broker层面的消息已发送但未被确认InFlightRequests队列,如果队列长度超过该大小则不允许继续发送给对应的broker节点;我们可以通过max.in.flight.requests.per.connection参数控制该队列的大小;

其实这个机制有点类似于简化版本的的Tcp滑动窗口,其中消息累加器中的消息为未发送未确认的消息,而inFlight队列中则为已发送未确认的消息,如果是已发送已确认的消息则无需存储;

而存在一定数量已发送未确认的消息后则不可以继续发送,类似一个没有缩减发送窗口过渡的拥塞控制机制;

这其实间接表示了对于网络传输的控制,一般分为可靠性与拥塞性(速度)两个方面进行考虑;

但因为其简化了滑动窗口,所以对于发送失败的包(超时未响应),其不能类似于连续ARQ或者SACK(快重传) 机制对于包顺序性的保证,因为其没有发送窗口这个概念,只要发送成功便移出了inFlight队列;因此其只可以通过将max.in.flight.requests.per.connection参数置为1来实现停止等待重传协议-ARQ,发往该节点的下一个消息只有在上一条消息收到ACK后移出队列,发送线程方可以继续发送,以此来保证包的顺序性;

即使完全实现了滑动窗口,进行连续ARQ的重传会造成重复消费的问题,因为发送消息的send()方法并不是幂等的(即如果重传了已发送的消息,broker并不会将原有的消息覆盖)。

broker接收消息阶段:

确认答复客户端的时机

当一条ProducerRequest请求消息发送到指定的分区对应的broker中的leader副本后,broker根据请求消息中的acks值决定响应客户端的时机

  • acks=1,即只要leader副本将该消息写入日志后,broker便可以响应客户端表示该消息已经处理成功;
  • acks=0,broker节点收到该条消息后,直接返回客户端表示该消息已经处理成功;
  • acks=-1,只有等到所有ISR(In-Sync Replicas)副本都成功将该消息写入日志后,broker才会响应客户端。

即acks参数定义了broker对于该消息的响应操作在broker处理消息步骤中的位置,broker对于消息的处理包括:写入自身leader日志,等待ISR集合中follower副本FetchRequest请求拉取该消息,ISR集合follower副本都同步完成后,标识该日志为commited

理解acks参数前,首先需要介绍kafka分区的主从机制;

分区主从机制

leader节点崩溃与选举

leader节点选举方法

在kafka中,每个分区都存在主从副本,读写策略为读主写主,从节点只做灾备使用,主从副本间通过复制状态机原则进行分区间状态的维护;

因此在该原则下,kafka在分区初始创建时首先要进行分区leader副本的选举,选举策略为:leader副本为在AR集合中的第一个处于ISR集合的节点,举个例子,如下方的AR与ISR集合:

AR[1 , 3, 4]ISR[3, 4];依次遍历AR:1不在ISR集合中,因此被Pass;3在ISR集合中,因此当选为leader节点;

分区初始时,分区的AR节点都为ISR节点,因此leader副本为分区的优先副本(AR集合中的第一个副本),leader节点即为AR集合中的第一个节点;而这也是当前leader副本节点崩溃后从ISR集合中决定新的leader副本的方法;

leader节点崩溃后选举新的leader

在读主写主的策略下,kafka会面临该策略下的两个关键问题:

  • leader节点应用状态机晚于答复客户端引发Read After Write问题(kafkaacks=1/-1时leader节点会先写消息日志后进行答复客户端,因此不存在该问题);
  • 脑裂问题,即因为网络分区或者原本的leader节点宕机后重启以为自身还是leader节点,导致集群中产生了两个leader节点,而两个leader节点都可以对外提供服务,而这会造成集群中数据不一致的问题;

因此kafka需要提供机制来解决脑裂问题,核心在于保证集群中只存在一个有效的leader节点对外提供服务;对于这种分布式共识的问题,kafka选择依赖实现了共识算法-ZAB的第三方组件zookeeper;kafka会在zk中增加一个/brokers/topics/{topicname}/partitios/{partitionname}/state的节点,节点中记录了当前分区主节点的状态:

image.png

接下来的核心问题在于:leader节点崩溃后,如何感知到leader崩溃,并由谁去触发下一轮的leader节点选举?kafka并没有向传统的分布式共识算法的崩溃-恢复算法一样,通过follower通过心跳超时等机制去感知,并由follower触发下一个epoch的选举,由获取到半数以上支持的follower节点担任leader开启新一轮的任期;

相对的,kafka在服务端引入了控制器这个概念,由控制器去管理整个集群中所有分区和副本的状态,包括感知leader分区副本节点的崩溃并决定下一个的leader副本分区; 在集群启动时,每个broker都会尝试读取zk中/controller节点,如果zk中不存在/controller节点或者该节点中的数据异常,则会尝试在zk中创建/controller临时会话节点;

  • 创建成功的broker节点会成为控制器,并在zk的持久节点/controller_epoch中存放controller_epoch值,该值对应了控制器节点的变更次数,控制器节点每变更一次,该值便会加1;
  • 创建失败的节点会向zk注册/controller节点的watcher监听,这样当原控制器节点崩溃时,其他broker都会收到通知从而触发新一轮的控制器选举;

每个broker节点(包括控制器节点)都会在各自的内存中存储当前控制器的brokerId值。

控制器从ISR副本中选出下一个leader副本
  • 控制器会在zk的/brokers/ids目录下注册Watcher监听,一旦某个broker出现宕机,由控制器处理该事件;
  • 由控制器决定set_p,该集合中包含了宕机的所有broker上的所有partition;
  • /brokers/topics/{topicname}/partitios/{partitionname}/state中读取该Partition当前的ISR来判断宕机的broker中的分区副本是否为leader副本;如果是leader副本,则需要在ISR中AR顺序的下一个follower副本作为该分区的leader副本;
  • 将新的leader副本、ISR、新的leader_epoch和控制器对应的controller_epoch写入/brokers/topics/{topicname}/partitios/{partitionname}/state中;
  • 通过Rpc向set_p相关的Broker发送LeaderAndISRRequest命令通知leader和Isr的变换;

至此kafka中对应分区的leader的主备切换便完成了;

关于主从架构下的数据一致性的更多问题,可以参考这篇文章浅析分布式主从架构下数据一致性问题

由上述论述我们可以知道,维护ISR集合是保证leader切换时不丢失数据的保证,因此下面讲述管理ISR集合的方法;

follower加入与移出ISR集合

移出ISR集合

但在集群运行过程中从节点如果存在以下两种情况,则会被移出ISR列表

  • 功能失效:该分区的follower副本所在的从节点处于宕机状态,无法与leader节点保持连接;
  • 同步失效:该分区的follower副本落后于leader副本一段时间(由replica.lag.time.max.ms指定,默认为10000),此处的落后是指follower副本的LEO未追赶上leader副本的LEO(Log End Offset),即在规定的时间段内一直处于落后状态;

如下图所示,三个节点都处于该分区的ISR集合中:partition Leader中已经存储了5条日志,对应的LEO为6(即标识下一条消息写入日志文件的位置);两个Follower中分别存储了4条与3条日志,LEO分别为5和4;

HW即为ISR副本集合中最小的LEO - 1,在上图中,即为3;HW表示该分区中已提交的消息位移;

image.png

加入ISR集合

分区的AR集合中被移除ISR的从节点如果要重新加入ISR,必须满足条件:该follower分区副本的LEO值赶上了当前ISR集合的HW;注意此处的赶上leader节点进度与上面因为同步失效移出ISR集合的追赶并不是判断标准;因为如果该分区即使回到了ISR集合,但是在replica.lag.time.max.ms时间内自身LEO并没有追赶上LEO,也是会被移出ISR集合的;

这样做的目的是保证分区存在一定数量的ISR节点:如果在一段时间内生产者ProducerRequest QPS突然暴增,导致全部节点被移出ISR,此时如果leader节点崩溃,并且unclean.leader.election.enable默认值为false,无法从非ISR节点中进行leader的选举,会导致该分区出现暂无leader的情况;

至此,kafka broker端主从选举的部分便讲完了,下面便是另一个步骤-日志写入:

日志维护

日志写入(应用状态机)

RabbitMQ等以内存做为存储日志的方式不同,kafka采用的是磁盘存储日志的方式,比如存储topic-partition主题的三个分区的数据即为log.dirs路径中的三个文件:

image.png

所谓的写入日志即为将消息Append到对应分区文件的尾部-顺序磁盘写入,在一些SSD上,磁盘顺序写入的效率甚至高于内存的随机写入;

kafka采用磁盘主要是基于以下的考虑:

  • 磁盘相比于内存更加稳定;
  • 操作系统对于磁盘的写入存在很多的优化算法(如预读异步flush等),基于操作系统的保证,kafka只需要调用操作系统接口进行文件写入即可,无需关心写入的信息丢失如何处理;

但当消费者对相应的分区文件进行读取时,需要对文件进行磁盘扫描,而磁盘扫描的效率十分低下;因此我们要为文件增加索引,<recordIndex,物理位置>,比如消费者想要读取第6条记录时,读取索引recordIndex == 6即可获取其在文件中的对应位置;

众所周知,kafka的吞吐量是比较高的,而如果采用聚簇索引的方式来组织索引,势必会造成索引占用空间的爆炸;因此kafka采用每插入几条数据加入一次索引构成稀疏索引

而因为对于一个大文件的读写效率会变慢-所有对于该分区的请求都对应于该文件,并且如果当前日志的条数过大,超过了Integer.MAX_VALUE(2的32次方)的大小后会导致recordIndex无法记录,因此kafka会按照log.segment.bytes进行日志文件的分段拆分;

而因为索引与文件存在1对1对应的关系,存在多个日志文件会导致存在多个索引,而这导致如果消费者要找的日志offset靠后,需要遍历多个索引文件才可以拿到对应的索引记录;因此我们需要增加索引的索引-跳表用来维护每个索引的最大值,这样我们就可以根据上层索引更快的找到我们对应offset对应的日志文件;

以上便是kafka写入日志与索引的方式,但是磁盘空间总是有限的,因此kafka需要提供日志压缩(compact)与删除两个功能来控制日志、索引文件的大小;

维护索引与日志

日志压缩(compact)

kafka日志是基于key-value存储的,而这与redis类似;

redis中存在两种日志记录方式RDBAOF,而kafka中compact则类似于RDB,对于日志文件中相同Key的kafka消息只保留最新的一条,这样使得日志空间得到释放;kafka会遍历两次可压缩的日志文件,第一次使用一个map结构来存储消息的<key的哈希值,以及其最新一条消息的offset>,第二次则按照这个map对于相同key但是offset小于map中的offset值时进行清理;

但是kafkaredis复杂的地方在于,对于redis的读写只需要保存键的最新值即可,而kafka的消费端可以按照offset进行消费,如果在这个offset -> LEO之间存在key相同的消息,并且该消息已经被压缩,则会造成当前offset消息消费失败的错误,因此我们需要一个指示信息告知当前offset是否已经被清理,即下图的cleanCheckPoint指针:

image.png

如果当前消费的日志位移在指针右侧,则表示该offset尚未被清理,可直接拉取;如果在指针左侧,则表示该offset可能已经被compact

而因为原本的日志文件压缩后可能会产生多个小文件,因此kafka采用分组压缩的方式,一个组包含多个日志段,并使用新的后缀为.clean的文件存储压缩后的日志,如果组内压缩后日志量小于log.segment.bytes则压缩后只会存在一个日志段文件;

日志压缩的触发时机一般是基于日志文件的污浊率,即(firstUncleanableOffset - cleanerCheckPoint) / cleanerCheckPoint,当该值超过log.cleaner.min.cleanable.ratio时会触发日志压缩;

日志删除

删除日志可以基于时间、日志大小、日志的起始偏移量这三个维度进行定时删除;

  • 基于时间:如果该消息中的时间戳与当前时间戳的差值大于log.rentention.hour时会进行删除;
  • 基于日志大小,即日志超过log.rentention.bytes时会删除指定大小的文件,一般该值为-1.即无穷大;
  • 基于日志的起始偏移量,即修改LogStartOffset指针,在该指针对应的日志offset前的日志会被删除,可以通过kafka-delete-records.sh文件进行手动设置;

不过最为核心的是kafka如何删除一条日志;Mysql中当我们使用delete命令删除一条数据行时,为了保证重复度隔离级别,mysql会将其标志为墓碑记录,等到所有活跃事务对该数据行的引用结束后,通过定时线程进行墓碑记录的清理;

与mysql类似,kafka通过将一条消息的value设置为NULL来标识其为墓碑记录,定时任务也会定时进行墓碑记录的删除;

消费者消费消息阶段

消费者消费一般分为几个阶段:

  • 开启消费者客户端,定义自身消费者组、订阅主题;
  • 通过FetchRequest请求拉取分区消息并进行业务处理(poll()方法);
  • 提交消费位移;
  • 关闭消费者客户端。

下面会对几个阶段中的分区策略同步、消费位移提交这两个点进行着重描述;

消费者初始化

PS D:\kafka_2.12-2.3.0> bin/windows/kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic topic-partition
`Hello, Nice to meet you`
复制代码

首先要明确的是kafka消费者获取消息的方式是推or拉,消息的投递模式是P2P or 发布/订阅(一对多广播)

kafka的消费者通过主动请求broker拉取数据;

kafka因为增加了消费者组的概念既可以支持P2P也可以支持发布/订阅:

  • 当一个消费者组中只有一个消费者时,那么该主题中的所有消息都可以被该消费者消费,即P2P
  • 当一个消费者组中存在多个消费者时,那么该主题中的所有消息会被该消费者组中的消费者按照相应的分区原则进行消费,即发布/订阅

但是无论按照何种消息投递模式,消费者首先要定义自身所在的消费者组,并订阅相应的主题;定义完成后,即可开始请求broker拉取数据;

消费者分区原则

与其他一些消息中间件不同,在kafka中还存在消费组这个概念,每个消费者属于一个消费者组,每个主题的消息只会投递给订阅它的每个消费者组中的一个消费者

topic-partition存在三个分区,存在订阅了该主题的消费者组A(由C1、C2二个消费者构成)、消费者组B(由C1、C2、C3三个消费者构成)与消费者组C(由C1、C2、C3、C4四个消费者组构成);

  • 对于A组中的消费者,C1被分配两个分区,C2被分配一个分区;
  • 对于B组中的消费者,每个消费者被分配一个分区;
  • 对于C组中的消费者,C1、C2、C3被分配一个分区,C4没有分区消费;

此为kafka默认的分区策略-RangeAssignor,原理为为消费者组的每个消费者分配分区数/消费组中消费者数的分区,而剩下的分区数%消费者组中的消费者数则分配个字典序在前(C1先于C2) 的消费者;

所有的分区策略都是为了在消费组中分区分配趋向均匀;

消费者开始消费

在开始请求主题的消息前,消费者需要回答这个问题:消费者如何知道自己应该拉取该主题哪些分区的数据呢?即分区策略如何在多个消费者之间同步?

分区策略的同步

这个涉及到多个节点同步的问题我们可以通过kafka服务端在确认分区策略后在zookeeper中增加相关的分区策略信息来进行协调(对应下图中/consumers/group1/owners/topic1节点):

image.png

如上图所示,对于group1中的topic1主题的分区1,消费者可以通过首次遍历topic1节点获取属于自己的分区节点;

当消费者组中的消费者发生变化时,即zk的/consumers/group/ids目录下临时节点的数量发生变化,需要触发分区再平衡进行主题的分区在消费者中的重新分配,因此当前消费者组中的所有消费者都需要对本组的/consumers/group/ids目录设置Watcher监听(Watcher监听会在对应节点发生改变时产生事件通知对应的监听者);

但是这种过于依赖zk的方式会导致下面两个问题:

  • 如果新增的消费者只订阅了topic1,并没有订阅其他主题,那么需要被触发的应该只有订阅了topic1的节点,但是此时却触发了组内全部的消费者节点;这种宽粒度的触发机制对于较大的group,会导致大量的watcher通知被发送到客户端,导致在通知期间zookeeper响应延迟,即羊群效应
  • 向zk写入时因为需要按照共识算法进行状态机的同步,以及状态机同步的操作并不是原子的,因此可能会导致多个消费者节点读取zk中存储主题分区的owner时获取的状态并不一致,最终导致异常问题的发生。

因此我们需要更细粒度的通知机制,使得只通知需要通知的节点,因此我们需要引入一个新的机制-协调者(Coordinator),这个协调者类似于2pc中的协调者角色,用于在多个分布式节点间对于分区策略达成共识

这个我们下面将提交消费位移时一起讲,因为两者都使用了_consumer_offset这个内部主题。

提交消费位移

kafka0.9版本前,采用如上图所示的:在zk的/consumers/{groupname}/offsets/{topicname}/{partitionname}目录中存储每个消费者组中对于相应主题分区的消费位移;

但由于如上所说,zk并不适用于对于时效性很高的多写场景,因此kafka在0.9版本后通过内置主题_consumer_offsets存储不同消费者组中的分区的消费位移;

_consumer_offsets主题的分区数由offsets.topic.num.partitions来配置,默认为50个分区;

image.png

group.id值为test_group_id时,kafka会通过下述的算式计算出用于该消费者组提交位移_consumer_offsets主题的分区id:

// groupMetadataTopicPartitionCount 即为 offsets.topic.num.partitions
Maths.abs(groupId.hashCode()) % groupMetadataTopicPartitionCount
复制代码

因此如果hash函数不够均匀,可能会使得_consumer_offsets主题的部分分区汇聚了大部分消费日志提交,从而导致部分broker的磁盘被写满,而其他的broker则处于空闲状态;

image.png

GroupCoordinator

对于每个消费者组,其用于提交消费位移的 _consumer_offsets主题分区的leader副本所在的broker 中运行着该消费者组的GroupCoordinator,而这便是用于解决之前羊群效应的协调者;

我们以消费者组中新加入一个消费者为例,因为消费者组的节点数量发生变化,因此需要触发分区重分配;不同于通过Watcher进行通知的机制,该方案是让新加入的消费者自己进行通知

1. 寻找GroupCoordinator节点

首先新加入的消费者需要向自己通信列表中的broker发送FindCoordinatorRequest请求,请求中包含自身所处的groupId

broker接收到请求后通过Maths.abs(groupId.hashCode()) % groupMetadataTopicPartitionCount公式计算出该消费者组对应的_consumer_offsets主题分区,并通过zk/brokers/topics/_consumer_offsets/partitions/{分区id}/state获取到当前分区leader副本所在的broker的ip进行响应;(该broker即为GroupCoordinator)

2. 请求加入消费者组(触发消费者组的rebalance)

消费者获取到对应的ip后,需要向GroupCoordinator发送JoinGroupRequest请求加入消费者组,该请求信息包括该消费者想要订阅主题的数组、该消费者支持的分区策略,然后会阻塞,直到收到GroupCoordinator发送的包含具体分区策略的JoinGroupResponse的响应信息;

此时GroupCoordinator收到该请求后,该Group的状态会从原本的 stable(稳定状态) -> preparingRebalance(准备开始分区重分配状态) ,并在该状态下停留rebalance.timeout.ms;在此时间区间中,该消费者组中的其他消费者调用poll()拉取消息时,该方法需要连接GroupCoordinator获取本次拉取分区的commited Offsets值,此时消费者会察觉到自身所处的Group的状态为preparingRebalance需要触发ReJoin机制,即发送JoinGroupRequestGroupCoordinator,消息中携带自身订阅的主题、支持的分区策略

image.png

rebalance.timeout.ms时间过去后,Group状态会由 preparingRebalance -> AwaitingSync(等待分区策略同步状态) ,收集到Group中的各个消费者提交的JoinGroupRequest请求后,服务端就决定使用何种的分区策略进行分区,但是此处并没有在服务端进行该消费者组所订阅主题的分区的分配,而是通过在消费者组中随机选择一个消费者作为Leader,并发送JoinGroupResponse至该节点,该响应包括了该组内每个消费者订阅的主题以及确定的一个分区策略;

image.png

由该节点负责按照分区策略进行分配,并生成相应的分区结果通过SyncGroupRequest返回给GroupCoordinator,并由GroupCoordinator进行分区结果在消费者组中的Sync

对于该状态流转期间,可能大家会有疑问,为什么需要绕这么一个大弯,直接在GroupCoordinator中进行分配不就好了吗?因为这样可以使得具体的分区分配细节不在broker端进行,即使以后的分区策略发生变化,也只需要重新启动消费端即可,无需重新启动服务端。

同步完成后Group状态会由 AwaitingSync -> stable

至此,我们完整的讲完了一个主题的分区如何在消费者间进行分配,消费者拉取消息消费后如何提交位移的完整流程。

总结:

这篇博客包含的细节太多,无法一一展开叙述,但即便如此,一条消息的历程和相关细节算是讲的比较清晰了,但是读起来可能会非常花时间去慢慢理逻辑~

参考:

《深入理解Kafka》、《从Paxos到zookeeper》、《数据密集型应用系统设计》

Kafka设计解析(二)- Kafka High Availability (上)

Kafka 之 Group 状态变化分析及 Rebalance 过程

文章分类
后端
文章标签