Kafka-Producer: acks, Idempotenz und Batching
1. Übersicht
Eine Nachricht zu Kafka zu senden, benötigt nur eine Zeile send(). Die schwierige Arbeit liegt in den begleitenden Fragen: Wurde die Nachricht wirklich persistiert? Wird sie bei einem Producer-Retry doppelt geschrieben? Und wie versendest du Zehntausende Nachrichten pro Sekunde, ohne den Broker zu überlasten?
Dieser Artikel behandelt drei Konfigurationsgruppen, die diese drei Fragen beantworten: acks, enable.idempotence und Batching (batch.size, linger.ms, compression.type). Nach dem Lesen wirst du wissen, wie du einen Producer sowohl sicher als auch schnell konfigurierst – mit reinem Kafka-Client oder Spring Boot 3.x – und wie du ihn testest.
2. Einrichtung
Mit reinem Java brauchst du nur kafka-clients:
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.9.0</version>
</dependency>
Mit Spring Boot 3.x verwendest du spring-kafka. Die Versionsverwaltung erfolgt durch Spring Boot, und es zieht automatisch kafka-clients mit sich:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
Durchgehend in diesem Artikel versenden wir ein Bestellereignis:
public record Order(String id, String customerId, BigDecimal total) {
public String toJson() {
return """
{"id":"%s","customerId":"%s","total":%s}""".formatted(id, customerId, total);
}
}
3. acks: Wann gilt eine Nachricht als versendet?
acks legt fest, wie viele Bestätigungen der Producer vom Broker warten muss, bevor er einen Request als erfolgreich einstuft.
| Wert | Bedeutung | Risiko |
|---|---|---|
acks=0 |
Wartet überhaupt nicht | Nachrichtenverlust ohne Benachrichtigung |
acks=1 |
Wartet, bis der Leader geschrieben hat | Nachrichtenverlust, wenn der Leader abstürzt, bevor der Follower die Nachricht repliziert |
acks=all (-1) |
Wartet, bis alle Replicas in der ISR geschrieben haben | Etwas höhere Latenz |
Beispiel für die Konfiguration mit reinem Client:
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.ACKS_CONFIG, "all");
try (var producer = new KafkaProducer<String, String>(props)) {
var order = new Order("o-1001", "c-42", new BigDecimal("250000"));
RecordMetadata meta = producer
.send(new ProducerRecord<>("orders", order.customerId(), order.toJson()))
.get();
System.out.printf("partition=%d offset=%d%n", meta.partition(), meta.offset());
}
Der Aufruf von .get() macht das Versenden synchron. Hier tun wir das nur zur Ausgabe des Ergebnisses. Mit acks=all gibt .get() erst zurück, nachdem alle Replicas in der ISR geschrieben haben:
partition=2 offset=17
Wir verwenden customerId als Schlüssel, daher gehen alle Bestellungen desselben Kunden in dieselbe Partition und behalten die richtige Reihenfolge.
3.1. acks=all ist nicht ausreichend ohne min.insync.replicas
acks=all bedeutet „alle Replicas, die gerade in der ISR sind”. Angenommen, die ISR schrumpft auf nur den Leader. Dann unterscheidet sich acks=all nicht von acks=1. Du musst zusätzlich im Topic oder Broker konfigurieren:
kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders \
--partitions 6 --replication-factor 3 \
--config min.insync.replicas=2
Mit replication-factor=3 und min.insync.replicas=2 müssen mindestens 2 Replikationen beim Schreiben vorhanden sein. Falls die ISR unter 2 fällt, erhält der Producer die Exception NotEnoughReplicasException, anstatt heimlich nur eine Kopie zu schreiben.
Hinweis: Setze
min.insync.replicasnicht gleich demreplication-factor. Dann reicht ein Broker-Neustart aus, um das Topic für alle Schreibvorgänge abzulehnen.
4. Idempotenz: Retry ohne Duplikate
acks=all bedeutet Retry. Betrachte dieses Szenario: Der Broker hat die Nachricht geschrieben, aber die Antwort geht auf dem Rückweg verloren. Der Producer denkt, der Versand ist fehlgeschlagen, und versendet die Nachricht erneut – sie wird zweimal geschrieben.
Idempotente Producer lösen genau dieses Szenario. Wenn du enable.idempotence=true aktivierst:
- Der Broker vergibt eine Producer-ID (PID) an den Producer.
- Jeder Batch, der an eine Partition gesendet wird, trägt eine aufsteigende Sequenznummer.
- Der Broker verwirft Batches mit bereits gesehenen Sequenznummern und lehnt Batches ab, die die Sequenznummer überspringen. Dadurch wird die Reihenfolge beibehalten, auch wenn mehrere Requests gleichzeitig unterwegs sind.
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);
Die Bedeutung jeder Zeile:
ENABLE_IDEMPOTENCE_CONFIGaktiviert den Mechanismus mit PID und Sequenznummer.- Idempotenz erzwingt
acks=all. Falls duacks=1zusammen mitenable.idempotence=trueexplizit setzt, wirft der Producer sofort beim Erstellen eineConfigException. max.in.flight.requests.per.connectionmuss ≤ 5 sein. Innerhalb dieses Limits garantiert Kafka die Reihenfolge innerhalb der Partition.retrieswird sehr groß gesetzt. Die tatsächliche Retry-Zeit wird durchdelivery.timeout.msbegrenzt (Standardwert 2 Minuten).
Seit Kafka 3.0 sind acks=all und enable.idempotence=true die Standardwerte. Dennoch solltest du sie explizit deklarieren. Wenn nicht, reicht aus, dass jemand acks=1 setzt, und die Idempotenz wird stillschweigend deaktiviert.
4.1. Was Idempotenz nicht kann
Dies ist ein häufiges Missverständnis:
- Sie verhindert Duplikate nur innerhalb einer Producer-Session, pro Partition. Wenn die Anwendung neu startet, erhält der Producer eine neue PID. Nachrichten, die nach einem Neustart erneut gesendet werden, werden immer noch doppelt geschrieben.
- Sie verhindert keine Duplikate, die durch deinen Code entstehen, z. B. wenn du
send()zweimal aufrufst, etwa bei eigenem Retry imcatch. - Sie ermöglicht keine atomaren Schreibvorgänge auf mehrere Partitionen.
Diese Fälle erfordern Transaktionen (transactional.id) oder einen Consumer, der Duplikate basierend auf orderId entfernen kann.
5. Batching: Latenz gegen Durchsatz eintauschen
Der Producer versendet nicht jede Nachricht einzeln. send() platziert die Nachricht nur in einem In-Memory-Buffer, organisiert nach Partition. Ein Background-Thread (Sender) fasst Datensätze zu Batches zusammen und versendet sie. Drei Hauptparameter:
| Parameter | Standard | Aufgabe |
|---|---|---|
batch.size |
16384 Byte | Maximale Größe eines Batch pro Partition |
linger.ms |
0 (5 ab Kafka 4.0) | Wartezeit zum Füllen eines Batch |
compression.type |
none |
Komprimierung des gesamten Batch: gzip, snappy, lz4, zstd |
buffer.memory |
32 MB | Speicher für alle ausstehenden Batches |
Ein Batch wird versendet, wenn er mit batch.size voll ist oder wenn linger.ms verstrichen ist – je nachdem, was zuerst eintritt. Mit linger.ms=0 werden Batches meist sofort versendet, enthalten also meist nur wenige Datensätze.
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
props.put(ProducerConfig.LINGER_MS_CONFIG, 10);
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
Diese Konfiguration akzeptiert bis zu 10 ms zusätzlicher Latenz. Im Gegenzug trägt jeder Request bis zu 64 KB komprimierte Daten. Bei wiederholten JSON-Daten wie Bestellereignissen reduzieren lz4 oder zstd oft deutlich die Netzwerkbandbreite und den Speicher auf dem Broker. Batch-Kompression ist viel effektiver als einzelne Nachrichtenkompression, weil größere Batches mehr wiederholte Muster bieten.
Du kannst dies anhand der Producer-Metriken überprüfen:
producer.metrics().forEach((name, metric) -> {
if (name.name().equals("batch-size-avg") || name.name().equals("records-per-request-avg")) {
System.out.println(name.name() + " = " + metric.metricValue());
}
});
Versende 10.000 Bestellungen hintereinander mit linger.ms=0, dann wiederhole es mit linger.ms=10. Du wirst sehen, dass records-per-request-avg deutlich ansteigt:
# linger.ms=0
batch-size-avg = 1830.4
records-per-request-avg = 21.7
# linger.ms=10
batch-size-avg = 41022.9
records-per-request-avg = 486.2
Die exakten Zahlen hängen von der Maschine und der Last ab, aber der Trend ist immer gleich.
Hinweis: Wenn der Buffer voll wird (langsamer Broker oder Netzwerkstau), blockiert
send()bis zumax.block.ms(Standard 60 Sekunden) und wirft dannTimeoutException. Rufesend()nicht im HTTP-Request-Handler auf, ohne diese Möglichkeit zu berücksichtigen.
Hinweis:
delivery.timeout.msmuss ≥linger.ms + request.timeout.mssein. Andernfalls lehnt der Producer die Initialisierung ab.
6. Konfiguration in Spring Boot 3.x
Spring Boot hat eigene Properties für gängige Parameter. Übrige Parameter gehen über properties:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
batch-size: 64KB
compression-type: lz4
buffer-memory: 64MB
properties:
enable.idempotence: true
linger.ms: 10
max.in.flight.requests.per.connection: 5
delivery.timeout.ms: 120000
Der Publisher verwendet KafkaTemplate. Seit Spring Kafka 3.0 gibt send() ein CompletableFuture zurück:
@Service
public class OrderEventPublisher {
private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public CompletableFuture<SendResult<String, String>> publish(Order order) {
return kafkaTemplate.send("orders", order.customerId(), order.toJson())
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Versand der Bestellung {} fehlgeschlagen", order.id(), ex);
} else {
var meta = result.getRecordMetadata();
log.info("Bestellung {} -> Partition {} Offset {}",
order.id(), meta.partition(), meta.offset());
}
});
}
}
Beachte drei Punkte:
- Der Callback läuft auf dem Sender-Thread des Producers. Führe im
whenCompletekeine schwere Arbeit aus und rufe keine blockierenden Operationen auf, da dies alle anderen Versendungen verlangsamt. - Wenn der Callback einen Fehler erhält, hat der Producer bereits alle Retries innerhalb des
delivery.timeout.msaufgebraucht. Versuche nicht, von Handsend()erneut aufzurufen. Das ist genau der Grund für Duplikate, die Idempotenz nicht verhindern kann. - Das Zurückgeben eines
CompletableFuturelässt den Aufrufer entscheiden, ob er warten möchte oder nicht.
7. Test des Producer-Verhaltens
kafka-clients bietet MockProducer zum Testen ohne Broker. Um ihn zu nutzen, trennst du die Logik in eine Klasse, die eine Producer-Schnittstelle akzeptiert:
public class OrderSender {
private final Producer<String, String> producer;
public OrderSender(Producer<String, String> producer) {
this.producer = producer;
}
public Future<RecordMetadata> send(Order order) {
return producer.send(new ProducerRecord<>("orders", order.customerId(), order.toJson()));
}
public static Properties reliableConfig(String bootstrap) {
Properties p = new Properties();
p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
p.put(ProducerConfig.ACKS_CONFIG, "all");
p.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
p.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
p.put(ProducerConfig.LINGER_MS_CONFIG, 10);
p.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
p.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
return p;
}
}
Test mit JUnit 5 und AssertJ:
class OrderSenderTest {
@Test
void whenSendingOrders_thenKeyIsCustomerId() {
var mock = new MockProducer<>(true, new StringSerializer(), new StringSerializer());
var sender = new OrderSender(mock);
sender.send(new Order("o-1", "c-42", new BigDecimal("100")));
sender.send(new Order("o-2", "c-42", new BigDecimal("200")));
assertThat(mock.history())
.hasSize(2)
.extracting(ProducerRecord::key)
.containsOnly("c-42");
}
@Test
void whenBrokerFails_thenFutureCompletesExceptionally() {
var mock = new MockProducer<>(false, new StringSerializer(), new StringSerializer());
var sender = new OrderSender(mock);
Future<RecordMetadata> future = sender.send(new Order("o-3", "c-7", BigDecimal.TEN));
mock.errorNext(new NotEnoughReplicasException("ISR < min.insync.replicas"));
assertThatThrownBy(future::get)
.hasCauseInstanceOf(NotEnoughReplicasException.class);
}
@Test
void reliableConfig_isAccepted() {
var props = OrderSender.reliableConfig("localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
var config = new ProducerConfig(props);
assertThat(config.getString(ProducerConfig.ACKS_CONFIG)).isEqualTo("-1");
assertThat(config.getBoolean(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG)).isTrue();
}
}
Jeder Test überprüft ein Verhalten:
- Der erste Test nutzt
autoComplete=true, daher gelingt jedersend()sofort.history()ermöglicht die Überprüfung des Schlüssels und bestätigt, dass die Reihenfolge pro Kunde gewahrt bleibt. - Der zweite Test nutzt
autoComplete=falseund setzt dannerrorNext(...), um das Broker-Ablehnen von Schreibvorgängen zu simulieren. So testest du, wie dein Code mit Fehlern umgeht. - Der letzte Test initialisiert
ProducerConfig, damit Kafka die Konfigurationskombination selbst validiert. Eine ungültige Kombination, etwaacks=1mit Idempotenz, führt zuConfigExceptionbei der Erstellung, nicht beim Deployment. Kafka normalisiertallzu"-1", daher wird auf"-1"verglichen.
8. Wann solltest du lockerer werden?
Eine „maximal sichere” Konfiguration ist nicht immer korrekt:
- Metriken, Logs, Clickstreams: Wenige verlorene Einträge sind akzeptabel. Nutze
acks=1, hoheslinger.msund Kompression mitzstdfür den besten Durchsatz. - Systeme, die geringe pro-Nachricht-Latenz benötigen (z. B. Echtzeitquotierungen): Halte
linger.msklein (0–2 ms). Erwarte dann aber nicht, dass Batches groß werden. - Zahlungen, Bestellungen: Nutze immer
acks=all, Idempotenz undmin.insync.replicas=2. Wenn du Lesen–Verarbeiten–Schreiben genau einmal brauchst, erwäge Transaktionen.
Hinweis:
max.in.flight.requests.per.connection=1„zum Sicherheitsgebot” zu setzen, ist unnötig, wenn Idempotenz aktiviert ist. Es senkt nur den Durchsatz.
9. Fazit
Drei Konfigurationsgruppen lösen drei verschiedene Probleme:
acks=allkombiniert mitmin.insync.replicasstellt keinen Nachrichtenverlust sicher.enable.idempotencestellt keine Duplikate beim Retry sicher und bewahrt die Reihenfolge innerhalb der Partition. Sein Geltungsbereich ist nur eine Producer-Session.batch.size,linger.msundcompression.typeentscheiden über den Durchsatz – auf Kosten etwas Latenz.
Deklariere diese Werte explizit, statt dich auf Standardwerte zu verlassen. Validiere sie durch Tests, um Überraschungen beim Deployment zu vermeiden. Und mache dein eigenes Retry für send() nicht, wenn der Producer das bereits für dich tut.