Гарантии доставки и идемпотентность: как получить effectively-once

Вся событийная архитектура держится на одной связке, которую редко проговаривают вслух: at-least-once доставка + идемпотентный консьюмер = effectively-once. Разбираем, почему сквозной exactly-once недостижим в принципе, чем at-least-once отличается от at-most-once, как сделать консьюмер идемпотентным (ключи идемпотентности, dedup-хранилище, естественная идемпотентность, окно дедупа) и где эта связка уже работает в серии — в outbox, saga и проекциях

Почти каждая статья этой серии в какой-то момент говорит: «консьюмер должен быть идемпотентным», «событие может прийти повторно», «at-least-once — это норма». Это фундамент, на котором стоят outbox, saga и проекцииготовится, с 10 августа, — но сам фундамент нигде не разобран в лоб. А он неочевиден: «доставить ровно один раз» звучит как то, чего все хотят и что вроде бы должно существовать, — и именно поэтому команды годами гоняются за exactly-once там, где его в принципе нет.

Эта статья центрирует россыпь ссылок серии в одну идею: сквозной exactly-once недостижим, но at-least-once доставка плюс идемпотентный консьюмер дают effectively-once — и это то, что вам на самом деле нужно. Разбираем семантику доставки честно и показываем, как сделать обработку идемпотентной на практике.

Конвейер со штампованными посылками проходит через dedup-арку с реестром ID; дубли перечёркнуты (формула «дубль + дубль = один») и сбрасываются в корзину отбраковки — at-least-once плюс идемпотентность даёт effectively-once

В статье

Три семантики доставки

Вся разница сводится к одному решению: когда подтверждать (ack) сообщение — до обработки или после? От этого зависит, чем вы рискуете при сбое.

Семантика Когда ack Риск потери Риск дублей Где уместно
at-most-once до обработки да нет метрики, телеметрия — где потеря дешевле дубля
at-least-once после обработки нет да бизнес-события — дефолт
exactly-once нет нет недостижимо сквозно (см. ниже)
  • at-most-once: подтвердили и только потом обрабатываем. Упал воркер в середине — сообщение уже подтверждено, оно потеряно. Дублей не будет никогда, но и гарантии обработки нет. Годится там, где потерять один замер температуры дешевле, чем обработать его дважды.
  • at-least-once: обрабатываем, и только потом подтверждаем. Упал воркер после обработки, но до ack — сообщение вернётся и обработается снова. Ничего не теряется, но появляются дубли. Это дефолт для бизнес-событий: потерять «заказ оплачен» нельзя, а дубль можно погасить.
  • exactly-once: ровно один раз, без потерь и дублей. Именно этого все хотят — и именно этого сквозно не бывает.

Почему сквозной exactly-once недостижим

Между «обработал» и «подтвердил» всегда есть окно, в котором может упасть что угодно — воркер, сеть, брокер. И в этом окне у вас ровно два варианта, третьего нет:

  • подтверждать до гарантии обработки → рискуешь потерять (at-most-once);
  • подтверждать после обработки → рискуешь, что ack не дойдёт и сообщение придёт снова (at-least-once).

Это задача двух генералов в прикладной форме: нельзя одним сообщением гарантированно синхронизировать две стороны через ненадёжный канал. Даже «exactly-once» Kafka — это транзакции внутри границы брокера (read-process-write в пределах Kafka), а не сквозная гарантия того, что ваш вызов внешнего API или запись в чужую БД случится ровно один раз. Как только эффект выходит за границу транзакционного движка — в платёжный шлюз, в почту, в стороннюю систему — сквозной exactly-once рассыпается. Детали брокерского варианта — в exactly-once в Kafkaготовится, с 7 августа.

Вывод не пессимистичный, а освобождающий: перестаём гоняться за недостижимым и строим то, что работает.

Формула effectively-once

at-least-once доставка + идемпотентный консьюмер = effectively-once

Берём at-least-once (ничего не теряем) и гасим неизбежные дубли на стороне консьюмера — так, чтобы повторная обработка одного события не меняла результат. Для внешнего наблюдателя эффект неотличим от «ровно один раз»: заказ оплачен один раз, письмо ушло один раз, баланс списан один раз — сколько бы раз событие ни доставилось.

Вся остальная статья — про правую часть формулы: как сделать консьюмер идемпотентным.

Как сделать консьюмер идемпотентным

Идемпотентность = «повторный вызов с тем же входом не меняет состояние сверх первого». Базовый механизм — ключ идемпотентности + dedup-хранилище:

on message(event):
    key = event.id                  -- стабильный ключ (ID события/операции)
    tx:                             -- одна транзакция!
        if seen.contains(key):      -- уже обрабатывали этот ключ?
            return                  -- дубль → тихо пропускаем
        apply_effect(event)         -- бизнес-эффект
        seen.insert(key)            -- пометка "обработано"
    ack(message)                    -- подтверждаем ПОСЛЕ (at-least-once)

Три вещи, без которых это не работает:

  • Стабильный ключ. ID события должен быть одинаковым при повторной доставке (генерируется источником, а не консьюмером). Случайный ключ на стороне получателя убивает всю схему.
  • Атомарность «эффект + пометка». apply_effect и seen.insert — в одной транзакции. Иначе после сбоя между ними эффект применится, а ключ не запишется → повтор задвоит. (Если эффект во внешней системе, где общей транзакции нет — переходим к естественной идемпотентности ниже.)
  • Окно дедупа. Хранить ключи вечно дорого. На практике — TTL или партиционирование по времени: держим ключи, пока возможна повторная доставка (ретраи брокера, окно переигрывания). За пределами окна дубль теоретически проскочит — окно выбирают под гарантии брокера.

Естественная идемпотентность

Часто дедуп-хранилище не нужно вовсе — если переформулировать операцию так, чтобы повтор был безвреден по своей природе:

  • UPSERT вместо INSERT — «вставить или обновить по ключу»; повтор просто перезапишет тем же значением.
  • Установка в абсолют вместо инкрементаset balance = 847 идемпотентно; balance += 120 — нет (повтор прибавит дважды). Если событие несёт итоговое значение, а не дельту, — оно идемпотентно само по себе.
  • Выставление флага/статусаorder.status = paid можно применить сто раз с одним результатом.
  • Условная операцияUPDATE ... WHERE status = 'pending': второй раз условие не выполнится.

Правило: где можно выразить эффект как «привести к состоянию X», а не «изменить на дельту» — дедуп не нужен. Это дешевле хранилища ключей и надёжнее.

Лунная база: списание кислорода без задвоения

Событие OxygenConsumed { tank: "A", event_id: "e-8842", amount: 120 } доставляется в подсистему учёта. Брокер работает at-least-once — событие может прийти дважды.

Наивно (дельта, без дедупа): balance["A"] -= 120 на каждую доставку. Дубль → списали 240 вместо 120 → диспетчерская видит ложную нехватку кислорода. В аварийном контексте такая ошибка дорого стоит.

Через дедуп-ключ: в одной транзакции проверяем e-8842 в таблице обработанных, если нового нет — списываем и помечаем ключ. Повтор e-8842 отсекается на входе.

Через естественную идемпотентность: событие несёт не дельту, а снимок — OxygenMeasured { tank: "A", event_id: "e-8842", remaining: 727 }; применение balance["A"] = 727 идемпотентно, дубль безвреден без всякого хранилища ключей.

Второй и третий варианты дают одно и то же наблюдаемое поведение — баланс списан один раз, — то есть effectively-once поверх честного at-least-once.

Где это уже работает в серии

Эта связка — не отдельный приём, а несущая конструкция всей серии:

  • Outbox/Inbox: таблица inbox — это и есть dedup-хранилище на входе; обработанные ключи гасят повторные доставки.
  • Saga на практикеготовится, с 18 августа: идемпотентными должны быть и шаги, и компенсации — ретрай саги вызовет их повторно.
  • Event Sourcing / проекцииготовится, с 10 августа: проектор применяет события идемпотентно и хранит чекпоинт позиции атомарно с read-моделью.
  • Temporal / activity: ретрай activity вызывает её снова — идемпотентность на стороне разработчика.

Не путать с идемпотентностью HTTP

Тот же принцип есть и на синхронной стороне: Idempotency-Key в APIСкоро защищает от двойного списания при ретрае HTTP-запроса (клиент повторил POST, не получив ответ). Механизм идентичен — стабильный ключ + дедуп, — но уровень другой: там повтор порождает клиент синхронного запроса, здесь — брокер асинхронной доставки. Полезно видеть, что это одна идея в двух контекстах, а не два разных приёма.

Эксплуатационные ошибки

  1. Ключ генерируется на стороне консьюмера. Тогда у двух копий одного события разные ключи → дедуп бесполезен. Ключ обязан приходить от источника и быть стабильным.
  2. Эффект и пометка ключа — в разных транзакциях. Сбой между ними → либо задвоение, либо потеря. Только атомарно.
  3. Инкремент вместо абсолюта там, где можно абсолют. Дельта-операции без дедупа задваиваются на каждом ретрае.
  4. Окно дедупа короче окна повторной доставки. Ключ уже вычистили по TTL, а брокер переиграл сообщение позже → дубль проскочил. Окно ≥ гарантий переигрывания брокера.
  5. Гонка за сквозным exactly-once. Недели на «настоящий exactly-once» вместо часа на идемпотентный консьюмер — классическая ловушка.

Checklist: идемпотентный консьюмер

  1. Доставка честно at-least-once (ack после обработки, ничего не теряем)?
  2. У каждого события есть стабильный ключ идемпотентности от источника?
  3. Эффект и пометка ключа применяются атомарно (одна транзакция)?
  4. Где возможно — операция естественно идемпотентна (UPSERT / абсолют / условие)?
  5. Окно дедупа ≥ окна повторной доставки брокера?
  6. Команда перестала гоняться за сквозным exactly-once и строит effectively-once?

Если по всем пунктам «да» — дубли вам больше не страшны, а сквозной exactly-once больше не нужен.

Демо и версии

  • Demo: digital-cookbook/architecture/idempotency — at-least-once консьюмер, получающий дубли (инъекция повторной доставки): без дедупа баланс задваивается; с dedup-хранилищем (Postgres-таблица ключей) — нет; отдельный вариант с естественной идемпотентностью (UPSERT/абсолют) без хранилища ключей. Go/Java.
  • Версии брокера/библиотек пиновать при написании (гарантии доставки и окна переигрывания зависят от версии).

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

Обсуждение в Telegram

Присоединиться →

Комментарии