Kafka技术知识总结之四——Kafka 再均衡

接上篇《Kafka技术知识总结之三——Kafka 高效文件存储设计》

四. Kafka 再均衡原理

4.1 消费者再均衡

Kafka 通过 消费组协调器 (GroupCoordinator)消费者协调器 (ConsumerCoordinator),实现消费者再均衡操作。

  • 消费组协调器 (GroupCoordinator):Kafka 服务端中,用于管理消费组的组件;
  • 消费者协调器 (ConsumerCoordinator):Consumer 客户端中,负责与 GroupCoordinator 进行交互;

注:新版消费者客户端将全部消费组分成多个子集,每个消费组的子集在服务端对应一个 GroupCoordinator 进行管理。

ConsumerCoordinator 与 GroupCoordinator 之间最重要的职责就是负责执行消费者再均衡操作。导致消费者再均衡的操作:

  • 新的消费者加入消费组;
  • 消费者宕机下线(不一定是真的下线,令消费组以为消费者宕机下线的本质原因是消费者长时间未向 GroupCoordinator 发送心跳包);
  • 消费者主动退出消费组;
  • 消费组对应的 GroupCoordinator 节点发生了变更;
  • 任意主题或主题分区数量发生变化;

4.2 再均衡策略

参考地址:
《kafka消费者分组消费的再平衡策略》
《深入理解 Kafka 核心设计与实践原理》7.1 章节

Kafka 提供了三种再均衡策略(即分区分配策略),默认使用 RangeAssignor

注:Kafka 提供消费者客户端参数 partition.assignment.strategy 设置 Consumer 与订阅 Topic 之间的分区分配策略。

4.2.1 RangeAssignor

RangeAssignor 分配策略,原理是按照消费者总数和分区总数进行整除运算,获得一个跨度,然后将分区按照跨度进行平均分配。
对于分区数可以整除消费组内消费者数量的情况(比如一个消费组内有 2 个消费者,某个 Topic 中有 4 个分区),这种方法的分配特性较好。但如果分区数除以消费组的消费者数量有余数(比如一个消费组内有 2 个消费者,某个 Topic 有 3 个分区),则会分配不均。这种情况下,如果类似情形扩大,可能会出现消费者过载情况。

注:算法如下:

  1. 将目标 Topic 下的所有 Partirtion 排序,存于 TP
  2. 对某 Consumer Group 下所有 Consumer 按照名字根据字典排序,存于 CG;此外,第 i 个 Consumer 记为 Ci
  3. N = size(TP) / size(CG)
  4. R = size(TP) % size(CG)
  5. Ci 获取的分区起始位置:N * i + min(i, R)
  6. Ci 获取的分区总数:N + (if (i + 1 > R) 0 else 1)

4.2.2 RoundRobinAssignor

RoundRobinAssignor 分配策略,原理是对某个消费组的所有消费者订阅的所有 Topic 的所有分区进行字典排序,然后用轮询方式将分区逐个分配给各消费者。
合理使用这种分配策略,最主要的要求是:消费组内所有消费者都有相同的订阅 Topic 集合。如果消费组内消费者订阅信息不同,则执行分区分配的时候就不能实现完全的轮询,可能导致分区分配不均的情况。

注:算法如下:

  1. 对所有 Topic 的所有分区按照 Topic + Partition 转 String 后的 Hash 计算,进行排序;
  2. 对消费者按照字典排序;
  3. 轮询方式,将所有分区分配给消费者;

4.2.3 StickyAssignor

StickyAssignor 分配策略注重两点:

  • 分配尽量均匀;
  • 分配尽量与上一次分配的相同;

从 StickyAssignor 的名称可以看出,该分配策略尽可能的保持“黏性”。在发生分区重分配后,尽可能让前后两次分配相同,减少系统的损耗。虽然该策略的代码实现很复杂,但通常从结果上看通常比其他两种分配策略更优秀。

4.3 消费者再均衡阶段

4.3.1 阶段一:寻找 GroupCoordinator

消费者需要确定它所述消费组对应 GroupCoordinator 所在 broker,并创建网络连接。向集群中负载最小的节点发送 FindCoordinatorRequest
Kafka 收到 FindCoordinatorRequest 后,根据请求中包含的 groupId 查找对应的 GroupCoordinator 节点。

4.3.2 阶段二:加入消费组

消费者找到消费组对应的 GroupCoordinator 之后,进入加入消费组的阶段。消费者会向 GroupCoordinator 发送 JoinGroupRequest 请求。每个消费者发送的 GroupCoordinator 中,都携带了各自提案的分配策略与订阅信息

Kafka Broker 收到请求后进行处理。

  1. GroupCoordinator 为消费组内的消费者,选举该消费组的 Leader;
    • 如果消费组内还没有 Leader,那么第一个加入消费组的消费者会成为 Leader;对于普通的选举情况,选举消费组 Leader 的算法很随意,基本上可以认为是随机选举;
  2. 选举分区分配策略
    • Kafka 服务端收到各个消费者支持的分配策略,构成候选集,所有的消费者从候选集中找到第一个分配策略进行投票,最后票数最多的策略成为当前消费组的分配策略。

注:如果有消费者不支持选出的分配策略,会报出异常。

Kafka 处理完数据后,将响应 JoinGroupResponse 返回给各个消费者。JoinGroupResponse 回执中包含着 GroupCoordinator 投票选举的结果,在这些分别给各个消费者的结果中,只有给 leader 消费者的回执中包含各个消费者的订阅信息

4.3.3 阶段三:同步阶段

加入消费者的结果通过响应返回给各个消费者,消费者接收到响应后,开始准备实施具体的分区分配。上一步中只有 leader 消费者收到了包含各消费者订阅结果的回执信息,所以需要 leader 消费者主导转发同步分配方案。转发同步分配方案的过程,就是同步阶段
同步阶段,leader 消费者是通过“中间人” GroupCoordinator 进行的。各个消费者向 GroupCoordinator 发送 SyncGroupRequest 请求,其中只有 leader 消费者发送的请求中包含相关的分配方案。Kafka 服务端收到请求后交给 GroupCoordinator 处理。处理过程有:

  1. 主要是将消费组的元数据信息存入 Kafka 的 __consumer_offset 主题中;
  2. 最后 GroupCoordinator 将各自所属的分配方案发送给各个消费者。

各消费者收到分配方案后,会开启 ConsumerRebalanceListener 中的 onPartitionAssigned() 方法,开启心跳任务,与 GroupCoordinator 定期发送心跳请求 HeartbeatRequest,保证彼此在线。

4.3.4 阶段四:心跳阶段

进入该阶段后的消费者,已经属于进入正常工作状态了。消费者通过向 GroupCoordinator 发送心跳,来维持它们与消费组的从属关系,以及对 Partition 的所有权关系。
心跳线程是一个独立的线程,可以在轮询消息空档发送心跳。如果一个消费者停止发送心跳的时间比较长,那么整个会话被判定为过期,GroupCoordinator 会认为这个消费者已经死亡,则会触发再均衡行为

触发再均衡行为的情况:

  1. 停止发送心跳请求;(包括消费者发生崩溃的情况)
  2. 参数 max.poll.interval.ms 是 poll() 方法调用之间的最大延迟,如果在该时间范围内,poll() 方法没有调用,那么消费者被视为失败,触发再均衡;
  3. 消费者可以主动发送 LeaveGroupRequest 请求,主动退出消费组,也会触发再均衡。

4.4 频繁再均衡

参考地址:《记一次线上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,导致消费者被踢出消费组,导致再均衡。

解决方法:

  1. 增加 max.poll.interval.ms 值的大小:将该参数调大至合理值,比如默认的 300s;
  2. 设置分区拉取阈值:通过用外部循环不断拉取的方式,实现客户端的持续拉取效果。消费者每次调用 poll 方法会拉取一批数据,可以通过设置 max.poll.records 消费者参数,控制每次拉取消息的数量,从而减少每两次 poll 方法之间的拉取时间。

此外,再均衡可能会导致消息的重复消费现象。消费者每次拉取消息之后,都需要将偏移量提交给消费组,如果设置了自动提交,则这个过程在消费完毕后自动执行偏移量的提交;如果设置手动提交,则需要在程序中调用 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;
                    }
                }
            }
        }

你可能感兴趣的:(Kafka,Java)