上周晚上十点多,一个做订单履约的客户给我发消息,说他们 Kafka 上的订单 topic 从下午三点开始就一直积压,监控大盘上的消费延迟曲线不是平缓上升,而是一段一段的台阶状每涨一截就卡住不动,过几分钟又突然掉下去一点,然后又慢慢涨。他一开始怀疑是下游业务处理慢,我让他先别动代码,先看消费组到底在干什么。
结果一查消费组状态,那台机器上的消费者几乎每隔几分钟就被踢出消费组一次,状态在 PreparingRebalance 和 CompletingRebalance 之间来回跳,每一次跳转都对应一次全量消费停顿,停顿时长在 38 秒到 45 秒之间。八百多万条积压就是这样一截一截堆上去的。这篇就把这次排查的完整过程写下来,Kafka 用得不算少的朋友应该能对上号。
正在搭消息队列、或者需要一台 IO 和网络都稳一点的机器跑 broker 与消费者,可以先看看这两家:狗云DOGYUN(高性价比云服务器,多机房可选)、RackNerd(全球VPS,特价常推)。Kafka 是个特别吃磁盘和网络毛刺的中间件,机器本身的抖动会直接放大成消费者心跳超时,后面你会看到我们这次就是这么翻车的。下面按时间顺序往下走。

第一步:先分清是「消费慢」还是「消费被停」
最容易被误判的就是这一点。消费慢和消费被停,现象上看都是 lag 在涨,但根因完全相反,处理方式也相反。判断方法很直接,看消费者的日志和线程栈,而不是猜。
我们用的排查命令是这套:
kafka-consumer-groups.sh --bootstrap-server 127.0.0.1:9092 --describe --group order-fulfill-consumer
这个命令输出的每一行代表一个分区,最右侧的 LAG 就是当前积压条数。关键不在 LAG 本身,而在「消费状态」那一列如果一直显示 CONSUMING,说明消费者活着但可能处理不过来;如果出现大量空分区、只有个别分区有 LAG,或者 LAG 数字隔一会儿整体跳变,那就不是慢,是消费组在反复重组。更有用的是再加 --state 参数:
kafka-consumer-groups.sh --bootstrap-server 127.0.0.1:9092 --describe --group order-fulfill-consumer --state
我们这台机器上打出来的状态是 PreparingRebalance 占了绝大多数时间,紧接着一个 CompletingRebalance,然后重置成 Stable,几秒后又开始下一轮。也就是说它根本没稳定过。这就是典型的 rebalance 抖动。
另一个佐证是消费者日志里的这几行:
Member order-fulfill-consumer-xxx sending LeaveGroup request to coordinator
Successfully joined group with generation 213
Successfully joined group with generation 214
generation 号一路涨,说明这个消费组一直在重新选举。注意这个 join generation 的序号,如果十分钟内涨了几十上百次,那就别再查业务逻辑了,问题百分之百在消费者进程的稳定性上。

第二步:rebalance 到底是被什么触发的
很多人一听到 rebalance 就去调参数,其实触发源就那么几个,认准了才知道该动哪里。
一是成员数量变化。消费者进程重启、发布新版本扩容、Pod 被调度器驱逐,都会让 group 里的成员数变化,broker 必须重新分配分区。这一类是「正常」的 rebalance,一次几十毫秒到几秒就结束了。
二是心跳超时。Kafka 用心跳机制判断成员是否还活着。心跳线程连续几次没在规定时间内上报,broker 就把该成员踢出组,触发一次 rebalance。
三是 poll 超时。consumer 调用 poll 之后,如果两次 poll 的间隔超过了 max.poll.interval.ms,broker 认为这个消费者已经不能工作了,同样踢出。
四是订阅的 topic 或分区列表变了。这个不常见但也踩过:有人在代码里动态改了 subscribe 的集合。
我们这次是第二种。第三种也验证过:把 max.poll.interval.ms 从默认的 300000 调到 900000,行为完全没变化,说明消费者 poll 间隔没超,问题出在心跳上。缩小范围了。
第三步:心跳为什么会被判超时
心跳线程是独立的,默认每 3 秒(heartbeat.interval.ms)发一次,broker 那边等 session.timeout.ms(默认 10 秒)收不到就踢人。理论上进程只要不卡死,心跳就断不了。可我们这台机器偏偏断。
我让客户把消费者进程 dump 出来看线程栈,结果一目了然。消费线程在 poll 循环里干了这么一串事:查数据库拿到订单详情、调用一次下游 HTTP 接口拿到物流轨迹、写一条 INFO 日志。整个处理一批 500 条消息要花掉将近 40 秒。这本身已经是隐患:max.poll.records 默认 500,一批处理 40 秒,下一轮 poll 之前就得保证处理时间小于 max.poll.interval.ms。
但真正的杀手是另一件事。看 jstack 的线程状态,有十几个线程都停在 FileOutputStream.writeBytes 上,日志配置里又开了同步落盘(fsync),磁盘一抖动,这十几个线程就集体卡在写日志上。更要命的是,消费者进程跑在一台开了 CPU 限额(cpu quota)的容器里,日志线程一多,CPU 被抢占到触发 CFS 限流,整个进程被 throttle,连心跳线程都跟着降速。
结论就出来了:消费者进程被自己的日志和 CPU 限额拖慢,心跳线程没能按时发出去,broker 判定成员失联并踢出组,于是触发 rebalance;rebalance 期间所有分区停止消费,停顿接近一个 session.timeout.ms 加重新分配的时间,正好是 40 秒左右。八百万条积压就是这么攒出来的。

第四步:改了哪几个参数,为什么这么改
参数不是瞎调的,每一个都对应上面查出来的一个具体原因。
session.timeout.ms 从 10000 调到 30000。心跳偶尔延迟不等于成员死了,给它三倍余量。这一条直接消除了「日志刷盘抖动一次就掉线」的误判。代价是真正宕机的成员要 30 秒才会被踢出,空闲分区的时间变长,但对我们的场景完全可以接受。
heartbeat.interval.ms 从 3000 改成 10000。心跳频率降下来三分之一,broker 侧压力小,消费者线程被调度到的机会更多。注意这个值必须明显小于 session.timeout.ms,默认约束是 session.timeout.ms 至少是 heartbeat.interval.ms 的 3 倍。
max.poll.records 从 500 降到 100。单批处理时间从 40 秒降到 8 秒左右,poll 循环的节奏更密,rebalance 到来时能更快交出分区。这条对稳定性帮助很大,很多积压问题本质是单批太大、退出时处理不完。
max.poll.interval.ms 从 300000 调到 900000。给下游偶发变慢留出缓冲。我们下游有第三方物流接口,高峰期偶尔慢到一两分钟,这个参数不调,早晚有消费者被踢。
消费者线程与心跳线程彻底解耦。这一步其实比调参更关键。我们原来在消费者进程里用同步阻塞的方式做日志落盘,改成了日志走异步队列、单独一个线程批量刷盘;同时把应用日志级别从 INFO 降到 WARN,只保留错误日志。更重要的是,消费者不再跑在有 CPU 限额的容器里,或者把限额放宽到 2 核,并且给心跳线程所在的进程设置了更高的调度优先级。
用 group.instance.id 开静态成员。这个是新版 Kafka 的特性,给每个消费者实例一个稳定的 ID。这样实例重启后,broker 会把它当成原来的成员直接恢复,不必触发全量 rebalance。我们有 24 个消费者实例,正常滚动发布一次就是 24 次 rebalance,开了静态成员之后基本感觉不到了。
把 partition.assignment.strategy 换成 CooperativeStickyAssignor。默认的 RangeAssignor 每次 rebalance 都会把整个消费组的所有分区收回再重新分配,代价是「先全部停止,再全部开始」。Cooperative 模式是增量的,只挪动真正需要变动的分区,对已经在消费的分区影响小很多。

第五步:怎么确认它真的好了
改完不能凭感觉。我们盯了三个指标:
一是消费组的 generation 序号。改之前十分钟能涨四五十次,改完之后一整天只涨了个位数,偶尔有发布重启才是两三次。
二是单次 poll 循环的处理耗时。埋点打出来,改之前 p99 是 41 秒,改完之后 p99 是 7.4 秒。
三是 lag 的下降速度。原来是阶梯状上涨,改完之后是一个平滑的下降斜率,每小时能稳定消化掉三百多万条,八百多万的积压用了不到三个小时清空。
顺带说一句,如果你的 Kafka 是自建或者跑的云厂商托管服务,也可以顺手查一下 broker 端的日志有没有 Session 0x... for group ... expired 这类记录,以及消费者主机有没有触发过 swap 或者被宿主机限流。我们这台机器当时恰好还在跑另一个吃内存的进程,容器一旦被 OOM 边缘化,Java 进程被 throttle,心跳延迟是必然的。这类「机器层面的问题伪装成应用层问题」的情况,在生产环境里太常见了。

最后几个容易踩的细节
graceful shutdown 一定要做。消费者收到 SIGTERM 之后,调用 consumer.wakeup() 或者 close(),主动触发 LeaveGroup,别等 session.timeout.ms 超时被动掉线。前者是毫秒级,后者是 30 秒起步。
不要用 Thread.sleep 去模拟消费耗时。早期版本为了控制节奏直接 sleep 了几秒,结果在扩缩容的时候把 poll 间隔拖超了 max.poll.interval.ms。现在统一改成用 pause/resume 控制流速。
监控要盯 group 状态,不只是 lag。在 Kafka UI 或者 Prometheus 侧把 group 的 state 和 generation 变化次数做成告警,比等 lag 涨起来再处理要早得多。
rebalance 期间的空窗是设计出来的,不是 bug。Kafka 默认用的是 eager 协议,本来就要「全体停下再重新分配」。所以别指望调完参数就完全没有停顿,正确做法是接受短停顿、避免长停顿,并且用静态成员和 cooperative 分配把停顿压到最小。
最后再强调一遍这次的核心教训:这类故障的排查顺序应该是「先看消费组状态,再看进程健康,最后才看业务逻辑」。反过来从业务代码开始查,往往要绕很大一圈。我们前后花了三个多小时,其中两个半小时花在确认业务处理耗时上那些代码最后一行都没改,真正的问题是一段同步日志落盘加上一个 0.5 核的容器限额。


喜欢
顶
难过
囧
围观
无聊



