Exactly-once vs At-least-once
1) Зачем вообще обсуждать семантики
Семантика доставки определяет как часто получатель увидит сообщение при сбоях и ретраях:- At-most-once — без повторов, но возможна потеря (редко приемлемо).
- At-least-once — не теряем, но возможны дубликаты (дефолт большинства брокеров/очередей).
- Exactly-once — каждое сообщение обрабатывается ровно один раз с точки зрения наблюдаемого эффекта.
Ключевая истина: в распределенном мире без глобальных транзакций и синхронной согласованности «чистое» end-to-end exactly-once недостижимо. Мы строим эффективно exactly-once: допускаем повторы на транспорте, но делаем обработку идемпотентной так, чтобы наблюдаемый эффект был «как будто один раз».
2) Модель отказов и где возникают дубликаты
Повторы появляются из-за:- Потери ack/commit (продюсер/брокер/консюмер «не услышали» подтверждение).
- Перевыборов лидеров/реплик, восстановлений после сетевых разрывов.
- Таймаутов/ретраев на любых участках (клиент→брокер→консюмер→синк).
Следствие: нельзя полагаться на «уникальность доставки» транспорта. Управляем эффектами: запись в БД, списание денег, отправка письма и т. п.
3) Exactly-once в поставщиках и что это на самом деле
3.1 Kafka
Дает кирпичики:- Idempotent Producer (`enable.idempotence=true`) — предотвращает дубли на стороне продюсера при ретраях.
- Транзакции — атомарно публикуют сообщения в несколько партиций и коммитят офсеты потребления (паттерн read-process-write без «пропусков»).
- Compaction — хранит последнее значение по ключу.
Но «конец цепи» (синк: БД/платеж/почта) все равно требует идемпотентности. Иначе дубль обработчика вызовет дубль эффекта.
3.2 NATS / Rabbit / SQS
По умолчанию — at-least-once с ack/redelivery. Exactly-once достигается на уровне приложения: ключи, дедуп-стор, upsert.
Вывод: Exactly-once транспортом ≠ exactly-once эффектом. Последний делается в обработчике.
4) Как построить эффективно exactly-once поверх at-least-once
4.1 Идемпотентный ключ (idempotency key)
Каждая команда/событие несет естественный ключ: `payment_id`, `order_id#step`, `saga_id#n`. Обработчик:- Проверяет «уже видел?» — дедуп-стор (Redis/БД) с TTL/ретеншном.
- Если видел — повторяет ранее вычисленный результат или делает no-op.
lua
-- SET key if not exists; expires in 24h local ok = redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", 86400)
if ok then return "PROCESS" else return "SKIP" end
4.2 Upsert в базе (идемпотентный синк)
Записи делаются через UPSERT/ON CONFLICT с проверкой версии/суммы.
PostgreSQL:sql
INSERT INTO payments(id, status, amount, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status,
updated_at = now()
WHERE payments.status <> EXCLUDED.status;
4.3 Транзакционный Outbox/Inbox
Outbox: бизнес-транзакция и запись «события к публикации» происходят в одной транзакции БД. Фоновый публикатор читает outbox и отправляет в брокер → нет расхождений между состоянием и событием.
Inbox: для входящих команд сохраняем `message_id` и результат до выполнения; повторная обработка видит запись и не повторяет побочные эффекты.
4.4 Консистентная обработка цепочки (read→process→write)
Kafka: транзакция «прочитал офсет → записал результаты → коммит» в один атомарный блок.
Без транзакций: «сначала запиши результат/Inbox, потом ack»; при крэше дубликат увидит Inbox и завершится no-op.
4.5 SAGA / компенсации
Когда идемпотентность невозможна (внешний провайдер списал деньги), используем компенсирующие операции (refund/void) и идемпотентные внешние API (повторный `POST` с тем же `Idempotency-Key` дает тот же итог).
5) Когда достаточно at-least-once
Обновления кэшей/материализованных представлений с компакцией по ключу.
Счетчики/метрики, где повторная инкрементация приемлема (или храним дельты с версией).
Нотификации, где вторичное письмо не критично (лучше все равно ставить ключ).
Правило: если дубль не меняет бизнес-смысл или легко обнаружим → at-least-once + частичная защита.
6) Производительность и стоимость
Exactly-once (даже «эффективно») стоит дороже: доп. записи (Inbox/Outbox), хранения ключей, транзакций, сложнее диагностика.
At-least-once дешевле/проще, лучше по throughput/p99.
Оценивайте: цена дубля × вероятность дубля vs стоимость защиты.
7) Примеры конфигураций и кода
7.1 Kafka продюсер (идемпотентность + транзакции)
properties enable.idempotence=true acks=all retries=INT_MAX max.in.flight.requests.per.connection=5 transactional.id=orders-writer-1
java producer.initTransactions();
producer.beginTransaction();
producer.send(recordA);
producer.send(recordB);
// также можно atomically commit consumer offsets producer.commitTransaction();
7.2 Консюмер с Inbox (псевдокод)
pseudo if (inbox.exists(msg.id)) return inbox.result(msg.id)
begin tx if!inbox.insert(msg.id) then return inbox.result(msg.id)
result = handle(msg)
sink.upsert(result) # идемпотентный синк inbox.set_result(msg.id, result)
commit ack(msg)
7.3 HTTP Idempotency-Key (внешние API)
POST /payments
Idempotency-Key: 7f1c-42-...
Body: { "payment_id": "p-123", "amount": 10.00 }
Повторный POST с тем же ключом → тот же результат/статус.
8) Наблюдаемость и метрики
`duplicate_attempts_total` — сколько раз ловили дубль (по Inbox/Redis).
`idempotency_hit_rate` — доля повторов, «спасенных» идемпотентностью.
`txn_abort_rate` (Kafka/БД) — доля откатов.
`outbox_backlog` — отставание публикации.
`exactly_once_path_latency{p95,p99}` vs `at_least_once_path_latency` — накладные расходы.
Аудит логов: связка `message_id`, `idempotency_key`, `saga_id`, `attempt`.
9) Тест-плейбуки (Game Days)
Повтор отправки: ретраи продюсера при искусственных таймаутах.
Крэш между «синк и ack»: убедиться, что Inbox/Upsert предотвращают дубль.
Пере-доставка: увеличить redelivery в брокере; проверить дедуп.
Идемпотентность внешних API: повторные POST с тем же ключом — одинаковый ответ.
Смена лидера/разрыв сети: проверить транзакции Kafka/поведение консюмеров.
10) Анти-паттерны
Полагаться на транспорт: «у нас Kafka с exactly-once, значит можно без ключей» — нет.
No-op ack до записи: ackнули, но синк упал → потеря.
Отсутствие DLQ/ретраев с джиттером: бесконечные повторы и шторм.
Случайные UUID вместо естественных ключей: нечем дедуплицировать.
Смешивание Inbox/Outbox с прод-таблицами без индексов: горячие блокировки и p99-хвосты.
Бизнес-операции без идемпотентного API у внешних провайдеров.
11) Чек-лист выбора
1. Цена дубля (деньги/юридика/UX) vs цена защиты (латентность/сложность/стоимость).
2. Есть ли естественный ключ события/операции? Если нет — придумайте стабильный.
3. Синк поддерживает Upsert/версионирование? Иначе — Inbox + компенсации.
4. Нужны ли глобальные транзакции? Если нет — сегментируйте на SAGA.
5. Требуется реплей/долгий ретеншн? Kafka + Outbox. Нужны быстрые RPC/низкая задержка? NATS + Idempotency-Key.
6. Мульти-тенантность и квоты: изоляция ключей/пространств.
7. Наблюдаемость: метрики idempotency и backlog включены.
12) FAQ
Q: Можно ли достичь «математического» exactly-once end-to-end?
A: Только в узких сценариях с одним консистентным хранилищем и транзакциями на всем пути. В общем случае — нет; используйте эффективно exactly-once через идемпотентность.
Q: Что быстрее?
A: At-least-once. Exactly-once добавляет транзакции/хранение ключей → выше p99 и стоимость.
Q: Где хранить ключи идемпотентности?
A: Быстрый стор (Redis) с TTL, либо таблица Inbox (PK=message_id). Для платежей — дольше (дни/недели).
Q: Как выбирать TTL дедуп-ключей?
A: Минимум = максимальное время повторной доставки + операционный запас (обычно 24–72 ч). Для финансов — больше.
Q: Нужен ли ключ, если у меня compaction по ключу в Kafka?
A: Да. Compaction уменьшит хранение, но не сделает ваш синк идемпотентным.
13) Итоги
At-least-once — базовая, надежная семантика транспорта.
Exactly-once как бизнес-эффект достигается на уровне обработчика: Idempotency-Key, Inbox/Outbox, Upsert/версии, SAGA/компенсации.
Выбор — это компромисс стоимость ↔ риск дубля ↔ простота эксплуатации. Проектируйте естественные ключи, делайте синки идемпотентными, добавляйте наблюдаемость и регулярно проводите game days — тогда ваши пайплайны будут предсказуемыми и безопасными даже при шторме ретраев и отказов.