常见说法是"消费组里加一个实例会触发 rebalance,所有人暂停消费"。这次用数据检验这句话:一个 6 分区的 topic,Producer 以约每 5 毫秒一条的速度持续写入;消费者 A 先独占全部分区,9 秒后再启动消费者 B;对每个分区统计"消息从产生到被消费"的最大延迟,看谁被暂停了、暂停了多久,并对比传统的 RangeAssignor 和 CooperativeStickyAssignor。
命令 · 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 要等下一次心跳才得知"有新成员加入、需要重新入组",这个推断只有这些间接证据。
策略=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 只有几毫秒)。
命令 · 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、测量端到端延迟得到的原始结果。