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 再继续;④ 发布前先扩容消费组(多一个成员),发布完再缩回,避免“成员数 < 分区数”时分配剧烈变化;⑤ 确保消费幂等,接受少量重放。