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.murmur2est la fonction hash qu’utilise le partitioner par défaut.toPositivesupprime 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é
1001avecLongSerializeret un autre service envoie"1001"avecStringSerializer, 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: alletenable.idempotence: trueaident à 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.flightjusqu’à 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 :
- Plus la clé est granulaire, meilleure est la distribution de charge.
orderIda des millions de valeurs donc se distribue bien.countryCoden’a que quelques valeurs, donc quelques partitions porteront presque tout. - 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, etPAIDpeut terminer avantCREATED. 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
DefaultErrorHandleravecBackOff. 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.