Kafka可以将主题划分为多个分区(Partition),会根据分区规则选择把消息存储到哪个分区中,只要分区规则设置合理的话,那么所有的消息将会均匀的分布到不同的分区中,这样就实现了负载均衡和水平扩展。另外,多个订阅者可以从一个或多个分区中同时消费数据,以支持海量数据处理能力。
副本机制
由于Producer和Consumer都只会与leader角色的分区副本相连,所以Kafka需要以集群的组织形式提供主题中的消息高可用。Kafka支持主从复制,所以消息具备高可用和持久性。
一个分区可以有多个副本,这些副本保存在不同的broker上,每个分区的副本中都会有一个作为leader。当一个broker失败时,leader在这台broker上的分区都会变得不可用,Kafka会自动移除leader,再其他副本中选一个作为新的leader。
在通常情况下,增加分区可以提供Kafka集群的吞吐量。然而,也应该意识到集群的总分区数或是单台服务器上的分区数过多,会增加不可用以及延迟的风险。
./kafka-topics.sh --describe --bootstrap-server localhost:9092 --topic mytopic1
分区leader选举
可以预见的是,如果某个分区的leader挂了,那么其他跟随者将会进行选举产生一个新的leader,之后所有的读写就会转移到这个新的leader上,在Kafka中,其不是采用常见的多数选举的方式进行副本的leader选举,而是会在zookeeper上针对每个topic维护一个称为ISR(In-sync replica 已同步副本)的集合,显然还有一些副本没有来得及同步。只有这个ISR列表里面的才有资格成为leader,先使用ISR里面的第一个,如果不行依次类推,因为ISR里面的是同步副本,消息是最完整且各个节点都是一样的。
通过ISR,Kafka需要的冗余度较低,可以容忍的失败数比较高。假设某个topic有f+1个副本,Kafka可以容忍f个不可用,当然,如果全部ISR里面的副本都不可用,也可以选择其他可用的副本,只是存在数据的不一致。
分区重新分配
往已经部署好的Kafka集群里面添加机器是最正常不过的需求,而且添加起来非常地方便,我们需要做的是从已经部署好的Kafka节点中复制相应的配置文件,然后把里面的broker id修改成全局统一的,最后启动这个节点即可将它加入到现有Kafka集群中。
但是问题是,新添加的Kafka节点并不会自动的分配数据,所以无法分担集群的负载,除非我们新建一个topic。但是现在我们想手动将部分分区移到新添加的Kafka节点上,Kafka内部提供了相关的工具来重新分布某个topic的分区。
- 首先,创建一个有三个节点的集群,可以在一台服务器开三个节点,将Kafka文件复制三份,分别改其server.properties文件中的端口号、brokerid、logdir后,分别启动即可。
./kafka-server-start.sh ../config/server.properties
- 创建一个新主题,有三个分区、三个副本
./kafka-topics.sh --create --bootstrap-server localhost:9092 --topic mytopic2 --partition 3 --replication-factor 3
- 给主题再添加一个分区
./kafka-topics.sh --alter --bootstrap-server localhost:9092 --topic mytopic2 --partitions 4
- 再添加一个broker节点,重新分配分区。
现在需要将原先分布在broker0-2节点上的分区重新分布到broker0-3节点上,借助kafka-reassign-partitions.sh工具生成reassign plan,不过需要先定义一个文件,其中说明哪些topic需要重新分区。
文件内容如下:
Kafka目录下reassign.json:
{
"topics":[{"topic":"mytopic2"}]
"version":1
}
然后使用工具kafka-reassign-partitions.sh生成reassign plan。
./kafka-reassign-partitions.sh --bootstrap-server localhost:9092 --topics-to-move-json-file reassign.json --broker-list "0,1,2,3" --generate
# --generate 表示指定类型参数
# --topics-to-move-json-file 指定分区重分配对应的主题清单路径
命令输出两个json字符串,第一个json内容为当前的分区副本分配情况,第二个为重新分配的候选方案,注意这里只是生成一份可行性方案,并没有真正执行重新分配的动作。
- 将第二个json内容保存到名为result.json文件里面,然后执行reassign plan:
./kafka-reassign-partitions.sh --bootstrap-server localhost:9092 --reassignment-json-file result.json --execute
- 查看分区重新分配的进度:
./kafka-reassign-partitions.sh --bootstrap-server localhost:9092 --reassignment-json-file result.json --verify
分区分配策略
按照Kafka默认的消费逻辑设定,一个分区只能被同一个消费组(ConsumerGroup)内的一个消费者消费,假设目前某消费组内只有一个消费者C0,订阅了一个topic,这个topic包含7个分区,也就是说这个消费者C0订阅了7个分区。
此时消费组内又加入了一个新的消费者C1,按照既定的逻辑需要将原来消费者C0的部分分区分配给消费者C1消费。消费者C0和C1各自负责消费所分配到的分区,相互之间并无实质性的干扰。
接着消费组内又加入一个新的消费者C2,如此消费者C0、C1、C2各自负责所分配到的分区。
如果消费者过多,出现了消费者的数量大于分区的数量的情况,就会有消费者分配不到任何分区。
上面各个示例中的整套逻辑是按照Kafka中默认的分区分配策略来实施的。kafka提供了消费者客户端参数partition.assignment.strategy用来设置消费者与订阅主题之间的分区分配策略。默认情况下,此参数的值为org.apache.kafka.cllients.consumer.RangeAssignor,即采用RangeAssignor分配策略。除此之外,kafka中还提供了另外两种分配策略RoundRobinAssignor和StrickyAssignor。消费者客户端参数partition.assignment.strategy可以配置多个参数,彼此之间用逗号分隔。
RangeAssignor分配策略
RangeAssignor策略的原理是按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。对于每一个topic,RangeAssignor策略会将消费组内所有订阅这个topic的消费者按照名称的字典序排序。然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。
假设n=分区数/消费者数量,m=分区数%消费者数量,那么前m个消费者每个分配n+1个分区,后面的(消费者数量m)个消费者每个分配n个分区。
假设消费组内有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有4个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t0p3、t1p0、t1p1、t1p2、t1p3。最终的分配结果为:
消费者c0:t0p0、t0p1、t1p0、t1p1
消费者c1:t0p2、t0p3、t1p2、t1p3
假设上面例子中2个主题都只有3个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终分配的结果为:
消费者c0:t0p0、t0p1、t1p0、t1p1
消费者c1:t0p2、t1p2
可以明显看到这样的分配不均匀,如果将类似的情形扩大,有可能会出现部分消费者过载的情况。
RoundRobinAssignor分配策略
RoundRobinAssignor策略的原理是将消费者以及消费者所订阅的所有topic的partition按照字典序排序,然后通过轮询方式逐个将分区以此分配给每个消费者。RoundRobinAssignor策略对应的partition.assignment.strategy参数值为:org.apache.kafka.clients.consumer.RoundRobinAssignor。
假设消费组中有2个消费者c0和c1,都订阅了主题t0和t1,并且每个主题都有3个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:
消费者c0:t0p0、t0p2、t1p1
消费者c1:t0p1、t1p0、t1p2
如果同一个消费组内的消费者所订阅的信息是不相同的,那么在执行分区分配的时候就不是完全的轮询分配,有可能会导致分区分配的不均匀。如果某个消费者没有订阅消费组内的某个topic,那么在分配分区的时候此消费者将分配不到这个topic的任何分区。
假设消费组内有3个消费者C0、C1和C2,它们共订阅了3个主题:t0、t1、t2,这3个主题分别有1、2、3个分区,即整个消费组订阅了t0p0、t1p0、t1p1、t2p0、t2p1、t2p2这6个分区。具体而言,消费者c0订阅的是主题t0,消费者c1订阅的是主题t0和t1,消费者c2订阅的是主题t0、t1和t2,那么最终的分配结果为:
消费者c0:t0p0
消费者c1:t1p0
消费者c0:t2p0、t2p1、t2p2
可以看到RoundRobinAssignor策略也不是十分完美的,这样分配其实并不是最优解,因为完全可以将分区t1p1分配给消费者C1。
StrickyAssignor分配策略
Kafka从0.11.x版本开始引入这种分配策略,它主要有两个目的:
- 分区的分配要尽可能的均匀;
- 分区的分配尽可能的与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标,StrickyAssignor策略的具体实现要比RangeAssignor和RoundRobinAssignor这两种分配策略要复杂很多。
假设消费组内有3个消费者:C0、C1、C2,它们都订阅了4个主题:t0、t1、t2、t3,并且每个主题有2个分区,也就是说整个消费组订阅了t0p0、t0p1、t1p0、t1p1、t2p0、t2p1、t3p0、t3p1这8个分区,最终的分配结果如下:
消费者c0:t0p0、t1p1、t3p0
消费者c1:t0p1、t2p0、t3p1
消费者c2:t1p0、t2p1
假设此时消费者C1脱离了消费组,那么消费组就会执行再平衡操作,进而消费分区会重新分配。如果采用RoundRobinAssignor策略,那么此时的分配结果如下:
消费者c0:t0p0、t1p0、t2p0、t3p0
消费者c2:t0p1、t1p1、t2p1、t3p1
如分配结果所示,RoundRobinAssignor策略会按照消费者c0和c2进行重新轮询分配。而如果此时使用的是StrickyAssignor策略,那么分配结果为:
消费者c0:t0p0、t1p1、t3p0、t2p0
消费者c2:t1p0、t2p1、t0p1、t3p1
可以看到分配结果中保留了上一次分配中对于消费者c0和c2的所有分配结果,并将原来消费者c1的负担分配给了剩余的两个消费者,最终两个消费者的分配还保持了均衡。