GH GambleHub

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.
Redis-эскиз:
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 — тогда ваши пайплайны будут предсказуемыми и безопасными даже при шторме ретраев и отказов.

Contact

Свяжитесь с нами

Обращайтесь по любым вопросам или за поддержкой.Мы всегда готовы помочь!

Начать интеграцию

Email — обязателен. Telegram или WhatsApp — по желанию.

Ваше имя необязательно
Email необязательно
Тема необязательно
Сообщение необязательно
Telegram необязательно
@
Если укажете Telegram — мы ответим и там, в дополнение к Email.
WhatsApp необязательно
Формат: +код страны и номер (например, +380XXXXXXXXX).

Нажимая кнопку, вы соглашаетесь на обработку данных.