Consumer group y rebalance en Kafka con Spring Boot

1. Descripción general

Cuando ejecutas múltiples instancias del mismo servicio leyendo de Kafka, necesitas saber qué mensaje es procesado por qué instancia. Kafka resuelve esto mediante consumer groups y el mecanismo de rebalance.

Este artículo explica cómo Kafka asigna particiones dentro de un grupo y en qué situaciones ocurre el rebalance. Luego configurarás Spring Boot para que el rebalance sea menos frecuente y no pierda ni duplique datos. Al final hay un test con Embedded Kafka para verificar cómo se asignan las particiones.

2. Cómo funciona el consumer group

Un consumer group es un conjunto de consumidores que comparten el mismo group.id. Kafka garantiza:

  • Cada partición del topic se asigna a exactamente un consumidor del grupo en cualquier momento.
  • Un consumidor puede recibir múltiples particiones.
  • Grupos diferentes leen de forma independiente. Cada grupo tiene su propio offset.

Supongamos que el topic orders tiene 4 particiones:

Grupo "billing" con 2 consumidores:
  consumer-A -> orders-0, orders-1
  consumer-B -> orders-2, orders-3

Grupo "billing" con 5 consumidores:
  consumer-A -> orders-0
  consumer-B -> orders-1
  consumer-C -> orders-2
  consumer-D -> orders-3
  consumer-E -> (ocioso, sin particiones)

Una consecuencia importante es que el número de particiones limita el paralelismo máximo de un grupo. Añadir consumidores más allá del número de particiones no ayuda a procesar más rápido.

La coordinación la realiza un broker, denominado group coordinator. El consumidor envía heartbeats regularmente al coordinador para indicar que sigue activo.

2.1. Dependencia

Añade Spring for Apache Kafka. Spring Boot 3.x gestiona la versión automáticamente:

<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("partición={} offset={} orden={}",
                record.partition(), record.offset(), record.value());
    }
}
  • groupId = "billing" coloca el consumidor en el grupo billing.
  • concurrency = "2" crea 2 consumidores (2 threads) en la misma instancia. Si ejecutas 2 instancias del servicio, el grupo tendrá 4 consumidores, cada uno recibiendo 1 partición.

El log al iniciar muestra el resultado de la asignación:

billing: particiones asignadas: [orders-0, orders-1]
billing: particiones asignadas: [orders-2, orders-3]

3. Qué es rebalance y cuándo ocurre

Rebalance es el proceso donde el grupo redistribuye las particiones entre sus miembros. Ocurre cuando:

  1. Se une un consumidor nuevo, por ejemplo escalas más pods o despliegas una nueva instancia.
  2. Un consumidor se va normalmente, por ejemplo al apagar y llamar a close().
  3. Un consumidor se considera muerto: el coordinador no recibe heartbeat en session.timeout.ms.
  4. Un consumidor procesa demasiado lentamente: el intervalo entre dos llamadas a poll() excede max.poll.interval.ms. El consumidor se retira del grupo automáticamente.
  5. El topic cambia: aumenta el número de particiones, o hay un nuevo topic que coincide con el patrón suscrito.

El problema es que en el protocolo eager antiguo, cada consumidor debe devolver todas sus particiones antes de ser reasignadas. Todo el grupo deja de procesar durante este tiempo, un fenómeno frecuentemente llamado “stop-the-world”. Con grupos grandes o despliegues tipo rolling, el rebalance puede repetirse continuamente.

4. Parámetros de timeout importantes

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
Parámetro Significado
session.timeout.ms Tiempo máximo sin heartbeat antes de que el consumidor sea expulsado
heartbeat.interval.ms Intervalo de envío de heartbeat, usualmente ≤ 1/3 de session.timeout.ms
max.poll.interval.ms Intervalo máximo entre dos llamadas a poll()
max-poll-records Número máximo de registros por cada poll()

El heartbeat se envía desde un thread de fondo separado. Por lo tanto, el consumidor sigue “vivo” según session.timeout.ms incluso cuando el thread principal está ocupado. Sin embargo, max.poll.interval.ms solo mide el thread de procesamiento. Si un lote tarda demasiado en procesarse, el consumidor aún será expulsado del grupo.

El error típico en el log se ve así:

El miembro consumer-billing-1 envía solicitud LeaveGroup al coordinador porque
el timeout de poll del consumidor ha expirado. Esto significa que el tiempo entre
llamadas consecutivas a poll() fue mayor que el max.poll.interval.ms configurado ...
CommitFailedException: No se puede completar el commit ya que el grupo ha hecho rebalance

La fórmula que debe satisfacerse:

max-poll-records × tiempo de procesamiento de 1 registro  <  max.poll.interval.ms

Nota: No intentes “arreglarlo” aumentando max.poll.interval.ms a varias horas. Un consumidor realmente congelado mantendrá las particiones durante mucho tiempo sin que nadie las procese. Reduce max-poll-records, o mueve las tareas pesadas fuera del hilo de consumo.

5. Reducir el impacto del rebalance

5.1. Rebalance cooperativo

Con CooperativeStickyAssignor, solo las particiones que realmente necesitan cambiar se recuperan. Los otros consumidores continúan procesando sus particiones mientras ocurre el rebalance.

spring:
  kafka:
    consumer:
      properties:
        partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor

Ejemplo: el grupo tiene A (orders-0..3), luego se une B.

Eager:       A devuelve las 4 particiones -> reasigna: A {0,1}, B {2,3}
Cooperativo: A solo devuelve {2,3}, sigue procesando {0,1} continuamente -> B recibe {2,3}

Nota: No cambies directamente un grupo en ejecución de eager a cooperative en un único despliegue. Necesitas rolling deploy dos veces. En el primer despliegue, declara ambos asignadores, por ejemplo CooperativeStickyAssignor,RangeAssignor. En el segundo despliegue, elimina RangeAssignor.

Desde Kafka 4.0, el nuevo protocolo de rebalance (KIP-848) es GA. Lo activas con group.protocol=consumer. Entonces el broker es quien realiza la asignación, el rebalance es incremental por defecto, y parámetros como partition.assignment.strategy y session.timeout.ms del lado cliente ya no se usan. Si tu cluster lo soporta, esta es la primera opción a considerar.

5.2. Static membership

Cada vez que reinicia un pod, el consumidor recibe un nuevo member.id, lo que dispara rebalance dos veces (al salir y volver a entrar). Static membership asigna una identidad fija al consumidor:

spring:
  kafka:
    consumer:
      properties:
        group.instance.id: billing-${HOSTNAME}
        session.timeout.ms: 60000

Si el consumidor vuelve dentro del tiempo session.timeout.ms, el coordinador le asigna nuevamente exactamente sus particiones anteriores sin necesidad de rebalance. Esto es muy adecuado para Kubernetes StatefulSet, donde HOSTNAME es estable (billing-0, billing-1…).

Nota: El group.instance.id debe ser único dentro del grupo. Con concurrency > 1, Spring Kafka añade automáticamente un sufijo -n a cada consumidor. Si dos instancias usan el mismo ID, la instancia que llega después “expulsará” a la anterior (FencedInstanceIdException). No uses static membership con Deployment que tenga nombres de pods aleatorios, porque no obtendrás beneficio.

6. Manejo de offset durante el rebalance

El mayor riesgo del rebalance es el procesamiento duplicado. El consumidor A ha procesado un registro pero aún no ha hecho commit, la partición se transfiere a B, y B lee nuevamente desde el último offset confirmado.

Spring Kafka por defecto hace commit después de cada lote (AckMode.BATCH) y automáticamente confirma los offsets pendientes antes de que se recupere una partición. Si necesitas intervenir, por ejemplo para vaciar un buffer o escribir en log, usas 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("A punto de perder particiones {}, vaciar datos en memoria", partitions);
    }

    @Override
    public void onPartitionsRevokedAfterCommit(Consumer<?, ?> consumer,
                                               Collection<TopicPartition> partitions) {
        log.info("Se ha confirmado y devuelto la partición {}", partitions);
    }

    @Override
    public void onPartitionsAssigned(Consumer<?, ?> consumer,
                                     Collection<TopicPartition> partitions) {
        log.info("Asignadas particiones {}", partitions);
    }

    @Override
    public void onPartitionsLost(Consumer<?, ?> consumer,
                                 Collection<TopicPartition> partitions) {
        log.warn("Particiones {} perdidas sin confirmar", partitions);
    }
}
  • onPartitionsRevokedBeforeCommit se ejecuta antes de que el container confirme los offsets. Aquí es donde finalizas el trabajo pendiente.
  • onPartitionsRevokedAfterCommit se ejecuta después de que los offsets han sido confirmados.
  • onPartitionsLost se llama cuando el consumidor es expulsado del grupo, por ejemplo por exceder max.poll.interval.ms. En este caso, la partición puede ya ser de otro consumidor, así que no hagas commit.

Registra el listener en la factory del container:

@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(...) mantiene toda la configuración de application.yml. La siguiente línea solo añade el listener de rebalance.

Nota: No importa cuán bien configures, Kafka solo garantiza at-least-once para consumidores normales. Haz que tu procesamiento sea idempotente, por ejemplo verificando que el orderId ya fue pagado antes de llamar a PaymentService. No asutas que el rebalance nunca causará duplicados.

7. Prueba de la asignación de particiones

Esta prueba usa @EmbeddedKafka para verificar que dos consumidores del mismo grupo se dividen las 4 particiones equitativamente:

@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);
        }
    }
}

Explicación:

  • @EmbeddedKafka inicia un broker en memoria, crea el topic orders con 4 particiones. El broker se inyecta directamente como parámetro del método de prueba.
  • Dos KafkaConsumer comparten el mismo group.id = "billing".
  • El bucle alterna llamadas a poll() en ambos consumidores, porque el consumidor solo participa en rebalance dentro de poll(). Si solo A hace poll, el rebalance no se completará. Ambos consumidores se ejecutan en el mismo thread pero son objetos separados, así que no violas la regla de “un consumidor solo es usado por un thread”.
  • Las aserciones verifican que cada consumidor tenga 2 particiones, que no se superpongan, y que en total cubran las 4 particiones.

Esta también es una buena forma de experimentar con diferentes partition.assignment.strategy. Intenta añadir un tercer consumidor, cierra uno, y observa cómo cambia assignment().

8. Errores comunes

  • Más consumidores que particiones: los consumidores excedentes estarán ociosos. Aumentar concurrency no acelera el sistema.
  • Procesamiento síncrono demasiado lento en el listener: excede max.poll.interval.ms causando un bucle de rebalance. El consumidor es expulsado, vuelve, recibe el mismo lote, y es expulsado de nuevo.
  • Hacer commit manual en onPartitionsLost: causa CommitFailedException, o peor, sobrescribe el offset del nuevo consumidor.
  • Usar el mismo group.id para dos servicios diferentes: los dos servicios “se compartirán” los mensajes en lugar de que cada uno reciba todos. Cada servicio independiente debe tener su propio grupo.
  • Rolling deploy con protocolo eager en grupos grandes: cada reinicio de pod causa dos paradas. Usa cooperative, static membership, o el nuevo protocolo de Kafka 4.x.

9. Conclusión

El consumer group te permite escalar la lectura de Kafka horizontalmente, con un límite superior dado por el número de particiones. El rebalance es el precio por esa flexibilidad. Ocurre cuando miembros entran, salen, mueren o procesan demasiado lentamente.

Para que el rebalance cause menos daño, deberías hacer lo siguiente:

  • Configurar razonablemente max.poll.interval.ms y max-poll-records.
  • Usar CooperativeStickyAssignor, o group.protocol=consumer en Kafka 4.x.
  • Habilitar static membership cuando la infraestructura lo permita.
  • Manejar el ciclo de vida de las particiones mediante ConsumerAwareRebalanceListener.

Finalmente, siempre diseña tu procesamiento para ser idempotente, porque at-least-once es la garantía por defecto que debes aceptar.

kafkaspring-kafkaconsumer-grouprebalancespring-boot