43.4 消费组与再均衡

一句话

再均衡(rebalance)是消费组的集体停顿:成员变化时,分区要重新分配,分配期间通常不能消费——所以消费组的稳定性不取决于消费者多少,而取决于成员变动的频率与再均衡协议是否增量。

谁触发再均衡

触发 原因 是否可避免
成员加入 扩容、重启 扩容必然触发
成员离开 缩容、崩溃 崩溃不可控
心跳超时 消费线程卡住、GC 长停顿 调大 session.timeout、把处理移出 poll 线程
处理超时 单批处理超过 max.poll.interval 减小 max.poll.records、异步化
订阅变化 动态订阅 topic 避免运行时改订阅
分区数变化 扩分区 见 43.3

🔑 最常见的“无故再均衡”不是网络问题,而是处理太慢:poll 之后处理了 6 分钟,超过 max.poll.interval.ms(默认 5 分钟),协调者认为你死了,把你踢出组。

两种协议:全停 vs 增量

协议 过程 停顿 重复处理
Eager(Range/RoundRobin) 所有成员放弃全部分区 → 重新加入 → 重新分配 全体停(秒级) 撤销时未提交的位点会重放
Cooperative(CooperativeSticky) 只撤销“要迁走”的分区,其余继续消费 只停受影响的分区 显著减少
package main

import "fmt"

const (
	members       = 6    // 消费组成员数
	partitions    = 24   // 分区数
	flapsPerHour  = 5    // 每小时成员变动次数(发布/崩溃/扩容)
	eagerStopSec  = 3.0  // eager:一次再均衡全组停顿
	coopStopSec   = 0.3  // cooperative:只停受影响分区(近似)
	msgPerSecEach = 2000 // 每个消费者每秒处理的消息数
	commitEverySec = 1.0 // 位点提交间隔
)

func main() {
	fmt.Printf("消费组:%d 成员、%d 分区、每小时 %d 次成员变动\n\n", members, partitions, flapsPerHour)

	eagerDown := float64(flapsPerHour) * eagerStopSec
	coopDown := float64(flapsPerHour) * coopStopSec
	fmt.Printf("%-22s %-16s %-16s\n", "指标", "Eager", "Cooperative")
	fmt.Printf("%-22s %-16s %-16s\n", "每小时总停顿",
		fmt.Sprintf("%.1fs", eagerDown), fmt.Sprintf("%.1fs", coopDown))
	fmt.Printf("%-22s %-16s %-16s\n", "停顿占比",
		fmt.Sprintf("%.2f%%", eagerDown/36), fmt.Sprintf("%.2f%%", coopDown/36))

	// 重复处理:撤销分区时,最后一次提交之后的消息会被重放
	// 每个分区每秒处理 = 单消费者速率 / 每个成员分到的分区数
	partsPerMember := float64(partitions) / float64(members)
	perPartRate := float64(msgPerSecEach) / partsPerMember
	replayPerPart := perPartRate * commitEverySec // 一个分区在提交窗口内会被重放的条数

	eagerDup := replayPerPart * float64(partitions) * flapsPerHour // 全部分区撤销
	coopDup := replayPerPart * partsPerMember * flapsPerHour       // 只撤销要迁走的分区
	fmt.Printf("%-22s %-16.0f %-16.0f\n", "每小时重复消息", eagerDup, coopDup)

	fmt.Printf("\n按 24 小时算:Eager 停顿 %.0f 秒、重复 %.0f 条;Cooperative 停顿 %.0f 秒、重复 %.0f 条\n",
		eagerDown*24, eagerDup*24, coopDown*24, coopDup*24)
	fmt.Println("\n注:停顿秒级看似不大,但发生在“发布期间”会叠加成分钟级不可用")
}

本机实测(macOS,Apple M1,Go 1.27):

消费组:6 成员、24 分区、每小时 5 次成员变动

指标                     Eager            Cooperative     
每小时总停顿                 15.0s            1.5s            
停顿占比                   0.42%            0.04%           
每小时重复消息                60000            10000           

按 24 小时算:Eager 停顿 360 秒、重复 1440000 条;Cooperative 停顿 36 秒、重复 240000 条

注:停顿秒级看似不大,但发生在“发布期间”会叠加成分钟级不可用

读数:

指标 Eager Cooperative 差别
每小时停顿 15s 1.5s 10 倍
每次再均衡重放的消息 12,000(24 个分区) 2,000(4 个分区) 6 倍
每小时重复消息 60,000 10,000 6 倍
24 小时累计停顿 360s 36s 发布窗口里会被放大

🔑 再均衡的成本不只是“停”,还有“重放”:撤销分区时,最后一次提交位点之后的消息会重投,业务要幂等(43.6)。Cooperative 协议把“全组停”改成“局部停”,是消费组稳定性最划算的一次升级。

反模式

反模式 后果
处理逻辑放在 poll 线程里且很慢 超过 max.poll.interval,被踢出组,循环再均衡
消费组成员频繁重启(滚动发布) 每次发布都全组停顿
用 eager 协议 + 大 max.poll.records 一次撤销要重放一大批
会话超时设得很短 网络抖动就误判死亡,反复再均衡
分区数远多于消费者数 再均衡时分配不均,且空闲分区占内存
运行时动态订阅 topic 触发全量再均衡

自检

Q1 为什么“消费者处理慢”会导致再均衡,而不是仅仅变慢?

▸ 答案:因为消费组用心跳判断成员存活,而心跳通常由 poll 循环发送。如果单批处理时间超过 max.poll.interval.ms,消费者无法及时 poll,协调者认为它已死,把它移出组并触发再均衡;重新加入后继续处理同一批,又超时——形成“再均衡风暴”。解法是把耗时处理交给异步线程、减小 max.poll.records、或调大 max.poll.interval。

Q2 Cooperative 协议为什么能减少重复消息?

▸ 答案:因为 Eager 协议要求“所有成员放弃全部分区”,撤销瞬间所有成员的未提交位点都要重放;Cooperative 只让“要迁走的那几个分区”撤销,其他分区继续消费、继续提交,重放范围从“全组”缩小到“迁移的那部分”。重复量与“撤销的分区数 × 提交间隔内的消息数”成正比。

Q3 滚动发布时怎么把再均衡的影响降到最低?

▸ 答案:① 用 Cooperative 协议;② 提高 session.timeout 与 max.poll.interval,让“正常重启”不被误判;③ 分批发布(一次只动一个消费者),并观察 lag 再继续;④ 发布前先扩容消费组(多一个成员),发布完再缩回,避免“成员数 < 分区数”时分配剧烈变化;⑤ 确保消费幂等,接受少量重放。

关联