Kafka 中的 Consumer Group 与 Rebalance 在 Spring Boot 中的应用
1. 概述
当运行多个相同服务的实例从 Kafka 读取时,你需要知道哪个消息由哪个实例处理。Kafka 通过 consumer group 和 rebalance 机制来解决这个问题。
本文解释了 Kafka 如何在组内分配分区,以及何时发生 rebalance。之后,你将配置 Spring Boot 使 rebalance 发生得更少,并确保不会丢失或重复数据。文末有一个使用 Embedded Kafka 的测试来验证分区分配方式。
2. Consumer Group 如何工作
consumer group 是具有相同 group.id 的一组 consumer。Kafka 确保:
- 主题的每个分区在任何时刻都分配给组内恰好一个 consumer。
- 一个 consumer 可以接收多个分区。
- 不同的组独立读取。每个组都有各自的 offset。
假设 orders 主题有 4 个分区:
Group "billing" 中有 2 个 consumer:
consumer-A -> orders-0, orders-1
consumer-B -> orders-2, orders-3
Group "billing" 中有 5 个 consumer:
consumer-A -> orders-0
consumer-B -> orders-1
consumer-C -> orders-2
consumer-D -> orders-3
consumer-E -> (闲置,没有分区)
重要的后果是分区数限制了一个组的最大并行度。添加超过分区数的 consumer 不会加快处理速度。
协调由一个 broker 担任,称为 group coordinator。Consumer 定期向 coordinator 发送 heartbeat 来表示它仍然活着。
2.1. 依赖
添加 Spring for Apache Kafka。Spring Boot 3.x 已经管理了版本:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
2.2. 一个简单的 Listener
@Component
public class OrderListener {
private static final Logger log = LoggerFactory.getLogger(OrderListener.class);
@KafkaListener(topics = "orders", groupId = "billing", concurrency = "2")
public void onOrder(ConsumerRecord<String, String> record) {
log.info("partition={} offset={} order={}",
record.partition(), record.offset(), record.value());
}
}
groupId = "billing"将 consumer 放入billing组。concurrency = "2"在同一实例中创建 2 个 consumer(2 个线程)。如果运行 2 个服务实例,该组将有 4 个 consumer,每个 consumer 获得 1 个分区。
启动时的日志显示分配结果:
billing: partitions assigned: [orders-0, orders-1]
billing: partitions assigned: [orders-2, orders-3]
3. Rebalance 是什么以及何时发生
Rebalance 是组在其成员之间重新分配分区的过程。它在以下情况下发生:
- 新 consumer 加入,例如你扩展了 pod 或部署了新实例。
- Consumer 正常离开,例如通过调用
close()进行关闭。 - Consumer 被认为已死亡:coordinator 在
session.timeout.ms内没有收到 heartbeat。 - Consumer 处理过慢:两次
poll()调用之间的间隔超过max.poll.interval.ms。Consumer 自动离开组。 - 主题变化:增加分区数,或有新主题与已订阅的模式匹配。
问题是在旧的 eager 协议中,所有 consumer 必须先归还所有分区,然后才能重新分配。整个组在此期间停止处理,这种现象通常称为 “stop-the-world”。对于大型组或滚动部署,rebalance 可能会反复发生。
4. 重要的超时参数
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: billing
enable-auto-commit: false
max-poll-records: 100
properties:
session.timeout.ms: 45000
heartbeat.interval.ms: 3000
max.poll.interval.ms: 300000
| 参数 | 含义 |
|---|---|
session.timeout.ms |
没有 heartbeat 的最大时间,超过该时间后 consumer 将被移除 |
heartbeat.interval.ms |
Heartbeat 发送周期,通常 ≤ 1/3 的 session.timeout.ms |
max.poll.interval.ms |
两次 poll() 调用之间的最大间隔 |
max-poll-records |
每次 poll() 的最大记录数 |
Heartbeat 从单独的后台线程发送。因此,即使主线程繁忙,consumer 仍然在 session.timeout.ms 内保持 “活着” 状态。但是 max.poll.interval.ms 只测量处理线程。如果一个批次处理时间过长,consumer 仍然会被踢出组。
日志中的典型错误看起来像这样:
Member consumer-billing-1 sending LeaveGroup request to coordinator due to
consumer poll timeout has expired. This means the time between subsequent
calls to poll() was longer than the configured max.poll.interval.ms ...
CommitFailedException: Commit cannot be completed since the group has already rebalanced
需要满足的公式:
max-poll-records × 处理 1 条记录的时间 < max.poll.interval.ms
注意: 不要通过将
max.poll.interval.ms增加到几小时来“修复”。真正挂起的 consumer 会长时间持有分区而没有人处理。减少max-poll-records,或将繁重任务移出 consume 线程。
5. 减少 Rebalance 的影响
5.1. Cooperative Rebalancing
使用 CooperativeStickyAssignor,只有真正需要转移的分区才会被收回。其他 consumer 在 rebalance 进行时继续处理自己的分区。
spring:
kafka:
consumer:
properties:
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
示例:组中有 A(orders-0..3),然后 B 加入。
Eager: A 归还全部 4 个分区 -> 重新分配:A {0,1}, B {2,3}
Cooperative: A 只归还 {2,3},继续处理 {0,1} -> B 获得 {2,3}
注意: 不能在一次部署中直接将运行中的组从 eager 切换到 cooperative。需要进行两次滚动部署。第一次声明两个 assignor,例如
CooperativeStickyAssignor,RangeAssignor。第二次才移除RangeAssignor。
从 Kafka 4.0 起,新的 rebalance 协议(KIP-848)已 GA。你可以通过 group.protocol=consumer 来启用它。此时分配由 broker 执行,rebalance 默认具有增量特性,客户端的 partition.assignment.strategy 和 session.timeout.ms 参数不再使用。如果你的集群支持此功能,这是首先应该考虑的选项。
5.2. 静态成员
每次 pod 重启时,consumer 获得一个新的 member.id,这会触发两次 rebalance(离开然后重新加入)。Static membership 为 consumer 分配一个固定的标识符:
spring:
kafka:
consumer:
properties:
group.instance.id: billing-${HOSTNAME}
session.timeout.ms: 60000
如果 consumer 在 session.timeout.ms 内回到,coordinator 会直接将相同的分区分配回来,无需 rebalance。这种方式非常适合 Kubernetes StatefulSet,其中 HOSTNAME 是稳定的(billing-0、billing-1 等)。
注意:
group.instance.id在组内必须唯一。当concurrency > 1时,Spring Kafka 会自动为每个 consumer 子实例添加后缀-n。如果两个实例使用相同的 ID,后进入的实例将“踢出”前一个实例(FencedInstanceIdException)。不要对具有随机 pod 名称的 Deployment 使用 static membership,因为你不会获得任何好处。
6. Rebalance 时的 Offset 处理
rebalance 的最大风险是重复处理。Consumer A 已处理完 record 但尚未提交,分区转移给 B,B 从最后提交的 offset 重新读取。
Spring Kafka 默认在每个批次后提交(AckMode.BATCH),并在分区被收回前自动提交待处理的 offset。如果需要干预,例如刷新缓冲区或记录日志,可以使用 ConsumerAwareRebalanceListener:
@Component
public class OrderRebalanceListener implements ConsumerAwareRebalanceListener {
private static final Logger log = LoggerFactory.getLogger(OrderRebalanceListener.class);
@Override
public void onPartitionsRevokedBeforeCommit(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
log.info("即将失去分区 {},刷新内存中的数据", partitions);
}
@Override
public void onPartitionsRevokedAfterCommit(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
log.info("已提交并归还分区 {}", partitions);
}
@Override
public void onPartitionsAssigned(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
log.info("收到分区 {}", partitions);
}
@Override
public void onPartitionsLost(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
log.warn("丧失分区 {},未能及时提交", partitions);
}
}
onPartitionsRevokedBeforeCommit在容器提交 offset 前运行。这是完成未完成工作的地方。onPartitionsRevokedAfterCommit在 offset 已提交后运行。onPartitionsLost在 consumer 被踢出组时调用,例如超出max.poll.interval.ms。此时分区可能已属于另一个 consumer,所以不要提交。
向容器工厂注册 listener:
@Configuration
public class KafkaConfig {
@Bean
ConcurrentKafkaListenerContainerFactory<Object, Object> kafkaListenerContainerFactory(
ConcurrentKafkaListenerContainerFactoryConfigurer configurer,
ConsumerFactory<Object, Object> consumerFactory,
OrderRebalanceListener rebalanceListener) {
var factory = new ConcurrentKafkaListenerContainerFactory<Object, Object>();
configurer.configure(factory, consumerFactory);
factory.getContainerProperties().setConsumerRebalanceListener(rebalanceListener);
return factory;
}
}
configurer.configure(...) 保留 application.yml 中的所有配置。下一行只是添加 rebalance listener。
注意: 无论配置多好,Kafka 仅对普通 consumer 提供 at-least-once 保证。使你的处理幂等,例如在调用
PaymentService之前检查orderId是否已支付。不要假设 rebalance 永远不会导致重复。
7. 测试分区分配
以下测试使用 @EmbeddedKafka 来验证同一组中的两个 consumer 均匀分配 4 个分区:
@EmbeddedKafka(partitions = 4, topics = "orders")
class ConsumerGroupAssignmentTest {
@Test
void twoConsumersInSameGroupShareAllPartitions(EmbeddedKafkaBroker broker) {
Map<String, Object> props = KafkaTestUtils.consumerProps("billing", "false", broker);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
try (var consumerA = new KafkaConsumer<String, String>(props);
var consumerB = new KafkaConsumer<String, String>(props)) {
consumerA.subscribe(List.of("orders"));
consumerB.subscribe(List.of("orders"));
long deadline = System.currentTimeMillis() + 30_000;
while (System.currentTimeMillis() < deadline
&& !(consumerA.assignment().size() == 2 && consumerB.assignment().size() == 2)) {
consumerA.poll(Duration.ofMillis(200));
consumerB.poll(Duration.ofMillis(200));
}
Set<TopicPartition> all = new HashSet<>(consumerA.assignment());
all.addAll(consumerB.assignment());
assertThat(consumerA.assignment()).hasSize(2);
assertThat(consumerB.assignment()).hasSize(2)
.doesNotContainAnyElementsOf(consumerA.assignment());
assertThat(all).hasSize(4);
}
}
}
说明:
@EmbeddedKafka启动内存中的 broker,创建具有 4 个分区的orders主题。Broker 直接作为测试方法的参数注入。- 两个
KafkaConsumer共享group.id = "billing"。 - 循环在两个 consumer 上交替调用
poll(),因为 consumer 只在poll()内参与 rebalance。如果只有 consumer A 进行 poll,rebalance 将无法完成。两个 consumer 运行在同一线程上但是两个独立对象,所以不违反 “一个 consumer 只能被一个线程使用” 的规则。 - 断言检查每个 consumer 有 2 个分区、彼此不重叠,且总共覆盖了 4 个分区。
这也是测试不同 partition.assignment.strategy 的好方法。添加第三个 consumer,关闭一个 consumer,然后观察 assignment() 的变化。
8. 常见错误
- Consumer 数量超过分区数:多余的 consumer 将闲置。增加
concurrency不会加快系统速度。 - Listener 中的同步处理过长:超过
max.poll.interval.ms会导致 rebalance 循环。Consumer 被踢出,回到,接收相同批次,然后再次被踢出。 - 在
onPartitionsLost中手动提交:导致CommitFailedException,或更糟的是覆盖新 consumer 的 offset。 - 在两个不同的服务之间共享
group.id:两个服务将“分享”消息而不是各自接收完整消息。每个独立服务应该有自己的组。 - 在大型组上使用 eager 协议进行滚动部署:每次 pod 重启都会导致两次 stop-the-world。使用 cooperative、static membership 或 Kafka 4.x 的新协议。
9. 总结
Consumer group 允许你在水平方向上扩展 Kafka 读取,其上限是分区数。Rebalance 是这种灵活性的代价。当成员加入、离开、死亡或处理过慢时,它就会发生。
为了减少 rebalance 的危害,你应该做以下事情:
- 合理配置
max.poll.interval.ms和max-poll-records。 - 使用
CooperativeStickyAssignor,或在 Kafka 4.x 上使用group.protocol=consumer。 - 在基础设施允许的情况下启用 static membership。
- 通过
ConsumerAwareRebalanceListener处理分区生命周期。
最后,始终设计幂等处理,因为 at-least-once 是你需要接受的默认保证。