接上篇《Kafka技术知识总结之三——Kafka 高效文件存储设计》
Kafka 通过 消费组协调器 (GroupCoordinator) 与消费者协调器 (ConsumerCoordinator),实现消费者再均衡操作。
注:新版消费者客户端将全部消费组分成多个子集,每个消费组的子集在服务端对应一个 GroupCoordinator 进行管理。
ConsumerCoordinator 与 GroupCoordinator 之间最重要的职责就是负责执行消费者再均衡操作。导致消费者再均衡的操作:
参考地址:
《kafka消费者分组消费的再平衡策略》
《深入理解 Kafka 核心设计与实践原理》7.1 章节
Kafka 提供了三种再均衡策略(即分区分配策略),默认使用 RangeAssignor。
注:Kafka 提供消费者客户端参数
partition.assignment.strategy
设置 Consumer 与订阅 Topic 之间的分区分配策略。
RangeAssignor 分配策略,原理是按照消费者总数和分区总数进行整除运算,获得一个跨度,然后将分区按照跨度进行平均分配。
对于分区数可以整除消费组内消费者数量的情况(比如一个消费组内有 2 个消费者,某个 Topic 中有 4 个分区),这种方法的分配特性较好。但如果分区数除以消费组的消费者数量有余数(比如一个消费组内有 2 个消费者,某个 Topic 有 3 个分区),则会分配不均。这种情况下,如果类似情形扩大,可能会出现消费者过载情况。
注:算法如下:
- 将目标 Topic 下的所有 Partirtion 排序,存于 TP;
- 对某 Consumer Group 下所有 Consumer 按照名字根据字典排序,存于 CG;此外,第 i 个 Consumer 记为 Ci;
N = size(TP) / size(CG)
R = size(TP) % size(CG)
- Ci 获取的分区起始位置:
N * i + min(i, R)
- Ci 获取的分区总数:
N + (if (i + 1 > R) 0 else 1)
RoundRobinAssignor 分配策略,原理是对某个消费组的所有消费者订阅的所有 Topic 的所有分区进行字典排序,然后用轮询方式将分区逐个分配给各消费者。
合理使用这种分配策略,最主要的要求是:消费组内所有消费者都有相同的订阅 Topic 集合。如果消费组内消费者订阅信息不同,则执行分区分配的时候就不能实现完全的轮询,可能导致分区分配不均的情况。
注:算法如下:
- 对所有 Topic 的所有分区按照 Topic + Partition 转 String 后的 Hash 计算,进行排序;
- 对消费者按照字典排序;
- 轮询方式,将所有分区分配给消费者;
StickyAssignor 分配策略注重两点:
从 StickyAssignor 的名称可以看出,该分配策略尽可能的保持“黏性”。在发生分区重分配后,尽可能让前后两次分配相同,减少系统的损耗。虽然该策略的代码实现很复杂,但通常从结果上看通常比其他两种分配策略更优秀。
消费者需要确定它所述消费组对应 GroupCoordinator 所在 broker,并创建网络连接。向集群中负载最小的节点发送 FindCoordinatorRequest;
Kafka 收到 FindCoordinatorRequest 后,根据请求中包含的 groupId 查找对应的 GroupCoordinator 节点。
消费者找到消费组对应的 GroupCoordinator 之后,进入加入消费组的阶段。消费者会向 GroupCoordinator 发送 JoinGroupRequest 请求。每个消费者发送的 GroupCoordinator 中,都携带了各自提案的分配策略与订阅信息。
Kafka Broker 收到请求后进行处理。
注:如果有消费者不支持选出的分配策略,会报出异常。
Kafka 处理完数据后,将响应 JoinGroupResponse 返回给各个消费者。JoinGroupResponse 回执中包含着 GroupCoordinator 投票选举的结果,在这些分别给各个消费者的结果中,只有给 leader 消费者的回执中包含各个消费者的订阅信息。
加入消费者的结果通过响应返回给各个消费者,消费者接收到响应后,开始准备实施具体的分区分配。上一步中只有 leader 消费者收到了包含各消费者订阅结果的回执信息,所以需要 leader 消费者主导转发同步分配方案。转发同步分配方案的过程,就是同步阶段。
同步阶段,leader 消费者是通过“中间人” GroupCoordinator 进行的。各个消费者向 GroupCoordinator 发送 SyncGroupRequest 请求,其中只有 leader 消费者发送的请求中包含相关的分配方案。Kafka 服务端收到请求后交给 GroupCoordinator 处理。处理过程有:
各消费者收到分配方案后,会开启 ConsumerRebalanceListener 中的 onPartitionAssigned() 方法,开启心跳任务,与 GroupCoordinator 定期发送心跳请求 HeartbeatRequest,保证彼此在线。
进入该阶段后的消费者,已经属于进入正常工作状态了。消费者通过向 GroupCoordinator 发送心跳,来维持它们与消费组的从属关系,以及对 Partition 的所有权关系。
心跳线程是一个独立的线程,可以在轮询消息空档发送心跳。如果一个消费者停止发送心跳的时间比较长,那么整个会话被判定为过期,GroupCoordinator 会认为这个消费者已经死亡,则会触发再均衡行为。
触发再均衡行为的情况:
参考地址:《记一次线上kafka一直rebalance故障》
由前面章节可知,有多种可能触发再均衡的原因。下述记录一次 Kafka 的频繁再均衡故障。平均间隔 2 到 3 分钟就会触发一次再均衡,分析日志发现比较严重。主要日志内容如下:
commit failed
org.apache.kafka.clients.consumer.CommitFailedException: Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member. This means that the time between subsequent calls to poll() was longer than the configured max.poll.interval.ms, which typically implies that the poll loop is spending too much time message processing. You can address this either by increasing the session timeout or by reducing the maximum size of batches returned in poll() with max.poll.records.
这个错误意思是消费者在处理完一批 poll 的消息之后,同步提交偏移量给 Broker 时报错,主要原因是当前消费者线程消费的分区已经被 Broker 节点回收了,所以 Kafka 认为这个消费者已经死了,导致提交失败。
导致该问题的原因,主要涉及构建消费者的一个属性 max.poll.interval.ms。这个属性的意思是消费者两次 poll() 方法调用之间的最大延迟。如果超过这个时间 poll 方法没有被再次调用,则认为该消费者已经死亡,触发消费组的再平衡。该参数的默认值为 300s,但我们业务中设置了 5s。
查询 Kafka 拉取日志后,发现有几条日志由于逻辑问题,单条数据处理时间超过了一分钟,所以在处理一批消息之后,总时间超过了该参数的设置值 5s,导致消费者被踢出消费组,导致再均衡。
解决方法:
此外,再均衡可能会导致消息的重复消费现象。消费者每次拉取消息之后,都需要将偏移量提交给消费组,如果设置了自动提交,则这个过程在消费完毕后自动执行偏移量的提交;如果设置手动提交,则需要在程序中调用 consumer.commitSync()
方法执行提交操作。
反过来,如果消费者没有将偏移量提交,那么下一次消费者重新与 Broker 相连之后,该消费者会从已提交偏移量处开始消费。问题就在这里,如果处理消息时间较长,消费者被消费组剔除,那么提交偏移量出错。消费者踢出消费组后触发了再均衡,分区被分配给其他消费者,其他消费者如果消费该分区的消息时,由于之前的消费者已经消费了该分区的部分消息,所以这里出现了重复消费的问题。
解决该问题的方式在于拉取后的处理。poll 到消息后,消息处理完一条就提交一条,如果出现提交失败,则马上跳出循环,Kafka 触发再均衡。这样的话,重新分配到该分区的消费者也不会重复消费之前已经处理过的消息。代码如下:
while (isRunning) {
ConsumerRecords<KEY, VALUE> records = consumer.poll(100);
if (records != null && records.count() > 0) {
for (ConsumerRecord<KEY, VALUE> record : records) {
// 处理一条消息
dealMessage(bizConsumer, record.value());
try {
// 消息处理完毕后,就直接提交
consumer.commitSync();
} catch (CommitFailedException e) {
// 如果提交失败,则日志记录,并跳出循环
// 跳出循环后,Kafka Broker 端会触发再均衡
logger.error("commit failed, will break this for loop", e);
break;
}
}
}
}