Consumer group et rebalance dans Kafka avec Spring Boot
1. Vue d’ensemble
Lorsque vous exécutez plusieurs instances du même service lisant depuis Kafka, vous devez savoir quel message est traité par quelle instance. Kafka résout ce problème grâce aux consumer groups et au mécanisme de rebalance.
Cet article explique comment Kafka divise les partitions au sein d’un groupe et les situations qui causent un rebalance. Ensuite, vous configurerez Spring Boot pour que les rebalances se produisent moins souvent et ne causent pas de perte ou de duplication de données. À la fin, il y a un test avec Embedded Kafka pour vérifier comment les partitions sont distribuées.
2. Comment fonctionne un consumer group
Un consumer group est un ensemble de consumers ayant le même group.id. Kafka garantit :
- Chaque partition du topic est assignée à exactement un consumer du groupe à tout moment.
- Un consumer peut recevoir plusieurs partitions.
- Les différents groupes lisent indépendamment. Chaque groupe a ses propres offsets.
Supposons que le topic orders a 4 partitions :
Groupe "billing" avec 2 consumers :
consumer-A -> orders-0, orders-1
consumer-B -> orders-2, orders-3
Groupe "billing" avec 5 consumers :
consumer-A -> orders-0
consumer-B -> orders-1
consumer-C -> orders-2
consumer-D -> orders-3
consumer-E -> (inactif, sans partition)
Une conséquence importante est que le nombre de partitions limite le parallélisme maximal d’un groupe. Ajouter des consumers au-delà du nombre de partitions n’aide pas à accélérer le traitement.
La coordination est effectuée par un broker, appelé group coordinator. Le consumer envoie régulièrement un heartbeat au coordinator pour signaler qu’il est actif.
2.1. Dépendance
Ajoutez Spring for Apache Kafka. Spring Boot 3.x gère déjà la version :
<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. Un listener simple
@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"place le consumer dans le groupebilling.concurrency = "2"crée 2 consumers (2 threads) dans la même instance. Si vous exécutez 2 instances du service, le groupe aura 4 consumers, chaque consumer recevant 1 partition.
Les logs au démarrage montrent le résultat de la distribution :
billing: partitions assigned: [orders-0, orders-1]
billing: partitions assigned: [orders-2, orders-3]
3. Qu’est-ce qu’un rebalance et quand se produit-il
Rebalance est le processus par lequel le groupe redistribue les partitions parmi ses membres. Cela se produit lorsque :
- Un nouveau consumer rejoint, par exemple lorsque vous augmentez le nombre de pods ou déployez une nouvelle instance.
- Un consumer se retire normalement, par exemple lors d’un arrêt appelant
close(). - Un consumer est considéré comme mort : le coordinator ne reçoit pas de heartbeat pendant
session.timeout.ms. - Un consumer traite trop lentement : l’intervalle entre deux appels
poll()dépassemax.poll.interval.ms. Le consumer se retire du groupe de lui-même. - Le topic change : augmentation du nombre de partitions, ou un nouveau topic correspond au pattern auquel vous êtes abonné.
Le problème avec le protocole eager ancien est que chaque consumer doit rendre toutes ses partitions avant d’être réassigné. Tout le groupe arrête le traitement pendant ce temps, ce phénomène est généralement appelé « stop-the-world ». Avec un grand groupe ou un déploiement de type rolling, les rebalances peuvent se produire continuellement.
4. Paramètres de timeout importants
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
| Paramètre | Signification |
|---|---|
session.timeout.ms |
Temps maximum sans heartbeat avant que le consumer soit exclu |
heartbeat.interval.ms |
Intervalle d’envoi des heartbeats, généralement ≤ 1/3 de session.timeout.ms |
max.poll.interval.ms |
Intervalle maximal entre deux appels poll() |
max-poll-records |
Nombre maximal de records à chaque appel poll() |
Les heartbeats sont envoyés par un thread d’arrière-plan séparé. Ainsi, le consumer reste « vivant » selon session.timeout.ms même si le thread principal est occupé. Cependant, max.poll.interval.ms ne mesure que le thread de traitement. Si un batch prend trop longtemps à traiter, le consumer est quand même expulsé du groupe.
Une erreur typique dans les logs ressemble à ceci :
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
La formule à satisfaire est :
max-poll-records × temps de traitement de 1 record < max.poll.interval.ms
Note : Ne « guérissez » pas en augmentant
max.poll.interval.msà plusieurs heures. Un consumer vraiment bloqué tiendra la partition pendant longtemps sans traitement. Réduisez plutôtmax-poll-records, ou déplacez les tâches lourdes en dehors du flux de consommation.
5. Réduire l’impact du rebalance
5.1. Cooperative rebalancing
Avec CooperativeStickyAssignor, seules les partitions qui ont réellement besoin d’être transférées sont révoquées. Les autres consumers continuent à traiter leurs partitions pendant que le rebalance se déroule.
spring:
kafka:
consumer:
properties:
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
Exemple : le groupe a A (orders-0..3), puis B rejoint.
Eager: A rend les 4 partitions -> redistribution : A {0,1}, B {2,3}
Cooperative: A rend seulement {2,3}, continue à traiter {0,1} -> B reçoit {2,3}
Note : Vous ne devez pas passer directement un groupe en cours d’exécution de eager à cooperative en un seul déploiement. Vous avez besoin d’un déploiement rolling deux fois. Première fois, déclarez les deux assignors, par exemple
CooperativeStickyAssignor,RangeAssignor. La seconde fois seulement, retirezRangeAssignor.
À partir de Kafka 4.0, le nouveau protocole de rebalance (KIP-848) est GA. Vous l’activez avec group.protocol=consumer. Ensuite, la distribution est effectuée par le broker, le rebalance par défaut est déjà progressif, et les paramètres partition.assignment.strategy et session.timeout.ms côté client ne sont plus utilisés. Si votre cluster le supporte, c’est l’option à considérer en premier.
5.2. Static membership
Chaque fois que le pod redémarre, le consumer reçoit un nouveau member.id, et cela déclenche un rebalance deux fois (quitter puis rejoindre). Static membership assigne un identifiant fixe au consumer :
spring:
kafka:
consumer:
properties:
group.instance.id: billing-${HOSTNAME}
session.timeout.ms: 60000
Si le consumer revient dans le délai session.timeout.ms, le coordinator réassigne exactement les mêmes partitions sans rebalance. C’est particulièrement adapté aux StatefulSets Kubernetes, où HOSTNAME est stable (billing-0, billing-1…).
Note :
group.instance.iddoit être unique dans le groupe. Avecconcurrency > 1, Spring Kafka ajoute automatiquement un suffixe-nà chaque sous-consumer. Si deux instances utilisent le même ID, l’instance qui arrive après « expulsera » la précédente (FencedInstanceIdException). N’utilisez pas static membership avec un Deployment ayant des noms de pods aléatoires, car vous n’en tirerez aucun bénéfice.
6. Gestion des offsets lors du rebalance
Le plus grand risque du rebalance est la duplication du traitement. Le consumer A a fini de traiter un record mais ne l’a pas encore commité, la partition est transférée à B, et B lit à partir du dernier offset commité.
Spring Kafka commite par défaut après chaque batch (AckMode.BATCH) et commite automatiquement les offsets en attente avant que la partition soit révoquée. Si vous avez besoin d’intervenir, par exemple pour vider un buffer ou écrire un log, vous utilisez 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("Perte imminente de partition {}, vidage des données en mémoire", partitions);
}
@Override
public void onPartitionsRevokedAfterCommit(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
log.info("Offset commité et partition {} restituée", partitions);
}
@Override
public void onPartitionsAssigned(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
log.info("Partition {} reçue", partitions);
}
@Override
public void onPartitionsLost(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
log.warn("Partition {} perdue sans temps de commiter", partitions);
}
}
onPartitionsRevokedBeforeCommits’exécute avant que le container ne commite l’offset. C’est le moment de terminer le travail en attente.onPartitionsRevokedAfterCommits’exécute après que l’offset a été commité.onPartitionsLostest appelé quand le consumer est expulsé du groupe, par exemple en raison du dépassement demax.poll.interval.ms. À ce stade, la partition peut appartenir à un autre consumer, donc ne commitez pas.
Enregistrez le listener dans la container factory :
@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(...) préserve toute la configuration d’application.yml. La ligne suivante ajoute simplement le listener de rebalance.
Note : Peu importe la qualité de la configuration, Kafka ne garantit que at-least-once pour les consumers normaux. Rendez votre traitement idempotent, par exemple en vérifiant si
orderIda déjà été payé avant d’appelerPaymentService. Ne supposez pas que le rebalance ne causera jamais de duplication.
7. Test de la distribution des partitions
Le test suivant utilise @EmbeddedKafka pour vérifier que deux consumers du même groupe partagent équitablement les 4 partitions :
@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);
}
}
}
Explication :
@EmbeddedKafkadémarre un broker en mémoire, crée le topicordersavec 4 partitions. Le broker est injecté directement en tant que paramètre de la méthode de test.- Deux
KafkaConsumerpartagentgroup.id = "billing". - La boucle appelle
poll()alternativement sur les deux consumers, car le consumer n’effectue le rebalance que danspoll(). Si seul le consumer A poll, le rebalance ne se complètera pas. Les deux consumers s’exécutent sur le même thread mais sont deux objets séparés, donc cela ne viole pas la règle « un consumer ne doit être utilisé que par un thread ». - Les assertions vérifient que chaque consumer a 2 partitions, ne se chevauchent pas, et que toutes les 4 partitions sont couvertes en total.
C’est aussi une bonne façon de tester différentes partition.assignment.strategy. Essayez d’ajouter un troisième consumer, de fermer un consumer, puis d’observer comment assignment() change.
8. Erreurs courantes
- Plus de consumers que de partitions : les consumers en excès resteront inactifs. Augmenter
concurrencyn’accélère pas le système. - Traitement synchrone trop long dans le listener : dépasser
max.poll.interval.mscause une boucle de rebalance. Le consumer est expulsé, revient, reçoit le même batch, puis est à nouveau expulsé. - Commit manuel dans
onPartitionsLost: cause uneCommitFailedException, ou pire, écrase l’offset du nouveau consumer. - Utiliser le même
group.idpour deux services différents : les deux services vont se partager les messages au lieu que chaque côté en reçoive. Chaque service indépendant doit avoir son propre groupe. - Déploiement rolling avec protocole eager sur un grand groupe : chaque redémarrage de pod cause deux arrêts complets. Utilisez cooperative, static membership ou le nouveau protocole de Kafka 4.x.
9. Conclusion
Consumer group vous permet de scaler la lecture depuis Kafka horizontalement, avec comme limite supérieure le nombre de partitions. Le rebalance est le prix à payer pour cette flexibilité. Il se produit lorsqu’un membre entre, sort, meurt ou traite trop lentement.
Pour que le rebalance cause moins de dégâts, vous devriez faire ce qui suit :
- Configurez correctement
max.poll.interval.msetmax-poll-records. - Utilisez
CooperativeStickyAssignor, ougroup.protocol=consumersur Kafka 4.x. - Activez static membership quand l’infrastructure le permet.
- Gérez le cycle de vie des partitions via
ConsumerAwareRebalanceListener.
Enfin, concevez toujours votre traitement comme idempotent, car at-least-once est la garantie par défaut que vous devez accepter.