《Java 消息中间件·可靠消息与消费治理》4.3 扩缩容与优雅停机

扩容一个消费者会触发整组再平衡,停机的姿势决定了消息是重复、丢失还是要等会话超时。本篇给出一份完整可运行的优雅停止代码,用 1200 条消息实测 SIGTERM 后零重复零丢失;再让一个消费者停 poll 25 秒,贴出 max.poll.interval.ms 踢出的原始 WARN 日志,并数清 1212 次投递里的重复。

扩容一定会触发再平衡

在 Kafka 里加一个消费者,不是「多一台机器分担流量」这么轻。新成员发 JoinGroup,协调器把组切到 PreparingRebalance,现有成员在下一次心跳或 poll() 时得知,然后全组重新走一遍加入与分配协议。

即使是 cooperative-sticky,协议本身也要走:成员要上报自己持有的分区,leader 要重新计算方案,协调器要等所有成员确认。区别只在撤销范围,不在「是否发生」。

本机实测的几个时间点(4.2 的实验环境,7 个分区):

动作从进程启动到拿到分区
第一个成员建立组3.5–4.0 s(含 group.initial.rebalance.delay.ms=3000)
第二个成员加入3.5–3.6 s;cooperative 要两轮,6.6 s
第三个成员加入1.5 s(range)到 4.5 s(cooperative)

扩容的两个硬约束:

  • 有效并行度上限是分区数。 7 个分区最多 7 个消费者,第 8 个只会拿到空分配,还白白触发一次再平衡。
  • 扩容是分步生效的。 新成员加入后,分区不会立刻均匀;要等这一轮协议结束。扩容期间 lag 可能短暂上升,因为部分分区在撤销与重新分配之间暂停消费。

因此扩容的正确节奏是:一次加一个(或一小批),等组回到 Stable、lag 开始下降,再加下一个。不要一次性把副本数翻倍,那会制造一串连续的再平衡。

停机的三种方式

同样是「把进程停掉」,三种方式的消息语义完全不同:

方式组协调器看到什么分区何时释放重复窗口风险
kill -9什么都不发,等会话超时session.timeout.ms 之后最后一次提交到死亡之间的全部处理默认 45 秒内该分区无人消费
SIGTERM,无 hook进程直接退出,同上同上同上同上,且没有任何日志
SIGTERM + 优雅关闭LeaveGroup立即最后一次提交之后的未提交批次实现错误会丢消息

表中 kill -9 与无 hook 两行的会话超时行为未在本机单独演练,本节只实测了优雅关闭与 max.poll.interval.ms 两条路径;会话超时的判定逻辑可参考卷一 6.2 的成员死亡检测。

优雅关闭要做四件事,顺序不能乱:

  1. 收到停止信号后,让主线程退出 poll()。KafkaConsumer 不是线程安全的,不能从信号线程直接调 close()。
  2. 停止拉取,把已处理但未提交的位移提交掉。
  3. 提交成功后再 close(),让客户端发 LeaveGroup,协调器立即触发再平衡。
  4. 给整个过程设一个上限,超时就让进程退出,避免卡死。

第 1 步的标准做法是 consumer.wakeup():它可以从其他线程安全调用,让阻塞中的 poll() 抛 WakeupException。第 3 步在 4.3.0 里可以用 CloseOptions 精确控制是否退组:

consumer.close(CloseOptions.timeout(Duration.ofSeconds(10))
        .withGroupMembershipOperation(CloseOptions.GroupMembershipOperation.LEAVE_GROUP));

CloseOptions.GroupMembershipOperation 有三个值:LEAVE_GROUP、REMAIN_IN_GROUP、DEFAULT。DEFAULT 对动态成员是退组,对配置了 group.instance.id 的静态成员是留在组里。静态成员留在组里的意义是:滚动重启期间分区不被搬走,回来还能拿回原分区;代价是这段时间该分区无人消费,要确保重启足够快,别超过 session.timeout.ms。

完整可运行的优雅停止代码

下面这份代码可以直接跑。它订阅 v2c4.shutdown,每处理完一批提交一次,收到 SIGTERM 后走完「wakeup → 提交 → LeaveGroup → close」。

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

import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;

public class GracefulConsumer {

    private static final String TOPIC = "v2c4.shutdown";

    public static void main(String[] args) throws Exception {
        String name = args[0];

        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:19292");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "v2c4-graceful");
        p.put(ConsumerConfig.CLIENT_ID_CONFIG, "graceful-" + 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.MAX_POLL_RECORDS_CONFIG, "10");
        p.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "10000");
        p.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, "3000");

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);
        AtomicBoolean running = new AtomicBoolean(true);
        Thread mainThread = Thread.currentThread();

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println(name + " SHUTDOWN-HOOK: stop flag + wakeup");
            running.set(false);
            consumer.wakeup();                 // 安全地打断 poll()
            try {
                mainThread.join(15000);        // 给主线程留出提交与关闭的时间
            } catch (InterruptedException ignored) {
                Thread.currentThread().interrupt();
            }
        }, "shutdown-" + name));

        try {
            consumer.subscribe(List.of(TOPIC), new ConsumerRebalanceListener() {
                @Override
                public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
                    // 撤销前提交,缩小下一个持有者的重复窗口。
                    try {
                        consumer.commitSync(Duration.ofSeconds(5));
                    } catch (Exception e) {
                        System.out.println(name + " commit on revoke failed: " + e);
                    }
                    System.out.println(name + " REVOKED " + partitions);
                }

                @Override
                public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
                    System.out.println(name + " ASSIGNED " + partitions);
                }
            });

            while (running.get()) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));
                for (ConsumerRecord<String, String> r : records) {
                    process(r);                // 业务处理
                }
                if (!records.isEmpty()) {
                    consumer.commitSync();     // 处理完再提交
                }
            }
        } catch (WakeupException e) {
            if (running.get()) {
                throw e;                       // 不是我们发起的 wakeup,向上抛
            }
            System.out.println(name + " WAKEUP received, leaving loop");
        } finally {
            try {
                consumer.commitSync(Duration.ofSeconds(5));
                System.out.println(name + " final commit ok");
            } catch (Exception e) {
                System.out.println(name + " final commit failed: " + e);
            }
            consumer.close(CloseOptions.timeout(Duration.ofSeconds(10))
                    .withGroupMembershipOperation(CloseOptions.GroupMembershipOperation.LEAVE_GROUP));
            System.out.println(name + " closed");
        }
    }

    private static void process(ConsumerRecord<String, String> r) throws Exception {
        Thread.sleep(20);                      // 模拟业务耗时
    }
}

三个容易写错的地方:

  • 不要在 shutdown hook 里直接 consumer.close()。 主线程可能正阻塞在 poll() 里,两个线程同时操作 consumer 会触发并发问题。必须先 wakeup(),等主线程退出循环后再 close。
  • wakeup() 只能打断阻塞调用,不会丢消息。 被打断时已经拉取但未处理的记录不会提交,下一次由其他成员从已提交位移重新消费,这是 at-least-once 的正常重复。
  • close() 的超时不完全由参数决定。 源码注释写明实际等待还受 request.timeout.ms 限制;即使传了更大的值,broker 请求最多等这么久。

实测:1200 条消息,第 8 秒停掉 A

设置

  • 主题 v2c4.shutdown,6 个分区,写入 1200 条 seq=1..1200。
  • 两个消费者 A、B,同一个组,每条处理 20 ms,每批提交。
  • 第 8 秒对 A 发 SIGTERM,B 继续跑 30 秒后也优雅停止。

A 的再平衡日志原文

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

13:21:23.301 [main] INFO ConsumerCoordinator - [Consumer clientId=graceful-A, groupId=v2c4-graceful] Successfully joined group with generation Generation{generationId=1, memberId='graceful-A-9fe12c1a-...', protocol='range'}
A ASSIGNED [v2c4.shutdown-0, v2c4.shutdown-1, v2c4.shutdown-2]
A SHUTDOWN-HOOK: stop flag + wakeup
13:21:24.854 [main] INFO ConsumerRebalanceListenerInvoker - [Consumer clientId=graceful-A, groupId=v2c4-graceful] Revoke previously assigned partitions [v2c4.shutdown-0, v2c4.shutdown-1, v2c4.shutdown-2]
A REVOKED [v2c4.shutdown-0, v2c4.shutdown-1, v2c4.shutdown-2]
13:21:24.859 [main] INFO ConsumerCoordinator - [Consumer clientId=graceful-A, groupId=v2c4-graceful] Member graceful-A-9fe12c1a-... sending LeaveGroup request to coordinator 127.0.0.1:19292 ... due to the consumer is being closed
A final commit ok
A closed, processed=60

从撤销到 LeaveGroup 只用了约 5 毫秒:撤销回调里提交了一次,finally 里又提交了一次,然后立即退组。

B 接管

13:21:26.611 [main] INFO ConsumerRebalanceListenerInvoker - [Consumer clientId=graceful-B, groupId=v2c4-graceful] Revoke previously assigned partitions [v2c4.shutdown-3, v2c4.shutdown-4, v2c4.shutdown-5]
13:21:26.638 [main] INFO ConsumerCoordinator - [Consumer clientId=graceful-B, groupId=v2c4-graceful] Successfully joined group with generation Generation{generationId=2, ...}
B ASSIGNED [v2c4.shutdown-0, v2c4.shutdown-1, v2c4.shutdown-2, v2c4.shutdown-3, v2c4.shutdown-4, v2c4.shutdown-5]

A 在 24.859 退组,B 在 26.638 完成第二代加入,间隔约 1.8 秒。这 1.8 秒里 A 的三个分区无人消费,属于扩缩容的正常停顿。

结果

logs/seq_A.txt   60 行
logs/seq_B.txt 1140 行
合计 1200 次处理
distinct=1200
processed_times=1 records=1200
seq 范围 1..1200

A 处理 60 条,B 处理 1140 条,两条日志合并后 1200 条每条恰好处理一次,没有重复也没有缺口。

这个「恰好一次」有前提:每条消息处理完立刻提交,且停机时提交成功。如果进程在处理完、提交前被 kill -9,那些消息会被下一个成员重新消费,变成重复;如果提交成功但下游写入失败,那就是另一种不一致。优雅停机只能把重复窗口缩到最小,不能替你保证业务恰好一次。

max.poll.interval.ms 导致的踢出

比「进程退出」更隐蔽的是「进程活着,但应用线程不回 poll()」。心跳线程仍在发心跳,协调器认为成员健康;但客户端自己会检查两次 poll() 的间隔,超过 max.poll.interval.ms 就主动退组。

复现设置

  • 主题 v2c4.stall,6 个分区,1200 条消息。
  • A:max.poll.interval.ms=10000,每 10 条提交一次,处理到第 13 条时停 25 秒。
  • B:正常消费,不设短超时。
  • 两者处理每条 20 ms。

原始 WARN

同一环境,单次运行,随调度波动,不作基准。下面这条 WARN 为排版做了折行,内容未改。

13:22:44.287 [kafka-coordinator-heartbeat-thread | v2c4-stall] WARN ConsumerCoordinator -
[Consumer clientId=stall-A, groupId=v2c4-stall] consumer poll timeout has expired. This means the time
between subsequent calls to poll() was longer than the configured max.poll.interval.ms, which typically
implies that the poll loop is spending too much time processing messages. You can address this either by
increasing max.poll.interval.ms or by reducing the maximum size of batches returned in poll() with
max.poll.records.
13:22:44.288 [kafka-coordinator-heartbeat-thread | v2c4-stall] INFO ConsumerCoordinator -
Member stall-A-... sending LeaveGroup request to coordinator 127.0.0.1:19292 ... due to consumer poll timeout has expired.

时间线:

时刻事件
13:22:33.958A 以第 2 代加入,拿到 p0、p1、p2
13:22:34.2A 处理到第 13 条,开始 25 秒不 poll(加入后约 0.3 秒)
13:22:44.287客户端心跳线程打 WARN,主动 LeaveGroup(停 poll 后约 10.1 秒)
13:22:46.192B 以第 3 代加入,拿到全部 6 个分区(约 1.9 秒后)
13:23:01.289A 醒来后重新加入,第 4 代,拿回 p0、p1、p2
13:23:40.322B 关闭,A 拿到全部 6 个分区
13:23:42.667A 关闭

从停 poll 到被踢出约 10.1 秒,由 max.poll.interval.ms=10000 加上心跳线程的检测粒度组成。这个路径和 session.timeout.ms 完全不同:后者是协调器收不到心跳,前者是客户端自己发现应用线程卡住。

重复了多少

两条消费者日志合并统计:

总投递尝试 1212 次
distinct=1200
processed_times=2 records=10
processed_times=3 records=1
A closed processed=16
B closed processed=1196

1200 条消息被处理了 1212 次,多出来的 12 次来自三个窗口:

  • A 被踢前最后一批未提交的记录,B 接管后重新处理;
  • A 重新加入触发再平衡,B 在撤销前也有未提交批次,重新处理;
  • 结尾 B 关闭时没有做最终提交,A 又处理了同一批尾部记录。

这正是「手动提交 + 再平衡」的固有重复。要压缩它:减小 max.poll.records、缩短提交间隔、把长耗时处理移出 poll 线程(12.1 会讲线程模型),或者把 max.poll.interval.ms 调到能覆盖最坏处理时间。

扩缩容与停机检查表

  • 消费者数不超过订阅主题的最小分区数。
  • 扩容一次加一个,等组回到 Stable 再加下一个。
  • 所有消费者配置了 shutdown hook,且 hook 只做 wakeup() 与等待,不直接 close。
  • 关闭流程包含「提交未提交位移」这一步。
  • close() 使用 LEAVE_GROUP(动态成员)或明确接受静态成员留组的停顿。
  • terminationGracePeriodSeconds 大于「hook 等待 + close 超时」。
  • max.poll.interval.ms 大于一批消息的最坏处理时间。
  • 停机后核对:处理条数、提交位移、重复条数,三者能对上。

本节判据

  1. 扩容为什么一定触发再平衡? 答:新成员加入改变组成员列表,协调器必须重新收集订阅并下发分配。
  2. 优雅停机的最小正确步骤是什么? 答:wakeup() 打断 poll,退出循环后提交已处理位移,再 close() 发 LeaveGroup。
  3. 为什么不能在 shutdown hook 里直接 close? 答:主线程可能正在 poll,KafkaConsumer 非线程安全。
  4. max.poll.interval.ms 超时和 session.timeout.ms 超时的区别? 答:前者是客户端心跳线程发现应用线程没 poll,主动退组;后者是协调器收不到心跳,判定成员死亡。
  5. 优雅停机能否保证不重复? 答:不能。它只缩小未提交窗口;真正的恰好一次要靠幂等消费或事务。

阅读导航:上一节 4.2 分配策略与消费者倾斜 | 下一节 5.1 lag 的三种口径与误读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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