Kafka Partition Key Design: Nachrichtenreihenfolge und Lastverteilung
1. Überblick
In Kafka bestimmt der Partition Key, in welche Partition eine Nachricht fällt. Dies entscheidet zwei Dinge: welche Nachrichten in der richtigen Reihenfolge bleiben und wie die Last auf die Consumer verteilt wird.
Wenn du den Key falsch wählst, führt das normalerweise zu einem von zwei Problemen. Erstens können Events derselben Bestellung durcheinander verarbeitet werden. Zweitens kann eine Partition sehr viel Last tragen, während die anderen leer sind (Hot Partition).
Nach dem Lesen dieses Artikels wirst du verstehen, wie Kafka Keys auf Partitionen abbildet. Du wirst auch lernen, wie du Keys für Reihenfolgenanforderungen wählst, wie du mit Hot Keys umgehen kannst, und wie du Producer/Consumer in Spring Boot 3.x konfigurierst, ohne die Reihenfolge versehentlich zu zerstören.
2. Wie Kafka Partitionen wählt
2.1. Key ungleich null: Hash und dann Modulo
Wenn eine Nachricht einen Key hat, berechnet der Producer die Partition nach dieser Formel:
partition = toPositive(murmur2(keyBytes)) % numPartitions
Wir können diese Logik genau mit den Utility-Funktionen in kafka-clients nachbilden:
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));
}
}
}
Erklärung einzelner Teile:
- Der Key wird mit
StringSerializerzu Bytes konvertiert, das heißt UTF-8. Im Beispiel verwenden wir UTF-8, um dieses Verhalten zu entsprechen. Utils.murmur2ist die Hash-Funktion, die der Standard-Partitioner verwendet.toPositiveentfernt das Vorzeichenbit, damit das Ergebnis nicht negativ ist. Der%-Operator gibt dann die Partitionsnummer.
Die Ausgabe sieht etwa so aus (die genauen Zahlen hängen vom Key ab):
order-1001 -> partition 3
order-1002 -> partition 0
order-1001 -> partition 3
Wichtiger Punkt: Mit demselben Key und derselben Anzahl von Partitionen landen Nachrichten immer in derselben Partition. Kafka garantiert Reihenfolge nur innerhalb einer Partition. Um die Reihenfolge für eine Gruppe von Nachrichten zu bewahren, müssen diese also den gleichen Key verwenden.
2.2. Key ist null: Keine Reihenfolgegarantie
Ab Kafka 3.3 werden Nachrichten ohne Key nach dem Mechanismus “uniform sticky” verteilt. Der Producer packt einen Batch in eine Partition, wechselt dann zu einer anderen, berücksichtigt dabei die Geschwindigkeit jedes Brokers. Die Last wird ziemlich gleichmäßig verteilt, aber zwei aufeinanderfolgende Nachrichten können in verschiedenen Partitionen liegen. Dann gibt es zwischen ihnen keine Reihenfolge mehr.
Hinweis: Der Hash hängt von den Bytes des Keys ab, nicht von seinem logischen Wert. Wenn ein Service den Key
1001mitLongSerializersendet und ein anderer Service"1001"mitStringSerializersendet, können die Nachrichten in verschiedene Partitionen gehen. Einige dich auf einen Serializer für Keys im gesamten System.
3. Nachricht mit Key mit Spring Boot senden
Abhängigkeit hinzufügen:
<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>
Producer-Konfiguration in 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
Bedeutung der Einstellungen:
acks: allundenable.idempotence: trueverhindern, dass Wiederholungen zu Duplikaten führen.- Wichtiger ist: Wenn Idempotenz aktiviert ist, lehnt der Broker Batches in falscher Reihenfolge ab. Dadurch bleibt die Reihenfolge in der Partition erhalten, auch bei Wiederholungen und
max.in.flightvon bis zu 5.
Service zum Senden von Events:
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());
}
});
}
}
Der zweite Parameter von send(topic, key, value) ist der Key. Hier verwenden wir orderId. Wenn wir nacheinander CREATED, PAID, SHIPPED für die gleiche Bestellung senden, wird die Ausgabe so aussehen:
order-1001 CREATED -> p3@120
order-1001 PAID -> p3@121
order-1001 SHIPPED -> p3@122
Alle drei Events sind in Partition 3, mit aufsteigenden Offsets. Der Consumer liest sie in dieser richtigen Reihenfolge.
4. Key wählen: Beginne mit Reihenfolgenanforderungen
Die erste Frage ist nicht “welcher Key verteilt die Last am besten”. Die richtige Frage ist: Welche Nachrichten müssen unbedingt in Reihenfolge verarbeitet werden? Der Key sollte der Bezeichner der kleinsten Gruppe sein, die diese Anforderung erfüllt.
| Geschäftsanforderung | Passender Key | Anmerkung |
|---|---|---|
| Events einer Bestellung müssen in Reihenfolge sein | orderId |
Viele Keys, gute Lastverteilung |
| Wallet-Saldo muss in richtiger Reihenfolge addiert/subtrahiert werden | customerId oder walletId |
Hot Key wahrscheinlich bei sehr großen Kunden |
| Zugriffslogs, keine Reihenfolge nötig | null |
Beste Lastverteilung |
| Bestand nach Produkt | sku |
“Heiße” Produkte können zu Ungleichgewicht führen |
Zwei begleitende Prinzipien:
- Je feiner der Key, desto besser die Lastverteilung.
orderIdhat Millionen von Werten und verteilt sich gleichmäßig.countryCodehat nur wenige Werte, also tragen wenige Partitionen fast die ganze Last. - Die Anzahl der Key-Werte sollte viel größer sein als die Anzahl der Partitionen. Wenn es nur 10 Keys gibt, aber das Topic 12 Partitionen hat, werden sicher einige Partitionen leer sein. Es wird auch wahrscheinlich Partitionen geben, die mehrere schwere Keys erhalten.
5. Hot Partitions und wie man sie handhabt
Angenommen, der Key ist customerId, und ein großer Geschäftskunde erzeugt 40% des Datenverkehrs. Die Partition mit diesem Kunden wird laggen. Das Hinzufügen von Consumern hilft nicht, weil eine Partition zu jedem Zeitpunkt nur von einem Consumer in der Gruppe gelesen wird.
5.1. Salting: Hot Key in mehrere Buckets aufteilen
Die Idee ist, ein Suffix zum Key hinzuzufügen, um einen Hot Key auf mehrere Partitionen zu verteilen:
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;
}
}
Bei normalen Kunden bleibt der Key einfach customerId. Bei Hot Customers wird der Bucket aus orderId berechnet, also hat der Key die Form cust-big#0 … cust-big#7.
Wichtige Konsequenzen:
- Alle Events derselben Bestellung haben noch den gleichen Key, daher bleibt die Reihenfolge erhalten.
- Die Reihenfolge zwischen verschiedenen Bestellungen desselben Kunden ist nicht mehr garantiert.
Salting kann nur verwendet werden, wenn die Geschäftslogik diesen Kompromiss akzeptiert. Wenn du absolute Reihenfolge nach Kunde brauchst (z.B. Wallet-Saldo), wird Salting zu Datenverlust führen.
Hinweis: Verwende keine Zufallswerte oder Timestamps als Bucket. Das würde dazu führen, dass Events der gleichen Bestellung in verschiedene Partitionen gehen, und die Reihenfolge wäre völlig zerstört.
5.2. Key-Strategie testen
Der folgende Test überprüft zwei Hauptverhalten: Gleiche Bestellung → gleiche Partition, und Hot Customer wird auf mehrere Partitionen verteilt.
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");
}
}
Der Test verwendet PartitionCalculator aus Abschnitt 2.1, das die Producer-Formel genau nachbildet. Daher brauchst du keinen Broker zu starten, sondern kannst trotzdem überprüfen, wo Nachrichten wirklich landen.
6. Consumer-Seite: Zerstöre nicht selbst die Reihenfolge
Wenn der Producer die Reihenfolge richtig einhält, ist das nicht genug. Der Consumer kann sie auch zerstören.
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) {
// Sequenzielle Verarbeitung im Thread des Listeners
System.out.println(Thread.currentThread().getName() + " " + event);
}
}
concurrency = "6" erstellt 6 Consumer-Threads. Jede Partition wird genau einem Thread zugewiesen, daher bleibt die Reihenfolge in der Partition erhalten. Wenn das Topic 6 Partitionen hat, bekommt jeder Thread eine Partition. Wenn concurrency größer als die Anzahl der Partitionen ist, werden überschüssige Threads untätig.
Häufige Fehler auf der Consumer-Seite:
- Nachrichten an einen separaten Thread Pool pushen (
executor.submit(...)) innerhalb des Listeners. Zwei Events der gleichen Bestellung können parallel laufen, undPAIDkönnte vorCREATEDfertig werden. Wenn du über die Partition-Ebene hinaus parallelisieren musst, parallelisiere nach Key: ein Key pro sequenzieller Warteschlange. Parallelisiere nicht pro Nachricht. - Wiederholungen durch Senden zu einem anderen Topic (Retry Topic, Non-Blocking Retry). Die fehlerhafte Nachricht wird aus dem Hauptstrom genommen, und nachfolgende Nachrichten des gleichen Keys werden trotzdem verarbeitet. Bei Streams mit strikter Reihenfolge solltest du Blocking Retry mit
DefaultErrorHandlerundBackOffverwenden. Dies bedeutet, dass die Partition vorübergehend blockiert ist.
7. Partitionszahl ändern: Die stille Falle
Weil die Formel den Modulo-Operator % numPartitions verwendet, werden die meisten Keys zu einer anderen Partition verschoben, wenn du von 6 auf 12 Partitionen erhöhst:
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
Alte Events von order-1001 sind in Partition 3 und wurden möglicherweise noch nicht verarbeitet. Neue Events gehen in Partition 9. Zwei verschiedene Consumer könnten sie gleichzeitig verarbeiten, und die Reihenfolge wird durcheinander gebracht.
So minimierst du das Risiko:
- Schätze die Partitionszahl von Anfang an großzügig. Berechne sie nach: Ziel-Durchsatz / Durchsatz pro Consumer, dann multipliziere mit einem Sicherheitsfaktor.
- Wenn du die Partitionen erhöhen musst, stoppe den Producer, warte bis der Consumer das Lag aufgeholt hat, erhöhe die Partitionen, und starte dann den Producer neu.
- Oder erstelle ein neues Topic mit mehr Partitionen und migriere den Stream schrittweise zum neuen Topic.
Hinweis: Kafka unterstützt keine Reduzierung der Partitionszahl. Zu viele Partitionen zu wählen hat auch Kosten: Ressourcen auf dem Broker, langsamere Rebalances und mehr offene Dateien. Sei großzügig, aber nicht extrem – setze nicht 1000 Partitionen “zur Sicherheit”.
8. Wann brauchst du einen Custom Partitioner
Du brauchst selten einen eigenen Partitioner zu schreiben. In den meisten Fällen reicht es, den Key richtig zu wählen. Custom Partitioner sind nur sinnvoll, wenn du explizite Zuordnung brauchst, z.B. um bestimmte Partitionen für VIP-Kunden zu reservieren.
Andererseits hat ein Custom Partitioner Nachteile:
- Alle Producer, die auf das Topic schreiben – auch Services in anderen Sprachen – müssen die gleiche Logik implementieren. Sonst wird die Reihenfolge zerstört.
- Tools wie Kafka Streams oder Kafka Connect verwenden standardmäßig murmur2. Sie stimmen möglicherweise nicht mit deiner Partitionierungs-Logik überein.
Daher solltest du zuerst versuchen, das Problem durch richtige Key-Gestaltung zu lösen, bevor du einen Custom Partitioner schreibst.
9. Fazit
Der Partition Key ist eine Designentscheidung, nicht einfach ein Parameter von send(). Wichtige Punkte zum Merken:
- Kafka garantiert Reihenfolge nur in einer Partition. Mit dem gleichen Key und der gleichen Anzahl von Partitionen landen Nachrichten immer in der gleichen Partition, nach der Murmur2-Hash-und-Modulo-Formel.
- Wähle den Key als Bezeichner der kleinsten Gruppe, die Reihenfolge braucht. Je mehr unterschiedliche Key-Werte, desto besser die Lastverteilung.
- Behandle Hot Keys mit Salting, aber nur wenn die Geschäftslogik den Verlust der Reihenfolge zwischen Buckets toleriert.
- Aktiviere Idempotenz beim Producer. Beim Consumer: Sende Nachrichten nicht willkürlich an einen Thread Pool, und überdenke Non-Blocking Retry genau.
- Vermeide es, Partitionen bei laufenden Topics zu erhöhen, wenn Reihenfolge wichtig ist. Schätze von Anfang an großzügig.
Wenn du diese Punkte beherrschst, kannst du Topics so gestalten, dass sie sowohl Reihenfolge garantieren als auch skalierbar sind, wenn der Durchsatz wächst.