同一个 group.id 下的多个消费者会瓜分一个 topic 的 Partition,成员一变,分配就重新洗牌(rebalance)。这次给一个 6 分区的 topic 依次加入 3 个消费者、再依次让其中两个离开,用的 ConsumerRebalanceListener 记录每一次分配变化,看谁分到了哪些分区、每次 rebalance 过程中发生了什么。
命令 · java // topic group-demo: 6 分区、3 副本;每个成员 group.id=grp-demo,group.protocol=classic, // 用 ConsumerRebalanceListener 打印 onPartitionsRevoked / onPartitionsAssigned // 时间线:A 启动(运行60s) -> 12s 后 B 启动(45s) -> 再 12s 后 C 启动(14s)
--- A 的事件序列 --- A assigned [0, 1, 2, 3, 4, 5] <- 只有 A,独占 6 个分区 A revoked [0, 1, 2, 3, 4, 5] <- B 加入,先全部收回 A assigned [0, 1, 2] A revoked [0, 1, 2] <- C 加入,再次全部收回 A assigned [0, 1] A revoked [0, 1] <- C 离开 A assigned [0, 1, 2] A revoked [0, 1, 2] <- B 离开 A assigned [0, 1, 2, 3, 4, 5] --- B 的事件序列 --- B assigned [3, 4, 5] -> [2, 3] -> [3, 4, 5] --- C 的事件序列 --- C assigned [4, 5] 三个成员都在线时(kafka-consumer-groups.sh --describe --members --verbose): CLIENT-ID #PARTITIONS B 2 C 2 A 2
一个成员时独占 6 个分区;B 加入后 A 拿 [0,1,2]、B 拿 [3,4,5];C 加入后变成 A [0,1]、B [2,3]、C [4,5],每人 2 个;C 离开后回到 A [0,1,2]、B [3,4,5];B 离开后 A 又拿回全部 6 个。每种成员数下,分区都是按连续区间均分的,kafka-consumer-groups.sh 里三个成员各自 2 个分区,也和监听器里看到的一致。
更值得注意的是"revoked":每一次成员变化,已有成员都先把自己手里全部分区 revoke,再拿到新的分配。例如 C 加入时,A 原本持有 [0,1,2],其中 0、1 最终还是它的,但依然先收回了 [0,1,2] 全部,然后才重新拿到 [0,1]。这次实验里的默认配置 partition.assignment.strategy = [RangeAssignor, CooperativeStickyAssignor](用 ConsumerConfig 打印),观察到的结果——连续区间、每次全部收回再分配——与 RangeAssignor 加"先全部收回"的传统 rebalance 方式一致;我没有对比换成 CooperativeStickyAssignor 之后是否能避免这种全部收回。
上面的事件序列里,revoked 到下一次 assigned 之间,该成员不持有任何分区,也就没法消费。从这次日志的 revoked/assigned 事件本身可以看到"收回—重新分配"的顺序,但我没有给这次实验加消息流量,所以没有量出这段停顿的时长。实验里另外一个线索来自上一篇《Consumer 为什么必须调用 poll()》:新成员加入时,前几次 poll() 会返回 0 条,大约 2 秒后才拿到 assigned,说明入组本身就需要几秒。因此结论只能限定为:成员变化会触发全组重新分配,分配期间成员手里没有分区;停顿的具体时长取决于配置和成员数,这里没有测量。
Consumer Group 的行为:同组成员均分分区(6 分区 3 成员各 2 个),成员加入或离开都会触发全组 rebalance,默认策略下每次已有成员会先收回全部分区再拿新分配。这是"多个消费者分摊 Partition"能自动扩缩容的代价,也是消费者频繁上下线会造成消费抖动的原因。
每一步的分配结果,都是本文上面一节用ConsumerRebalanceListener记录得到的原始事件序列。