Представим классическую схему — два сервиса интегрированы через брокер:
producer → broker → consumer → side effect
Хочется обеспечить exactly-once обработку на всей цепочке
Что может пойти не так?
producer → broker
Продюсер записал сообщение в брокер → брокер отправляет ack → сеть сбойнула → продюсер ретраит → в брокере дубликат
Как обычно решается: зачастую общий механизм выглядит так, что каждый продюсер отправляет сообщения с монотонно-возрастающим sequenceNumber (в рамках данного продюсера), а брокер дедуплицирует по (producerId, sequenceNumber). Т.е. если от данного producerId пришло сообщение с sequenceNumber меньшим или равным, чем уже записано, то брокер его дискардит
Конкретная реализация зависит от брокера: например у кафки и нашего яндексового logbroker механизмы чуть разные
broker → consumer → side effect
Консюмер обработал сообщение → не успел закомитить offset → упал → после восстановления обрабатывает то же самое сообщение
Как обычно решается: у каждого сообщения есть уникальный идентификатор + обработка внутри консюмера идемпотентна по уникальному идентификатору сообщения
Например, консюмер может хранить идентификаторы обработанных сообщений в своей БД и выполнять дедупликацию перед применением бизнес-логики (например, через insert ... on conflict do nothing в транзакции с бизнес логикой). Если сообщение уже было обработано, повторная обработка пропускается, и применение сообщения к системе становится идемпотентным
Однако если обработка включает side effect во внешнюю систему, то для сохранения exactly-once внешняя система тоже должна поддерживать идемпотентность
—
Поэтому важно понимать: в большинстве случаев под капотом “exactly-once” — это at-least-once доставка + идемпотентная обработка на каждом стыке. Физически сообщения могут обрабатываться несколько раз, но система спроектирована так, что итоговое состояние меняется ровно один раз