大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
消费者组
这一课你将能做到
- 配置
group.id、enable.auto.commit=false、auto.offset.reset,用 Java Consumer 拉取消息并 处理成功后再commitSync - 画出「分区数 vs 实例数」与 rebalance 时暂停消费的图像,说明为什么处理逻辑要快且幂等
- 对照 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 Group | Redis 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」课。
讲清楚要点
- 因为组内一个分区同时只分配给一个消费者,所以并行上限 ≈ 分区数;多出来的实例空闲,只做故障备份。
- 因为offset 按「组 × 分区」提交到内部 Topic
__consumer_offsets,所以换group.id≈ 新订阅者(按 reset 策略读);同组重启则从已提交位点续读。 - 因为「先处理再提交」在提交前崩溃会重复,所以这是 at-least-once——业务必须幂等;反过来「先提交再处理」会丢,关键业务禁止。
- 因为自动提交按时间间隔推位点(默认 5s),不管业务是否处理完,所以关键路径用
enable.auto.commit=false+ 手动commitSync/commitAsync。 - 因为rebalance 期间整个组的分区会被回收重分,所以监听逻辑避免超长阻塞:单次 poll 间隔受
max.poll.interval.ms约束,超时会被踢出组再触发 rebalance,雪崩式恶化。 - 因为
auto.offset.reset只在 无已提交位点 时生效,所以「改成 earliest 却没从头读」通常是旧组还有位点——换组名或用 CLI 重置位点。
你可能会懵的地方
-
「为何我改了 earliest 还不从头?」 该组已有 committed offset,reset 不生效。换新 group 名验证,或用
kafka-consumer-groups.sh --reset-offsets --to-earliest --execute(生产慎用,先--dry-run)。 -
「重复消费是不是 Kafka 坏了?」 正常现象。at-least-once 模型下提交窗口内的崩溃必然重复;用业务键/去重表解决(投递语义课),别幻想「它不重复」。
-
「poll 循环能不能睡很久?」 不能。
max.poll.interval.ms(默认 5 分钟)内必须再次 poll,否则被判死亡、踢出组触发 rebalance。下游慢要异步化或分批处理,而不是在循环里长睡。 -
「commitSync 和 commitAsync 怎么选?」
commitSync同步等待、可靠但阻塞;commitAsync异步不阻塞但失败不自动重试(可能漏提交)。常见组合:循环里用 async 保吞吐,关闭/批末尾用 sync 兜底。 -
「为什么位点提交那么重要?」 位点 = 消费进度唯一凭证。提交早了丢消息(崩溃后从下一条开始),提交晚了重复消费(崩溃后从旧位点重来)。面试答「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();
卡点急救
- 消费者收不到任何消息:按序查——Topic 名对不对(
--list)、组是否已有到 latest 的位点(换新组试--from-beginning语义)、Broker 是否可达(docker compose ps)。 - 日志刷
Attempting to heartbeat failed / rebalance:处理太慢或 GC 太长导致心跳/拉取超时。调大max.poll.interval.ms、减小max.poll.records、优化处理耗时三选一或组合。 - 重启后消息重复处理一大段:上次是「处理完但没提交」就崩了(异步提交失败或强杀)。属预期行为,补幂等,而不是想方设法「让它不重复」。
面试常问
-
offset 提交时机有哪几种?各有什么问题? ①自动提交(定时推位点):最省事,但位点可能与业务进度脱节——提交后处理失败会丢,处理慢于间隔会重;②同步提交 commitSync:可靠、阻塞,配合「先处理后提交」= at-least-once;③异步提交 commitAsync:不阻塞但失败不重试,常与 sync 组合。核心结论:先处理再提交会重复(幂等兜底),先提交再处理会丢失(关键业务禁止)。
-
什么是 rebalance?什么时候触发?有什么危害? Consumer Group 内分区所有权重新分配。触发:成员增减(上下线、崩溃、被踢)、订阅 Topic/分区数变化、组协调器故障转移。危害:rebalance 期间组内全部停止消费;处理慢被踢 → 再 balance → 再踢的雪崩;STABLE 时段靠
session.timeout.ms(心跳)与max.poll.interval.ms(拉取节奏)调优,避免频繁触发。 -
消费者数和分区数什么关系? 一个分区同一组内只能给一个消费者:消费者 ≤ 分区数时全员干活;消费者 > 分区数时空闲;想扩并行必须先扩分区。所以建 Topic 时分区数要按「目标并行度」预留。
自测三问
- 开两个相同
group.id进程,如何用日志证明分区被分摊? - 换成新
group.id+earliest,为什么又能从头消费?这验证了哪个概念? - 举一种重复消费场景,并给出一种 MySQL 幂等方案(唯一键/去重表)。
小结一句话
消费者组 = 组内负载均衡 + 组间独立位点;手动提交意识、幂等设计、控制 rebalance 影响,是消费侧三件套。
延伸
- Kafka 官方文档 — Consumer Configs
- 下一课「Spring Kafka」:把这套 poll/commit 样板收敛成
@KafkaListener注解,并补上错误处理与死信。