lenec ru

← все посты

Exactly-once vs at-least-once: что реально гарантируют брокеры

19K

Exactly-once delivery — самый продаваемый и самый недопонятый термин в распределёнке. На презентациях производителей брокеров он звучит как магия. На проде через полгода работы инженеры обнаруживают, что «exactly-once» в их системе означает совсем не то, что думал product owner. И уж точно не то, что писали в докладе с конференции.

За двенадцать лет я строил системы и на Kafka, и на RabbitMQ, и на NATS. Везде был один и тот же разговор: «у нас exactly-once, потому что мы используем X». В реальности — at-least-once с дедупом, и это нормально. Разберу, что брокеры реально гарантируют, чем отличается delivery от effect, и где практическая граница между «потеряли» и «дублировали».

Три уровня гарантий

Стандартная классификация:

  • At-most-once. Сообщение либо доставлено, либо потеряно. Дублей нет.
  • At-least-once. Сообщение доставлено хотя бы раз. Дубли возможны.
  • Exactly-once. Сообщение доставлено ровно один раз. Без потерь и без дублей.

Все три — про доставку, про сетевой акт прихода сообщения от A к B. Они ничего не говорят про то, что произойдёт с сообщением после доставки.

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

Это базовый факт распределёнки, и он не зависит от брокера. Producer отправил сообщение, не получил ack. Что произошло?

  • Сообщение не дошло. Надо повторить.
  • Сообщение дошло, ack потерялся. Не надо повторять.
  • Сообщение дошло, обработалось, ack потерялся. Не надо повторять.

Producer не может различить эти случаи. Значит, либо он ретраит и рискует дублем, либо не ретраит и рискует потерей. Третьего нет.

Это формализуется через FLP-теорему и связано с CAP. Любая система, которая претендует на exactly-once delivery в общем случае, на самом деле что-то ослабляет: либо требует синхронизированных часов, либо строит дедуп поверх at-least-once и называет это exactly-once.

Что значит «exactly-once в Kafka»

Kafka вышла с термином exactly-once около 2017 года. Технически это работает, но узко.

Гарантии Kafka exactly-once:

  • Producer может атомарно записать в несколько партиций (transactional producer).
  • Consumer с isolation.level=read_committed не увидит сообщения из аборченных транзакций.
  • В Kafka Streams комбинация «прочитать → обработать → записать → закоммитить offset» становится атомарной.

Что НЕ гарантируется:

  • Если consumer читает из Kafka и пишет в Postgres — атомарность Kafka не распространяется на Postgres. Это классический dual write.
  • Если consumer вызывает внешний API — он может вызвать дважды. Никакая транзакция Kafka это не предотвратит.
  • Если producer записал в Kafka и в свою БД — это два разных коммита, между ними окно отказа.

Реальный смысл «exactly-once в Kafka» — это «атомарность чтения-обработки-записи внутри Kafka-экосистемы». Шаг за её пределы — обычный at-least-once с потенциальными дублями, и нужен дедуп.

RabbitMQ и NATS

RabbitMQ исторически рекламировал at-least-once и не пытался продавать exactly-once. Это честнее. Publisher confirms + manual consumer ack дают надёжный at-least-once. Дедуп — на стороне приложения.

NATS Core — at-most-once. Сообщение может потеряться. Это нормально для метрик и неважных событий.

NATS JetStream — at-least-once с дедупом по messageId на producer-стороне. Это ближайшее к «effectively-once», но не настоящий exactly-once: дедуп работает в окне (по умолчанию 2 минуты), и consumer всё равно может получить дубль при разрыве ack-таймаута.

Никто из этих брокеров не заявляет универсального exactly-once. И это правильно: они честно говорят, что вы получаете.

Effectively-once: что нужно на самом деле

Бизнесу не нужно «доставить ровно один раз». Бизнесу нужно «не списать деньги дважды», «не отправить два письма», «не зарезервировать товар повторно». Это эффект, не доставка.

Effectively-once = at-least-once delivery + идемпотентная обработка. Сообщение может прийти 1, 2, 5 раз — но эффект один. Это достижимо и работает на проде.

Реализация — стандартный inbox pattern или прямая бизнес-идемпотентность:

@Transactional
fun handle(event: ChargeRequested) {
    val inserted = inboxRepo.tryInsert(event.id)
    if (!inserted) {
        log.info("event ${event.id} already processed, skipping")
        return
    }
    paymentService.charge(event.userId, event.amount)
    inboxRepo.markProcessed(event.id)
}

Тут at-least-once брокера превращается в exactly-once-effect. Дубли отбрасываются на входе, эффект происходит ровно один раз.

Когда нужен «настоящий» exactly-once

Я искренне не помню задач, где нужен был именно exactly-once delivery без возможности дедупа на стороне получателя. Все реальные кейсы решаются через at-least-once + идемпотентность.

Если в вашем дизайне consumer не может быть идемпотентным — это знак, что дизайн неправильный. Сделать обработку идемпотентной всегда дешевле, чем строить exactly-once-инфраструктуру.

Возможные исключения — узкоспециализированные системы вроде real-time биржевой торговли, где даже однократный дубль ордера критичен. Там используют синхронные подтверждения с трейдерской стороны и снимают неоднозначность через диалог. Это не выглядит как обычная очередь сообщений.

Подводные камни «у нас exactly-once»

Несколько типичных заблуждений.

«Мы используем Kafka transactional producer, значит, у нас exactly-once»

Только если вы пишете только в Kafka. Если consumer пишет в Postgres — нет. Если producer читает из Postgres — тоже нет. Транзакция Kafka покрывает топики, не БД.

Часто команда внедряет transactional producer и снимает дедуп с consumer'а, считая, что он не нужен. Через месяц начинаются загадочные дубли.

«Мы используем messageId в JetStream, значит, дублей нет»

Дедуп messageId работает в окне (duplicate_window). По умолчанию 2 минуты. Если producer повторит сообщение через 3 минуты — оно пройдёт. Если consumer перепрочитает после rebalance — оно придёт второй раз.

JetStream messageId — это полезный инструмент дедупа, но не «exactly-once». Без consumer-side дедупа всё равно нужно.

«У нас идемпотентный producer, значит, дубли не возможны»

Idempotent producer Kafka гарантирует, что одно и то же сообщение от одного producer'а не запишется дважды в одну партицию из-за ретрая. Это локальная гарантия.

Если producer перезапустился, у него новый PID — и дедуп не работает. Если несколько producer'ов пишут одно и то же — дедуп тоже не работает. Это полезная функция, но узкая.

Практическое руководство

Что я делаю на проектах в зависимости от требований.

Дубли допустимы (метрики, аналитика, нотификации): at-least-once без дополнительных мер. Если событие задвоилось — пережить.

Дубли допустимы редко (логи, аудит): at-least-once с базовым дедупом по UUID на стороне consumer'а. Простой кеш в Redis с TTL.

Дубли недопустимы (платежи, заказы): at-least-once + полноценный inbox pattern с PRIMARY KEY в Postgres + бизнес-идемпотентность по operation_id.

Особо чувствительные операции: то же, что выше, плюс мониторинг дублей. Метрика «процент отбракованных дублей» — индикатор того, что система работает.

Метрики и наблюдаемость

На проекте с at-least-once + дедупом я держу несколько метрик:

  • Producer retry rate. Сколько повторов делает producer. Резкий рост — проблема со стабильностью брокера.
  • Consumer dedup rate. Сколько дублей отбрасывается на входе. 0% — подозрительно (возможно, дедуп не работает). 5% — норма. 50% — что-то не так с producer'ом.
  • Inbox table size. Растёт быстрее очистки — будет деградация.
  • Lag консьюмеров. Если consumer лагает, ack-таймауты растут, и брокер начинает редоставлять — петля.

Без этих метрик любые гарантии — вера, не факт.

Что запомнить

Exactly-once delivery в общем случае физически недостижим, и любые маркетинговые заявления требуют чтения мелкого шрифта. То, что вам нужно бизнесу — exactly-once effect, и он достигается через at-least-once + идемпотентность.

Когда выбираете брокер, не смотрите на кричащую плашку «exactly-once». Смотрите на то, как он помогает вам построить идемпотентную обработку: дедуп messageId на producer-стороне (NATS JetStream), publisher confirms (RabbitMQ), идемпотентный producer и transactional API (Kafka). Это инструменты, которыми вы строите гарантию эффекта, а не само-собой-разумеющаяся доставка-один-раз.

И главное — не отказывайтесь от дедупа на consumer-стороне. Любая «exactly-once» гарантия брокера ослабнет в одном из сценариев отказа. Inbox или эквивалент остаётся последней линией обороны, и она работает.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.