Tuning the producer
Producer tuning is a triangle: durability, throughput, latency —
pick the corner that matters and move deliberately toward it. The good news
from the benchmarks is that the safe corner is no longer
the slow corner: at the default acks=all + idempotence config, kacrab
outruns the Java client’s own perf tool. You do not need to trade safety for
speed; you tune within safety.
The defaults are the durability recipe
acks=all # leader + in-sync replicas must confirm
enable.idempotence=true # exactly-once, in-order per partition
retries=<effectively ∞> # bounded by delivery.timeout.ms instead
These are Kafka’s defaults and kacrab keeps them. Resist the classic anti-patterns:
- Don’t set
retries=0to avoid duplicates. That trades duplicates for data loss on any transient error. Idempotence already removes retry duplicates — the broker deduplicates by sequence number (how) — so retries are safe. - Don’t turn off idempotence “for performance”. Its wire cost is a few bytes per batch; every benchmark in this book ran with it on. Turning it off also forfeits ordering under retry (below).
- Complete the recipe broker-side with
min.insync.replicas=2on replication-factor-3 topics;acks=allwithmin.insync.replicas=1only promises one disk.
Ordering depends on idempotence, not just
max.in.flightWith
max.in.flight.requests.per.connection > 1and idempotence off, a retried batch can land behind a newer one — silent reordering. With idempotence on, sequence numbers pin the order and up to 5 in-flight requests stay strictly ordered per partition. Keep idempotence on and you keep both pipeline depth and order; the mechanism is the idempotency chapter’s whole story.
Chasing throughput
Throughput comes from batching — fewer, fuller requests. In descending order of effect:
linger.ms(default 0): the number-one lever.5–20ms lets the accumulator fill batches under moderate traffic; at very high send rates batches fill before the linger expires and the added latency is ~zero. The pipeline chapter shows where the wait sits.compression.type:zstdfor the best ratio (needs thezstdfeature and a C toolchain),lz4for the cheapest CPU. Compression multiplies effective network and broker disk throughput — it is often the biggest win on 1 KiB+ payloads. Codec levels viacompression.{zstd,gzip,lz4}.level; notecompression.lz4.levelneeds thelz4-hcfeature to have any effect (why).batch.size(default 16 KiB): raise to 64–256 KiB when records are large or throughput is high. A batch is up to this size, never a wait for it — oversizing wastes only memory, undersizing caps batching. With 10 KiB records a 16 KiB batch holds one record; per-broker request coalescing is what saved that workload (benchmarks), but a right-sized batch is still cheaper.buffer.memory(default 32 MiB): the total unsent-record budget. Size it ≈target throughput × worst-case broker stall you want to absorb. When it fills,send()blocks up tomax.block.ms— that back-pressure is a feature (failure modes); raising the buffer only delays it.
Two field lessons that outrank any key:
- Never
flush()per record.flushwaits for everything in flight — per-record it serializes the pipeline down to one round-trip at a time. Flush at commit points and shutdown (closeflushes for you). - Keep
send()cheap. The send path is an in-memory append; anything slow around it (per-recordenv::varon macOS took a global libc lock and cost 28% of throughput in our own harness) dominates long before the client does.
Chasing latency
linger.ms=0(default) dispatches as soon as the pipeline has a slot.max.in.flight.requests.per.connection: depth 5 (default) maximizes pipeline utilization; on a single low-RTT broker it adds queue latency (p99 ~13 ms in our bench vs ~2 ms at depth 1 — same throughput). But depth also absorbs broker pauses: at depth 5 a GC/fsync stall on one request lets four others drain; at depth 1 everything waits (p99.9 ~10 ms vs ~100 ms under an injected pause). Rule of thumb: keep 5 unless you’ve measured your own tail on your own RTT.acks=1buys a little latency at a real durability cost (leader-only confirmation). Prefer keepingacks=alland spending the latency budget onlinger.ms=0+ right-sized batches.
Large records
max.request.size (1 MiB default) caps a single request; the broker’s
message.max.bytes caps what it accepts — raise both together or the
producer’s larger request just gets rejected. Oversized batches are split
and requeued automatically on MESSAGE_TOO_LARGE; a single unsplittable
record fails its delivery. If a payload doesn’t have to be a Kafka record
(files, images), a blob store + reference usually beats raising the caps.
Keys and partitioning
- A record key is an ordering contract: same key → same partition → same order, byte-identically between kacrab and Java (partitioning). Choose keys for the ordering you need, and check cardinality — a hot key is a hot partition no client setting can fix.
- Null keys are fine — the sticky/adaptive partitioner batches them
efficiently and steers around slow leaders
(
partitioner.adaptive.partitioning.enable, on by default). Don’t forceRoundRobinPartitionerfor “fairness”; per-record scatter defeats batching. partitioner.ignore.keys=truegives keyed records sticky treatment too — only when you don’t rely on key ordering.
Transactions
For atomic multi-partition writes (or consume-transform-produce):
- Set
transactional.id— stable per logical producer instance, unique per instance. Two producers sharing an id fence each other by design (that’s the failover mechanism, not a bug). - Keep transactions short; every open transaction holds coordinator
state and delays
read_committedconsumers. - The consumer side needs
isolation.level=read_committed(consumer tuning).
Field notes
| Goal | Change | Leave alone |
|---|---|---|
| Maximum safety | min.insync.replicas=2 (broker) | acks, idempotence, retries — already right |
| Throughput | linger.ms=5–20, compression.type=zstd, batch.size=64Ki+ | max.in.flight |
| Low latency | linger.ms=0; measure before touching max.in.flight | acks |
| Bounded staleness | delivery.timeout.ms down to your real budget | retries |
| Large records | max.request.size and broker message.max.bytes | — |
And the two habits: flush at boundaries, not per record; state a reason for every non-default line in the config.