At-most, at-least, exactly-once

О лекции

At-least-once — рабочая норма с дублями. Exactly-once в Kafka — идемпотентный продюсер и транзакции между топиками, а не «ровно один UPDATE в вашей Postgres». Граница системы важнее маркетингового EOS.

Kafka kafka собеседование

Семантика доставки — сколько раз сообщение могут потерять или продублировать. Речь про путь продюсер → брокер → консьюмер. Это не кнопка в конфиге, которая закрывает HTTP-оплату и строку в БД.

Пока побочный эффект живёт в другой системе, брокер не может обещать «ровно раз» для этого эффекта. Kafka обещает вещи внутри своего лога. Ваш UPDATE в Postgres — уже другая граница.

At-most-once: сообщение могут потерять. Типичные пути — commit offset до обработки, acks=0 на продюсере, «отправили и забыли» без ретраев. Дублей мало. Дыры есть.

At-least-once: сообщение могут обработать дважды. Обработали, не успели commit. Продюсер ретраинул без идемпотентности. Rebalance отдал партицию соседу. Это честный дефолт. С ним живут через идемпотентность получателя.

Exactly-once в брокере — узкое обещание. Внутри Kafka запись в топики и commit offset можно сделать атомарно. Ретраи одного продюсера на партицию не создадут дубль в логе. Это не XA с Postgres и не «ровно один заряд карты».

Пример. Консьюмер записал оплату в БД и упал до commit offset. Сообщение придёт снова. Брокер здесь честный at-least-once. «Ровно одна оплата» — уже задача вашей таблицы inbox, не Kafka.

Идемпотентный продюсер

Флаг enable.idempotence=true выдаёт продюсеру PID и последовательность номеров на партицию. Брокер отбрасывает ретрай с тем же номером. Нужны acks=all и ограниченный in-flight.

Это защищает от «сеть моргнула, клиент послал ещё раз ту же запись». Это не защищает от двух сервисов, каждый из которых честно шлёт «заказ создан» по одному бизнес-событию. Два разных продюсера — два PID, два сообщения.

Пример. Один процесс отправил «заказ 15 создан», не дождался ACK, отправил снова. Идемпотентный продюсер: в логе одна запись. Два разных сервиса независимо шлют «заказ 15 создан»: в логе две записи. PID разные.

Транзакции Kafka и граница системы

Транзакция брокера атомарна для produce в топики плюс commit offset потреблённых партиций в той же группе. Паттерн consume-process-produce: либо выходная запись и курсор уехали вместе, либо нет ни того ни другого.

Консьюмер должен читать read_committed. Иначе он может увидеть сообщения транзакции, которую потом откатили.

Запись в Postgres в эту транзакцию не входит. Нет общего координатора. Если process делает UPDATE в БД и Send в Kafka, вы снова в dual write. Для БД остаются outbox или upsert по event_id.

if err := producer.BeginTx(); err != nil {
	return err
}
if err := producer.Send(ctx, outTopic, result); err != nil {
	_ = producer.AbortTx()
	return err
}
if err := producer.SendOffsetsToTx(consumed); err != nil {
	_ = producer.AbortTx()
	return err
}
return producer.CommitTx()
// SQL в Postgres в эту транзакцию не входит — нужен outbox или upsert

Выходное сообщение и commit offset в одной транзакции брокера. SQL в Postgres сюда не входит.

Типичная ошибка

Обещать exactly-once «у нас Kafka, включили EOS» и писать в БД внутри process без inbox. Транзакция брокера не покрывает ваш SQL. Идемпотентный продюсер не защищает от двух разных сервисов с одним заказом. Консьюмер без read_committed увидит лишнее на транзакционном топике.

На собеседовании

Рабочая норма: at-least-once плюс идемпотентный consumer. Exactly-once у Kafka — до топика: идемпотентный продюсер и транзакции produce+offset. В БД — upsert по event_id или outbox. EOS не закрывает границу системы.