bigywater.tech
Kafka 内部原理 · 25

Consumer Rebalance:为什么加一个实例会暂停消费

常见说法是"消费组里加一个实例会触发 rebalance,所有人暂停消费"。这次用数据检验这句话:一个 6 分区的 topic,Producer 以约每 5 毫秒一条的速度持续写入;消费者 A 先独占全部分区,9 秒后再启动消费者 B;对每个分区统计"消息从产生到被消费"的最大延迟,看谁被暂停了、暂停了多久,并对比传统的 RangeAssignor 和 CooperativeStickyAssignor。

验证:默认的 Range 策略下,rebalance 期间的停顿在本机只有几毫秒
三次 Range 运行里,所有分区的最大延迟都在 74ms 以内(另两次分别是 50ms 和 47ms)。
验证:Cooperative 策略下,没被移动的分区完全不受影响,被移动的分区停顿了整整 3 秒
(另一次运行:p3、p4、p5 = 3011ms、3042ms、3036ms,p0 到 p2 都在 50ms 以内。

验证:默认的 Range 策略下,rebalance 期间的停顿在本机只有几毫秒

命令 · java // topic rebalance-demo: 6 分区 x 3 副本,Producer 每 5ms 发一条(轮流写 6 个分区,消息带发送时间戳) // A 先启动(assigned [0..5]),9s 后启动 B;enable.auto.commit=false,撤销分区前 commitSync,poll 后 commitAsync // 统计每个分区 (消费时刻 - 消息时间戳) 的最大值;partition.assignment.strategy=RangeAssignor
真实实测 · 本机执行输出
策略=Range  各分区最大端到端延迟: p0=36ms p1=29ms p2=26ms p3=50ms p4=45ms p5=39ms
  [8497ms] A assigned [0, 1, 2, 3, 4, 5]
  [9005ms] >>> 启动 B
  [11479ms] A revoked  [0, 1, 2, 3, 4, 5]     <- 全部收回
  [11484ms] A assigned [0, 1, 2]              <- 5ms 之后就重新分配完成
  [11484ms] B assigned [3, 4, 5]

三次 Range 运行里,所有分区的最大延迟都在 74ms 以内(另两次分别是 50ms 和 47ms)。A 把全部 6 个分区收回,到 A 拿到 [0,1,2]、B 拿到 [3,4,5],中间只隔了 4 到 8 毫秒。所以"所有人暂停消费"在机制上是对的(A 确实先收回了全部分区),但在这个本机环境里,这个暂停几乎测不出来。B 从被创建到拿到分区大约花了 2.5 秒,A 在此期间一直在正常消费。这 2.5 秒是什么,我起初以为是 B 自己创建和连接的耗时,但后面把心跳间隔调小的几组运行里,B 只花了 0.03 到 0.5 秒就拿到了第一次分配,所以更可能是 A 要等下一次心跳才得知"有新成员加入、需要重新入组",这个推断只有这些间接证据。

验证:Cooperative 策略下,没被移动的分区完全不受影响,被移动的分区停顿了整整 3 秒

真实实测 · 本机执行输出
策略=CooperativeSticky  各分区最大端到端延迟: p0=34ms p1=33ms p2=22ms p3=3013ms p4=3009ms p5=3003ms
  [11485ms] B assigned []
  [11487ms] A revoked  [3, 4, 5]              <- 只收回要移动的分区
  [11487ms] A assigned []
  [14484ms] B assigned [3, 4, 5]              <- 3 秒后才真正分给 B
  [14484ms] A assigned []

(另一次运行:p3、p4、p5 = 3011ms、3042ms、3036ms,p0 到 p2 都在 50ms 以内。)

CooperativeStickyAssignor 的行为和 Range 完全不同:A 只收回了要移动的 [3,4,5],保留了 [0,1,2],这三个分区的最大延迟只有 22 到 34ms,没有被打断。但被移动的 [3,4,5] 从 A 手里收回到分给 B,中间隔了大约 3.0 秒,这三个分区在这 3 秒里没有任何消费者,延迟都是 3 秒出头。B 第一次拿到的分配是空的(B assigned []),要等第二轮 rebalance 才拿到分区。也就是说,Cooperative 让"没动的分区"零停顿,代价是"要动的分区"停顿得比 Range 更久(Range 只有几毫秒)。

验证:这个 3 秒等于 heartbeat.interval.ms,改成 500ms 和 1000ms 它就跟着变

命令 · shell # 只改消费者的 heartbeat.interval.ms(默认 3000),其他不变,CooperativeStickyAssignor
真实实测 · 本机执行输出
heartbeat.interval.ms=500   p3=503ms p4=497ms p5=490ms    (p0~p2 <= 44ms)
heartbeat.interval.ms=500   p3=501ms p4=495ms p5=490ms    (p0~p2 <= 37ms)
heartbeat.interval.ms=1000  p3=1002ms p4=1002ms p5=999ms   (p0~p2 <= 79ms)
heartbeat.interval.ms=3000  p3=3013ms p4=3009ms p5=3003ms   (前面的运行)

被移动分区的停顿时长几乎精确等于 heartbeat.interval.ms:500 对应约 500ms,1000 对应约 1000ms,默认 3000 对应约 3000ms。这个对应关系是实测出来的;至于为什么,我的解释是第二轮 rebalance 需要 A 通过心跳响应得知"需要重新入组",而心跳每隔这么久才发一次,但我没有去抓协议包或读源码来确认,只能说"停顿长度跟这个参数一致"。

结论

"加一个实例会暂停消费"在这个实验里的情况是:传统的 Range 会先让 A 收回全部分区,但在本机上只停顿几毫秒;CooperativeStickyAssignor 让不受影响的分区完全不停,但被移动的分区要等心跳驱动的第二轮 rebalance,停顿约等于 heartbeat.interval.ms(默认 3 秒)。所以"合作式 rebalance 停顿更少"只对没被移动的分区成立,对被移动的分区,本机实测反而更慢。

分步演示:两种分配策略,的停顿差在哪

每一步都是本文上面几节运行Java Consumer、测量端到端延迟得到的原始结果。

Cooperative策略让"没动的分区"零停顿,代价是"要动的分区"停顿更久
这一步新验证的策略/参数

    参考与说明

    • 数据来自本机 3 节点 Kafka 4.3.1 集群,group.protocol=classic,A 和 B 是同一 JVM 里的两个线程,Producer 也在同一 JVM 里。延迟用消息自带的创建时间戳计算,所以包含 Producer 到 Broker 的时间。
    • 我最先用的指标是"A 收到消息的最大间隔",结果几种策略都只有 70 多毫秒,看不出区别,因为它没有统计被移到 B 的那些分区;后来换成"按分区统计端到端延迟"才看出 Cooperative 的 3 秒停顿。前一个指标的输出没有采用。
    • 我还试了让 A 每批处理 3 秒(Range),想看慢消费者会不会拖慢 rebalance:B 拿到分区的时间和不慢时基本一样(都在 11.5 到 11.6 秒),而这组的分区延迟本来就被 A 自己的 3 秒睡眠抬高(3 到 9 秒),没法作为对照,这个尝试的结果不采用,也没有得出"慢消费者是否拖慢 rebalance"的结论。
    • 和《Consumer Group:多个消费者如何分摊 Partition》一起看:那篇看到每次成员变化先收回全部分区,这篇量化了这个"收回再分配"的耗时。
    • 没有验证:group.protocol=consumer(KIP-848)下的行为;静态成员(group.instance.id);多台机器、更大消费者规模下 Range 的停顿;消费者数量很多时 Cooperative 需要的轮数。
    京ICP备2026067502号-1京公网安备11010502063487号