Гарантии доставки в распределённых системах

Предпосылки: Паттерны надёжности (retry, idempotency), HTTP.

Кэширование | Message Queues: асинхронная коммуникация между сервисами

Retry решает проблему transient failures: запрос не дошёл — отправь ещё раз. Но retry отвечает на вопрос «дойдёт ли вызов до получателя?», а не на вопрос «сколько раз получатель его обработает?». Разница критична.

Интернет-магазин: обработчик заказа вызывает платёжный сервис. Запрос ушёл, платёжный сервис списал деньги — и упал до того, как отправил ответ. Обработчик получил timeout. С точки зрения обработчика запрос мог не дойти вообще. Retry? Деньги спишутся второй раз. Не retry? Возможно, первый запрос действительно потерялся в сети, и покупатель получит товар бесплатно.

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

At-most-once: не более одного раза

Отправитель посылает запрос и не повторяет его при неудаче. Получатель обрабатывает сообщение не более одного раза — возможно, ноль.

Sender → [request] → Receiver
                        ↓ обработал
                        ↓ crash до ответа
Sender: timeout, не повторяет
Receiver: работа выполнена (или нет)

Потеря при at-most-once: если получатель упал до обработки — запрос потерян; если после обработки, но до ответа — работа выполнена, но отправитель не знает об этом и не повторяет. В обоих случаях отправитель считает запрос неудачным и идёт дальше.

Запись метрик в аналитику — типичный at-most-once сценарий. Потеря одного события из миллиона незаметна в статистике: графики и отчёты остаются точными. Платёж — нет: потерянное списание означает, что покупатель получил товар бесплатно.

At-least-once: не менее одного раза

Для платежа потеря неприемлема — отправитель должен повторять запрос до подтверждения.

Отправитель повторяет запрос, пока не получит подтверждение. Получатель обрабатывает сообщение минимум один раз, возможно несколько.

Подтверждение (acknowledgment, ACK) — это сигнал от получателя: «я обработал запрос». Пока ACK не пришёл — отправитель считает запрос необработанным и повторяет.

Sender → [request] → Receiver
                        ↓ обработал (деньги списаны)
                        ↓ crash до отправки ACK
Sender: timeout → retry
Sender → [тот же request] → Receiver₂
                               ↓ обрабатывает повторно (деньги списаны второй раз)
                               ↓ ACK → Sender

At-least-once гарантирует: запрос будет обработан. Цена — дубликаты. Получатель (платёжный сервис) списал деньги, но ACK не дошёл до отправителя. Отправитель повторяет — деньги списываются второй раз. Покупатель заплатил дважды.

Дубликаты возникают не от ошибок в коде, а от фундаментального свойства сети: отправитель не может отличить «получатель обработал, но ответ потерялся» от «получатель не получил запрос вообще». Единственное безопасное действие — повторить.

Exactly-once: ровно один раз

Покупатель не должен ни потерять платёж, ни заплатить дважды.

Exactly-once на транспортном уровне невозможна. Окно между «получатель выполнил работу» и «отправитель получил ACK» неустранимо. Никакой протокол не может гарантировать, что оба процесса атомарно согласятся о факте обработки — это вариация проблемы двух генералов (два процесса, связанных ненадёжным каналом, не могут гарантированно достичь согласия о совместном действии).

Kafka маркетирует «exactly-once semantics», но внутри это at-least-once доставка плюс дедупликация: producer присваивает каждому сообщению sequence number, broker отклоняет повторы с тем же номером. Гарантия действует только в пределах одного Kafka-кластера и требует idempotent producer. Для цепочки consume-transform-produce Kafka добавляет транзакции — атомарная запись в несколько партиций плюс коммит offset’а consumer’а.

Exactly-once = at-least-once + idempotency

Раз транспорт не может гарантировать однократность, ответственность перемещается на получателя. Получатель должен быть написан так, чтобы повторная обработка того же запроса давала тот же результат без побочных эффектов.

Платёжный сервис при списании проверяет: «запрос с idempotency key payment-order-42 уже обработан?» Если да — возвращает сохранённый результат без повторного списания. Обработчик заказа повторяет запрос сколько угодно раз — деньги списываются ровно один раз.

Это не exactly-once доставка — это exactly-once обработка, построенная поверх at-least-once доставки. Механизм подробно описан в idempotency.

Где действуют гарантии доставки

Три гарантии — не специфика очередей сообщений. Один платёж из нашего сценария проходит через несколько слоёв, и на каждом возникает то же окно между обработкой и подтверждением.

Обработчик заказа отправляет HTTP-запрос к платёжному сервису — прямой вызов, который мы уже разобрали. Retry без idempotency key даёт at-least-once с риском двойного списания, с idempotency key — exactly-once обработку, без retry — at-most-once. Подробнее о retry и idempotency в контексте HTTP — в API Design.

Платёжный сервис записывает транзакцию в базу данных. Эта запись должна попасть на реплику: leader отправляет follower’у WAL-записи (журнал изменений — последовательный лог всех модификаций данных). При асинхронной репликации падение leader’а до отправки записи означает at-most-once для этой транзакции — реплика её не получит. При синхронной репликации follower подтверждает получение, что даёт at-least-once; при failover возможен replay уже применённых записей, но LSN (позиция записи в WAL-потоке) обеспечивает идемпотентность — запись с уже применённым LSN не применяется повторно. Подробнее — в репликации.

Одновременно событие «платёж завершён» публикуется в очередь сообщений для аналитики, нотификаций и сверки. Общий механизм подтверждения в очередях — ACK: пока consumer не подтвердил обработку, сообщение считается недоставленным. В Kafka consumer читает партицию (append-only лог на диске брокера) с определённого offset’а (позиция в партиции). Если consumer коммитит offset до обработки — at-most-once: при crash событие пропущено, и запись о платеже не появится в аналитике. Если коммитит после — at-least-once: при crash событие обработается повторно. Для аналитики дубликат некритичен; для триггера нотификации клиенту нужна дедупликация. Подробнее — в разделе о Kafka.

Тот же платёж, та же фундаментальная проблема — но на каждом слое выбор гарантии определяется ценой ошибки. At-most-once проще и дешевле: нет retry, нет дедупликации, нет хранения idempotency key. At-least-once требует idempotency на стороне получателя — дополнительная логика, хранилище ключей, проверка перед каждой операцией. Потеря аналитического события незаметна; двойное списание денег — инцидент.

Sources

  • Kleppmann, 2017, Designing Data-Intensive Applications, Chapter 11 — stream processing, message brokers, exactly-once semantics
  • Helland, 2012, Idempotence Is Not a Medical Condition — exactly-once as application-level concern
  • Gray, 1978, Notes on Data Base Operating Systems — two generals problem and impossibility of distributed agreement

Кэширование | Message Queues: асинхронная коммуникация между сервисами