Гарантии доставки в распределённых системах
Предпосылки: Паттерны надёжности (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-кластера и требует, чтобы producer работал именно в этом режиме (idempotent producer). Для цепочки «прочитать → обработать → записать дальше» (consume-transform-produce) Kafka добавляет транзакции — атомарную запись в несколько партиций (независимых логов, из которых состоит поток сообщений) плюс фиксацию позиции чтения (offset) получателя.
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 (переключении на реплику после падения leader’а) возможен 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: асинхронная коммуникация между сервисами →