Exactly-once семантика
Что такое exactly-once на самом деле
Под «exactly-once» часто понимают две разные вещи:- Доставка: сообщение будет доставлено потребителю ровно один раз.
- Обработка: конечный побочный эффект (запись в БД, изменение баланса, эмиссия другого события) произойдет ровно один раз, даже если доставок или попыток было больше.
В распределенных системах надежнее говорить о семантике обработки. Доставку ровно один раз обеспечить трудно (дубликаты и повторы неизбежны), но можно сделать так, чтобы итоговое состояние было эквивалентно единственной обработке.
Когда EOS нужна, а когда нет
Требуется EOS, если:- Денежные транзакции и балансы: двойное списание недопустимо.
- Учет лицензий/квот, биллинговые счетчики.
- Необратимые внешние вызовы (например, единоразовая активация ключа).
- Эффекты обратимы или компенсируемы (саги, возвраты).
- Допускаются временные дубликаты в витринах/логах.
- Дешевле обеспечить идемпотентный 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 там, где стоимость ошибки неприемлема, и проверяйте ее реальность тестами падений и дублей — не верой в транспорт.