Zum Hauptinhalt springen
← Blog

Kein Kampf mehr um Kafka-Topics: Wie Routing-Keys Consumer-Tests auf einem gemeinsamen Broker isolieren

Erfahren Sie, wie Routing-Keys Kafka-Consumer-Tests auf einem gemeinsamen Broker isolieren und flaky Tests vermeiden, inklusive Codebeispielen.

10 Min. LesezeitSimon-Daniel März
Kein Kampf mehr um Kafka-Topics: Wie Routing-Keys Consumer-Tests auf einem gemeinsamen Broker isolierenMit Hilfe von KI generiert

Ihre CI-Pipeline wird grün, dann rot, dann wieder grün, und niemand hat den Code angefasst. Drei Entwickler-Teams veröffentlichen alle auf dasselbe order-events-Topic auf dem gemeinsamen Kafka-Broker, und jeder Durchlauf liest stillschweigend die Nachrichten der anderen beiden. Die Assertions bestehen oder scheitern je nachdem, welcher Test die Consumer-Gruppe zuerst erreicht.

Das ist kein Bug in Ihrer Geschäftslogik. Es ist ein strukturelles Problem, wie die meisten Teams Kafka-Consumer-Tests angehen. Einen dedizierten Broker pro Testlauf zu starten, ist teuer, ein einzelner Kafka-Broker verbraucht 500 MB bis 1 GB RAM und braucht 30-60 Sekunden zum Hochfahren. Embedded Kafka hilft bei Unit-Tests, verhält sich aber nicht identisch zum echten Broker, was bedeutet, dass produktionsspezifische Überraschungen durchrutschen.

Das Routing-Key-Pattern löst das: Taggen Sie jede Nachricht mit einer eindeutigen Kennung, die mit dem Testlauf verbunden ist, lassen Sie den Consumer danach filtern, und ermöglichen Sie mehreren Tests, denselben Broker ohne Konflikte zu nutzen. Diese Anleitung führt Sie durch die Implementierung, die Fallstricke und ein vollständiges funktionierendes Beispiel, das Sie heute in Ihr Projekt einfügen können.

Warum Kafka-Consumer-Tests auf gemeinsamen Brokern brechen

Das Testen eines Kafka-Consumers unterscheidet sich grundlegend vom Testen eines REST-Endpunkts. Bei REST senden Sie eine Anfrage und erhalten eine Antwort in derselben Transaktion. Bei Kafka reagiert Ihr Consumer asynchron auf Nachrichten, die jederzeit von jedem Producer auf jeder Partition eintreffen können.

Die Kernprobleme, die in gemeinsamen Testumgebungen auftreten, sind:

  • Nachrichten-Kontamination. Test A veröffentlicht ein Order-Event. Test Bs Consumer nimmt es auf und führt Assertions auf der Grundlage seiner eigenen Payload durch. Der Test schlägt fehl, oder schlimmer, er besteht fälschlicherweise.
  • Consumer-Group-Kollisionen. Zwei Testinstanzen teilen sich dieselbe group.id. Kafka verteilt die Partitionen zufällig zwischen ihnen. Ein Test verhungert; der andere liest Nachrichten, die er nie sehen sollte.
  • Offset-Drift. Nach einem fehlgeschlagenen Lauf verbleiben veraltete Offsets in __consumer_offsets. Der nächste Lauf startet von der falschen Position und überspringt die erste benötigte Nachricht.
  • Langsames Teardown. Von einem Test erstellte Topics bleiben bestehen. Mit der Zeit sammeln sich Hunderte von verwaisten Topics auf dem gemeinsamen Broker an, was die Leistung für alle beeinträchtigt.

Eine dedizierte Kafka-Instanz pro Entwickler löst das Kontaminationsproblem, schafft aber ein Kostenproblem. Fünf Kafka-Broker auf einem CI-Server zu betreiben bedeutet 2,5-5 GB RAM allein für die Broker-JVMs, plus ZooKeeper-Knoten, falls Sie noch nicht auf den KRaft-Modus migriert haben. Das ist noch ohne die 30-60 Sekunden, die jeder Broker benötigt, um einen Controller zu wählen und bereit zu sein.

Embedded Kafka (spring-kafka-test, kafka-testcontainers oder Confluents Test-Images) reduziert die Startkosten, aber embedded Broker verhalten sich subtil anders, sie überspringen bestimmte interne Topic-Management-Schritte und können das exakte Rebalancing-Verhalten von Consumer-Gruppen nicht nachbilden. Sie testen letztendlich gegen eine Simulation statt gegen das echte System, genau die Lücke, die Integrationstests schließen sollen.

Routing-Keys ermöglichen es Ihnen, einen einzigen gemeinsamen Broker zu behalten, während jeder Test glaubt, den Broker für sich allein zu haben.

Das Routing-Key-Isolationsmuster: Wie es funktioniert

Die Idee ist einfach. Jeder Testlauf generiert eine eindeutige Kennung, einen Routing-Key, beim Start. Dieser Key wird auf jede Nachricht gestempelt, die der Test produziert, und der Consumer ist so verdrahtet, dass er nur Nachrichten akzeptiert, die seinen eigenen Routing-Key tragen.

So sieht der Lebenszyklus in vier konkreten Schritten aus:

  1. Routing-Key generieren. Typischerweise test-{UUID-erste-8-Zeichen} oder ci-{run-id}-{parallel-index}. Der Key muss über alle gleichzeitigen Testläufe auf demselben Broker eindeutig sein.
  2. Produzierte Nachrichten taggen. Fügen Sie den Routing-Key als Kafka-Header (X-Routing-Key) auf jedem ProducerRecord hinzu. Betten Sie ihn nicht in den Nachrichtentext ein, die Consumer-Logik soll routing-agnostisch bleiben.
  3. Im Consumer filtern. Der Consumer liest alle Nachrichten vom Topic, verwirft aber sofort jeden Datensatz, dessen X-Routing-Key-Header nicht mit dem erwarteten Key übereinstimmt. Nicht übereinstimmende Datensätze berühren die Geschäftslogik nicht.
  4. Mit Zuversicht asserten. Jede Nachricht, die Ihr Test erhält, gehört zu Ihrem Testlauf. Kein Thread.sleep(5000) mehr in der Hoffnung, dass die Nachricht des anderen Tests bereits konsumiert wurde.

Der Consumer zahlt immer noch die Netzwerkkosten, um jede Nachricht vom Topic zu empfangen, aber die CPU-Kosten für das Verwerfen eines Header-Mismatches sind vernachlässigbar, ein einziger Arrays.equals-Vergleich auf einem kurzen Byte-Array.

Warum nicht einfach eindeutige Topics pro Test verwenden? Das können Sie, und für rein isolierte Unit-Tests funktioniert es. In Integrationstests müssen Sie jedoch oft überprüfen, ob Ihr Client einen bestimmten Topic-Namen abonniert (z. B. order-events), da Wildcard- oder pattern-basierte Abonnements Teil des zu testenden Verhaltens sind. Routing-Keys ermöglichen es Ihnen, den echten Topic-Namen beizubehalten.

Schritt-für-Schritt-Implementierung

Schritt 1: Einen eindeutigen Routing-Key generieren

public final class TestRoutingKeys {

    private TestRoutingKeys() {}

    public static String generate() {
        // First 8 hex chars of a UUID: statistically collision-free
        // even with hundreds of parallel CI runners.
        return "test-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
    }
}

Wenn Sie Tests parallel auf einem CI-System ausführen (GitHub Actions, GitLab CI, Jenkins), können Sie den Key aus Umgebungsvariablen ableiten, um eine bessere Rückverfolgbarkeit zu erreichen:

public static String generateFromCI() {
    String runId = System.getenv("CI_RUN_ID");       // e.g. GitLab CI: $CI_JOB_ID
    int parallel = Integer.parseInt(
        System.getenvOrDefault("CI_NODE_INDEX", "0")  // GitLab CI: $CI_NODE_INDEX
    );
    return "ci-" + (runId != null ? runId : UUID.randomUUID().toString().substring(0, 8))
           + "-" + parallel;
}

Schritt 2: Den Routing-Key an jede produzierte Nachricht anhängen

Wickeln Sie Ihre Kafka-Producer-Logik so ein, dass der Routing-Key-Header automatisch injiziert wird, anstatt ihn manuell in jedem Test hinzuzufügen:

public class RoutingAwareProducer<K, V> {

    private final KafkaTemplate<K, V> kafkaTemplate;
    private final String routingKey;

    public RoutingAwareProducer(KafkaTemplate<K, V> kafkaTemplate, String routingKey) {
        this.kafkaTemplate = kafkaTemplate;
        this.routingKey = routingKey;
    }

    public CompletableFuture<SendResult<K, V>> send(
            String topic, K key, V value) {

        ProducerRecord<K, V> record = new ProducerRecord<>(topic, key, value);
        record.headers().add("X-Routing-Key", routingKey.getBytes(StandardCharsets.UTF_8));

        return kafkaTemplate.send(record).completable();
    }
}

In Ihrem Test-Setup:

@BeforeEach
void setUp() {
    this.routingKey = TestRoutingKeys.generate();
    this.producer = new RoutingAwareProducer<>(kafkaTemplate, routingKey);
}

Schritt 3: Einen routing-bewussten Consumer-Filter bauen

Statt Routing-Key-Prüfungen über alle Listener-Methoden zu verstreuen, wickeln Sie die Filterung in einen einzigen Interceptor oder Decorator ein:

@Component
public class RoutingKeyFilter {

    public boolean shouldProcess(ConsumerRecord<?, ?> record, String expectedKey) {
        Header header = record.headers().lastHeader("X-Routing-Key");
        if (header == null) {
            // Production traffic, no routing key means "always process."
            return true;
        }
        String actual = new String(header.value(), StandardCharsets.UTF_8);
        return expectedKey.equals(actual);
    }
}

Dann in Ihrem Listener:

@KafkaListener(
    topics = "order-events",
    groupId = "order-processor-test",
    properties = "auto.offset.reset=earliest"
)
public void onOrderEvent(ConsumerRecord<String, String> record) {
    if (!routingKeyFilter.shouldProcess(record, routingKey)) {
        return; // Silently skip messages from other test runs.
    }

    OrderEvent event = objectMapper.readValue(record.value(), OrderEvent.class);
    orderService.process(event);
}

Kritisches Detail: Verwenden Sie auto.offset.reset=earliest in Test-Consumern. Der Standardwert (latest) führt dazu, dass der Consumer Nachrichten überspringt, die vor dem Start des Listeners produziert wurden, eine klassische Quelle für "Nachricht verschwunden"-Bugs in Tests.

Schritt 4: Eine eindeutige Consumer-Gruppe pro Test konfigurieren

Routing-Keys partitionieren den Nachrichtenraum, aber Sie benötigen dennoch eindeutige Consumer-Gruppen-IDs, um Offset-Kollisionen zwischen Testläufen zu vermeiden:

@TestConfiguration
static class TestKafkaConfig {

    @Bean
    public ConsumerFactory<String, String> testConsumerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "test-" + UUID.randomUUID());
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        return new DefaultKafkaConsumerFactory<>(props);
    }
}

Ohne eindeutige Gruppen-IDs weist Kafkas kooperatives Rebalancing Partitionen dem falschen Test-Runner mitten im Test zu, sodass Nachrichten von einem Consumer verschwinden und bei einem anderen auftauchen.

Vollständiges funktionierendes Beispiel: End-to-End-Test mit Testcontainers

Alles zusammengeführt mit Testcontainers Kafka und Spring Boot:

@SpringBootTest
@Testcontainers
class OrderConsumerIntegrationTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
    );

    @Autowired private KafkaTemplate<String, String> kafkaTemplate;
    @Autowired private OrderService orderService; // The real service under test

    private RoutingAwareProducer<String, String> producer;
    private String routingKey;
    private CountDownLatch latch;

    @DynamicPropertySource
    static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }

    @BeforeEach
    void setUp() {
        routingKey = TestRoutingKeys.generate();
        producer = new RoutingAwareProducer<>(kafkaTemplate, routingKey);
        latch = new CountDownLatch(1);
    }

    @Test
    void consumerProcessesOrderEventWithCorrectRoutingKey() throws Exception {
        // Given: an order event tagged with our routing key
        String orderId = UUID.randomUUID().toString();
        String payload = """
            {"orderId":"%s","customerId":"C-1042","total":89.50}
            """.formatted(orderId);

        producer.send("order-events", orderId, payload).get(5, TimeUnit.SECONDS);

        // When: wait for the consumer to process it
        boolean processed = latch.await(10, TimeUnit.SECONDS);

        // Then: the order service received exactly our message
        assertThat(processed).isTrue();
        Order order = orderService.findById(orderId).orElseThrow();
        assertThat(order.getCustomerId()).isEqualTo("C-1042");
        assertThat(order.getTotal()).isEqualTo(89.50);
    }
}

Auf einem gemeinsamen Broker veröffentlicht ein paralleler Testlauf eines anderen Entwicklers seine eigene order-events-Nachricht mit einem anderen Routing-Key. Unser Consumer überspringt sie in Millisekunden. Keine flaky Ergebnisse, keine sleep-basierten Workarounds.

Der gesamte Test, inklusive Broker-Start, wird mit Testcontainers in 8-12 Sekunden abgeschlossen. Vergleichen Sie das mit den 30-60 Sekunden, die ein dedizierter Standalone-Broker zum Booten benötigt.

Häufige Fallstricke und wie man sie behebt

1. Header in der Produktion vergessen. Wenn Ihr Produktions-Producer X-Routing-Key nicht setzt, muss der header == null-Zweig des Filters true zurückgeben (immer verarbeiten). Andernfalls verschwinden Produktionsnachrichten stillschweigend. Loggen Sie eine Warnung, wenn der Header fehlt, damit Sie Abweichungen zwischen Umgebungen bemerken.

2. Consumer liest dennoch jede Nachricht. Das Routing-Key-Filtern findet statt, nachdem Kafka den Datensatz an Ihren Consumer ausgeliefert hat. Bei einem stark frequentierten Topic mit 10.000 Nachrichten pro Sekunde liest Ihr Test-Consumer alle 10.000 und verwirft 9.998. Für die meisten Integrationstests ist das in Ordnung. Wenn es zu einem Engpass wird, verwenden Sie den Routing-Key als Partitionierungs-Key, Nachrichten mit demselben Key landen immer auf derselben Partition, und Sie können nur diese Partition Ihrem Test-Consumer zuweisen:

// In test configuration, assign a specific partition
consumer.assign(Collections.singleton(new TopicPartition("order-events", targetPartition)));

Wobei targetPartition = Math.abs(routingKey.hashCode()) % numPartitions. Dies reduziert das Nachrichtenvolumen auf einen Bruchteil, allerdings um den Preis einer engeren Kopplung zwischen Test und Topic-Konfiguration.

3. Header-Bytes vs. String-Encoding. Kafka-Header sind rohe Byte-Arrays. Verwenden Sie auf Producer- und Consumer-Seite immer die gleiche Zeichenkodierung (UTF-8). Ein Mismatch erzeugt zwei nicht gleiche Byte-Sequenzen, die in Logs identisch aussehen.

4. Race-Conditions bei der Topic-Auto-Erstellung. Wenn auto.create.topics.enable=true auf dem Broker gesetzt ist, erstellt der erste send()-Aufruf in jedem Test implizit das Topic. Zwei Tests, die beide denselben Topic-Namen erstellen wollen, können einen TopicExistsException als LeaderNotAvailableException erleben, während der Controller kurzzeitig die Repliken zuweist. Behandeln Sie dies mit AdminClient.createTopics() und einem idempotenten Topic-Initialisierer, oder deaktivieren Sie die Auto-Erstellung und verwalten Sie Topics explizit in einem @BeforeAll-Setup.

5. Veraltete Consumer-Gruppen nach Testfehlern. Wenn ein Test mid-execution fehlschlägt, kann seine Consumer-Gruppe im __consumer_offsets-Topic des Brokers verbleiben. Bereinigen Sie Testgruppen regelmäßig mit AdminClient.deleteConsumerGroups() in einem @AfterEach oder einer CI-Bereinigungsphase. Alternativ können Sie die statische Mitgliedschaft group.instance.id verwenden, um Rebalancing über Neustarts hinweg vorhersehbar zu machen.

Best Practices für Kafka-Consumer-Tests in CI

  1. Verwenden Sie Testcontainers mit standardmäßigen embedded Clustern. Ein einzelner KafkaContainer, der über alle Tests in derselben Klasse geteilt wird oder @Testcontainers mit einem statischen Container, stellt einen Broker pro Testklasse sicher. Kombinieren Sie dies mit Routing-Keys für die isolierung zwischen Klassen auf einem gemeinsamen CI-Runner.

  2. Halten Sie die Routing-Key-Generierung in einer Utility-Klasse. Streuen Sie die UUID-Generierung nicht über Testdateien. Eine zentrale TestRoutingKeys.generate()-Methode erleichtert das Ändern des Formats (z. B. Migration von UUID zu CI-Pipeline-basierten Keys), ohne jeden Test anfassen zu müssen.

  3. Setzen Sie Consumer-Timeouts explizit. Standardmäßige Kafka-Consumer-Poll-Timeout von 300 Sekunden verwandeln einen fehlschlagenden Test in einen fünfminütigen Hänger. Verwenden Sie awaitility oder einen CountDownLatch mit einem 10-Sekunden-Timeout:

await()
    .atMost(Duration.ofSeconds(10))
    .until(() -> orderService.findById(orderId).isPresent());
  1. Testen Sie die Giftpille. Kafka-Consumer-Tests konzentrieren sich oft auf den Happy Path. Fügen Sie einen Test hinzu, der eine fehlerhafte Nachricht mit dem korrekten Routing-Key produziert, und überprüfen Sie, ob Ihr Dead-Letter-Topic (DLT) oder Error-Handler sie empfängt. Dies fängt Deserialisierungsfehler, die nur unter Last in der Produktion auftreten.

  2. Führen Sie mindestens zwei Consumer-Instanzen in einem Test aus. Wenn Ihre Produktionsumgebung drei Consumer-Instanzen verwendet, sollte Ihr Test das Rebalancing-Verhalten validieren. Starten Sie zwei Consumer mit derselben Gruppen-ID, überprüfen Sie, dass beide Datensätze empfangen (von unterschiedlichen Partitionen), und bestätigen Sie, dass das Routing-Key-Filtern funktioniert, wenn jeder Consumer nur eine Teilmenge der Nachrichten erhält.

  3. Versionieren Sie Ihre Test-Topic-Schemata. Wenn Sie ein neues Feld zu Ihrem Avro- oder Protobuf-Schema hinzufügen, sollte Ihr Consumer-Test das neue Format produzieren. Erstellen Sie einen Helfer, der die neueste Schema-Version generiert, und speichern Sie Golden-Master-Payloads in einem Ressourcenverzeichnis. Dies entkoppelt die Wartung von Testdaten vom Kafka-Schlüsselcode.

Wie wir bereits in unserer Analyse analysiert haben, warum Entwickler-Speed-Gewinne nicht immer Liefertermine verschieben, sind flaky Integrationstests einer der häufigsten versteckten Blockierer. Eine Testsuite, die 15 % der Zeit fehlschlägt, zwingt Entwickler dazu, Pipelines erneut auszuführen, während des Wartens den Kontext zu wechseln und schließlich das Signal nicht mehr zu vertrauen. Die Ursache zu beheben, die Kontamination gemeinsamer Ressourcen, ist günstiger als jede Menge KI-gestützter Codegenerierung.

Wann Routing-Keys nicht ausreichen

Routing-Keys lösen das Nachrichten-Kontaminationsproblem für funktionale Integrationstests. Sie lösen nicht alles:

  • Last- und Performancetests erfordern einen dedizierten Broker, da Header-Filtern bei hoher Skalierung messbare CPU-Overhead verursacht. Das Benchmarken eines Consumers gegen 50.000 Nachrichten pro Sekunde, während andere Tests den Broker teilen, ergibt bedeutungslose Durchsatzzahlen.
  • Chaos- und Failover-Tests (Broker-Leader-Wahl, Netzwerk-Partitionssimulation) benötigen einen Multi-Broker-Kafka-Cluster. Verwenden Sie Testcontainers mit mehreren Containern oder ein Tool wie toxiproxy, um Fehler zu injizieren.
  • Schema-Evolutionstests (Produzieren mit Schema v1 und Konsumieren mit Schema v2) funktionieren am besten mit einem gemeinsamen Schema Registry, nicht mit Routing-Keys. Koordinieren Sie Schema-Versionen durch eine Registry-Bereinigung in @BeforeAll.

Wenn Ihr Projekt alle drei Ebenen umfasst, funktionale, Performance- und Chaos-Tests, dann stehen Sie vor einer erheblichen Infrastrukturinvestition. Teams, die diese Testinfrastruktur nicht selbst aufbauen und warten möchten, holen sich einen erfahrenen Custom-Software-Partner für ereignisgesteuerte Systeme, der das Test-Harness als wiederverwendbares internes Tool einrichtet und das Team so entlastet, sich auf die Geschäftslogik statt auf CI-Klempnerarbeit zu konzentrieren.

Zusammenfassung: Was Sie heute implementieren sollten

Beginnen Sie mit dem minimal lebensfähigen Routing-Key-Setup:

  1. Fügen Sie ein TestRoutingKeys.generate()-Utility hinzu.
  2. Wickeln Sie Ihren Test-Producer, um jede Nachricht mit X-Routing-Key zu stempeln.
  3. Fügen Sie eine einzige Header-Prüfung in Ihrem Consumer hinzu (oder einen wiederverwendbaren Interceptor).
  4. Verwenden Sie eindeutige group.id-Werte pro Testlauf.
  5. Führen Sie die Suite auf einem gemeinsamen Broker aus und überprüfen Sie eine Null-Kontamination.

Die gesamte Integration umfasst etwa 60-90 Codezeilen, verteilt auf drei Klassen. Sie eliminiert die häufigste Form von Kafka-Test-Flakiness, ohne Infrastruktur hinzuzufügen, ohne simulierte Broker einzuführen und ohne Ihre Produktions-Consumer-Logik zu ändern.

Hören Sie auf, um Topics zu kämpfen. Routen Sie Ihre Nachrichten, isolieren Sie Ihre Tests, und lassen Sie die CI-Pipeline grün werden, weil der Code korrekt ist, nicht, weil Sie Glück mit der Consumer-Gruppenzuweisung hatten.


Quelle: Wie Routing-Keys Kafka-Consumer-Tests auf einem gemeinsamen Broker isolieren

In diesem Thema weiterlesen

Softwareprodukte