Producer: KafkaTemplate
KafkaTemplate — high-level API Spring Kafka для отправки records. Он скрывает низкоуровневый producer lifecycle и интегрируется с сериализацией, transactions и callbacks.
В production обычно явно задают key, topic, headers и correlation id. Key нужен не только для routing, но и для порядка событий одной бизнес-сущности.
kafkaTemplate.send("orders.events", orderId, new OrderPaid(orderId));
Consumer: @KafkaListener
@KafkaListener создаёт listener method, а listener container управляет poll loop, threads, assignment, commits и error handling.
Concurrency у listener-а не может эффективно превысить число partitions для одной consumer group: лишние threads будут простаивать.
@KafkaListener(topics = "orders.events", groupId = "billing")
void on(OrderPaid event, Acknowledgment ack) {
billing.handle(event);
ack.acknowledge();
}
Ack modes и offset
Ack mode определяет, когда Spring Kafka коммитит offsets: автоматически по batch/time/count или вручную через Acknowledgment.
Для at-least-once обычно обрабатывают сообщение, фиксируют бизнес-эффект, затем коммитят offset. Если упасть до commit, сообщение придёт повторно.
Retries, DLT и идемпотентность
DefaultErrorHandler может ретраить обработку с backoff, а после исчерпания попыток отправлять запись в DLT через DeadLetterPublishingRecoverer.
Отдельная ловушка — poison pill: запись, которую невозможно десериализовать (битый JSON, сменившаяся схема). Ошибка возникает ещё ДО вызова вашего listener-а, в десериализаторе, поэтому обычный error handler её не ловит, и контейнер встаёт в бесконечный цикл на одном offset. Лекарство — ErrorHandlingDeserializer: он оборачивает реальный десериализатор, ловит исключение и кладёт запись с пометкой, чтобы её можно было отправить в DLT, а не зацикливаться.
DLT не заменяет идемпотентность consumer-а. Любой retry/ре-баланс/падение между business commit и offset commit может дать повтор.
Transactions
Kafka transactions полезны, когда нужно атомарно consume-process-produce внутри Kafka. Для связки БД + Kafka чаще применяют transactional outbox, потому что одна транзакция на БД и Kafka сложно поддерживается надёжно.
Если listener пишет в БД, думайте о порядке: DB transaction, idempotency key и offset commit должны образовывать понятную гарантию.
Гарантии доставки
Семантика доставки определяется порядком «обработка ↔ commit offset». At-most-once: сначала commit, потом обработка — при падении сообщение теряется (зато без дублей). At-least-once: сначала обработка и бизнес-эффект, потом commit — при падении до commit сообщение придёт повторно (дубли возможны → нужна идемпотентность). Это разумный дефолт для большинства систем.
Exactly-once в пределах Kafka даёт транзакционный consume-process-produce (read_committed + транзакции продюсера). Но как только в цепочке появляется внешняя БД, «ровно один раз» обеспечивают не распределённой транзакцией, а идемпотентностью потребителя и/или transactional outbox.
Как отвечать на собеседовании
Свяжите producer (KafkaTemplate, key → партиционирование и порядок внутри партиции) и consumer (@KafkaListener + listener container, concurrency ≤ числа партиций в группе). Разберите три семантики доставки через момент commit offset (at-most/at-least/exactly-once) и почему at-least-once требует идемпотентности. Назовите ретраи + DLT (DefaultErrorHandler/DeadLetterPublishingRecoverer) и что DLT не заменяет идемпотентность; для БД+Kafka — transactional outbox, а не 2PC. Упомяните rebalancing при изменении состава группы.