Kafka Producers
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A KafkaProducer does far more between the moment application code calls send() and the moment a record is
actually on the wire than the one-line call suggests: the record is serialized, routed to a partition, appended
to an in-memory batch that may sit and grow for a few milliseconds, and only then handed to a background thread
that talks to the broker. This page walks that pipeline stage by stage, then the configuration knobs that shape
what each stage does — acks and idempotence for durability, compression.type for wire efficiency, and the
callback/Future pair the application gets back once a batch is actually acknowledged. The topic/partition/
broker/replication concepts the partitioner and acks lean on are covered in full on
Apache Kafka Architecture; the at-least-once/exactly-once
semantics acks and idempotence combine into are covered in full on
Delivery Guarantees rather than re-derived here.
The producer pipeline
Every record passed to send() moves through four stages inside the client, entirely on the calling thread up
to the point it is handed off, before a separate background thread ever touches the network:
| Stage | What happens |
|---|---|
Serializer |
The record’s key and value — ordinary Java objects as far as application code is concerned — are converted to
|
Partitioner |
The serialized record is assigned to one of the topic’s partitions, deciding which broker (that partition’s leader) it will eventually be sent to. Key hashing, the sticky partitioner, and custom partitioners are covered below. |
Record accumulator |
The record is appended to an in-memory batch dedicated to its destination partition, rather than sent
immediately. Batches accumulate until either |
Sender thread |
A single background I/O thread, separate from every thread calling |
The figure below draws this same pipeline as a left-to-right flow, with the broker’s asynchronous reply feeding
back into the callback/Future the application registered when it called send().
send() itself never blocks on the broker — it returns as soon as the record has been serialized, partitioned,
and appended to its batch, which is what lets a producer sustain high throughput without one slow broker
round-trip stalling every subsequent call.
ProducerRecord and serializers
A ProducerRecord<K, V> is the unit of data passed to send(): a topic, an optional partition, an optional key,
a value, an optional timestamp, and optional headers. Only the topic and value are strictly required — everything else can be left to the producer’s own defaults (partition chosen by the partitioner, timestamp set to
"now", no headers).
ProducerRecord<String, String> record =
new ProducerRecord<>("orders", "order-42", "{\"status\":\"created\"}");
The key.serializer and value.serializer configuration entries name the classes that turn the record’s typed
key and value into the byte[] Kafka actually stores; org.apache.kafka.common.serialization.StringSerializer
is the common case for JSON-as-text payloads, with ByteArraySerializer, IntegerSerializer, and a handful of
others available for other payload shapes, plus the Serializer<T> interface for a custom one.
See Apache Kafka — Producer Configuration Reference
for key.serializer/value.serializer and every other configuration entry named on this page.
Batching: the record accumulator, batch.size, and linger.ms
The record accumulator exists because sending one record per network request would be enormously wasteful — every request carries fixed framing and round-trip overhead regardless of how much data it contains, so batching several records that are headed to the same partition into one request amortizes that overhead across all of them. Two configuration entries govern when a batch stops accumulating and becomes eligible to send:
-
batch.size(default16384bytes) caps how large a single batch for one partition is allowed to grow. Once a batch reaches this size, it is sent as soon as the sender thread can get to it, without waiting any longer. -
linger.msadds a deliberate, bounded wait: even a batch that is not yet full is sent once it has been accumulating forlinger.msmilliseconds, so a low-traffic partition does not leave a small batch sitting indefinitely. The default changed from0to5milliseconds in Apache Kafka 4.0 (KIP-1030) — previously a producer would send a partially-filled batch essentially immediately once the sender thread was free, which favored latency over batching efficiency; the new 5 ms default trades a small, usually imperceptible amount of added latency for materially better batching (and, indirectly, compression) on the same traffic, without needing the application to opt in explicitly.
A batch is therefore sent at whichever of the two happens first: it fills up to batch.size, or linger.ms
elapses since the first record was added to it. Raising linger.ms further trades additional per-record latency
for larger, more efficient batches under sustained load; raising batch.size alone, without also raising
linger.ms, mostly matters for producers that are already sending fast enough to fill batches on their own.
See Apache Kafka — Producer Configuration Reference
for batch.size and linger.ms.
The partitioner
Every record needs a partition before it can be batched, and the partitioner decides that assignment using one
of three strategies, in order of how a given send() call is shaped:
-
Key hashing. When a record carries a non-null key, the default partitioner hashes it (murmur2) and maps the hash onto one of the topic’s partitions. Every record with the same key is therefore always routed to the same partition, which is what makes per-key ordering possible in the first place — see Apache Kafka Architecture for how partitions and their ordering guarantee actually work.
-
Sticky partitioning for null/no-key records. When a record has no key, hashing has nothing to hash, so spreading records evenly by picking a partition at random for every single record would defeat batching — each record would likely land in a different, mostly-empty batch. Instead, the default partitioner sticks to one partition for an entire batch’s worth of null-key records, switching to a new partition only once the current batch is sent (or
linger.mselapses), which lets same-destination records accumulate into full batches while still spreading load across partitions over time. -
Custom partitioners. Application code that needs different routing logic entirely — geographic sharding, a fixed partition per tenant regardless of key hash, and so on — implements the
Partitionerinterface and configures it viapartitioner.class; the interface’spartition()method receives the topic, key, serialized key, value, serialized value, and cluster metadata, and returns the partition index to use.
package com.example.orders;
import java.util.List;
import java.util.Map;
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
public class PriorityAwarePartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes,
Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
// route "priority" orders to a dedicated partition (index 0), everything else spreads over the rest
if (key != null && key.toString().startsWith("priority-")) {
return 0;
}
int remaining = partitions.size() - 1;
return 1 + (Math.abs(key == null ? 0 : key.hashCode()) % remaining);
}
@Override
public void close() {
// no resources to release
}
@Override
public void configure(Map<String, ?> configs) {
// no configuration needed for this partitioner
}
}
Partitioner and the sticky-partitioning default are documented in the client Javadoc; see
Apache Kafka Javadoc — Partitioner and partitioner.class in the
Apache Kafka — Producer Configuration Reference.
Durability: acks, min.insync.replicas, retries, and delivery.timeout.ms
acks controls how many replicas must have the record before the broker responds to the producer, and is one
of the two configuration entries — together with enable.idempotence below — most directly responsible for
what a Kafka producer actually guarantees about a message’s fate. The three values, and how min.insync.replicas
interacts with the strongest of them, are covered in full, alongside the rest of Kafka’s delivery-semantics
picture, on Delivery Guarantees; in short, from the producer
configuration’s own point of view:
acks value |
What the producer waits for |
|---|---|
|
Nothing — the record is considered sent the instant it leaves the client, with no acknowledgement and no retry on failure. Highest throughput, weakest guarantee: a lost record is never even detected by the producer. |
|
The partition leader has appended the record to its own log, but replication to follower replicas has not yet necessarily happened. A leader that crashes immediately after acknowledging, before followers replicate, can still lose the record. |
|
Every in-sync replica (the partition’s current ISR) has appended the record. |
Retries and the ambiguous-failure handling that goes with them (a request that times out without the producer
knowing whether it landed) are governed by delivery.timeout.ms as an overall upper bound: the producer keeps
retrying a failed send internally — governed by retries, which defaults to effectively unlimited
(Integer.MAX_VALUE) — until either the send succeeds or delivery.timeout.ms (default 120 seconds) has
elapsed since the record was originally handed to send(), at which point the send is finally reported as
failed to the application. This makes delivery.timeout.ms the single knob that bounds how long send()’s
returned `Future (or its callback) can remain unresolved, regardless of how many individual retry attempts
happen underneath it.
The idempotent producer and max.in.flight.requests.per.connection
enable.idempotence (default true) prevents a producer’s own retries from creating duplicate records at the
broker: the broker assigns each producer session a producer ID and tracks a per-partition sequence number, and
silently discards a retried write whose sequence number it has already seen, rather than appending a second copy.
This is what makes retrying on an ambiguous send failure safe rather than a duplication risk in its own right — the full at-least-once-versus-exactly-once discussion this enables is on
Delivery Guarantees. Idempotence requires acks=all and caps
max.in.flight.requests.per.connection at 5 (still allowing pipelining, unlike capping it at 1), both of which
the client enforces automatically when idempotence is enabled rather than leaving them for the application to
get wrong.
Compression
compression.type (default none) compresses an entire batch as one unit before it is sent, rather than
compressing individual records — which is another reason larger batches (via linger.ms/batch.size) matter:
a bigger batch compresses more effectively than several small ones sent separately. Four algorithms are built in:
| Value | Trade-off |
|---|---|
|
Highest compression ratio of the four, at the highest CPU cost — favors producers that are bandwidth- or storage-constrained over ones that are CPU-constrained. |
|
Low CPU cost, moderate compression ratio — favors throughput and low producer-side latency over squeezing the most bytes out of the payload. |
|
Similar profile to |
|
Configurable compression level, generally the best ratio-for-CPU-cost trade-off of the four across a wide level range, at the cost of being the newest and least universally battle-tested of the four in older client/broker combinations. |
Compressed batches are stored compressed on the broker and decompressed only by the consumer (or never, if the
consumer only needs to relay the batch onward), so choosing a compression algorithm is a producer-and-consumer
CPU trade-off, not just a producer one. See compression.type in the
Apache Kafka — Producer Configuration Reference.
Send callbacks, the returned Future, and headers
send() returns a Future<RecordMetadata> immediately and, optionally, invokes a Callback once the broker
has actually responded — the two are two ways of observing the same asynchronous outcome, and an application
typically uses one or the other rather than both. The callback fires on the sender thread, not the thread that
called send(), so it must not block; RecordMetadata reports the partition, offset, and timestamp the record
was actually written at once the send succeeds, and the callback’s Exception argument is non-null exactly when
it fails.
Headers are optional key/value metadata carried alongside a record — a Headers instance on ProducerRecord,
holding an ordered list of string keys mapped to raw byte[] values — commonly used for cross-cutting concerns
such as a trace id or a schema-registry content-type hint that do not belong in the business payload itself.
A complete producer example
package com.example.orders;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
import org.apache.kafka.clients.producer.Callback;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.common.header.internals.RecordHeader;
import org.apache.kafka.common.serialization.StringSerializer;
public class OrderEventProducer {
private static final String TOPIC = "orders";
public static void main(String[] args) throws InterruptedException, ExecutionException {
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.ACKS_CONFIG, "all"); // wait for every in-sync replica
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4"); // low CPU cost, good ratio for JSON payloads
props.put(ProducerConfig.LINGER_MS_CONFIG, 10); // batch slightly harder than the 5 ms default
// enable.idempotence defaults to true already; set explicitly here for clarity at the call site
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
try (Producer<String, String> producer = new KafkaProducer<>(props)) {
ProducerRecord<String, String> record =
new ProducerRecord<>(TOPIC, "order-42", "{\"status\":\"created\"}");
// a trace id header travels with the record without polluting the JSON payload
record.headers().add(new RecordHeader("trace-id", "a1b2c3d4".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
Callback callback = (RecordMetadata metadata, Exception exception) -> {
if (exception != null) {
// a genuinely failed send after delivery.timeout.ms and all internal retries are exhausted
System.err.println("send failed: " + exception.getMessage());
} else {
System.out.printf("sent to partition %d at offset %d%n", metadata.partition(), metadata.offset());
}
};
// fire the send asynchronously with a callback, then also demonstrate blocking on the returned Future
producer.send(record, callback).get();
}
}
}
This example builds directly on the KafkaProducer/ProducerConfig/ProducerRecord/Callback/RecordMetadata
API surface documented in the client Javadoc; see
Apache Kafka Javadoc — KafkaProducer and Apache Kafka — Producer
Configuration Reference for the full configuration surface `ProducerConfig’s constants name. For the initial,
simpler send/receive example this page builds on, see
Getting Started with Messaging.