Diseño de partition key en Kafka: orden de mensajes y distribución de carga

1. Descripción general

En Kafka, la partition key determina en qué partición va cada mensaje. Esto, a su vez, determina dos cosas: qué mensajes se mantienen en el orden correcto, y cómo se distribuye la carga entre los consumidores.

Elegir la clave de forma incorrecta generalmente tiene consecuencias en una de dos direcciones. La primera es que los eventos del mismo pedido se procesan desordenados. La segunda es que una partición “caliente” recibe casi todo el tráfico mientras las otras particiones quedan ociosas.

Después de leer este artículo, comprenderás cómo Kafka mapea la clave a la partición. También aprenderás cómo elegir la clave según los requisitos de orden, cómo manejar claves calientes, y cómo configurar el producer/consumer en Spring Boot 3.x para no romper accidentalmente el orden.

2. Cómo elige Kafka la partición

2.1. Clave diferente de null: hash y división con residuo

Cuando el mensaje tiene una clave, el producer calcula la partición según la siguiente fórmula:

partition = toPositive(murmur2(keyBytes)) % numPartitions

Podemos reproducir exactamente esta lógica usando las funciones auxiliares disponibles en kafka-clients:

import org.apache.kafka.common.utils.Utils;
import java.nio.charset.StandardCharsets;

public final class PartitionCalculator {

    private PartitionCalculator() {}

    public static int partitionFor(String key, int numPartitions) {
        byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
        return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
    }

    public static void main(String[] args) {
        for (String orderId : new String[]{"order-1001", "order-1002", "order-1001"}) {
            System.out.println(orderId + " -> partition " + partitionFor(orderId, 6));
        }
    }
}

Explicación de cada parte:

  • La clave se convierte a bytes usando StringSerializer, es decir, UTF-8. Usamos UTF-8 para coincidir con ese comportamiento.
  • Utils.murmur2 es la función hash que usa el partitioner por defecto.
  • toPositive elimina el bit de signo para que el resultado no sea negativo. La operación % después proporciona el número de partición.

El resultado de ejecutarlo es algo como (los números específicos dependen de la clave):

order-1001 -> partition 3
order-1002 -> partition 0
order-1001 -> partition 3

Punto importante: con la misma clave y el mismo número de particiones siempre va a la misma partición. Kafka solo garantiza el orden dentro de una partición. Por lo tanto, si quieres mantener el orden para un grupo de mensajes, todos deben usar la misma clave.

2.2. Clave es null: sin garantía de orden

Desde Kafka 3.3, los mensajes sin clave se distribuyen mediante un mecanismo “uniform sticky”. El producer agrupa un lote en una partición, luego cambia a otra partición, considerando la velocidad de cada broker. La carga se distribuye bastante bien, pero dos mensajes consecutivos pueden estar en particiones diferentes. En ese caso, no hay orden entre ellos.

Nota: El hash depende de los bytes de la clave, no del valor lógico. Si un servicio envía la clave 1001 usando LongSerializer y otro servicio envía "1001" usando StringSerializer, los dos mensajes pueden ir a particiones diferentes. Asegúrate de unificar el serializer para la clave en todo el sistema.

3. Envío de mensajes con clave en Spring Boot

Añade la dependencia:

<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>

Configura el producer en application.yml:

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 5

Significado de cada configuración:

  • acks: all y enable.idempotence: true ayudan a que los reintentos no creen registros duplicados.
  • Más importante aún, cuando se habilita la idempotencia, el broker rechaza los lotes que llegan en orden incorrecto. Gracias a eso, el orden en la partición se mantiene, incluso con reintentos y max.in.flight de hasta 5.

Servicio que envía eventos:

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

public record OrderEvent(String orderId, String customerId, String type) {}

@Service
public class OrderEventPublisher {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public OrderEventPublisher(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publish(OrderEvent event) {
        kafkaTemplate.send("order-events", event.orderId(), event)
                .whenComplete((result, ex) -> {
                    if (ex == null) {
                        var md = result.getRecordMetadata();
                        System.out.printf("%s %s -> p%d@%d%n",
                                event.orderId(), event.type(), md.partition(), md.offset());
                    }
                });
    }
}

El segundo parámetro de send(topic, key, value) es la clave. Aquí usamos orderId. Si enviamos CREATED, PAID, SHIPPED secuencialmente para el mismo pedido, la salida será algo como:

order-1001 CREATED -> p3@120
order-1001 PAID -> p3@121
order-1001 SHIPPED -> p3@122

Los tres eventos están en la partición 3, con offset creciente. El consumer los leerá en este orden exacto.

4. Elegir la clave: comienza por los requisitos de orden

La primera pregunta no es “¿qué clave distribuye la carga de forma más uniforme?”. La pregunta correcta es: ¿qué mensajes deben procesarse necesariamente en orden? La clave debe ser el identificador del grupo más pequeño que satisfaga ese requisito.

Requisito de negocio Clave apropiada Nota
Los eventos de un pedido deben estar en orden orderId Muchas claves, buena distribución
El saldo de la billetera del cliente debe sumar/restar en orden correcto customerId o walletId Fácil de encontrar claves calientes si hay un cliente muy grande
Log de acceso, sin necesidad de orden null Distribución más uniforme
Inventario por producto sku Los productos “populares” pueden causar desequilibrio de carga

Dos principios van asociados:

  1. Cuanto más granular sea la clave, mejor la distribución. orderId tiene millones de valores, así que se distribuye uniformemente. countryCode solo tiene unos pocos valores, así que unos pocos particiones gestionarán casi todo.
  2. El número de valores de clave debe ser mucho mayor que el número de particiones. Si solo hay 10 claves pero el tema tiene 12 particiones, habrá particiones vacías. También es probable que haya una partición que reciba múltiples claves pesadas.

5. Partición caliente y cómo manejarla

Supongamos que la clave es customerId, y un cliente empresarial genera el 40% del tráfico. La partición que contiene este cliente se retrasará. Añadir más consumers no ayuda, porque una partición solo puede ser leída por un consumer en el grupo a la vez.

5.1. Salting: dividir la clave caliente en múltiples buckets

La idea es añadir un sufijo a la clave para dispersar la clave caliente entre múltiples particiones:

import java.util.Set;

public class OrderKeyStrategy {

    private final Set<String> hotCustomers;
    private final int buckets;

    public OrderKeyStrategy(Set<String> hotCustomers, int buckets) {
        this.hotCustomers = hotCustomers;
        this.buckets = buckets;
    }

    public String keyFor(OrderEvent event) {
        if (!hotCustomers.contains(event.customerId())) {
            return event.customerId();
        }
        int bucket = Math.floorMod(event.orderId().hashCode(), buckets);
        return event.customerId() + "#" + bucket;
    }
}

Para clientes normales, la clave se mantiene como customerId. Para clientes calientes, el bucket se calcula a partir de orderId, así que la clave tiene la forma cust-big#0 … cust-big#7.

Las consecuencias que debes entender:

  • Todos los eventos de el mismo pedido todavía tienen la misma clave, por lo que mantienen el orden.
  • El orden entre pedidos diferentes del mismo cliente ya no está garantizado.

El salting solo se puede usar cuando el negocio acepta ese compromiso. Si necesitas orden absoluto por cliente (por ejemplo, saldo de billetera), el salting causará datos incorrectos.

Nota: No uses valores aleatorios o timestamps como bucket. Si lo haces, los eventos del mismo pedido irán a diferentes particiones, y el orden se rompe completamente.

5.2. Prueba de la estrategia de clave

La siguiente prueba verifica dos comportamientos principales: el mismo pedido va a la misma partición, y un cliente caliente se dispersa entre múltiples particiones.

import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;

class OrderKeyStrategyTest {

    private static final int PARTITIONS = 12;
    private final OrderKeyStrategy strategy = new OrderKeyStrategy(Set.of("cust-big"), 8);

    @Test
    void sameOrder_alwaysGoesToSamePartition() {
        var created = new OrderEvent("order-1", "cust-big", "CREATED");
        var paid = new OrderEvent("order-1", "cust-big", "PAID");

        int p1 = PartitionCalculator.partitionFor(strategy.keyFor(created), PARTITIONS);
        int p2 = PartitionCalculator.partitionFor(strategy.keyFor(paid), PARTITIONS);

        assertThat(p1).isEqualTo(p2);
    }

    @Test
    void hotCustomer_isSpreadAcrossPartitions() {
        Set<Integer> partitions = IntStream.range(0, 1_000)
                .mapToObj(i -> new OrderEvent("order-" + i, "cust-big", "CREATED"))
                .map(e -> PartitionCalculator.partitionFor(strategy.keyFor(e), PARTITIONS))
                .collect(Collectors.toSet());

        assertThat(partitions).hasSizeGreaterThan(1);
    }

    @Test
    void normalCustomer_keepsCustomerIdAsKey() {
        var event = new OrderEvent("order-9", "cust-small", "CREATED");
        assertThat(strategy.keyFor(event)).isEqualTo("cust-small");
    }
}

La prueba usa PartitionCalculator de la sección 2.1, que reproduce exactamente la fórmula del producer. Por lo tanto, no necesitamos ejecutar un broker y aún podemos verificar en qué partición cae realmente el mensaje.

6. En el lado del consumer: no rompas el orden por tu cuenta

Que el producer mantenga el orden no es suficiente. El consumer también puede arruinarlo.

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class OrderEventListener {

    @KafkaListener(topics = "order-events", groupId = "order-service", concurrency = "6")
    public void onEvent(OrderEvent event) {
        // Procesa de forma secuencial en el thread del listener
        System.out.println(Thread.currentThread().getName() + " " + event);
    }
}

concurrency = "6" crea 6 threads de consumer. Cada partición se asigna a exactamente un thread, así que el orden dentro de la partición se mantiene. Si el tema tiene 6 particiones, cada thread recibe una. Si estableces concurrency mayor que el número de particiones, los threads adicionales quedarán ociosos.

Errores comunes en el lado del consumer:

  • Empujar mensajes a un thread pool separado (executor.submit(...)) dentro del listener. Dos eventos del mismo pedido pueden ejecutarse en paralelo, y PAID puede terminar antes que CREATED. Si necesitas más paralelismo que el nivel de partición, paraleliza por clave, con una cola secuencial para cada clave. No paralelices por cada mensaje.
  • Reintentar enviando a otro tema (retry topic, non-blocking retry). El mensaje con error se separa del flujo principal, y los mensajes posteriores de la misma clave se siguen procesando. Para flujos que requieren orden estricto, usa blocking retry mediante DefaultErrorHandler con BackOff. Este enfoque acepta que la partición se detenga temporalmente.

7. Cambiar el número de particiones: la trampa silenciosa

Porque la fórmula incluye % numPartitions, aumentar de 6 a 12 particiones hará que la mayoría de las claves cambien a una partición diferente:

public class RepartitionDemo {
    public static void main(String[] args) {
        String key = "order-1001";
        System.out.println("6 partitions  -> " + PartitionCalculator.partitionFor(key, 6));
        System.out.println("12 partitions -> " + PartitionCalculator.partitionFor(key, 12));
    }
}
6 partitions  -> 3
12 partitions -> 9

Los eventos antiguos de order-1001 están en la partición 3 y es posible que aún no se hayan procesado. Los eventos nuevos van a la partición 9. Dos consumers diferentes pueden procesarlos simultáneamente, y el orden se invierte.

Formas de reducir el riesgo:

  • Estima las particiones de reserva desde el principio. Calcula según el throughput objetivo dividido por el throughput de un consumer, luego multiplica por un factor de seguridad.
  • Si necesitas aumentar particiones, detén el producer, espera a que el consumer procese todo el retraso, aumenta las particiones, y luego reinicia el producer.
  • O crea un tema nuevo con más particiones, y migra gradualmente el flujo al nuevo tema.

Nota: Kafka no soporta reducir el número de particiones. Elegir demasiadas particiones también tiene costos: consume recursos en los brokers, los rebalances son más lentos, y hay más archivos abiertos. Elige un número prudente, no 1000 particiones “por si acaso”.

8. Cuándo necesitas un partitioner personalizado

Raramente necesitarás escribir un Partitioner personalizado. En la mayoría de casos, elegir la clave correcta es suficiente. Un partitioner personalizado solo tiene sentido cuando necesitas un mapeo explícito, por ejemplo, reservar ciertas particiones para clientes VIP.

Por el contrario, un partitioner personalizado tiene desventajas:

  • Todos los productores que escriben en ese tema, incluidos los servicios en otros lenguajes, deben usar la misma lógica. Si no, el orden se rompe.
  • Las herramientas como Kafka Streams o Kafka Connect usan murmur2 por defecto. Podrían no coincidir con tu forma de particionar.

Por lo tanto, intenta resolver el problema diseñando la clave antes de considerar un partitioner personalizado.

9. Conclusión

La partition key es una decisión de diseño, no solo un parámetro de send(). Los puntos principales para recordar:

  • Kafka solo garantiza el orden dentro de una partición. Con la misma clave y el mismo número de particiones va a la misma partición, según la fórmula murmur2 con división.
  • Elige la clave como el identificador del grupo más pequeño que necesita mantener el orden. Cuantos más valores tenga la clave, mejor la distribución.
  • Maneja las claves calientes con salting, pero solo si el negocio acepta perder el orden entre buckets.
  • Habilita idempotencia en el producer. En el consumer, no empujes mensajes a threads pools arbitrarios, y considera cuidadosamente antes de usar non-blocking retry.
  • Evita aumentar particiones en un tema en ejecución si el flujo requiere orden. Estima bien el número desde el principio.

Entendiendo estos puntos, puedes diseñar temas que mantengan el orden correcto y se escalen cuando el tráfico aumenta.

kafkaspring-bootpartitionmessagingjava