消费积压,按标准剧本加机器:从 3 个消费者加到 6 个。lag 没降,反而在扩容后的十分钟里涨得更快。日志里每 30 秒出现一次 Revoking previously assigned partitions,业务方看到的是"每隔半分钟,所有消费者一起停 40 多秒"。
两个独立的问题叠在一起:加机器不一定增加并行度(分区数是硬上限),而加机器本身会触发一次全组暂停(再均衡期间组内所有成员停止消费)。第一个问题让扩容白花钱,第二个问题让扩容当场造成二次事故。
这篇把再均衡拆成四件:并行的上限在哪、暂停为什么是全组、三种触发源怎么区分、以及哪两个配置项会让它反复发生。
1. 并行的上限是分区数,不是消费者数
一个分区同一时刻只能被一个消费者消费,这是顺序性的代价。所以:
实际并行度 = min(消费者数, 分区数)
| 分区数 | 消费者数 | 实际并行 | 多出来的消费者在干什么 |
|---|---|---|---|
| 6 | 6 | 6 | — |
| 6 | 9 | 6 | 3 个空闲(分不到分区) |
| 6 | 3 | 3 | 每个消费者扛 2 个分区 |
| 12 | 6 | 6 | 每个消费者扛 2 个分区(可扩到 12) |
第二行就是"加了机器 lag 不降"的最常见解释:三个新消费者拿不到分区,只是多参加了三次再均衡。而扩容前如果不看分区数,这个错误会稳定复现。
// 扩容前的两行核对,能省掉一整次无效扩容
分区数: 6 消费者数: 3 → 可扩到 6,多一个都白加
分区数: 6 消费者数: 6 → 已到上限,要提并行只能扩分区(会打乱 key 顺序)
// 判据:消费者数 == 分区数 时,加机器无效,加分区才有效
2. 为什么暂停的是"整个组"
传统协议(eager)的流程是:任何成员变化 → 全组撤销所有分区 → 重新分配 → 各自恢复。中间那段没有任何消费者在消费。
| 阶段 | 发生什么 | 谁在消费 |
|---|---|---|
| 成员加入/离开 | 组协调器收到 JoinGroup | 全部暂停 |
| Revoke | 所有成员放弃全部分区 | 无 |
| 重新分配 | leader 计算新方案 | 无 |
| 恢复 | 成员按新方案开始拉取 | 逐步恢复 |
所以"加一个消费者"的代价不是"那一个消费者启动时间",而是整个组的一次停机。3 个消费者时,一次再均衡可能是 5 秒;30 个消费者时,可能就是 40 秒——组越大,代价越高,这与直觉相反。
// cooperative(增量协作)与 eager 的差别:只撤需要迁移的分区
// 配置一行即可切换,但要求客户端与服务端版本都支持
props.put("partition.assignment.strategy",
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
// eager: revoke ALL → 全组暂停
// cooperative: revoke PART → 只有迁移中的分区短暂停
3. 三种触发源,怎么区分
日志里都是 Revoking,但原因不同,修法完全不同。
| 触发源 | 日志特征 | 修法 |
|---|---|---|
| 成员变化 | 与发布/扩缩容时间对齐 | 用 static membership |
| 订阅变化 | 与配置变更对齐 | 订阅列表固定,别动态改 |
| 心跳超时 | 反复出现、周期性 | 查 GC 停顿与网络 |
| poll 超期 | 与处理耗时对齐 | 调 max.poll.interval.ms |
第三、四行是"反复 rebalance"的两大来源,而它们在日志里长得几乎一样:都是"某个成员被踢出,然后全组重来"。区分方法只有一个:看被踢的时间点与处理耗时/GC 时间是否对得上。
// 区分 poll 超期与心跳超时:前者一定有"处理耗时 > 阈值"
$ grep -E "Member .* has left|rebalance" consumer.log | tail -20
// 若被踢的成员正在处理一批大消息 → max.poll.interval 太小
// 若被踢时 GC 日志有长停顿 → session.timeout 太小,或堆太小
$ grep -E "Full GC|Pause" gc.log | awk '{print $NF}' | sort -rn | head -3
4. 那个恶性循环:处理慢 → 被踢 → 再均衡 → 更慢
这是积压场景里最典型的自我放大:
- 积压变多 → 每批拉更多消息;
- 处理一批的耗时超过
max.poll.interval.ms(默认 5 分钟); - 协调器认为该成员死了,踢出组;
- 触发全组再均衡,暂停期间积压继续增长;
- 恢复后每批更大,处理更慢 → 回到 1。
| 参数 | 默认 | 它约束什么 | 调错的表现 |
|---|---|---|---|
max.poll.records |
500 | 单批条数 | 太大 → 超时被踢 |
max.poll.interval.ms |
300000 | 两次 poll 的最大间隔 | 太小 → 正常慢处理也被踢 |
session.timeout.ms |
45000 | 心跳超时 | 太小 → GC 抖动就掉线 |
heartbeat.interval.ms |
3000 | 心跳频率 | 应约为 timeout 的 1/3 |
正确顺序是先降 max.poll.records,再考虑调大 max.poll.interval.ms。前者让单批处理时间落在阈值内(治因),后者只是放宽宽容度(治标)——而放宽阈值会让"真卡死"的成员更久才被发现。
// 一个稳的起点:单批处理时间目标 << max.poll.interval
consumer:
max.poll.records: 100 // 处理 100 条 ≈ 10s,远小于 300s
max.poll.interval.ms: 300000 // 保持默认,不因积压而放宽
session.timeout.ms: 45000
heartbeat.interval.ms: 15000
// 若单条处理就要 5s,那就该改架构(异步化/批处理下沉),不是调参
5. static membership:把"滚动重启"的代价降到零
滚动发布时,每个实例重启都会触发一次再均衡。10 个实例就是 10 次全组暂停——发布期间的积压有一半是发布自己造成的。
group.instance.id(Kafka 2.3+)让协调器"记住"成员:同一 id 在 session.timeout 内重连,直接拿回原来的分区,不触发再均衡。
| 机制 | 解决的场景 | 代价 |
|---|---|---|
| static membership | 滚动重启、短暂网络抖动 | 需要给每个实例固定 id(常用 pod 名) |
| cooperative 协议 | 扩容/缩容的暂停时长 | 需要客户端版本支持 |
| 两者一起用 | 扩容 + 滚动发布 | 配置复杂度上升 |
// 一次 10 实例滚动发布的两种结果
eager + 无 static id: 10 次 rebalance,累计暂停约 7 分钟
cooperative + static id: 0 次 rebalance,单实例重启约 8 秒
// 差别不在客户端性能,而在"协调器是否记得你"
组合使用的效果可以量化:一次 10 实例滚动发布,从"10 次再均衡、累计暂停 6–8 分钟"降到"0 次再均衡"。
6. 排错:五种"扩容反而更糟"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 加消费者 lag 不降 | 已到分区数上限 | 扩分区或优化单条处理 |
| 扩容瞬间全组停 40 秒 | eager 再均衡 | cooperative + 分批扩容 |
| 每 30 秒一次 rebalance | poll 超期或心跳超时 | 降 max.poll.records |
| 发布期间积压翻倍 | 每次重启触发再均衡 | static membership |
| 顺序被打乱 | 扩分区改变了 key → 分区映射 | 不要扩,改按 key 分片 |
第五行是硬边界:扩分区会改变 hash(key) % partitions 的结果,同一业务 key 的历史消息在新旧分区之间失去顺序。所以"顺序敏感"的 topic 只能扩消费者到分区数为止,再往上要走别的路。
7. 边界:什么情况不该靠扩容解决
| 场景 | 为什么扩容无效 | 该做什么 |
|---|---|---|
| 消费者数已等于分区数 | 并行度到顶 | 扩分区(非顺序场景)或优化处理 |
| 单条处理 5 秒以上 | 瓶颈在处理,不在并行 | 异步化、批量下沉、加索引 |
| 下游是数据库且已饱和 | 更多消费者 = 更多并发压库 | 先提升下游承载力 |
| 顺序消息 | 加消费者不增加并行,还可能打乱 | 单分区串行 + 内部并行 |
| 消费者频繁 Full GC | 是内存问题 | 扩堆或减批大小 |
判断口径一句话:先看"并行度上限"(分区数)再看"单条处理成本",两者都没到顶才轮到扩容;而扩容这个动作本身要付出一次全组暂停,所以它必须排在生产突增之外的时间窗里做。顺序反了,就是本文开头那个场景:lag 没降,还多了一次事故。
8. 自测
- 并行度的实际上限由什么决定?消费者数超过它会发生什么?
- 为什么"加一个消费者"的代价是整个组暂停,而不是那一个的启动时间?
max.poll.interval.ms太小会怎样?它和session.timeout.ms的区别是什么?- 积压场景下"处理慢 → 被踢 → 再均衡 → 更慢"的循环怎么打断?应该先调哪个参数?
- static membership 解决的是哪类再均衡?为什么它能让滚动发布期间零暂停?
- 什么情况下不能通过扩分区来提升并行度?
9. 一句话总结
扩容消费者的两个约束是分开的:并行度上限由分区数决定(超过就白加),而扩容动作本身要付一次全组暂停(eager 再均衡)。反复再均衡几乎总是 max.poll.records 与处理耗时错配,修法是降批大小而不是放宽超时;滚动发布的暂停用 static membership 消掉。判据:先看分区数,再看单条成本,两者都没到顶才谈扩容,且扩容要避开流量高峰。