Почти каждая статья этой серии в какой-то момент говорит: «консьюмер должен быть идемпотентным», «событие может прийти повторно», «at-least-once — это норма». Это фундамент, на котором стоят outbox, saga и проекцииготовится, с 10 августа, — но сам фундамент нигде не разобран в лоб. А он неочевиден: «доставить ровно один раз» звучит как то, чего все хотят и что вроде бы должно существовать, — и именно поэтому команды годами гоняются за exactly-once там, где его в принципе нет.
Эта статья центрирует россыпь ссылок серии в одну идею: сквозной exactly-once недостижим, но at-least-once доставка плюс идемпотентный консьюмер дают effectively-once — и это то, что вам на самом деле нужно. Разбираем семантику доставки честно и показываем, как сделать обработку идемпотентной на практике.
В статье
- Три семантики доставки
- Почему сквозной exactly-once недостижим
- Формула effectively-once
- Как сделать консьюмер идемпотентным
- Естественная идемпотентность
- Лунная база: списание кислорода без задвоения
- Где это уже работает в серии
- Не путать с идемпотентностью HTTP
- Эксплуатационные ошибки
- Checklist: идемпотентный консьюмер
Три семантики доставки
Вся разница сводится к одному решению: когда подтверждать (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, не получив ответ). Механизм идентичен — стабильный ключ + дедуп, — но уровень другой: там повтор порождает клиент синхронного запроса, здесь — брокер асинхронной доставки. Полезно видеть, что это одна идея в двух контекстах, а не два разных приёма.
Эксплуатационные ошибки
- Ключ генерируется на стороне консьюмера. Тогда у двух копий одного события разные ключи → дедуп бесполезен. Ключ обязан приходить от источника и быть стабильным.
- Эффект и пометка ключа — в разных транзакциях. Сбой между ними → либо задвоение, либо потеря. Только атомарно.
- Инкремент вместо абсолюта там, где можно абсолют. Дельта-операции без дедупа задваиваются на каждом ретрае.
- Окно дедупа короче окна повторной доставки. Ключ уже вычистили по TTL, а брокер переиграл сообщение позже → дубль проскочил. Окно ≥ гарантий переигрывания брокера.
- Гонка за сквозным exactly-once. Недели на «настоящий exactly-once» вместо часа на идемпотентный консьюмер — классическая ловушка.
Checklist: идемпотентный консьюмер
- Доставка честно at-least-once (ack после обработки, ничего не теряем)?
- У каждого события есть стабильный ключ идемпотентности от источника?
- Эффект и пометка ключа применяются атомарно (одна транзакция)?
- Где возможно — операция естественно идемпотентна (UPSERT / абсолют / условие)?
- Окно дедупа ≥ окна повторной доставки брокера?
- Команда перестала гоняться за сквозным exactly-once и строит effectively-once?
Если по всем пунктам «да» — дубли вам больше не страшны, а сквозной exactly-once больше не нужен.
Демо и версии
- Demo:
digital-cookbook/architecture/idempotency— at-least-once консьюмер, получающий дубли (инъекция повторной доставки): без дедупа баланс задваивается; с dedup-хранилищем (Postgres-таблица ключей) — нет; отдельный вариант с естественной идемпотентностью (UPSERT/абсолют) без хранилища ключей. Go/Java. - Версии брокера/библиотек пиновать при написании (гарантии доставки и окна переигрывания зависят от версии).
Документация и первоисточники
- Канон: Martin Fowler — What do you mean by “Event-Driven”?, Tyler Treat — You Cannot Have Exactly-Once Delivery, microservices.io — Idempotent Consumer, Gregor Hohpe — Enterprise Integration Patterns (Idempotent Receiver, Guaranteed Delivery).
- Транспорт: Confluent — Exactly-Once Semantics.
- Смежное на сайте: событийная архитектура — карта, Outbox/Inbox, Saga на практикеготовится, с 18 августа, Event Sourcing на практикеготовится, с 10 августа, exactly-once в Kafkaготовится, с 7 августа, идемпотентность в APIСкоро, матрица решенийготовится, с 8 августа.
Комментарии