Freitagabend, 18:47. Ein Kunde kauft ein Konzertticket für 49€. Die Zahlung geht durch. Alles gut, bis er auf seinen Kontoauszug schaut und 49€ zweimal abgebucht sieht. Support-Ticket, Rückerstattung, ein verärgerter Kunde.
Was ist passiert? Kein Bug im eigentlichen Sinne. Der Producer hat die Nachricht an Kafka gesendet. Kafka hat sie gespeichert. Aber das ACK ging auf dem Rückweg verloren, ein Netzwerk-Hickup, passiert ständig. Der Producer hat keinen Beweis, dass die Nachricht angekommen ist. Also: Retry. Kafka speichert dieselbe Nachricht ein zweites Mal. Der Consumer verarbeitet beide.
Klingt theoretisch? Probier’s aus. Schick eine Zahlung und simulier dann den Netzwerkfehler:
Siehst du das? €353 statt €402. Drei Abbuchungen statt zwei. Der Producer hat einmal wiederholt, und der Consumer hat blind alles verarbeitet was in der Partition lag.
At-Least-Once ist der Default
Kafka garantiert standardmäßig at-least-once delivery. Das heißt: jede Nachricht kommt mindestens einmal an. Nicht genau einmal. Mindestens einmal.
Klingt harmlos. Ist es nicht. Jede Operation die nicht idempotent ist (Geld abbuchen, Counter inkrementieren, E-Mail verschicken) wird bei einem Retry doppelt ausgeführt.
Idempotenz bedeutet: eine Operation mehrfach ausführen hat denselben Effekt wie sie einmal auszuführen. SET balance = 451 ist idempotent. SET balance = balance - 49 ist es nicht.
“Dann stell ich halt acks=all ein.” Hilft nicht. acks=all garantiert, dass die Nachricht auf allen Replicas liegt bevor der Broker ACK sendet. Aber wenn das ACK auf dem Rückweg zum Producer verloren geht, wird trotzdem retried. Das Problem sitzt eine Ebene tiefer.
Producer Idempotency
Kafka löst das seit Version 0.11 mit einem simplen Mechanismus: Sequence Numbers. Jeder Producer bekommt beim Start eine Producer ID (PID). Jede Nachricht bekommt eine fortlaufende Sequence Number pro Partition. Der Broker trackt die letzte Sequence pro PID: kommt dieselbe nochmal, wird sie verworfen.
Klick dich durch die einzelnen Schritte und schau was beim Retry passiert:
Das ist der entscheidende Moment: der Broker sieht PID:7, SEQ:2 zum zweiten Mal und verwirft die Nachricht. Die Partition hat am Ende genau zwei Nachrichten, keine Duplikate. Und das einzige was du dafür tun musst:
enable.idempotence=true
Ein Config-Flag. Fertig. Kafka handelt den Rest intern: PID-Zuweisung, Sequence Tracking, Deduplication. Kein Application-Code nötig.
enable.idempotence=true setzt implizit acks=all, retries=Integer.MAX_VALUE und max.in.flight.requests.per.connection=5. Kafka macht die sichere Config für dich.
Das reicht nicht
Producer Idempotency schützt gegen Duplikate auf Broker-Ebene. Aber was ist mit dem Consumer?
Szenario: Dein Consumer liest Nachricht X, verarbeitet sie (bucht 49€ ab), und stirbt bevor er den Offset committen kann. Kafka weiß nichts davon. Der Consumer startet neu, liest ab dem letzten Offset und verarbeitet X nochmal.
Producer Idempotency hilft hier null. Die Nachricht ist nur einmal in der Partition. Aber der Consumer hat sie trotzdem zweimal verarbeitet.
Idempotency Keys
Also musst du es selbst machen. Jede Nachricht bekommt einen Idempotency Key, eine eindeutige ID die die Operation identifiziert. Der Consumer checkt vor der Verarbeitung: hab ich diesen Key schon gesehen?
async function processPayment(message: KafkaMessage) {
const { idempotencyKey, amount, accountId } = message.value;
// Check: schon verarbeitet?
const exists = await redis.get(`idem:${idempotencyKey}`);
if (exists) {
// Duplikat — skip
return;
}
// Verarbeiten
await db.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, accountId]
);
// Key speichern (mit TTL)
await redis.set(`idem:${idempotencyKey}`, '1', 'EX', 86400);
// Offset committen
await consumer.commitOffsets();
}
Der Key muss vom Producer generiert werden, nicht vom Consumer, nicht von Kafka. Warum? Weil nur der Producer weiß, welche logische Operation dahinter steht. UUID pro Request, Order-ID, ${userId}:${timestamp}:${action} — egal, solange es die Operation eindeutig identifiziert.
Die Lücke zwischen Check und Write
Der Code oben hat ein Problem. Zwischen dem Redis-Check und dem DB-Update liegt eine Lücke. Zwei Consumer-Instanzen könnten den Key gleichzeitig checken, beide “nicht gefunden” bekommen, und beide die Zahlung ausführen.
Wenn deine DB Transaktionen unterstützt (und das sollte sie), mach es atomar:
async function processPayment(message: KafkaMessage) {
const { idempotencyKey, amount, accountId } = message.value;
await db.transaction(async (tx) => {
// Insert-or-ignore: wenn Key existiert, passiert nichts
const { rowCount } = await tx.query(
`INSERT INTO processed_keys (key, processed_at)
VALUES ($1, NOW())
ON CONFLICT (key) DO NOTHING`,
[idempotencyKey]
);
if (rowCount === 0) return; // Duplikat
await tx.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, accountId]
);
});
}
Ein INSERT mit ON CONFLICT DO NOTHING in derselben Transaktion wie der Update. Atomar. Keine Lücke.
Die processed_keys-Tabelle wächst. Räum sie regelmäßig auf: alles älter als die maximale Consumer-Lag-Zeit deiner Consumer Group kann weg. Oder nutz eine TTL in Redis wenn du mit eventual consistency leben kannst.
Exactly-Once Semantics
Kafka bietet seit 0.11 auch Transactions: transactional.id auf dem Producer, read_committed auf dem Consumer. Damit bekommst du Exactly-Once innerhalb von Kafka, Produce und Offset-Commit passieren atomar.
enable.idempotence=true
transactional.id=payment-processor-1
Ich bin ehrlich: für die meisten Use Cases sind Kafka Transactions overkill. Sie machen Sinn wenn du von einem Topic liest, verarbeitest und in ein anderes Topic schreibst: klassisches Stream Processing. Aber sobald du eine externe DB updaten musst, hilft dir Kafka’s Exactly-Once allein nicht weiter. Du brauchst trotzdem Application-Level Idempotency.
Zusammenfassung in drei Sätzen
enable.idempotence=true auf dem Producer setzt du einmal und vergisst es. Consumer-seitig brauchst du Idempotency Keys mit atomarer Dedup-Logik, das ist Application-Code den dir kein Framework abnimmt. Und wenn jemand dir erzählt, Kafka hätte “out of the box Exactly-Once”: frag ihn, ob er damit schon mal eine Datenbank aktualisiert hat.