Kafka-Fehlerbehandlung mit Dead Letter Queue: Fehler ablegen, nach dem Fix nachverarbeiten

Eine einzige Nachricht ließ sich nicht verarbeiten, und danach stand der Consumer. Alle Chats, die nach ihr kamen, blieben liegen. So fing im Sommer 2025 die Fehlerbehandlung in der TI-Messenger-Integration an, an der ich für eine gesetzliche Krankenversicherung arbeite. Die Lösung ist ein bekanntes Muster, die Dead Letter Queue. Was in Projekten meist zu kurz kommt, ist das Danach: der Rerun nach einem Fix, die Retention als Frist und die Frage, wer überhaupt in die DLQ schaut.
Das Problem: eine Nachricht hält alle anderen auf
Kafka liefert Nachrichten je Partition in fester Reihenfolge aus. Der Consumer committet seinen Offset und nimmt sich die nächste Nachricht. Für geordnete Abläufe ist das genau richtig. Scheitert eine Nachricht aber dauerhaft und versucht der Consumer es immer wieder, kommt er an dieser Stelle nicht vorbei. Alles, was in derselben Partition danach kommt, wartet.
In der TI-Messenger-Integration kommen Chat-Ereignisse über Kafka herein, werden mit Stammdaten angereichert und ans Archiv weitergegeben. Hängt der Consumer, bleiben alle folgenden Chats unbearbeitet, obwohl mit ihnen alles in Ordnung ist.
Das andere Extrem ist übrigens nicht besser. Spring Kafka versucht eine Nachricht standardmäßig zehnmal, schreibt dann einen Log-Eintrag auf ERROR und macht mit der nächsten weiter1. Dann blockiert nichts, aber die Nachricht ist weg, und an sie erinnert nur noch eine Log-Zeile. Für ein Archiv ist das eine so schlecht wie das andere.
Die Ursachen sind meist unspektakulär:
- Fehler im eigenen Code, etwa ein Sonderfall in den Daten, mit dem niemand gerechnet hat.
- Ausfall eines Nachbarsystems, zum Beispiel des Dienstes, der die Stammdaten liefert.
- Daten, die nicht zum erwarteten Schema passen.
Die Entscheidung: Fehler-Flow und Business-Flow trennen
Im Juni 2025 fiel im Projekt die Entscheidung für eine Dead Letter Queue (DLQ), Anfang Juli ging sie live. Die Eckpunkte:
- Eine Nachricht, die nicht verarbeitet werden kann, landet in einem eigenen DLQ-Topic, und der Consumer macht mit der nächsten weiter. Das Haupttopic bleibt frei.
- Die Fehler-Metadaten stehen im Message-Header: Ursprungstopic, Partition, Offset, Consumer Group und die Exception. Damit lässt sich jedes abgelegte Event später eindeutig zuordnen.
- Die DLQ sitzt zuerst hinter dem Eingangstopic, später auch hinter den Publisher-Topics, über die Ergebnisse weitergehen.
- Für die Nachverarbeitung nach einem Fix entstand ein Rerun-Mechanismus als wiederverwendbare Bibliothek, damit nicht jeder Service ihn neu baut.
Vorgeschaltete Retry-Topics mit Wartezeit gab es am Anfang nicht. Eine fehlgeschlagene Nachricht ging direkt in die DLQ. Das hielt das erste Release klein und war eine bewusste Abwägung. Was das kostet, steht weiter unten in der Tabelle.
So sieht das Muster mit Spring Kafka aus
Spring Kafka bringt die Bausteine bereits mit1. Das folgende Beispiel zeigt das Standardmuster, nicht den Projektcode. Ein DefaultErrorHandler übergibt eine Nachricht, die nicht verarbeitet werden konnte, an einen DeadLetterPublishingRecoverer, der sie in das DLQ-Topic schreibt.
@Configuration
public class KafkaFehlerbehandlung {
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
// Fehlgeschlagene Nachrichten: "<topic>.dlq", gleiche Partition
var recoverer = new DeadLetterPublishingRecoverer(template,
(record, ex) -> new TopicPartition(
record.topic() + ".dlq", record.partition()));
// Kein Wiederholen: der erste Fehler führt direkt in die DLQ
return new DefaultErrorHandler(recoverer, new FixedBackOff(0L, 0L));
}
}Spring Boot hängt einen einzelnen Error-Handler-Bean automatisch an die auto-konfigurierte Container Factory. Der Recoverer schreibt die Herkunft der Nachricht in Header wie kafka_dlt-original-topic, kafka_dlt-original-partition, kafka_dlt-original-offset und kafka_dlt-original-consumer-group, dazu die Exception mit Klasse, Meldung und Stacktrace. Mit diesen Angaben findet man ein Event später wieder, ohne im Ursprungstopic suchen zu müssen.
Ohne eigenen Resolver schriebe Spring in ein Topic <topic>-dlt. Der Resolver lohnt sich trotzdem, weil Topic-Namen in Unternehmen meist einer Konvention des Betriebs folgen.
Zwei Details, die gern übersehen werden:
- Partitionen der DLQ: Wer wie oben in dieselbe Partitionsnummer schreibt, braucht im DLQ-Topic mindestens so viele Partitionen wie im Ursprungstopic. Sonst scheitert schon das Ablegen, und die Nachricht kommt wieder beim Consumer an.
- Topics anlegen: In vielen Unternehmen legt der Betrieb Topics an, nicht die Anwendung. Dann gehört die DLQ in dieselbe Beauftragung wie das Haupttopic, inklusive Berechtigungen für die Consumer Group.
Nachverarbeiten: erst der Fix, dann der Rerun
Eine DLQ ist nur die halbe Lösung. Die Events liegen dort sicher, verarbeitet sind sie noch nicht. Im Projekt hat sich dieser Ablauf eingespielt:
Technisch reicht für den Rerun ein Listener auf dem DLQ-Topic, der erst nach dem Fix bewusst gestartet wird. Als Muster sieht das so aus:
@Component
public class DlqRerun {
private final Eingangsverarbeitung verarbeitung;
public DlqRerun(Eingangsverarbeitung verarbeitung) {
this.verarbeitung = verarbeitung;
}
// Startet nicht automatisch, sondern erst nach dem Fix
@KafkaListener(id = "dlq-rerun", topics = "eingang.dlq",
groupId = "eingang-dlq-rerun", autoStartup = "false",
properties = "auto.offset.reset=earliest")
void rerun(ConsumerRecord<String, String> record) {
verarbeitung.verarbeite(record.value());
}
}Gestartet wird er zum Beispiel über die KafkaListenerEndpointRegistry: registry.getListenerContainer("dlq-rerun").start(). Zwei Einstellungen machen den Rerun verlässlich. Weil er eine eigene Consumer Group hat, merkt sich Kafka, wie weit er gekommen ist, und ein zweiter Start spielt nur ein, was seitdem dazukam. Und auto.offset.reset=earliest gehört dazu, weil eine neue Consumer Group sonst mit dem Kafka-Default latest am Ende des Topics beginnt2. Der erste Rerun meldet dann Erfolg und hat genau die Events übersprungen, um die es ging.
Ist die Verarbeitung idempotent? Kommt ein Event doppelt an, darf daraus kein doppelter Eintrag im Zielsystem werden. Was passiert, wenn ein Event im Rerun erneut scheitert? Es darf nicht still verschwinden. Mit dem Error Handler von oben würde es in ein Topic eingang.dlq.dlq wandern. Für den Rerun-Container gehört deshalb ein eigener Error Handler dazu, der den Fehler meldet und anhält.
Die Retention ist eine Frist
Ein DLQ-Topic ist kein Archiv. Kafka löscht Nachrichten nach Ablauf der Retention, auch die in der DLQ. Im Projekt lag sie bei sechs Monaten und zwei Wochen. Das klingt nach viel Zeit, bis die ältesten Events aus dem Juli stammen und es Dezember ist.
Ende 2025 stand deshalb die Wahl an, die Retention zu verlängern oder nachzuverarbeiten. Am 13. Januar 2026 lief der erste produktive Rerun. Das älteste Event war vom 1. Juli, bis zum Löschen blieben damit nur noch wenige Tage.
Ein Teil der Events stammte von einem Ausfall des Stammdatendienstes im August 2025, der Rest von Fehlern, die inzwischen behoben waren. Seit 2026 ist der Rerun fester Nachgang nach jedem Bugfix-Release, das DLQ-Events betrifft. Im Juli 2026 waren es zum Beispiel 32 Events.
Was über die DLQ entscheidet
Keiner dieser Punkte ist neu. Im Projekt hat jeder davon eine Rolle gespielt.
| Thema | Einordnung |
|---|---|
| Retention | Die Retention der DLQ ist die Frist für die Nachverarbeitung. Sie gehört als Termin in den Kalender, denn ein halbes Jahr nach dem Fehler denkt niemand mehr daran. |
| Vorübergehende Fehler | Ein Ausfall eines Nachbarsystems ist kein Datenfehler. Retry-Topics mit Wartezeit vor der DLQ fangen kurze Ausfälle ab, bevor überhaupt ein Rerun nötig wird. Im Projekt standen sie von Anfang an auf der Liste der offenen Punkte. |
| Zuständigkeit | Eine DLQ, auf die niemand schaut, ist ein stiller Datenverlust mit Verzögerung. Wer wie oft hineinschaut, gehört festgelegt, bevor die erste Nachricht dort landet. |
| Monitoring | Ein Alarm bei wachsender DLQ gehört ins erste Release. Im Projekt wurde er erst nach dem Start abgestimmt, im Herbst 2025. |
| Werkzeuge | Die Header-Filter in der Kafka-Oberfläche funktionierten im Projekt nicht, Exception und Offset mussten Event für Event angesehen werden. Wer die Metadaten zusätzlich ins Log schreibt, kann dort filtern. |
| Wiederverwendung | Der Rerun steckt in einer eigenen Bibliothek. So nutzen die Consumer am Eingang und an den Publisher-Topics denselben Mechanismus, und ein Fix daran gilt überall. |
Checkliste für die eigene DLQ
- DLQ-Topic je Eingangstopic, mit genug Partitionen und Berechtigungen.
- Fehler-Metadaten im Header: Topic, Partition, Offset, Consumer Group, Exception.
- Entscheidung, welche Fehler wiederholt werden (vorübergehend) und welche direkt in die DLQ gehen (dauerhaft).
- Rerun-Mechanismus, der bewusst gestartet wird, mit eigener Consumer Group,
auto.offset.reset=earliestund idempotenter Verarbeitung. - Retention der DLQ als Termin im Kalender, ein Ablauf für die Nachverarbeitung dokumentiert.
- Alarm bei wachsender DLQ und eine klare Zuständigkeit.
Fazit
Eine Dead Letter Queue verhindert, dass eine einzelne Nachricht ein ganzes System anhält. Ihren Wert hat sie erst zusammen mit dem Danach, also einem eingespielten Rerun, der Retention als Termin und jemandem, der regelmäßig hineinschaut. Wie die TI-Messenger-Integration insgesamt aufgebaut ist, beschreibe ich in der Case Study TI-Messenger. Wie ich Event-getriebene Backends mit Kafka und Spring Boot umsetze, steht auf der Seite Java & Microservices.
Kafka-Fehlerbehandlung in Ihrem System?
Ob DLQ, Retry-Topics oder ein sauberer Ablauf für die Nachverarbeitung: Im Erstgespräch sehen wir uns an, wo Ihre Event-Verarbeitung heute hängen bleiben kann.
Java & Microservices ansehen- Spring for Apache Kafka, Reference Documentation, „Handling Exceptions“ (DefaultErrorHandler mit Standardverhalten nach zehn Fehlversuchen, DeadLetterPublishingRecoverer, Header der Dead-Letter-Records, Partitionen des DLQ-Topics): https://docs.spring.io/spring-kafka/reference/kafka/annotation-error-handling.html
- Apache Kafka Documentation, Consumer Configs, auto.offset.reset (Default latest): https://kafka.apache.org/documentation/#consumerconfigs_auto.offset.reset