GH GambleHub

Exactly-once семантика

Что такое exactly-once на самом деле

Под «exactly-once» часто понимают две разные вещи:
  • Доставка: сообщение будет доставлено потребителю ровно один раз.
  • Обработка: конечный побочный эффект (запись в БД, изменение баланса, эмиссия другого события) произойдет ровно один раз, даже если доставок или попыток было больше.

В распределенных системах надежнее говорить о семантике обработки. Доставку ровно один раз обеспечить трудно (дубликаты и повторы неизбежны), но можно сделать так, чтобы итоговое состояние было эквивалентно единственной обработке.


Когда EOS нужна, а когда нет

Требуется EOS, если:
  • Денежные транзакции и балансы: двойное списание недопустимо.
  • Учет лицензий/квот, биллинговые счетчики.
  • Необратимые внешние вызовы (например, единоразовая активация ключа).
Можно обойтись at-least-once + идемпотентность, если:
  • Эффекты обратимы или компенсируемы (саги, возвраты).
  • Допускаются временные дубликаты в витринах/логах.
  • Дешевле обеспечить идемпотентный sink, чем тащить транзакции через весь тракт.

Модель: end-to-end vs. hop-by-hop

Hop-by-hop EOS: каждый участок (источник → процессор → приемник) гарантирует, что свое действие он применит ровно один раз.
End-to-end EOS: вся цепочка гарантирует, что от «факта» до «сайда-эффекта» результат эквивалентен единственной обработке.

На практике end-to-end достигается комбинацией транзакций и/или идемпотентности на каждом хопе.


Базовые строительные блоки

1. Идемпотентные операции

Повтор одного и того же запроса по ключу операции дает тот же результат.

Ключи: `idempotency_key`/`event_id`/`operation_id`.
Реализация: таблица «увиденных» операций с TTL ≥ ретенции входного лога.

2. Транзакции «читаю-обрабатываю-пишу» (read-process-write)

В одной атомарной единице работы фиксируются и побочный эффект, и прогресс чтения (офсеты/позиция). Это устраняет «призраки» при падении между шагами.

3. Версионирование/SEQUENCE

Для агрегата хранится версия/счетчик; изменения применяются только если `expected_version` совпадает. Повторы одинакового события не повышают версию → эффект один раз.

4. Дедупликация

Индекс по `(consumer_id, event_id)` или по естественному `business_id` операции.


Паттерны реализации

1) Транзакционный лог + транзакционный sink с фиксацией офсета

Идеально для стрим-процессинга.

Читаем из лога (только подтвержденные записи).
Выполняем обработку.

В одной транзакции:
  • a) записываем эффект в sink (БД/таблицу),
  • b) фиксируем «прочитан до офсета N» (в этой же БД).
  • Коммит. При рестарте либо все закоммичено (и офсет сдвинут), либо ничего.

Свойства: повторное выполнение не вредит; «ровно один раз» по эффекту, даже если сообщение читалось дважды.

2) Outbox + идемпотентный консьюмер

Для транзакционных сервисов-продюсеров.

В одной БД-транзакции: изменяем доменную запись и пишем событие в outbox.
Републикатор доставляет событие в шину с тем же `event_id`.
Консьюмеры применяют события идемпотентно (дедуп по `event_id`).

Свойства: продюсер гарантирует, что факт не потеряется; консьюмеры гарантируют ровно один эффект.

3) EOS в Kafka/Flink-подобных системах (концептуально)

Идемпотентный продюсер: защищает от дублей при ретраях отправки.
Транзакции продюсера: группа записей в топики + сдвиг консюмера коммитятся атомарно; читатели используют изоляцию `read_committed`.
Сторона процессинга хранит состояние (state store) и коммитит его вместе с транзакцией.

Свойства: повторный запуск сторы/таска не приводит к двойному эффекту; дубликаты «не видны» downstream.

4) Идемпотентные «сики» (sinks) через upsert/merge

Sink принимает `operation_id`/`event_id` и исполняет `UPSERT … WHERE NOT EXISTS`.
Побочный эффект (например, начисление) исполняется атомарно с проверкой «не применялось ли уже».

Свойства: дешевый способ EOS на границе с хранилищем, без распределенных транзакций.


Ключевые детали реализации

Идентификаторы операции

Должны быть детерминированы для повторов (не генерируйте новый UUID при ретрае).
Иметь устойчивую область видимости (на консьюмера/на агрегат/на систему).

Таблица дедупликации

Колонки: `consumer_id`, `operation_id`, `applied_at`, `ttl_expires_at`.
Индексы по `(consumer_id, operation_id)`.
TTL ≥ максимальному окну повторов (ретенция лога + потенциальные задержки).

Оптимистичная конкуренция

В write-модели храните версию агрегата.
При применении события/команды используйте `WHERE version =:expected`; дубликат не увеличит версию.

Заказ/порядок

EOS не равна «точно тот же порядок». Обеспечьте консистентность через ключ партиции (все события агрегата → одна партиция) и/или сравнение `sequence`.

Идемпотентные внешние вызовы

Для небезопасных методов (например, HTTP-вебхуки в сторонний сервис) добавляйте `Idempotency-Key` и требуйте, чтобы партнер поддерживал его.


Частые ловушки

EOS только в одном месте: если sink идемпотентен, но вы эмитите вторичные события без идемпотентности, получите «ровно много раз» downstream.
Два коммита: сначала в БД, потом коммит офсета в брокере — падение между ними создает дубликаты эффектов.
Сырые CDC наружу: изменение схемы БД ломает идемпотентность потребителей.
Неустойчивые ключи: `operation_id` зависит от времени/рандома и меняется при ретрае.


Стоимость и компромиссы

Латентность: транзакции/изолированные чтения → рост p95/p99.
Оверхед хранилищ: таблицы дедупа, state stores, логи транзакций.
Сложность эксплуатации: таймауты транзакций, ребаланс потоков, «залипшие» сессии.
Диагностика: больше состояний («в камите», «видно как read_committed», «откатилось»).

Выбирайте EOS точечно: для критических агрегатов и эффектов; остальное покрывайте идемпотентностью и компенсациями.


Тестирование exactly-once

1. Fault-injection: падение процесса между шагами «записал эффект» и «зафиксировал офсет».
2. Дубликаты: прокачайте то же сообщение 2–5 раз, убедитесь в одном эффекте.
3. Рестарты и ребаланс: остановка/перезапуск воркеров, проверка отсутствия двойной обработки.
4. Сетевые флаппи: таймауты в середине транзакции, повтор коммита.
5. Нагрузочные тесты: рост очередей → нет ли деградации до «навсегда в транзакции».


Мини-шаблоны (псевдо)

Идемпотентный sink с фиксированием офсета

pseudo begin tx if not exists(select 1 from dedup where consumer_id=:c and op_id=:id)
then apply_effect(...)    -- upsert / merge / add_one_time_action insert into dedup(c, id, applied_at) values(:c,:id, now)
end if update offsets set pos=:pos where consumer_id=:c commit

Команда с версией агрегата

pseudo begin tx update account set balance = balance +:delta,
version = version + 1 where id=:account_id and version=:expected_version;
if row_count=0 then error CONCURRENT_MODIFICATION commit

Безопасность и комплаенс

PII/PCI в таблицах дедупа: храните минимум, используйте токены вместо «сырых» данных.
Аудит: логируйте `operation_id`, `trace_id`, исход (APPLIED/ALREADY_APPLIED).
Политика хранения: TTL на дедуп-таблицах, архивирование офсетов/логов.


Анти-паттерны

«Настоящая exactly-once доставка»: попытка исключить дубли на уровне транспортного протокола без идемпотентности эффекта.
Глобальные распределенные транзакции на все: XA/2PC через все сервисы — хрупко и медленно.
Смешивание неидемпотентных побочек (например, e-mail отправлен до коммита офсета).
Отсутствие ключей операции: упование на «уникальность» полезной нагрузки.


Чек-лист продакшена

  • На каждом критическом эффекте есть идемпотентный ключ.
  • Офсет/позиция чтения фиксируется в одной транзакции с эффектом.
  • Таблицы дедупа проиндексированы; TTL ≥ ретенции лога.
  • Для агрегатов включена оптимистичная конкуренция (версия/sequence).
  • Потоки/топики читаются в режиме «только закоммиченные» (если доступно).
  • Тесты дубликатов и падений присутствуют в CI/CD.
  • Дашборды: доля повторов, неудачных транзакций, время блокировок, лаги.
  • Документация для интеграторов по `Idempotency-Key`/повторам/таймаутам.

FAQ

Можно ли обеспечить EOS без транзакций?
Часто да — через идемпотентность sink’ов (upsert/merge) и версионирование агрегатов. Транзакции упрощают гарантии, но повышают стоимость.

Нужен ли «exactly-once» всем?
Нет. Он дорог. Применяйте точечно там, где компенсация невозможна/дорога.

Как связать письма/вебхуки с EOS?
Буферизуйте уведомление до коммита, отправляйте после фиксации эффекта; храните `notification_id` и делайте отправку идемпотентной.

Что важнее — доставка или обработка?
Обработка. Доставки могут повторяться; итоговое состояние должно быть корректным и единственным.


Итог

Exactly-once — это про корректность эффекта, а не про отсутствие дублей в проводке. Ее достигают сочетанием идемпотентности, атомарной фиксации эффекта и прогресса чтения, разумного партиционирования и дисциплины версионирования. Применяйте EOS там, где стоимость ошибки неприемлема, и проверяйте ее реальность тестами падений и дублей — не верой в транспорт.

Contact

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

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

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

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

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

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