Модуль: Распределённые системы · Уровень: Senior+

TL;DR#

  • Гарантии доставки определяются тем, когда отправляется ack: до обработки (at-most-once, риск потери) или после (at-least-once, риск дублей).
  • at-most-once — каждое сообщение доставляется 0 или 1 раз (никогда не дублируется, но может потеряться).
  • at-least-once — 1+ раз (никогда не теряется, но возможны дубли). Это дефолт большинства брокеров.
  • exactly-once delivery — миф. Нельзя атомарно «доставить сообщение и зафиксировать ack» через ненадёжную сеть (проблема двух генералов, FLP-невозможность консенсуса в асинхронной модели).
  • То, что в индустрии называют exactly-once, на практике есть effectively-once / exactly-once processing = at-least-once delivery + идемпотентность + дедупликация (dedup по message id, idempotent consumer).
  • Kafka EOS реализует это инженерно: idempotent producer (PID + sequence number), транзакции (transactional.id, atomic read-process-write), isolation.level=read_committed на консьюмере.
  • 2PC даёт атомарность распределённого commit, но это блокирующий протокол: отказ координатора в фазе uncertainty подвешивает участников с захваченными локами. 3PC добавляет pre-commit (неблокирующий), но ломается при network partition.

Теория#

Что вообще гарантируется и где#

«Гарантия доставки» — характеристика контракта между отправителем (producer), брокером/каналом и получателем (consumer). Важно различать три уровня:

  • Producer → Broker (запись).
  • Broker → Consumer (доставка/чтение).
  • Consumer side effect (обработка: запись в БД, вызов API, отправка следующего сообщения).

End-to-end гарантия не сильнее самого слабого звена. Если producer пишет at-least-once, а consumer коммитит offset до обработки (at-most-once на своей стороне), суммарно вы получите смесь потерь и дублей. Senior-ответ всегда уточняет: «гарантия на каком плече?».

Ключевой выбор: ack до обработки или после#

Вся таксономия сводится к моменту подтверждения. Рассмотрим консьюмера, читающего из очереди:

Вариант A (at-most-once):
  receive(msg) -> commit/ack -> process(msg)
                       ^ если упали ЗДЕСЬ — msg потерян (ack уже отправлен, при рестарте не перечитаем)

Вариант B (at-least-once):
  receive(msg) -> process(msg) -> commit/ack
                                       ^ если упали ДО commit — при рестарте перечитаем => дубль

Это фундаментальный trade-off: между потерей и дублированием нельзя выбрать «ничего», потому что нет атомарной операции, охватывающей и сетевой ack, и локальный side effect одновременно. Можно лишь сдвинуть риск.

ПоведениеКогда ackРискКогда выбирают
at-most-onceдо обработкипотеряметрики, телеметрия, логи — где дубль хуже потери и допустимы пропуски
at-least-onceпосле обработкидублидефолт для бизнес-событий, платежей, заказов

at-most-once#

Сообщение доставляется не более одного раза. Реализуется отправкой fire-and-forget (producer не ждёт ack, acks=0 в Kafka) или ранним коммитом offset на консьюмере.

  • Плюсы: минимальная латентность, нет накладных на дедупликацию, нет дублей.
  • Минусы: при сбое сети/процесса сообщение теряется молча.
  • Где уместно: высокочастотная телеметрия, sampled-метрики, ephemeral-уведомления.

at-least-once#

Сообщение доставляется минимум один раз, возможны повторы. Producer ретраит до получения ack; consumer коммитит offset только после успешной обработки.

Источники дублей:

  • Producer retry: producer отправил, брокер записал, но ack потерялся в сети → producer ретраит → запись дублируется.
  • Consumer redelivery: consumer обработал, но упал до commit offset → при рестарте читает заново.
  • Rebalance: при перебалансировке consumer-группы partition переходит к другому консьюмеру до коммита offset.

at-least-once — практический дефолт. На нём строится почти всё «exactly-once» прикладного уровня.

Почему exactly-once delivery — это миф#

Утверждение: невозможно гарантировать, что сообщение будет доставлено ровно один раз через ненадёжную сеть. Три независимых аргумента:

1. Нельзя атомарно «доставить и зафиксировать ack». Доставка сообщения и подтверждение его получения — две разнесённые во времени операции через сеть, которая может терять, дублировать и переупорядочивать пакеты. После отправки сообщения отправитель не знает: дошло ли оно и потерялся ack, или не дошло вовсе. У него ровно два выбора:

  • ретраить → риск дубля (at-least-once);
  • не ретраить → риск потери (at-most-once).

Третьего не дано, потому что нет распределённой транзакции, которая бы атомарно охватила «сообщение материализовалось у получателя» И «отправитель узнал об этом».

2. Проблема двух генералов (Two Generals Problem). Два генерала должны атаковать одновременно, договариваясь через гонцов, которых могут перехватить. Доказывается, что никакой конечный обмен сообщениями не гарантирует общего знания (common knowledge): какое бы сообщение ни было последним, его отправитель не уверен, что оно дошло, поэтому не может действовать. Это прямой аналог: «отправитель и получатель не могут одновременно быть уверены, что сообщение доставлено ровно один раз». Формально over an unreliable channel consensus с уверенностью недостижим за конечное число шагов.

3. FLP (Fischer–Lynch–Paterson, 1985). В асинхронной модели (нет верхней границы на задержки) нет детерминированного протокола консенсуса, который терминируется при отказе хотя бы одного процесса. Невозможно надёжно отличить «упавший узел» от «медленного». Поскольку exactly-once delivery по сути требует согласия отправителя и получателя о факте единственной доставки (а это форма консенсуса), FLP закрывает гарантированный детерминированный exactly-once в асинхронной сети.

Вывод: exactly-once delivery недостижим. Но достижимо exactly-once processing — наблюдаемый эффект «как будто обработано один раз».

Effectively-once / exactly-once processing#

Рецепт, который реально работает в продакшене:

exactly-once processing = at-least-once delivery
                        + идемпотентность side effects
                        + дедупликация по идентификатору сообщения

Идея: смиритесь с дублями на уровне доставки, но сделайте так, чтобы повторная обработка не меняла наблюдаемого состояния системы.

Идемпотентность. Операция идемпотентна, если её многократное применение даёт тот же результат, что и однократное.

  • Естественно идемпотентные: SET balance = 100 (но не balance += 10), PUT /resource/42, upsert по ключу.
  • Делаем идемпотентными: добавляем idempotency key (например, order_id), и операция выполняется только если ключ ещё не виден.

Дедупликация (idempotent consumer). Consumer хранит множество уже обработанных message id и пропускает повторы. Критично: запись в dedup-store и применение side effect должны быть атомарны (одна транзакция БД), иначе при сбое между ними дубль снова просочится.

// Idempotent consumer: dedup и side effect в одной транзакции БД.
func handle(ctx context.Context, db *sql.DB, msg Message) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback() // no-op после Commit

    // Атомарно фиксируем факт обработки. PK на message_id отсекает дубли.
    res, err := tx.ExecContext(ctx,
        `INSERT INTO processed_messages (message_id) VALUES ($1)
         ON CONFLICT (message_id) DO NOTHING`, msg.ID)
    if err != nil {
        return err
    }
    if n, _ := res.RowsAffected(); n == 0 {
        // Уже обрабатывали — это дубль, side effect не выполняем.
        return tx.Commit()
    }

    // Бизнес-эффект в ТОЙ ЖЕ транзакции, что и отметка о дедупликации.
    if err := applyBusinessEffect(ctx, tx, msg); err != nil {
        return err // откат: и отметка, и эффект не зафиксируются
    }
    return tx.Commit()
}

Ограничения дедупа:

  • Dedup-store не может расти бесконечно → нужно TTL/окно дедупликации. Если дубль приходит позже окна, он просочится. Окно выбирают больше максимально возможной задержки ретраев.
  • Если side effect — вызов внешнего API без идемпотентности (отправка email, charge карты), атомарность с локальной БД недостижима → нужен transactional outbox + идемпотентность на стороне API (idempotency key, который понимает провайдер, напр. Stripe).

Transactional outbox (распространённый паттерн): бизнес-изменение и запись «исходящего события» делаются в одной транзакции БД; отдельный relay читает outbox и публикует в брокер at-least-once. Это решает атомарность «изменили состояние И отправили событие» без распределённой транзакции.

Kafka exactly-once semantics (EOS)#

Kafka не нарушает теорию — он реализует exactly-once processing для замкнутого паттерна read-process-write внутри Kafka. Три механизма:

1. Idempotent producer (дедуп на стороне брокера). Включается enable.idempotence=true (дефолт в современных версиях). Каждому producer присваивается PID (Producer ID), и для каждой партиции ведётся монотонный sequence number. Брокер хранит последний принятый sequence по (PID, partition):

  • Если приходит sequence = last+1 → принимает.
  • Если приходит уже виденный sequence (producer ретраил из-за потерянного ack) → брокер отбрасывает дубль, но возвращает success.
  • Если sequence «прыгнул» вперёд (gap) → ошибка OutOfOrderSequence.

Это устраняет дубли от producer-retry в пределах сессии одного producer и одной партиции. Требует acks=all для надёжности.

producer.send(seq=5) --X-- ack потерян
producer.send(seq=5)  ---->  брокер: "уже видел seq=5, дубль" -> ack без повторной записи

2. Транзакции (atomic multi-partition write + read-process-write). Idempotent producer защищает один send. Транзакции дают атомарность группы записей в несколько партиций/топиков плюс коммит consumer offset как часть той же транзакции:

// Псевдокод транзакционного read-process-write (семантика confluent-kafka-go).
producer.InitTransactions(ctx)            // получаем/восстанавливаем PID по transactional.id
for {
    msgs := consumer.Poll()
    producer.BeginTransaction()
    for _, m := range msgs {
        out := process(m)
        producer.Produce(out)             // запись результата
    }
    // offset входных сообщений коммитится В ТРАНЗАКЦИИ, не отдельно
    producer.SendOffsetsToTransaction(consumer.Position(), consumerGroupMeta)
    producer.CommitTransaction(ctx)       // атомарно: и output, и offsets
    // при ошибке -> producer.AbortTransaction(ctx)
}
  • transactional.id — стабильный идентификатор транзакционного producer. По нему брокер при рестарте узнаёт старый PID, повышает epoch и «огораживает» (fencing) зомби-инстанс со старым epoch: его записи отвергаются. Это защищает от split-brain, когда «упавший» producer ожил и пишет параллельно с новым.
  • SendOffsetsToTransaction делает коммит offset частью транзакции → input-offset и output-записи фиксируются вместе. Именно это даёт атомарность read-process-write.

3. isolation.level=read_committed на консьюмере. Транзакционные записи помечаются маркерами commit/abort. Консьюмер с read_committed не видит сообщений из незакоммиченных/прерванных транзакций (читает только до Last Stable Offset). С read_uncommitted (дефолт) увидит и aborted-сообщения. Без read_committed транзакционные гарантии на стороне чтения теряются.

Важные оговорки про Kafka EOS:

  • Гарантия exactly-once распространяется на поток внутри Kafka (Kafka→processing→Kafka). Side effects во внешние системы (другая БД, HTTP) не покрываются транзакцией Kafka — там снова нужна идемпотентность/outbox.
  • EOS дороже: транзакции добавляют латентность (маркеры, координатор транзакций) и снижают throughput.

2PC (Two-Phase Commit)#

Протокол атомарного распределённого commit. Один координатор, несколько участников. Цель: либо все коммитят, либо все откатывают.

Фаза 1 — PREPARE (voting):
  Координатор -> всем участникам: PREPARE
  Каждый участник: записывает изменения в лог (durably), удерживает локи,
                   отвечает VOTE-COMMIT (готов) или VOTE-ABORT (не может).

Фаза 2 — COMMIT/ABORT (decision):
  Если ВСЕ ответили VOTE-COMMIT -> Координатор: GLOBAL-COMMIT всем
  Если хоть один VOTE-ABORT/timeout -> Координатор: GLOBAL-ABORT всем
  Участники применяют решение, освобождают локи, шлют ACK.

После VOTE-COMMIT участник входит в uncertainty / in-doubt состояние: он пообещал коммитить, удерживает локи и обязан дождаться решения координатора — сам решить не может.

Проблемы 2PC (почему senior его не любит как универсальное решение):

  • Блокирующий протокол. Если координатор падает после фазы prepare, но до рассылки решения, участники, проголосовавшие COMMIT, застревают в uncertainty: они не знают, был ли решён commit или abort, и держат локи неопределённо долго. Прогресс невозможен до восстановления координатора.
  • Single point of failure — координатор. Без HA-координатора надёжность системы = надёжность одного узла.
  • Holds locks на протяжении обеих фаз → снижает конкурентность, повышает риск дедлоков и латентность; в высоконагруженных системах неприемлемо.
  • Синхронность. Все участники должны быть доступны одновременно; latency = латентность самого медленного.
  • Не отказоустойчив к network partition. Партиция между координатором и участником = блокировка.

2PC всё ещё используется: XA-транзакции в БД, распределённые транзакции внутри одного датацентра, где узлы надёжны и latency низок.

3PC (Three-Phase Commit) — кратко#

Попытка сделать 2PC неблокирующим, добавив промежуточную фазу:

Фаза 1 — CanCommit?   (как prepare, но без удержания локов на этом шаге)
Фаза 2 — PreCommit    (координатор сообщает "все согласны", участники готовятся коммитить)
Фаза 3 — DoCommit     (финальный коммит)

Ключевая идея: фаза pre-commit распространяет знание о «достигнут консенсус на commit» до самого commit. Если координатор падает, участники по таймауту могут принять решение сами: тот, кто дошёл до pre-commit, знает, что все согласились, и может безопасно коммитить.

  • Плюс: неблокирующий при fail-stop отказах (узлы падают, но сеть надёжна).
  • Минус: не работает при network partition. При разделении сети две части могут принять противоположные решения (одна коммитит по pre-commit, другая по таймауту абортит) → нарушение consistency. Плюс лишний раунд сообщений = выше латентность. Поэтому в реальном мире (где partition неизбежен) 3PC почти не применяют; его вытеснили consensus-протоколы (Paxos/Raft), которые корректны при partition (ценой возможной недоступности меньшинства — CAP: CP).

Сравнительная таблица семантик#

Критерийat-most-onceat-least-onceexactly-once processing (effectively-once)
Потеривозможнынетнет
Дубли (наблюдаемые)нетданет (погашены дедупом/идемпотентностью)
Когда ackдо обработкипосле обработкиafter, в одной транзакции с side effect/offset
Накладные расходыминимальныенизкиевысокие (dedup-store, транзакции, координация)
Латентностьминимальнаянизкаяповышенная
Достижимо «по-настоящему»?дадатолько processing, не delivery
Требования к consumerнетретраи/redeliveryидемпотентность + dedup + атомарность
Типичное применениеметрики, логибизнес-события (дефолт)платежи, биллинг, финансы
Примерacks=0acks=all, commit после обработкиKafka EOS, outbox + idempotent consumer

Подводные камни / gotchas#

  • «Включили exactly-once» ≠ end-to-end exactly-once. Kafka EOS покрывает только Kafka→Kafka. Любой внешний side effect (запись в стороннюю БД, HTTP, отправка email) выпадает из транзакции и требует отдельной идемпотентности.
  • Idempotent producer не спасает между сессиями. При рестарте producer без transactional.id получает новый PID, и брокер не свяжет старые sequence с новыми → дубли при ретраях, пересекающих рестарт. Нужен transactional.id + fencing по epoch.
  • Дедуп без атомарности с side effect бесполезен. Если сначала применяете эффект, потом пишете message id в dedup-store, и падаете между — дубль просочится. Должна быть одна транзакция.
  • Окно дедупликации конечно. Дубль, пришедший позже TTL, не отсечётся. Окно должно превышать максимальную задержку ретраев/redelivery.
  • acks=all ≠ exactly-once. Это про durability записи (реплицировано на ISR), а не про отсутствие дублей. Дубли от producer-retry убирает enable.idempotence, не acks.
  • read_committed обязателен на консьюмере транзакционного пайплайна; иначе он прочитает aborted-сообщения и гарантия рушится на стороне чтения.
  • Auto-commit offset в Kafka даёт at-most-once или дубли непредсказуемо. enable.auto.commit=true коммитит по таймеру, не привязанному к завершению обработки → при сбое либо потеря (закоммитили необработанное), либо дубли. Для at-least-once нужен ручной commit после обработки.
  • 2PC и микросервисы. Тащить XA/2PC между сервисами с отдельными БД — антипаттерн: блокировки, связность, хрупкость. Предпочтительны Saga + outbox + идемпотентность.
  • «Точно один раз» в формулировке от продакта. На senior-собеседовании важно перевести бизнес-требование «не списать дважды» в «at-least-once + идемпотентный charge по idempotency key», а не обещать недостижимый exactly-once delivery.

Вопросы на собеседовании#

В: Почему exactly-once delivery невозможен, а exactly-once processing — возможен? О: Delivery требует, чтобы отправитель и получатель атомарно согласовали факт единственной доставки через ненадёжную сеть; это форма консенсуса, недостижимая детерминированно (проблема двух генералов о common knowledge, FLP о невозможности консенсуса в асинхронной модели при отказе узла). У отправителя только выбор «ретраить или нет» = дубль или потеря. Processing достижим иначе: принимаем at-least-once delivery (дубли допустимы на проводе), но делаем side effect идемпотентным и дедуплицируем по message id, так что наблюдаемый эффект — ровно один раз.

В: В чём разница между at-most-once и at-least-once на уровне реализации? О: В моменте ack/commit относительно обработки. at-most-once: ack до обработки (упали после ack — сообщение потеряно). at-least-once: ack после успешной обработки (упали до ack — при рестарте перечитаем, возможен дубль). Между потерей и дублём выбрать «ни то ни другое» нельзя, потому что нет атомарной операции, охватывающей сетевой ack и локальный side effect.

В: Как Kafka реализует exactly-once и где границы этой гарантии? О: Три части: (1) idempotent producer — PID + per-partition sequence number, брокер отбрасывает дубли от producer-retry; (2) транзакции с transactional.id — атомарная запись в несколько партиций плюс коммит consumer offset через SendOffsetsToTransaction, fencing зомби по epoch; (3) isolation.level=read_committed — консьюмер не видит aborted/незакоммиченных сообщений. Граница: гарантия только для read-process-write внутри Kafka. Внешние side effects транзакцией не покрыты.

В: Что такое idempotent consumer и почему дедуп должен быть в одной транзакции с side effect? О: Consumer, который хранит обработанные message id и пропускает повторы. Если отметка о дедупликации и бизнес-эффект не атомарны (разные транзакции/системы), сбой между ними даёт: либо эффект применён, а отметки нет (повтор пройдёт → дубль), либо отметка есть, а эффект не применён (сообщение «потеряно»). Поэтому INSERT message id и применение эффекта делают в одной транзакции БД (например, INSERT ... ON CONFLICT DO NOTHING + бизнес-апдейт).

В: Объясните 2PC и его главную слабость. О: Координатор + участники, две фазы: prepare (участники durable-логируют, удерживают локи, голосуют) и commit/abort (координатор рассылает решение). Главная слабость — блокирующий протокол: если координатор падает между prepare и решением, участники в состоянии uncertainty держат локи и не могут сами решить commit/abort до восстановления координатора. Плюс SPOF-координатор, удержание локов, синхронность, неустойчивость к partition.

В: Чем 3PC лучше 2PC и почему его всё равно не используют? О: 3PC добавляет фазу pre-commit, которая распространяет знание «consensus on commit достигнут» до самого commit, поэтому при fail-stop отказе координатора участники по таймауту могут безопасно решить сами — протокол неблокирующий. Но 3PC некорректен при network partition: разделённые части могут принять противоположные решения, нарушив атомарность. Поскольку partition в реальных сетях неизбежен, 3PC вытеснен consensus-протоколами (Raft/Paxos), корректными при partition.

В: Достаточно ли acks=all, чтобы не было дублей у producer? О: Нет. acks=all — про durability (запись подтверждена всеми ISR-репликами), это защита от потери. Дубли возникают, когда ack потерян и producer ретраит уже записанное сообщение. Их убирает enable.idempotence=true (PID + sequence number), а не acks. Для надёжного idempotent producer нужны оба.

В: Бизнес говорит «нельзя списать с карты дважды». Как спроектируете? О: Перевожу в at-least-once + идемпотентность. Каждой операции charge присваиваю idempotency key (например, payment_id). На своей стороне — idempotent consumer: проверка/запись ключа и применение в одной транзакции. На стороне платёжного провайдера передаю idempotency key, который он понимает (как Stripe), чтобы повторный запрос с тем же ключом не списал второй раз. Для атомарности «изменили заказ И отправили событие» использую transactional outbox. exactly-once delivery не обещаю — обещаю exactly-once effect.

В: Что плохого в auto-commit offset в Kafka для надёжной обработки? О: enable.auto.commit=true коммитит offset по таймеру, не синхронно с завершением обработки. Если упали после авто-коммита, но до обработки — потеря (at-most-once). Если обработали, но авто-коммит ещё не сработал и упали — дубль. Поведение недетерминированно. Для at-least-once нужен ручной commit строго после успешной обработки.

На что копают на senior+#

  • Умение чётко сказать «exactly-once delivery невозможен» и обосновать через two generals + FLP, не путая delivery и processing.
  • Понимание, что гарантия задаётся на конкретном плече (producer→broker, broker→consumer, consumer side effect) и end-to-end не сильнее слабейшего звена.
  • Знание, что рецепт реального exactly-once = at-least-once + идемпотентность + дедуп, и почему dedup обязан быть атомарен с side effect.
  • Внутренности Kafka EOS: PID, sequence number, transactional.id, epoch/fencing, SendOffsetsToTransaction, read_committed — и явное указание границы (только Kafka→Kafka).
  • Различие idempotent producer (один send / одна партиция / одна сессия) vs transactions (мульти-партиция + offset + между сессиями).
  • 2PC: понимание uncertainty/in-doubt window, почему это блокирующий протокол, удержание локов, SPOF.
  • 3PC: знание, что pre-commit делает его неблокирующим при fail-stop, но он некорректен при partition → почему индустрия выбрала Raft/Paxos (CP по CAP).
  • Прикладные паттерны вместо распределённых транзакций в микросервисах: Saga, transactional outbox, inbox/idempotent consumer, и осознанный отказ от XA/2PC между сервисами.
  • Способность перевести бизнес-требование («не списать дважды») в корректную инженерную модель (idempotency key + at-least-once), а не обещать недостижимое.