CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Kafka 消息队列入门/课时/消费者组
RawBlame
课时目录12 节

Kafka 消息队列入门

  1. 01消息队列是什么
  2. 02核心概念
  3. 03Docker 运行 Kafka
  4. 04生产者
  5. 05消费者组
  6. 06Spring Kafka
  7. 07消息格式
  8. 08投递语义
  9. 09Kafka Streams 入门
  10. 10监控与 lag
  11. 11Kafka vs RabbitMQ
  12. 12综合练习

消费者组

group.id、rebalance、at-least-once。

35 分钟

消费者组

这一课你将能做到

  1. 配置 group.id、enable.auto.commit=false、auto.offset.reset,用 Java Consumer 拉取消息并 处理成功后再 commitSync
  2. 画出「分区数 vs 实例数」与 rebalance 时暂停消费的图像,说明为什么处理逻辑要快且幂等
  3. 对照 Kafka Consumer Group 与 Redis Pub/Sub,说出离线补消息、竞争消费、进度记录三点差异

为什么要学

Producer 把事件写进分区后,真正产生业务副作用的是 Consumer:发通知、写审计、刷搜索索引。Consumer Group 决定「谁来消费、消费多少、消费到哪」:

  • 同一组内多实例分摊分区——这就是 Kafka 的水平扩展方式(部署两个实例吞吐翻倍的前提是分区 ≥ 2)
  • 不同组各自完整消费——通知和审计都要全量事件,互不干扰

默认工程语义接近 at-least-once:可能重复,必须幂等。本课把位点提交、rebalance、poll 循环三件事钉死,它们同时是 Kafka 面试出现频率最高的三个问题。

人话:一组读者共用一本日记的不同页(组内分摊);另一组读者有自己的书签,可以从头再读一遍全书(组间独立)。

先建立图像

Topic order-events   P0  P1  P2
                      │
        ┌─────────────┼─────────────┐
        ▼                            ▼
 group notify (负载均衡)        group audit (再订阅)
  实例1 → P0, P1                 实例X → P0, P1, P2(可以一个人扛)
  实例2 → P2                     各组独立维护 CURRENT-OFFSET

Rebalance 触发:实例上线/下线/崩溃、订阅变更
  → 组内暂停拉取 → 重新分配分区 → 从各组已提交位点继续
Kafka Consumer GroupRedis Pub/Sub
离线后补消息可从未提交/earliest 位点读收不到历史
多实例竞争分摊分区广播给所有订阅者
消费进度Offset(组 × 分区)无

跟着做

步骤 1:准备 Topic 与假数据

确保 coderoadmap.order-events 存在。用 console producer 先灌 5 条:

docker exec -it kafka-lab /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic coderoadmap.order-events

本课用一个 新的 group.id(如 coderoadmap-notify-lab),避免和旧实验位点搅在一起。

步骤 2:写手动提交 Consumer

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.List;
import java.util.Properties;

public class OrderEventConsumerDemo {
    public static void main(String[] args) {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        // 同组多进程分摊分区;换组名 = 新订阅者
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "coderoadmap-notify-lab");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        // 关闭自动提交:避免"位点推了但业务没做完"
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
        // 无已提交位点时才生效:earliest 从最早读,latest 只吃新消息
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        // 心跳与拉取节奏(面试常问,先认识)
        props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "45000");
        props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, "300000");

        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
            consumer.subscribe(List.of("coderoadmap.order-events"));
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
                for (ConsumerRecord<String, String> r : records) {
                    System.out.printf("partition=%d offset=%d key=%s value=%s%n",
                            r.partition(), r.offset(), r.key(), r.value());
                    handleOrderEvent(r.value()); // 业务处理:必须幂等
                }
                if (!records.isEmpty()) {
                    consumer.commitSync(); // 全部处理成功后再提交位点
                }
            }
        }
    }

    static void handleOrderEvent(String json) {
        // 实验:打印即可;生产要有幂等(去重表),见"投递语义"课
    }
}

运行后应看到 5 条历史消息逐条打印。重启同组进程再跑:不会再从 earliest 重复扫已提交部分(位点已提交)。

步骤 3:同组双实例观察分摊

开两个终端跑同一个类(相同 group.id),在循环里加一行打印分配情况:

consumer.poll(Duration.ofMillis(500));
Set<TopicPartition> assigned = consumer.assignment();
System.out.println("assigned=" + assigned);

应看到:分区被拆给两个进程(如实例1 拿 P0,P1、实例2 拿 P2),同一分区不会同时给两个实例。

步骤 4:换 group 名验证「再订阅」

把 GROUP_ID_CONFIG 改成 coderoadmap-audit-lab,auto.offset.reset=earliest,再跑。应看到:又能从头读全部历史——这是多订阅者模型,不是 bug。

步骤 5:制造一次 rebalance 直觉

双实例运行时 Ctrl+C 停掉其中一个,观察存活实例:短暂停顿后 assignment 变成三个分区全归它。这就是 rebalance:触发 → 回收分配 → 重新分配。处理逻辑阻塞越久,这段「暂停期」越疼。

步骤 6:对照 Spring 注解形态

@KafkaListener(topics = "coderoadmap.order-events", groupId = "coderoadmap-notify")
public void onMessage(String payload) {
    // 底层仍是 Consumer Group + poll 循环;幂等要求不变
}

细节留给「Spring Kafka」课。

讲清楚要点

  1. 因为组内一个分区同时只分配给一个消费者,所以并行上限 ≈ 分区数;多出来的实例空闲,只做故障备份。
  2. 因为offset 按「组 × 分区」提交到内部 Topic __consumer_offsets,所以换 group.id ≈ 新订阅者(按 reset 策略读);同组重启则从已提交位点续读。
  3. 因为「先处理再提交」在提交前崩溃会重复,所以这是 at-least-once——业务必须幂等;反过来「先提交再处理」会丢,关键业务禁止。
  4. 因为自动提交按时间间隔推位点(默认 5s),不管业务是否处理完,所以关键路径用 enable.auto.commit=false + 手动 commitSync/commitAsync。
  5. 因为rebalance 期间整个组的分区会被回收重分,所以监听逻辑避免超长阻塞:单次 poll 间隔受 max.poll.interval.ms 约束,超时会被踢出组再触发 rebalance,雪崩式恶化。
  6. 因为auto.offset.reset 只在 无已提交位点 时生效,所以「改成 earliest 却没从头读」通常是旧组还有位点——换组名或用 CLI 重置位点。

你可能会懵的地方

  1. 「为何我改了 earliest 还不从头?」 该组已有 committed offset,reset 不生效。换新 group 名验证,或用 kafka-consumer-groups.sh --reset-offsets --to-earliest --execute(生产慎用,先 --dry-run)。

  2. 「重复消费是不是 Kafka 坏了?」 正常现象。at-least-once 模型下提交窗口内的崩溃必然重复;用业务键/去重表解决(投递语义课),别幻想「它不重复」。

  3. 「poll 循环能不能睡很久?」 不能。max.poll.interval.ms(默认 5 分钟)内必须再次 poll,否则被判死亡、踢出组触发 rebalance。下游慢要异步化或分批处理,而不是在循环里长睡。

  4. 「commitSync 和 commitAsync 怎么选?」 commitSync 同步等待、可靠但阻塞;commitAsync 异步不阻塞但失败不自动重试(可能漏提交)。常见组合:循环里用 async 保吞吐,关闭/批末尾用 sync 兜底。

  5. 「为什么位点提交那么重要?」 位点 = 消费进度唯一凭证。提交早了丢消息(崩溃后从下一条开始),提交晚了重复消费(崩溃后从旧位点重来)。面试答「offset 提交时机」就是答这两种风险的权衡。

错法对照

错法问题正解
enable.auto.commit=true 且业务可能失败位点先行,业务失败也推进 → 丢消息手动提交;失败不 commit 或进重试/死信
通知与审计共用 group.id组内互斥,审计收不全一职责一 group
无幂等,赌「Kafka 不会重复」提交窗口崩溃必然重复默认按会重复设计,唯一键兜底
实例数远大于分区数期望线性加速超出部分空转分区数 ≥ 期望并行消费者数
处理里同步调 30s 的慢 HTTP触发 max.poll.interval 踢组 → rebalance 风暴拉取批量调小/异步化下游/调大 interval
// 错:先 commit 再处理 → 接近 at-most-once,崩溃丢消息
consumer.commitSync();
handleOrderEvent(r.value());

// 对:先处理再 commit → at-least-once,配合幂等
handleOrderEvent(r.value());
consumer.commitSync();

卡点急救

  1. 消费者收不到任何消息:按序查——Topic 名对不对(--list)、组是否已有到 latest 的位点(换新组试 --from-beginning 语义)、Broker 是否可达(docker compose ps)。
  2. 日志刷 Attempting to heartbeat failed / rebalance:处理太慢或 GC 太长导致心跳/拉取超时。调大 max.poll.interval.ms、减小 max.poll.records、优化处理耗时三选一或组合。
  3. 重启后消息重复处理一大段:上次是「处理完但没提交」就崩了(异步提交失败或强杀)。属预期行为,补幂等,而不是想方设法「让它不重复」。

面试常问

  • offset 提交时机有哪几种?各有什么问题? ①自动提交(定时推位点):最省事,但位点可能与业务进度脱节——提交后处理失败会丢,处理慢于间隔会重;②同步提交 commitSync:可靠、阻塞,配合「先处理后提交」= at-least-once;③异步提交 commitAsync:不阻塞但失败不重试,常与 sync 组合。核心结论:先处理再提交会重复(幂等兜底),先提交再处理会丢失(关键业务禁止)。

  • 什么是 rebalance?什么时候触发?有什么危害? Consumer Group 内分区所有权重新分配。触发:成员增减(上下线、崩溃、被踢)、订阅 Topic/分区数变化、组协调器故障转移。危害:rebalance 期间组内全部停止消费;处理慢被踢 → 再 balance → 再踢的雪崩;STABLE 时段靠 session.timeout.ms(心跳)与 max.poll.interval.ms(拉取节奏)调优,避免频繁触发。

  • 消费者数和分区数什么关系? 一个分区同一组内只能给一个消费者:消费者 ≤ 分区数时全员干活;消费者 > 分区数时空闲;想扩并行必须先扩分区。所以建 Topic 时分区数要按「目标并行度」预留。

自测三问

  1. 开两个相同 group.id 进程,如何用日志证明分区被分摊?
  2. 换成新 group.id + earliest,为什么又能从头消费?这验证了哪个概念?
  3. 举一种重复消费场景,并给出一种 MySQL 幂等方案(唯一键/去重表)。

小结一句话

消费者组 = 组内负载均衡 + 组间独立位点;手动提交意识、幂等设计、控制 rebalance 影响,是消费侧三件套。

延伸

  • Kafka 官方文档 — Consumer Configs
  • 下一课「Spring Kafka」:把这套 poll/commit 样板收敛成 @KafkaListener 注解,并补上错误处理与死信。
加载本节练习…
← 上一课生产者下一课 →Spring Kafka