Partition key dans Kafka : ordre des messages et répartition de charge

1. Aperçu

Dans Kafka, la partition key détermine la partition dans laquelle le message arrive. De là, elle décide de deux choses : quels messages conservent leur ordre, et comment la charge est distribuée entre les consumers.

Mal choisir la clé entraîne généralement l’un de deux scénarios. Le premier est que les événements d’une même commande se font traiter en désordre. Le deuxième est qu’une partition « chaude » porte presque tout le trafic tandis que les autres partitions restent inactives.

Après avoir lu cet article, vous comprendrez comment Kafka mappe la clé vers la partition. Vous saurez également comment choisir la clé selon les exigences d’ordre, comment traiter les hot keys, et configurer le producer/consumer dans Spring Boot 3.x pour ne pas casser accidentellement l’ordre.

2. Comment Kafka choisit la partition

2.1. Clé non null : hash puis modulo

Quand le message a une clé, le producer calcule la partition selon la formule suivante :

partition = toPositive(murmur2(keyBytes)) % numPartitions

On peut reproduire exactement cette logique en utilisant les fonctions utilitaires disponibles dans 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));
        }
    }
}

Explication de chaque partie :

  • La clé est convertie en byte par StringSerializer, c’est-à-dire en UTF-8. On utilise UTF-8 dans cet exemple pour correspondre à ce comportement.
  • Utils.murmur2 est la fonction hash qu’utilise le partitioner par défaut.
  • toPositive supprime le bit de signe pour que le résultat ne soit pas négatif. L’opération % donne alors l’indice de la partition.

L’exécution donne un résultat comme suit (les chiffres exactes dépendent de la clé) :

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

Point important : la même clé et le même nombre de partitions produisent toujours la même partition. Kafka ne garantit l’ordre que dans une seule partition. Donc, pour préserver l’ordre d’un groupe de messages, ces messages doivent partager la même clé.

2.2. Clé null : pas de garantie d’ordre

Depuis Kafka 3.3, les messages sans clé sont distribués selon un mécanisme « uniform sticky ». Le producer regroupe un batch dans une partition, puis passe à une autre, en tenant compte de la vitesse de chaque broker. La charge est répartie de manière assez uniforme, mais deux messages consécutifs peuvent se retrouver dans des partitions différentes. Dans ce cas, aucun ordre n’est maintenu entre eux.

Attention : Le hash dépend des bytes de la clé, pas de sa valeur logique. Si un service envoie la clé 1001 avec LongSerializer et un autre service envoie "1001" avec StringSerializer, les deux messages peuvent aller dans des partitions différentes. Synchronisez le serializer pour les clés sur tout votre système.

3. Envoyer des messages avec clé dans Spring Boot

Ajouter la dépendance :

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

Configurer le producer dans 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

Signification de ces configurations :

  • acks: all et enable.idempotence: true aident à ce que les retry ne créent pas de doublons.
  • Plus important encore, quand l’idempotence est activée, le broker rejette les batchs qui arrivent dans le mauvais ordre. Cela préserve l’ordre dans la partition, même en cas de retry et avec max.in.flight jusqu’à 5.

Service pour envoyer des événements :

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

Le deuxième paramètre de send(topic, key, value) est la clé. Ici, on utilise orderId. Si on envoie successivement CREATED, PAID, SHIPPED pour la même commande, le résultat sera comme suit :

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

Les trois événements se trouvent dans la même partition 3, avec des offsets croissants. Le consumer les lira dans cet ordre exact.

4. Choisir la clé : commencer par les exigences d’ordre

La première question n’est pas « quelle clé distribue le mieux la charge ». La bonne question est : quels messages doivent être traités dans un ordre obligatoire ? La clé doit être l’identifiant du groupe le plus petit satisfaisant cette exigence.

Exigence métier Clé appropriée Remarque
Les événements d’une commande doivent être en ordre orderId Beaucoup de clés, bonne distribution de charge
Le solde du portefeuille client doit être crédité/débité dans le bon ordre customerId ou walletId Risque de hot key si un client très important
Logs d’accès, pas besoin d’ordre null Distribution la plus uniforme
Inventaire par produit sku Les produits « hot » peuvent causer un déséquilibre

Deux principes associés :

  1. Plus la clé est granulaire, meilleure est la distribution de charge. orderId a des millions de valeurs donc se distribue bien. countryCode n’a que quelques valeurs, donc quelques partitions porteront presque tout.
  2. Le nombre de valeurs de clé doit être beaucoup plus grand que le nombre de partitions. Si vous n’avez que 10 clés mais le topic a 12 partitions, certaines partitions seront vides. Il y aura aussi probablement une partition qui reçoit plus d’une clé lourde.

5. Partition chaude et comment la gérer

Supposons que la clé est customerId et qu’un client professionnel génère 40 % du trafic. La partition contenant ce client aura du lag. Ajouter plus de consumers ne aide pas, car une seule partition ne peut être lue que par un seul consumer du groupe à un moment donné.

5.1. Salting : diviser la clé chaude en plusieurs buckets

L’idée est d’ajouter un suffixe à la clé pour disperser la clé chaude sur plusieurs partitions :

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

Pour un client ordinaire, la clé reste customerId. Pour un client chaud, le bucket est calculé à partir de orderId, donc la clé a la forme cust-big#0 … cust-big#7.

Conséquences à bien comprendre :

  • Tous les événements d’une même commande ont toujours la même clé, donc conservent toujours leur ordre.
  • L’ordre entre les différentes commandes du même client n’est plus garanti.

Salting ne peut être utilisé que si le métier accepte ce compromis. Si vous avez besoin d’un ordre absolu par client (par exemple le solde du portefeuille), salting causera des incohérences de données.

Attention : N’utilisez pas de valeur aléatoire ou de timestamp pour le bucket. Faire cela ferait que les événements de la même commande tombent dans plusieurs partitions, et l’ordre serait complètement cassé.

5.2. Tester la stratégie de clé

Le test ci-dessous vérifie deux comportements clés : une même commande dans une partition, et un client chaud dispersé sur plusieurs partitions.

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

Le test utilise PartitionCalculator de la section 2.1, qui reproduit exactement la formule du producer. Par conséquent, on peut vérifier où le message va réellement sans démarrer un broker.

6. Côté consumer : ne cassez pas l’ordre vous-même

Le producer préservant correctement l’ordre ne suffit pas. Le consumer peut aussi le casser.

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) {
        // Traiter séquentiellement dans le thread du listener
        System.out.println(Thread.currentThread().getName() + " " + event);
    }
}

concurrency = "6" crée 6 threads consumer. Chaque partition est assignée à exactement un thread, donc l’ordre dans la partition est préservé. Si le topic a 6 partitions, chaque thread reçoit une partition. Si vous mettez concurrency plus grand que le nombre de partitions, les threads excédentaires resteront inactifs.

Erreurs courantes côté consumer :

  • Pousser les messages vers un thread pool séparé (executor.submit(...)) à l’intérieur du listener. Deux événements de la même commande peuvent s’exécuter en parallèle, et PAID peut terminer avant CREATED. Si vous avez besoin de paralléliser au-delà du niveau partition, parallélisez par clé, avec une file d’attente séquentielle par clé. Ne parallélisez pas par message individuel.
  • Retry en envoyant vers un autre topic (retry topic, non-blocking retry). Le message en erreur est séparé du flux principal, et les messages suivants avec la même clé continuent à être traités. Pour un flux nécessitant un ordre strict, utilisez le blocking retry via DefaultErrorHandler avec BackOff. Cette approche accepte une partition temporairement arrêtée.

7. Changer le nombre de partitions : un piège silencieux

Comme la formule contient % numPartitions, augmenter le nombre de partitions de 6 à 12 fera que la plupart des clés vont vers une autre partition :

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

Les anciens événements de order-1001 sont dans la partition 3 et peuvent ne pas être entièrement traités. Les nouveaux événements vont à la partition 9. Deux consumers différents peuvent les traiter simultanément, et l’ordre est inversé.

Comment réduire le risque :

  • Estimez le nombre de partitions généreusement dès le départ. Calculez selon le débit cible divisé par le débit d’un consumer, puis multipliez par un facteur de sécurité.
  • Si vous devez augmenter le nombre de partitions, arrêtez le producer, attendez que le consumer rattrape le lag, augmentez le nombre de partitions, puis redémarrez le producer.
  • Ou créez un nouveau topic avec plus de partitions, puis migrez progressivement le flux vers le nouveau topic.

Attention : Kafka ne supporte pas la réduction du nombre de partitions. Choisir trop de partitions a aussi un coût : consommation de ressources sur le broker, rebalances plus lentes, et plus de fichiers ouverts. Soyez généreux, mais raisonnable ; ne mettez pas 1000 partitions « juste pour être sûr ».

8. Quand avez-vous besoin d’un partitioner personnalisé

Vous avez rarement besoin d’écrire votre propre Partitioner. Dans la plupart des cas, choisir correctement la clé suffit. Un partitioner personnalisé n’est justifié que si vous avez besoin d’un mappage explicite, par exemple réserver quelques partitions pour les clients VIP.

En revanche, un partitioner personnalisé a des inconvénients :

  • Chaque producer écrivant à ce topic, y compris les services dans d’autres langages, doit utiliser la même logique. Sinon, l’ordre est cassé.
  • Les outils comme Kafka Streams ou Kafka Connect utilisent murmur2 par défaut. Ils peuvent ne pas correspondre à votre schéma de partitioning.

Par conséquent, privilégiez la résolution par la conception des clés avant de penser à un partitioner.

9. Conclusion

La partition key est une décision de conception, pas juste un paramètre de send(). Les points clés à retenir :

  • Kafka ne garantit l’ordre que dans une partition. Même clé et même nombre de partitions signifient la même partition, selon la formule murmur2 avec modulo.
  • Choisissez comme clé l’identifiant du groupe le plus petit nécessitant l’ordre. Plus la clé a de valeurs, mieux la charge est distribuée.
  • Gérez les hot keys par salting, mais seulement si le métier accepte de perdre l’ordre entre les buckets.
  • Activez l’idempotence au niveau du producer. Au niveau du consumer, ne poussez pas les messages vers un thread pool de façon arbitraire, et réfléchissez bien avant d’utiliser le non-blocking retry.
  • Évitez d’augmenter le nombre de partitions d’un topic en cours de fonctionnement si le flux a besoin d’ordre. Dimensionnez généreusement dès le départ.

Maîtriser ces points vous permet de concevoir des topics qui préservent l’ordre tout en scalant quand le trafic augmente.

kafkaspring-bootpartitionmessagingjava