order-events). Append-only, immutable.Topic: order-events (4 partitions)
Consumer Group A (3 consumers):
Consumer 1 → Partition 0, Partition 1
Consumer 2 → Partition 2
Consumer 3 → Partition 3
Consumer Group B (6 consumers):
Consumers 1-4 → Partitions 0-3
Consumers 5-6 → IDLE (more consumers than partitions = waste)
acks=all: producer waits for all in-sync replicas (ISR) to acknowledge. Strongest durability.min.insync.replicas=2: at least 2 replicas must confirm. Prevents data loss if leader dies.| Semantic | Mechanism | Risk |
|---|---|---|
| At-most-once | acks=0, commit before processing | Message loss |
| At-least-once | acks=all + retry, commit after processing | Duplicates |
| Exactly-once (EOS) | Idempotent producer + transactions | ~4% throughput overhead |
props.put("enable.idempotence", "true");
// Kafka assigns each producer a PID + sequence number per partition.
// Broker deduplicates retried messages with the same sequence number.
// Enables safe retries without duplicates on the broker side.
producer.initTransactions();
try {
producer.beginTransaction();
producer.send(new ProducerRecord("debit-events", debitMsg));
producer.send(new ProducerRecord("credit-events", creditMsg));
producer.sendOffsetsToTransaction(offsets, groupMetadata); // atomic commit
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
}
props.put("isolation.level", "read_committed");
// Consumer only sees messages from committed transactions.
// Aborted messages are filtered out.
At-least-once + idempotent consumers is the standard in most production systems. EOS adds complexity and latency — use it only for critical internal pipelines where deduplication logic is impractical (e.g., financial ledger updates).
session.timeout.ms.During rebalance: all consumption pauses until partitions are reassigned. Can cause duplicate processing if offsets weren't committed before the rebalance.
group.instance.id): consumer rejoining within session.timeout.ms gets its old partitions back without triggering a rebalance. Great for rolling restarts.session.timeout.ms = 45000 # how long before consumer is considered dead
heartbeat.interval.ms = 3000 # must be < session.timeout / 3
max.poll.interval.ms = 300000 # max time between poll() calls before leaving group
partition.assignment.strategy = CooperativeStickyAssignor
max.poll.interval.ms or reduce max.poll.records to process fewer messages per poll.Partition = hash(key) % numPartitions. Messages with the same key always go to the same partition → ordering guaranteed within a key.
| Key | Result |
|---|---|
customerId | All events for one customer are ordered |
orderId | All events for one order are ordered |
eventType | Few distinct values → hot partitions |
| null | Round-robin — no ordering, even distribution |
| same key for all | Everything goes to partition 0 — single consumer, no parallelism |
// Bad: "VIP" customers generate 80% of traffic
// All their events go to partition 2 → Consumer 2 is overwhelmed
// Fix: add a suffix to distribute VIP customers across partitions:
String key = customerId + "-" + (System.currentTimeMillis() % 3);
// Trade-off: ordering only within the sub-key, not across all events for that customer
Producer retries can reorder messages if max.in.flight.requests.per.connection > 1. Fix: enable idempotent producer (enable.idempotence=true) — handles reordering safely with up to 5 in-flight requests.
An integer per (consumer group, topic, partition) tracking how far the consumer has read. Stored in the __consumer_offsets internal topic.
enable.auto.commit=true
auto.commit.interval.ms=5000 # commits every 5 seconds
// Risk 1: message committed before processing completes
// → consumer dies mid-processing → message is "lost" (skipped on restart)
// Risk 2: message processed but commit fails
// → consumer restarts → reprocesses (duplicate)
while (true) {
ConsumerRecords<String, Event> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, Event> record : records) {
processEvent(record.value()); // process first
}
consumer.commitSync(); // then commit — blocks until confirmed
}
// commitAsync() — non-blocking, higher throughput, but may fail silently.
// Use commitAsync() in the loop, commitSync() on shutdown for safety.
// Per-partition commit for fine-grained control:
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
offsets.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)); // +1 = next to read
consumer.commitSync(offsets);
A service needs to update the database AND publish a Kafka event atomically. These are two different systems — there's no distributed transaction spanning both.
// BROKEN — dual write with a gap:
db.save(order); // success
kafka.send(orderEvent); // crash here → event never sent, DB updated
// Downstream services never know the order was placed.
// In one DB transaction:
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order); // save business data
outboxRepo.save(new OutboxEvent( // save event in same transaction
"order-events", order.getId(), toJson(order)));
}
// Separate relay process (Debezium CDC or scheduled poller):
// Reads unprocessed outbox rows → publishes to Kafka → marks as sent
A poison pill is a message that always fails processing (malformed JSON, unexpected schema, bad data). The consumer retries, fails, retries again → partition consumption is blocked. No new messages from that partition are processed.
// Spring Kafka DefaultErrorHandler with DLT:
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<?, ?> template) {
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template,
(record, ex) -> new TopicPartition(record.topic() + ".DLT", record.partition()));
FixedBackOff backOff = new FixedBackOff(1000L, 3); // 3 retries, 1s apart
return new DefaultErrorHandler(recoverer, backOff);
}
order-events
→ order-events-retry-1 (delay: 5s)
→ order-events-retry-2 (delay: 30s)
→ order-events-retry-3 (delay: 5min)
→ order-events-DLT (manual investigation)
Exponential backoff across separate retry topics. The main topic never slows down. DLT messages are monitored — alert ops, allow manual replay after fixing the root cause.
KStream<String, Order> orders = builder.stream("orders");
orders
.filter((key, order) -> order.getAmount().compareTo(THRESHOLD) > 0)
.groupByKey()
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
.count() // orders per customer per 5-min window
.toStream()
.filter((key, count) -> count > 10) // suspicious activity
.to("fraud-alerts");
| Use case | Choose |
|---|---|
| Simple event forwarding / enrichment | Consumer API |
| Aggregations, counts, sums over time windows | Kafka Streams |
| Joining two event streams | Kafka Streams |
| Calling external REST API per message | Consumer API |
| Real-time fraud scoring (sliding windows) | Kafka Streams |
num.standby.replicas), failover is near-instant.