外观
消费组与再均衡:为什么会重复消费
消费组解决两件事:并行消费和位移管理。代价是再均衡——每次成员变化都会暂停消费、重新分配分区,这段时间最容易出重复和超时。
场景
- 消费慢,想加实例。
- 日志里频繁出现
Revoking previously assigned partitions。 - 明明处理了,重启后又收到同样的消息。
一、模型
text
topic order (6 分区)
└── group order-group
├── consumer-1 → 分区 0,1
├── consumer-2 → 分区 2,3
└── consumer-3 → 分区 4,5两条铁律:
- 同一分区在同一时刻只能被同组的一个消费者消费。
- 消费者数 > 分区数时,多出来的消费者完全空闲(不是分担负载)。
不同 group 之间互不影响,各自维护一份位移。
bash
/opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server 10.0.0.31:9092 \
--describe --group order-group --statetext
GROUP COORDINATOR (ID) ASSIGNMENT-STRATEGY STATE #MEMBERS
order-group 10.0.0.32:9092 (2) range Stable 3状态机:
| 状态 | 含义 |
|---|---|
Empty | 组存在但没有成员 |
PreparingRebalance | 有成员变动,等所有人重新加入 |
CompletingRebalance | 等 leader 下发分配方案 |
Stable | 正常工作 |
Dead | 组被删除 |
PreparingRebalance 期间所有消费者停止消费。这就是"一加机器就全组卡几秒"的原因。
二、三个超时参数
| 参数 | 默认值 | 管什么 |
|---|---|---|
session.timeout.ms | 45000 | 多久没心跳就认为成员死了 |
heartbeat.interval.ms | 3000 | 心跳间隔,通常是 session 的 1/3 |
max.poll.interval.ms | 300000 | 两次 poll() 之间允许的最大间隔 |
前两个是心跳(后台线程自动发),第三个是业务处理时间。
max.poll.interval 超时是最常见的 rebalance 原因
处理一批消息超过 5 分钟 → 协调器认为你死了 → 踢出组 → 触发 rebalance → 你处理完回来发现分区没了 → 提交位移失败 → 消息重复。
三个解法:
- 减小
max.poll.records(默认 500,降到 100 或更小)。 - 加大
max.poll.interval.ms(但要考虑真卡死时的恢复时间)。 - 把耗时处理异步化:
poll()拿到就快速入本地队列,另起线程池处理——此时必须自己控制提交位移,不能用自动提交。
session.timeout.ms 不要设太小。3 台 broker 的集群,一次 Full GC 停顿 20 秒很正常,设 10 秒就会误判成员死亡。
三、再均衡的触发条件
| 触发 | 说明 |
|---|---|
| 消费者加入 | 扩容、重启 |
| 消费者离开 | 停机、崩溃、session 超时 |
max.poll.interval 超时 | 处理太慢 |
| 订阅的 topic 变化 | subscribe() 参数变了 |
| 分区数变化 | 加分区 |
| 协调器迁移 | 该 group 的 coordinator broker 挂了 |
四、eager vs cooperative
| 策略 | 行为 | 影响 |
|---|---|---|
RangeAssignor(默认,eager) | 全部撤销再重新分配 | 整个组暂停 |
CooperativeStickyAssignor | 只撤销需要迁移的分区 | 大部分消费者不中断 |
开启协作式:
java
props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor");yaml
# Spring Boot
spring:
kafka:
consumer:
properties:
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor切换策略要滚动重启两次
协作式和老协议不兼容。组里同时存在两种策略时,Kafka 会选择双方都支持的 RangeAssignor,等于没切。
正确流程:先全部滚动重启一次(仍在 Range)→ 再改配置全部滚动重启一次(此时都用 Cooperative)。中间会有两次 rebalance,安排在低峰。
五、静态成员:避免重启触发 rebalance
java
props.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, "order-consumer-1");group.instance.id 让协调器记住"这个成员只是短暂离线"。在 session.timeout.ms 内重启,不触发 rebalance,直接拿回原分区。
要求:
- 每个实例的 ID 必须唯一且稳定(用 Pod 名或配置项,不要用随机值)。
- 只适合滚动重启,不适合弹性伸缩(新实例的 ID 不在名单里,照样触发 rebalance)。
六、位移提交的三种姿势
java
// 1. 自动提交(默认)——省事,但不可控
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 5000);
// 2. 同步提交——处理完再提交,可靠但慢
consumer.commitSync();
// 3. 异步提交——快,但失败时不知道丢了哪条
consumer.commitAsync((offsets, e) -> {
if (e != null) log.error("commit failed", e);
});生产推荐:
java
try {
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> r : records) {
handle(r);
}
consumer.commitSync(Duration.ofSeconds(5));
}
} finally {
consumer.commitSync(); // 停机前最后提交一次
consumer.close();
}如果批次大、提交频繁,用"异步提交 + 定期同步":
java
long lastCommit = System.currentTimeMillis();
for (ConsumerRecord<String, String> r : records) {
handle(r);
if (System.currentTimeMillis() - lastCommit > 1000) {
consumer.commitSync();
lastCommit = System.currentTimeMillis();
}
}
consumer.commitAsync();提交了但没处理完 = 丢消息
commitSync() 提交的是下一次要读的位置。如果先提交后处理,处理失败时这条消息永远不会再来。顺序必须是:拉取 → 处理 → 提交。
七、重复消费的四个真实原因
1. 处理完、提交前崩溃
text
poll → handle(100 条) → commit ← 在这一步之前进程被杀重启后从上次提交位置开始,这 100 条重新消费。这是 at-least-once 的固有代价,只能靠业务幂等解决。
2. rebalance 时分区被抢走
协作式能减少影响,但迁移的分区仍会回退到上次提交位置。正在处理的那几条会重复。
对策:处理时间不要超过 max.poll.interval,尽量让单批处理时间短。
3. 自动提交的滞后期
auto.commit.interval.ms=5000,且在 poll() 里提交。你处理完的这批还没提交,下一批就来了;一次 rebalance 就会回退。
4. 位移动了但下游失败
Kafka 提交成功、写数据库失败、事务回滚 —— Kafka 侧已经认为消费完了。
对策:consume → process → commit 必须放在同一个业务事务里,或者用幂等写。
sql
-- 幂等写:重复消费不会产生重复数据
INSERT INTO orders (id, amount) VALUES (?, ?)
ON CONFLICT (id) DO NOTHING;八、消费者配置模板
yaml
spring:
kafka:
bootstrap-servers: 10.0.0.31:9092,10.0.0.32:9092,10.0.0.33:9092
consumer:
group-id: order-group
enable-auto-commit: false
auto-offset-reset: earliest
max-poll-records: 100
properties:
session.timeout.ms: 45000
heartbeat.interval.ms: 3000
max.poll.interval.ms: 300000
isolation.level: read_committed
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
listener:
ack-mode: manual_immediate
concurrency: 3spring.kafka.listener.concurrency 是每个实例起的消费线程数。总并行度 = 实例数 × concurrency,应 ≤ 分区数。
九、优雅停机
停机时不 close() 会导致:
- 位移没提交,重启后重复消费。
- 协调器要等
session.timeout才发现你走了,期间 rebalance 卡住。
java
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
running = false;
consumer.wakeup(); // 打断正在进行的长 poll
}));Spring Boot 里:
yaml
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
server:
shutdown: gracefuljava
@KafkaListener(topics = "order", groupId = "order-group")
public void onMessage(ConsumerRecord<String, String> r, Acknowledgment ack) {
handle(r);
ack.acknowledge();
}ack-mode: manual_immediate + ack.acknowledge() 表示"这条处理完立刻提交"。要比 record 模式更精确。
十、踩过的坑
| 现象 | 根因 | 处置 |
|---|---|---|
| 加了实例但吞吐没涨 | 实例数 > 分区数 | 扩分区(注意 key 顺序) |
| 日志反复出现 rebalance | 处理超 max.poll.interval | 减小 max.poll.records 或异步化 |
| 每次发版重复消费几分钟的量 | 停机没提交位移 | 加 shutdown hook,优雅停机 |
消费组一直是 PreparingRebalance | 有成员起不来/一直超时 | 看 --describe --state --members |
| 重启后从头消费 | auto.offset.reset=earliest 且组没有提交记录 | 确认 group 名没变,或先提交一次 |
| 部分消费者拿不到分区 | 分配策略不均 / 消费者数超过分区 | 换 CooperativeStickyAssignor |
CommitFailedException | rebalance 期间提交 | 处理完成后立即提交,缩短处理时间 |
| 改了策略但没生效 | 组内策略不一致,回退到 Range | 分两次滚动重启 |