《Java 消息中间件·Kafka 内核与客户端》6.2 经典再平衡协议

两个消费者加进同一个组,分区怎么从 3-0 变成 2-1?本节用实测时间线拆开 JoinGroup、SyncGroup、Heartbeat 三个阶段,说明 leader 成员在哪一步算分配、eager 与 cooperative 各自撤销哪些分区,以及 stop-the-world 卡在 poll() 的哪一行;再让一个消费者停 poll 超时,看它如何被踢出。

分配不是消费者自己决定的事

一个 topic 有 3 个分区,先起一个消费者,它拿全部 3 个;再起第二个,理想状态是 2 和 1。这个从「3-0」到「2-1」的调整过程就叫再平衡(rebalance,也译重平衡)。

很多人对再平衡的印象停留在「会卡一下」。这一节要把这一下拆开:卡在哪个协议阶段、卡在客户端哪一行、eager 和 cooperative 卡的范围有什么不同。全程用两个真实消费者进程的时间线说话。

先给结论,后面再用数据支撑:

  • 再平衡由协调器(group coordinator)发起,但分配方案由组内的 leader 成员在客户端算出来。
  • 协议分三段:JoinGroup 收集成员与订阅,SyncGroup 下发分配,Heartbeat 维持存活。
  • 应用线程的停顿发生在 poll() 内部等待 JoinGroup/SyncGroup 完成的那段,不是在收消息的时候。
  • eager 撤销全部分区,cooperative 只撤销要搬走的那部分,代价是多一轮协议。

三个阶段各做什么

JoinGroup:把人凑齐

协调器把组状态改成 PreparingRebalance,然后等所有成员重新发 JoinGroup。每个成员在请求里带上:

  • memberId:第一次为空,协调器分配一个;之后带上,表示「还是我」。
  • protocols:自己支持的分配器列表,例如 range、cooperative-sticky。
  • sessionTimeoutMs、rebalanceTimeoutMs:会话超时与本次再平衡的等待上限。
  • 订阅信息:订阅了哪些 topic。

协调器选出一个所有成员都支持的协议,选出 leader(通常是第一个加入的成员),把成员列表放进 JoinGroup 响应发给 leader。普通成员拿到的响应里没有成员列表,它们只需要等 SyncGroup。

SyncGroup:leader 算,协调器发

leader 成员在 onLeaderElected 回调里调用分配器,算出「谁拿哪些分区」,然后把完整方案放进 SyncGroup 请求发给协调器。其他成员发的是空方案的 SyncGroup。

协调器把 leader 给的方案分发给每个成员。成员收到响应后,在 onJoinComplete 里安装分配、恢复拉取。到这里一轮再平衡结束,组状态变成 Stable。

Heartbeat:活着,并且知道组变了

加入成功后,客户端会启动一个独立的心跳线程,按 heartbeat.interval.ms 给协调器发 Heartbeat。它的返回值不只是「你还活着」:

  • 正常:什么都不用做。
  • REBALANCE_IN_PROGRESS:组开始新一轮再平衡,主线程下一次 poll() 要重新加入。
  • UNKNOWN_MEMBER_ID / ILLEGAL_GENERATION:成员身份失效,必须清掉 memberId 重新加入。

心跳线程独立于应用线程,这一点很关键:应用线程在 poll() 里等 JoinGroup 时,心跳仍然在发,所以成员不会因为「正在再平衡」而超时。

谁在算分配

这是经典协议和 KIP-848 最大的区别之一:经典协议的分配器跑在客户端。

读 4.3.0 的源码可以确认这条链路。ConsumerCoordinator.poll() 在发现 rejoinNeededOrPending() 后调用 ensureActiveGroup,后者进入 AbstractCoordinator.joinGroupIfNeeded,里面有一行决定停顿的代码:

// org/apache/kafka/clients/consumer/internals/AbstractCoordinator.java
final RequestFuture<ByteBuffer> future = initiateJoinGroup();
client.poll(future, timer);
if (!future.isDone()) {
    // we ran out of time
    return false;
}

client.poll(future, timer) 是一个阻塞等待:网络线程收响应,应用线程停在这里。JoinGroup 响应回来后,leader 还要算分配、再发 SyncGroup,然后继续等 SyncGroup 响应。这两段等待都发生在应用线程调用 poll() 的栈里。

还有一个常被忽略的事实:源码 GroupRebalanceConfig 里,消费者协议的 rebalanceTimeoutMs 直接取 max.poll.interval.ms。默认 300000 意味着,一轮再平衡理论上可以等 5 分钟。这也解释了常见现象:某个消费者卡住不 poll,其他成员会一直等在 PreparingRebalance,直到它被踢掉或超时。

三种触发条件

再平衡不只由「消费者上下线」触发。经典的触发条件有三类:

触发例子谁先知道
成员变化新消费者加入、消费者关闭、成员被判定超时协调器
订阅变化subscribe() 换了 topic 列表、正则匹配到新 topic消费者自己,下一次心跳或 poll 时上报
分区数变化topic 增加分区消费者刷新元数据后发现,主动请求重新加入

第二类常被低估:用正则订阅时,新建一个匹配的 topic 会让整个组再平衡一次;如果各成员刷新元数据的时间不一致,还可能连续触发多轮。源码在加入前强制刷新一次元数据来缓解这个问题。

eager 与 cooperative 的区别

两者的差别只在撤销范围,但影响很大。

eager(RangeAssignor、RoundRobinAssignor 等):所有成员先撤销全部分区,再重新分配。分配方案里 p2 从 A 搬到 B,A 也会把 p0、p1 一起停掉再拿回来。停的范围是「全部」。

cooperative(CooperativeStickyAssignor):成员只撤销「新方案里不再属于自己」的分区。p2 要搬走,A 只停 p2,p0、p1 继续消费。代价是协议要多走一轮:第一轮 A 撤销 p2,第二轮 B 才拿到 p2。

partition.assignment.strategy 的默认值是一个列表,本机配置日志里是:

partition.assignment.strategy = [class org.apache.kafka.clients.consumer.RangeAssignor, class org.apache.kafka.clients.consumer.CooperativeStickyAssignor]

实际生效的是「所有成员都支持的第一个」。两个消费者都用默认值时,选中的是 Range,也就是 eager。我们可以在组元数据里看到协商结果:

protocolType":"consumer","generation":1,"protocol":"range"

要让组走 cooperative,必须显式把 partition.assignment.strategy 设成 CooperativeStickyAssignor,并且组内所有成员都这么配。混配的后果是协商失败或退回 eager。

stop-the-world 到底停在哪一步

把一次 eager 再平衡按时间顺序摊开:

  1. 协调器收到成员变化,组进入 PreparingRebalance,通过心跳响应告诉现有成员。
  2. 现有成员在下一次 poll() 里发现要重新加入。
  3. onJoinPrepare 被调用:先做一次自动提交(如果开了自动提交),再调用用户的 onPartitionsRevoked,此时分区已经被撤销,拉取停止。
  4. 发 JoinGroup,应用线程在 client.poll(future, timer) 上等。
  5. leader 收到成员列表,算分配,发 SyncGroup。
  6. 所有成员收到 SyncGroup 响应,onJoinComplete 安装新分配,调用 onPartitionsAssigned。
  7. poll() 返回,拉取恢复。

所以「停」的准确位置是 第 3 步到第 6 步之间:从撤销回调开始,到分配回调结束。对 eager 来说,这段时间内这个消费者一个分区都不消费;对 cooperative 来说,只停被撤销的那些分区,其余分区不受影响。

停的时长由最慢的成员决定,不是由本地算分配的速度决定。协调器要等所有成员都重新 JoinGroup,任何一个成员卡住,其他人都得等,直到它被踢出或 rebalanceTimeoutMs 到期。这就是「一个消费者卡住,全组停摆」的机制来源。

三个时间参数的关系

参数默认值谁在检查作用
session.timeout.ms45000协调器多久收不到心跳就判定成员死亡
heartbeat.interval.ms3000消费者心跳线程多久发一次心跳
max.poll.interval.ms300000消费者心跳线程两次 poll() 间隔超过多久就主动退组
  • heartbeat.interval.ms 必须明显小于 session.timeout.ms,官方建议 1/3。否则一次网络抖动就可能误判死亡。broker 端还限制了会话超时的上下界,本机实测 group.min.session.timeout.ms=6000、group.max.session.timeout.ms=1800000。
  • max.poll.interval.ms 要大于「处理一批消息的最长时间」。它同时是再平衡的等待上限,配得太小会把正常的长耗时处理误判成卡死。
  • session.timeout.ms 和 max.poll.interval.ms 管的是两种不同的「不响应」:前者是进程没了/网络断了,心跳发不出去;后者是进程活着、心跳正常,但应用线程没调用 poll()。

本机还有一个影响首次分配的参数:group.initial.rebalance.delay.ms=3000。组第一次建立时协调器多等 3 秒,给其他成员加入的机会;下面实验里第一个消费者 3.4 秒才拿到分配,主要就是这 3 秒。

实验一:两个消费者加入同一个组

程序

一个可复用的观察程序,打印每次撤销/分配的时间戳和分区:

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;

import java.time.Duration;
import java.util.*;

public class RebalanceObserver {
    private static final long T0 = System.currentTimeMillis();
    private static void log(String s) {
        System.out.printf("[%6d ms] %s%n", System.currentTimeMillis() - T0, s);
    }

    public static void main(String[] args) throws Exception {
        String name = args[0];
        long runMs = Long.parseLong(args[1]);
        String mode = args.length > 2 ? args[2] : "normal";
        String strategy = args.length > 3 ? args[3] : "range";

        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:19211");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "b1.rebal.group");
        p.put(ConsumerConfig.CLIENT_ID_CONFIG, "b1-rebal-" + name);
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
        p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        p.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "10000");
        p.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, "3000");
        p.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, "10000");
        p.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
                strategy.equals("cooperative")
                        ? "org.apache.kafka.clients.consumer.CooperativeStickyAssignor"
                        : "org.apache.kafka.clients.consumer.RangeAssignor");

        log(name + " start mode=" + mode + " strategy=" + strategy);
        try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
            c.subscribe(List.of("b1.offsets.demo"), new ConsumerRebalanceListener() {
                public void onPartitionsRevoked(Collection<TopicPartition> ps) {
                    log(name + " REVOKED " + ps);
                }
                public void onPartitionsAssigned(Collection<TopicPartition> ps) {
                    log(name + " ASSIGNED " + ps);
                }
            });
            long start = System.currentTimeMillis();
            boolean stalled = false;
            while (System.currentTimeMillis() - start < runMs) {
                if (mode.equals("stall") && !stalled && System.currentTimeMillis() - start > 2000) {
                    log(name + " STALL: sleep 20s without poll");
                    stalled = true;
                    Thread.sleep(20000);
                    log(name + " STALL over, polling again");
                }
                c.poll(Duration.ofMillis(500));
            }
        }
        log(name + " closed");
    }
}

运行

A 先跑 42 秒,第 8 秒启动 B 跑 22 秒,第 18 秒抓一次成员表:

java -cp ... RebalanceObserver A 42000 normal range > eager_a.log 2>&1 &
sleep 8
java -cp ... RebalanceObserver B 22000 normal range > eager_b.log 2>&1 &
sleep 10
docker exec mq-b1 /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server 127.0.0.1:9092 \
  --describe --group b1.rebal.group --members --verbose > eager_members.log
wait

A 的时间线

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29;单次运行,随调度波动,不作基准):

[     7 ms] A start mode=normal strategy=range
[  3447 ms] A ASSIGNED [b1.offsets.demo-0, b1.offsets.demo-1, b1.offsets.demo-2]
[  9442 ms] A REVOKED [b1.offsets.demo-0, b1.offsets.demo-1, b1.offsets.demo-2]
[  9450 ms] A ASSIGNED [b1.offsets.demo-0, b1.offsets.demo-1]
[ 33471 ms] A REVOKED [b1.offsets.demo-0, b1.offsets.demo-1]
[ 33489 ms] A ASSIGNED [b1.offsets.demo-0, b1.offsets.demo-1, b1.offsets.demo-2]
[ 42469 ms] A end, leaving group

B 的时间线

[     6 ms] B start mode=normal strategy=range
[  1437 ms] B ASSIGNED [b1.offsets.demo-2]
[ 22568 ms] B end, leaving group

三段时间可以直接读出来:

  • 首次加入用了 3.4 秒:不是网络慢,是 group.initial.rebalance.delay.ms=3000 的启动延迟。
  • B 加入触发的再平衡:B 从启动到拿到 p2 用了 1437 ms;A 在 9442 ms 撤销全部三个分区,9450 ms 拿回两个,本地停顿只有 8 ms。B 的 1.4 秒大部分花在协调器等 A 重新加入上。A 的下一次 poll 在 500 ms 内,心跳间隔 3 秒,所以最坏要等一轮心跳。
  • B 退出触发的再平衡:B 在约 30.5 秒关闭,A 在 33.47 秒拿回全部三个分区,间隔约 3 秒,正好是一个 heartbeat.interval.ms。协调器通过心跳响应把「组变了」告诉 A,A 才知道要重新加入。

成员表也能看到这一刻的分配:

GROUP           CONSUMER-ID                                     HOST            CLIENT-ID       #PARTITIONS     CURRENT-ASSIGNMENT
b1.rebal.group  b1-rebal-A-...                                  /172.17.0.1     b1-rebal-A      2               b1.offsets.demo:0,1
b1.rebal.group  b1-rebal-B-...                                  /172.17.0.1     b1-rebal-B      1               b1.offsets.demo:2

A 两个、B 一个,和日志里的分配回调一致。--describe 的普通输出同样能看到每个分区归谁;这里 CURRENT-OFFSET 是 -,因为程序关掉了自动提交也没手动提交,不影响看分配归属。

实验二:同样的两个人,换成 cooperative

只把分配器换成 CooperativeStickyAssignor,时间参数不变。本机实测:

=== A ===
[  3598 ms] A ASSIGNED [b1.offsets.demo-0, b1.offsets.demo-1, b1.offsets.demo-2]
[  9631 ms] A REVOKED [b1.offsets.demo-2]
[  9639 ms] A ASSIGNED []
[ 12621 ms] A ASSIGNED []
[ 33635 ms] A ASSIGNED [b1.offsets.demo-2]

=== B ===
[  1030 ms] B ASSIGNED []
[  4030 ms] B ASSIGNED [b1.offsets.demo-2]

对比 eager 的 A 日志,差别一目了然:

  • A 只撤销了 p2,p0、p1 没有出现在撤销列表里。这两个分区在整个再平衡期间继续被 A 消费。
  • A 在 9639 ms 拿到空分配 [],12621 ms 又收到一次,这是 cooperative 两轮协议完成的信号。
  • B 先拿到空分配,到 4030 ms 才拿到 p2。B 从启动到真正持有分区用了约 4 秒,比 eager 的 1.4 秒长。

cooperative 的取舍很清楚:用多一轮协议换更小的停顿范围。分区多、单分区流量大的场景,这个交换通常值得;分区很少、再平衡本来就只有几毫秒的场景,eager 的简单性更划算。

实验三:停 poll 超过 max.poll.interval.ms

让 A 正常运行 60 秒,B 启动后先正常 poll,2 秒后停 20 秒不 poll。B 的 max.poll.interval.ms 设成 10000,心跳仍然每 3 秒发一次。

本机实测 A 的时间线:

[  3464 ms] A ASSIGNED [p0, p1, p2]
[  9447 ms] A REVOKED [p0, p1, p2]
[  9458 ms] A ASSIGNED [p0, p1]          <- B 加入,A 让出 p2
[ 21460 ms] A REVOKED [p0, p1]
[ 21470 ms] A ASSIGNED [p0, p1, p2]      <- B 被踢,A 收回全部
[ 30476 ms] A REVOKED [p0, p1, p2]
[ 30484 ms] A ASSIGNED [p0, p1]          <- B 重新加入,A 再让出 p2

B 的时间线:

[     7 ms] B start mode=stall strategy=range
[  2345 ms] B STALL: sleep 20s without poll (max.poll.interval.ms=10000)
[ 22356 ms] B STALL over, polling again
[ 24300 ms] B ASSIGNED [b1.offsets.demo-2]

关键时间点:B 在约 8.35 秒(全局)停止 poll,A 在 21.46 秒收回全部三个分区,间隔约 13 秒。这个数字由两段组成:max.poll.interval.ms=10000 到期后心跳线程发起退组,加上最多一个 heartbeat.interval.ms=3000 的传播延迟。

在 B 停 poll 期间抓成员表,本机输出:

Warning: Consumer group 'b1.rebal.group' is rebalancing.

GROUP           CONSUMER-ID     HOST            CLIENT-ID       #PARTITIONS     CURRENT-ASSIGNMENT
b1.rebal.group  b1-rebal-A-...  /172.17.0.1     b1-rebal-A      2               b1.offsets.demo:0,1

B 已经不在成员列表里,组处于 rebalancing。B 的 20 秒睡眠结束后再次 poll,重新加入,又触发一轮再平衡,A 再次让出 p2。

「被踢出」这条路径的判定在客户端心跳线程里,不在协调器。源码 AbstractCoordinator 的心跳循环:

} else if (heartbeat.pollTimeoutExpired(now)) {
    // the poll timeout has expired, which means that the foreground thread has stalled
    // in between calls to poll().
    handlePollTimeoutExpiry();
}

handlePollTimeoutExpiry 会打一条明确的 WARN,然后主动发 LeaveGroup:

consumer poll timeout has expired. This means the time between subsequent calls to poll()
was longer than the configured max.poll.interval.ms ...

这条日志是排查「消费者莫名被踢」的第一线索。它和 session.timeout.ms 超时的区别是:进程还活着,心跳也正常,只是应用线程没回来 poll。

把三条路径放在一张表里

现象检测方判定依据本机实测间隔
新成员加入协调器JoinGroupB 启动到拿到分区约 1.4 s(eager)
成员主动关闭协调器LeaveGroupB 关闭到 A 收回约 3 s(一轮心跳)
进程卡住但心跳在发客户端心跳线程max.poll.interval.ms约 13 s(10 s + 心跳传播)
进程冻结/网络断开协调器session.timeout.ms未在本机执行;预期约 10 s(本实验配置)

最后一行未在本机执行:本机没有冻结进程或断网。做法可以是 docker pause 消费者所在环境,预期会话超时后成员被移除、客户端恢复后收到 UNKNOWN_MEMBER_ID;只给方法和预期形态,不给编造的数字。

配置建议

session.timeout.ms 不要低于 10 秒。 本机设成 10 s / 3 s / 10 s 是为了缩短实验;生产环境默认 45 s / 3 s 更稳,除非能确定网络抖动很小。

max.poll.interval.ms 按一批消息的最坏处理时间定。 它同时是再平衡等待上限,配小会误杀慢处理,配大会让卡住的成员拖住全组。默认 5 分钟对多数同步处理偏宽松。

分配器优先 cooperative,但要求全组同时支持。 混配会协商失败或退回 eager。group.initial.rebalance.delay.ms=3000 是启动期保护,不是故障,不要为了「启动快」随手改成 0。

本节判据

  1. JoinGroup 和 SyncGroup 分别传什么? 答:JoinGroup 传成员身份、订阅、支持的协议;SyncGroup 由 leader 传分配方案,协调器分发给所有成员。
  2. 分配是谁算的? 答:经典协议里是 leader 成员在客户端算的,协调器只负责收集和转发。
  3. stop-the-world 停在哪? 答:从 onPartitionsRevoked 开始,到 onJoinComplete 安装新分配为止;应用线程阻塞在 poll() 内的 client.poll(future, timer)。
  4. eager 和 cooperative 的核心差别? 答:eager 撤销全部分区再重分;cooperative 只撤销要搬走的分区,代价是多一轮协议。
  5. 停 poll 超时和会话超时的区别? 答:前者由客户端心跳线程按 max.poll.interval.ms 主动退组;后者由协调器按 session.timeout.ms 判定成员死亡。

下一节换掉这套客户端主导的协议:KIP-848 把分配搬到服务端,客户端只上报成员状态和订阅。我们会用同一个程序分别跑 group.protocol=classic 和 consumer,打印真正被实例化的实现类,再从 __consumer_offsets 里读出新协议多出来的五种记录类型。

阅读导航:上一节 6.1 位移与 __consumer_offsets | 下一节 6.3 KIP-848 新一代消费组协议

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收