/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
我不会起名字322 · 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- Kafka 消息积压 100 万条:消费者组 rebalance 的 4 个触发条件与 3 步定位
-
- 一、先说那个最容易误判的现象
- 二、rebalance 到底什么时候会发生
-
- 触发条件 1:组成员数量变化
- 触发条件 2:订阅的 topic 元数据变化
- 触发条件 3:两次 poll 的间隔超过 `max.poll.interval.ms`
- 触发条件 4:心跳真的发不出去
- 三、3 步定位
-
- 第 1 步:确认积压是"慢"还是"停"
- 第 2 步:确认是不是真的在 rebalance
- 第 3 步:区分是被"踢"还是"主动加入"
- 四、怎么改,以及各自的代价
- 五、带走物
Kafka 消息积压 100 万条:消费者组 rebalance 的 4 个触发条件与 3 步定位
一、先说那个最容易误判的现象
线上最常见的报警长这样:某个 topic 的积压量一路涨到几十万、上百万条,消费者像死掉一样一动不动。上去重启一下服务,积压开始往下掉,掉到一半又停住,然后继续涨。
第一反应通常是"消费能力不够",于是加消费者实例、加 Pod。但加完之后问题照旧——因为多出来的消费者根本没分到分区,它们只是空转。
真正的原因往往在别处:这个消费组正在反复 rebalance,或者消费者已经被 coordinator 踢出了组,只是进程还活着、连接还在,看起来"很健康"。
这类问题我在排查时会先确认一件事:积压期间,分区到底有没有被分配出去。
# 看每个分区的当前 offset、末端 offset、积压量,以及谁在消费
kafka-consumer-groups.sh –bootstrap-server 10.0.0.1:9092 \\
–describe –group order-service
输出里如果 CONSUMER-ID 那一列是空的、或者一会儿有一会儿没有,那就不是"消费慢",而是分区被反复收回再分配。消费慢和拿不到分区,是两条完全不同的排查路线。
二、rebalance 到底什么时候会发生
Kafka 的消费组里,分区要分配给成员。只要"成员集合"或者"订阅关系"发生变化,就需要重新分配,这个重新分配的过程就是 rebalance。触发它的一共有四类情况。
触发条件 1:组成员数量变化
这是最直观的一类:
- 手动扩缩容消费者实例;
- 发布时的滚动重启(旧 Pod 退出、新 Pod 加入,一进一出算两次变化);
- 消费者进程被 OOM 杀掉、被 K8s 驱逐;
- 消费者优雅退出时没有主动 LeaveGroup,coordinator 要等 session.timeout.ms 超时才发现它走了。
滚动发布是最常见也最容易被忽略的触发源。 一个 6 实例的消费者组做一次滚动发布,如果每个 Pod 都是"先杀后起",就会连续触发 6 次以上的 rebalance,每次 rebalance 期间整个组的消费都是暂停的。
触发条件 2:订阅的 topic 元数据变化
- 订阅用的是正则(比如 order-.*),新建了一个匹配该正则的 topic;
- topic 的分区数被扩过(kafka-topics.sh –alter –partitions);
- 订阅关系本身被改了。
分区数变化尤其隐蔽:运维为了提升吞吐扩了分区,结果消费组立刻进入一轮 rebalance,而如果消费者数量没跟着涨,扩出来的分区反而没人消费。
触发条件 3:两次 poll 的间隔超过 max.poll.interval.ms
这一条是"重启后又能消费,过一会儿又停"的标准答案。
Kafka 用两个参数判断消费者是否健康:
| session.timeout.ms | 45s(旧版 10s) | 后台心跳线程 | 消费者进程还活着吗 |
| max.poll.interval.ms | 5 分钟 | 业务线程 | 消费者还在正常调 poll() 吗 |
关键点在于:从 0.10.1.0 开始,心跳是后台线程发的,所以哪怕你的业务处理卡了 10 分钟,session.timeout.ms 也不会超时——心跳线程照发不误。真正会超时的是 max.poll.interval.ms:它衡量的是两次 poll() 调用之间隔了多久。
一旦单次业务处理(或者一次失败重试、一次批量落库)超过 5 分钟,coordinator 就判定这个消费者"hung 住了",直接把它踢出组、收回分区、触发 rebalance。
被踢出去的那个消费者此时进程还在、日志也不报错,它只是拿着已经不属于自己的分区在慢慢处理,处理完再提交 offset 会被拒绝。表现出来就是:消费停了,但服务看起来一切正常。
这才是"重启后好一阵、过一会儿又不行"的完整链路:重启 → 重新加入组 → 拿到分区 → 开始消费 → 某批消息处理超过 5 分钟 → 被踢 → 消费停 → 积压继续涨。
触发条件 4:心跳真的发不出去
- JVM Full GC 停顿超过 session.timeout.ms(Go 服务对应的是长时间 STW 或者调度延迟);
- 网络抖动、broker 侧压力过大导致心跳请求超时;
- 消费者所在节点 CPU 被打满,心跳线程抢不到调度。
这一类在 Go 服务里相对少见(没有 JVM GC 那种秒级停顿),但容器 CPU throttling 是等效的风险源:如果 limits.cpu 设得太紧,进程会被内核限流,心跳照样发不出去。
三、3 步定位
第 1 步:确认积压是"慢"还是"停"
# 连续采几次,看 LAG 是缓慢下降还是完全不动
watch -n 5 'kafka-consumer-groups.sh –bootstrap-server 10.0.0.1:9092 \\
–describe –group order-service | awk "{print \\$1, \\$2, \\$5, \\$6}"'
- LAG 缓慢下降 → 消费慢,去查处理耗时、批量大小、下游依赖;
- LAG 完全不动、或者周期性归零后又涨 → 几乎可以断定是 rebalance。
第 2 步:确认是不是真的在 rebalance
三个地方能看到:
# 1) 消费者日志(最直接的证据)
grep -E "Rebalance|revoked|assigned|Attempt to heartbeat failed" consumer.log
# 2) broker 侧 coordinator 日志
grep -i "Preparing to rebalance group\\|Stabilized group" server.log
# 3) 消费者 JMX / 客户端指标
# rebalance-total、rebalance-rate-per-hour、last-rebalance-seconds-ago
用 sarama 的话,可以在 ConsumerGroupHandler 里直接打点,把每次 rebalance 都记下来:
type handler struct{}
// Setup 在每次 rebalance 之后、消费开始前调用
func (h *handler) Setup(sarama.ConsumerGroupSession) error {
log.Printf("rebalance: 拿到分区 %v, generation=%d",
s.Claims(), s.GenerationID())
rebalanceCounter.Inc()
return nil
}
// Cleanup 在分区被收回时调用
func (h *handler) Cleanup(sarama.ConsumerGroupSession) error {
log.Printf("rebalance: 分区被收回, generation=%d", s.GenerationID())
return nil
}
只要 rebalanceCounter 在积压期间持续上涨,第 2 步就确认了。
第 3 步:区分是被"踢"还是"主动加入"
看被踢之前有没有这些日志:
- 出现 Attempt to heartbeat failed since group is rebalancing → 有成员进出;
- 出现 max.poll.interval.ms exceeded / (Re-)joining group 但业务线程还在跑 → 触发条件 3;
- 消费者没有任何异常日志,但 coordinator 侧记录成员离开 → 触发条件 4(心跳超时)。
判定的分水岭就一句话:业务线程有没有真的卡住超过 5 分钟。 卡住了就是 max.poll.interval.ms,没卡住就是心跳或成员变化。
四、怎么改,以及各自的代价
| 减小 max.poll.records(默认 500) | 触发条件 3 | 单次拉取变少,吞吐略降,但最安全 |
| 把重活挪到线程池异步做,poll 线程只做拉取和分发 | 触发条件 3 | 必须自己管 offset,否则会丢消息 |
| 调大 max.poll.interval.ms | 触发条件 3 | 只是拖延,处理慢的根因没解决,只是把 5 分钟改成 10 分钟 |
| 滚动发布改成先起后停 / 调大 maxSurge 之外的摘除窗口 | 触发条件 1 | 发布期间会短暂超配 |
| 退出时主动 LeaveGroup(优雅关闭里调 Close()) | 触发条件 1 | 依赖优雅停机链路是通的 |
| 放宽容器 limits.cpu,避开 throttling | 触发条件 4 | 成本上升 |
我的建议顺序:先把单次处理时间压下来(max.poll.records 调小 + 重活异步化),再考虑调参数。直接把 max.poll.interval.ms 调到 10 分钟是最常见的"治标"操作——它能让报警消失,但你的消费者依然会在每个小时里停止消费若干分钟。
五、带走物
一个可以直接贴到 runbook 里的排查清单:
1. kafka-consumer-groups.sh –describe 看 LAG 与 CONSUMER-ID
└─ LAG 不动 / CONSUMER-ID 时有时无 → 进入第 2 步
2. grep Rebalance 日志 + rebalance 指标
└─ 确认在 rebalance → 进入第 3 步
3. 看业务线程是否卡住 > max.poll.interval.ms(默认 5min)
├─ 是 → 压单次处理耗时(调小 max.poll.records / 异步化)
└─ 否 → 查成员进出(发布?扩缩容?)和心跳(CPU throttling?GC?)
4. 复盘中记录:触发原因 + 证据 + 动作,避免下次从零排查
另外记一条容易被忽略的:分区数 > 消费者数时,多出来的消费者是空闲的。加实例前先看分区数,不然加了也白加。

