В чём разница между at-most-once, at-least-once и exactly-once
Разница между тремя семантиками доставки сводится к тому, как система реагирует на сбои сети, серверов и повторные попытки (retries):
🟢 Junior Level
30-секундный ответ
Разница между тремя семантиками доставки сводится к тому, как система реагирует на сбои сети, серверов и повторные попытки (retries):
- At-most-once (Не более одного раза): $0 \le N \le 1$. Продюсер отправляет данные в режиме «fire-and-forget» без повторов, а консьюмер коммитит оффсет до фактической обработки. Если что-то упало — сообщение теряется навсегда. Дублей нет, потерь много.
- At-least-once (Как минимум один раз): $N \ge 1$. Продюсер повторяет отправку при ошибках до подтверждения брокером, а консьюмер коммитит оффсет строго после успешной записи в БД. Сообщения не теряются никогда, но из-за сбоев сети могут прийти дубликаты. Это дефолтный стандарт индустрии.
- Exactly-once (Ровно один раз, EOS): $N = 1$. Сообщение доставляется и обрабатывается строго один раз без потерь и дубликатов. В Kafka это работает атомарно через Transaction API для сценариев вычитки и записи внутри кластера (Kafka $\to$ Обработка $\to$ Kafka).
Наглядная аналогия из жизни
- At-most-once — объявление по громкой связи на вокзале: диктор один раз объявил платформу отправления поезда. Если пассажир отвлекся или зашумел поезд — объявление пропущено. Повторов нет, риска услышать дважды нет.
- At-least-once — курьерская доставка пиццы с оплатой при получении: курьер звонит в дверь, пока клиент не откроет. Если домофон сбоит, курьер может перезвонить дважды или приехать повторно. Пицца гарантированно доставлена, но клиент может получить два звонка.
- Exactly-once — безналичный платеж по банковской карте с Idempotency Key: клиент нажал кнопку «Оплатить», терминал отправил уникальный
UUIDплатежа. Даже если интернет моргнет и терминал пошлет запрос повторно, банк спишет деньги со счета строго один раз.
Визуальное сравнение сценариев сбоя
Сценарий: Падение сервера консьюмера в процессе обработки
At-most-once:
[ Чтение ] ──► [ Коммит оффсета ] ──► [ Взрыв 💥 ] ──► [ Сохранение в БД ] (НЕ ВЫПОЛНЕНО!)
Результат: Сообщение утеряно навсегда, оффсет сдвинут вперед.
At-least-once:
[ Чтение ] ──► [ Сохранение в БД ] ──► [ Взрыв 💥 ] ──► [ Коммит оффсета ] (НЕ ВЫПОЛНЕНО!)
Рестарт: Консьюмер снова читает то же сообщение ──► Вторичное сохранение в БД (ДУБЛИКАТ).
Exactly-once (Kafka Transaction):
[ Чтение + Транзакция ] ──► [ Запись в Kafka-топик ] ──► [ Атомарный коммит оффсета ]
Результат: Либо оба действия зафиксированы, либо оба откатаны (чистое состояние).
🟡 Middle Level
Матрица конфигурации всех трех режимов
Чтобы система работала в нужном режиме, параметры продюсера, брокера и консьюмера должны быть согласованы:
| Компонент | At-most-once | At-least-once | Exactly-once (Kafka-to-Kafka) |
|---|---|---|---|
Producer acks |
0 (или 1) |
all (ждем кворум ISR) |
all |
Producer retries |
0 |
Integer.MAX_VALUE |
Integer.MAX_VALUE |
| Producer Idempotence | false |
true |
true |
| Producer Transactional | Не используется | Не используется | transactional.id = "tx-producer-1" |
| Broker Replication | min.insync.replicas=1 |
min.insync.replicas=2 |
min.insync.replicas=2 |
| Consumer Auto-commit | true |
false |
false |
| Consumer Isolation | read_uncommitted |
read_uncommitted |
read_committed |
| Момент коммита | Сразу при получении | После сохранения в хранилище | Внутри producer.sendOffsetsToTransaction() |
Паттерн «At-least-once + Идемпотентный консьюмер» в Spring Boot 3
Поскольку построить чистый Exactly-once за пределы Kafka (в БД PostgreSQL) невозможно силами одного брокера, в 99% микросервисов используют связку At-least-once доставки с дедупликацией на стороне БД:
@Service
@RequiredArgsConstructor
public class IdempotentPaymentConsumer {
private final JdbcTemplate jdbcTemplate;
@Transactional
@KafkaListener(topics = "payments", groupId = "billing-group")
public void consume(PaymentEvent event, Acknowledgment ack) {
// Идемпотентная вставка: ON CONFLICT DO NOTHING
// Таблица payments имеет UNIQUE(payment_id)
int updated = jdbcTemplate.update("""
INSERT INTO payments (id, user_id, amount, status)
VALUES (?, ?, ?, 'SUCCESS')
ON CONFLICT (id) DO NOTHING
""", event.id(), event.userId(), event.amount());
if (updated == 0) {
log.warn("Обнаружен дубликат сообщения с ID: {}. Пропускаем обработку.", event.id());
} else {
processBalanceUpdate(event);
}
// Ручной коммит смещения строго после завершения транзакции БД
ack.acknowledge();
}
}
🔴 Senior Level
Проблема двойной записи (Dual-Write Problem) и Transactional Outbox
Архитектурная катастрофа многих микросервисов:
// АНТИПАТТЕРН: Dual-Write
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // Запись в Postgres
kafkaTemplate.send("orders", order.getId(), order); // Отправка в Kafka 💥
}
Почему это ломает и At-least-once, и Exactly-once:
- Если отправка в Kafka упала по таймауту сети, транзакция БД откатится, но продюсер мог успеть залить сообщение в брокер (фантомное событие в Kafka без заказа в БД).
- Если транзакция БД закоммитилась, но упал под приложения до отправки в Kafka, заказ в БД есть, а событие в Kafka не ушло (потеря данных).
Решение: Transactional Outbox Pattern + Debezium CDC:
- Сервис в одной ACID-транзакции PostgreSQL пишет заказ в таблицу
ordersи событие в таблицуoutbox_table. - Debezium читает WAL журнала PostgreSQL и гарантированно доставляет событие в Kafka с семантикой At-least-once.
- Консьюмер на другом конце дедуплицирует события по первичному ключу.
Внутреннее устройство Transaction Coordinator в EOS
Когда продюсер отправляет транзакционные данные в Kafka:
initTransactions(): регистрируетtransactional.idу координатора транзакций. Координатор увеличиваетproducer_epochи огораживает (fences) любые старые зомби-инстансы с тем же ID.beginTransaction(): открывает локальную транзакцию на клиенте.send(): сообщения уходят в партиции с флагом транзакционности. Брокер сохраняет их в журнале, но скрывает от консьюмеров сread_committed.commitTransaction():- Фаза 1: Координатор пишет статус
PREPARE_COMMITв служебный топик__transaction_state. - Фаза 2: Координатор параллельно пишет маркеры
COMMITво все задействованные пользовательские партиции и топик__consumer_offsets. - Фаза 3: Координатор пишет статус
COMPLETE_COMMIT.
- Фаза 1: Координатор пишет статус
- Консьюмер с
read_committedвидит маркерCOMMITи отдает накопившиеся закоммиченные записи приложению.
4 Tricky Questions
1. Почему паттерн двойной записи (Dual-Write) ломает семантику доставки, и как Transactional Outbox решает эту проблему без использования тяжелого распределенного двухфазного коммита (XA / 2PC)?
Ответ:
- При наивной двойной записи невозможно атомарно связать коммит локальной СУБД (PostgreSQL) и отправку в брокер (Kafka). Если упадет сеть или сервер между шагом 1 и шагом 2, возникнет рассинхронизация: данные сохранены в БД, но событие не попало в Kafka (потеря), либо транзакция БД откатана, а событие уже ушло подписчикам (фантомные данные).
- Transactional Outbox устраняет распределенную транзакцию: и бизнес-сущность, и исходящее событие сохраняются в рамках одной локальной ACID-транзакции БД в таблицы
ordersиoutbox_events. - Далее отдельный надежный агент (Debezium через чтение Write-Ahead Log БД или фоновый poller) вычитывает
outbox_eventsи гарантированно публикует их в Kafka с семантикой At-least-once. Потеря исключена математически, а возможные дубликаты отсекаются идемпотентным консьюмером.
2. Может ли включение семантики Exactly-Once (EOS) в Kafka защитить систему от дубликатов, если внешний клиент (например, мобильное приложение) дважды дернул REST API сервиса-продюсера из-за сбоя в сотовой сети?
Ответ:
- Категорически нет!
- Семантика Kafka EOS защищает от дублирования исключительно внутри границы кластера Kafka (между клиентом KafkaProducer и брокерами Kafka при retry).
- Если мобильное приложение отправило HTTP POST
/orders, сервер выполнил заказ и начал отвечать200 OK, но сотовая вышка оборвала соединение, мобильный клиент честно повторит POST/ordersс новым HTTP-запросом. - Для сервиса-продюсера это два совершенно разных бизнес-запроса. Продюсер сгенерирует два разных сообщения и оба атомарно закоммитит в Kafka.
- Для предотвращения дублей на этом уровне требуется передавать сквозной Idempotency Key от мобильного клиента в заголовках HTTP и валидировать его в сервисе (через Redis/БД) до отправки в Kafka.
3. Что прочитает консьюмер с настройкой isolation.level = read_uncommitted (значение по умолчанию), если продюсер начал транзакцию, отправил 10 сообщений, а затем вызвал producer.abortTransaction()?
Ответ:
- Консьюмер с
read_uncommittedпрочитает все 10 сообщений отмененной транзакции так, будто они были успешно закоммичены! - В Kafka сообщения пишутся в лог-сегменты последовательно в порядке поступления сетевых пакетов. Брокер не удаляет записи из лога при откате транзакции: он лишь дописывает в конец лога специальный служебный маркер управления
ABORT. - Режим
read_uncommittedполностью игнорирует маркеры транзакций и отдает все «грязные» данные наверх в приложение. Это может привести к обработке отмененных заказов или ложным платежам. - Для корректной работы с транзакциями консьюмер обязан явно конфигурироваться параметром
isolation.level = read_committed.
4. В чем заключается «штраф за надежность» (Performance Overhead) при переходе от At-least-once к Exactly-once в Kafka?
Ответ:
- Переход на EOS влечет измеримые накладные расходы по ресурсам и времени отклика:
- Latency (Задержка): увеличивается в 2–3 раза. Консьюмер не может прочитать сообщения сразу после записи: он обязан ждать, пока продюсер не пришлет маркер
COMMITи координатор не сдвинет отметку LSO (Last Stable Offset). - Write Amplification (Нагрузка на диск и сеть): на каждую транзакцию брокер обязан дополнительно записать маркеры управления в партиции топика и состояние в топик
__transaction_stateс выполнением fsync. - Снижение Throughput: частые мелкие транзакции (коммит на каждое отдельное сообщение) катастрофически просаживают пропускную способность (вплоть до падения на 70–80%). Транзакции в Kafka эффективны только при батчинге (коммит раз в 100–500 мс или на пачку из сотен сообщений).
- Latency (Задержка): увеличивается в 2–3 раза. Консьюмер не может прочитать сообщения сразу после записи: он обязан ждать, пока продюсер не пришлет маркер
🎯 Шпаргалка для интервью
Главное о различиях гарантий доставки
- At-most-once: быстро, дешево, ненадежно. $0 \le N \le 1$. Данные теряются при любых сбоях (
acks=0, автокоммит до обработки). - At-least-once: надежно, стандарт де-факто. $N \ge 1$. Данные не теряются, возможны дубликаты (
acks=all,min.insync.replicas=2, ручной коммит после сохранения). - Exactly-once: дорого, сложно, изолированно. $N = 1$. Работает из коробки строго для топологий Kafka-to-Kafka через Transaction API.
- Миф о сквозном EOS: Kafka не умеет управлять транзакциями внешних СУБД. На границе с БД всегда используют Transactional Outbox и идемпотентные консьюмеры.
- Dual-write — зло: одновременная несогласованная запись в БД и Kafka гарантированно приводит к потере или фантомам.
- Консьюмеры при EOS: обязаны иметь
isolation.level=read_committed, иначе они читают «грязные» отмененные транзакции.
Сравнительный чеклист
| Свойство | At-most-once | At-least-once | Exactly-once | | :— | :— | :— | :— | | Потери данных | Возможны | ❌ Исключены | ❌ Исключены | | Дубликаты | ❌ Исключены | Возможны | ❌ Исключены | | Latency | Базовая ($1\times$) | Незначительная ($1.1 - 1.2\times$) | Высокая ($2 - 3\times$) | | Throughput | $100\%$ | $85 - 95\%$ | $50 - 70\%$ | | Основной Use Case | Логирование, метрики | Заказы, бизнес-события | Банковский клиринг (Kafka Streams) |
Красные флаги на собеседовании (Чего говорить нельзя)
- ❌ «Exactly-once решает все проблемы дубликатов на клиентах» — повторный клик в приложении создаст два разных сообщения.
- ❌ «Мы настроили Exactly-once на продюсере, поэтому консьюмер можно не настраивать» — без
isolation.level=read_committedконсьюмер прочитает не закоммиченные и отмененные данные. - ❌ «Чтобы не терять сообщения, мы используем XA 2PC транзакции между Kafka и PostgreSQL» — распределенный 2PC в высоконагруженных распределенных системах убивает производительность и порождает зависшие блокировки.