Как обеспечить Eventual Consistency в сложных распределенных системах

18 декабря 2025

Современные комплексы бизнес-приложений отличаются высокой сложностью, из-за чего могут происходить сбои - сообщения теряются, consumer’ы падают, очереди переполняются. В этой статье мы разберем реальный кейс, в котором Eventual Consistency удалось обеспечить без серьезной переработки существующих систем — за счет сохранения транзакций, стороннего наблюдателя и контролируемой повторной отправки.

Обеспечение Eventual Consistency в сложных системах

Уже давно стандартом де-факто стали микросервисы, поэтому практически любая система представляет собой набор компонентов, взаимодействующих между собой как синхронно (например, по REST), так и асинхронно — через шины сообщений (RabbitMQ, Kafka).

Если к этому добавить интеграцию между разными системами и наличие общей межсистемной шины, архитектурная картина становится еще более многослойной и уязвимой к сбоям.

Где именно все может сломаться

Рассмотрим две системы:

  • Alfa — система оформления авиабилетов
  • Beta — система расчета прибыли

При оформлении билета в Alfa информация о продаже должна быть передана в Beta. Для этого Alfa формирует объект «транзакция продажи» и отправляет его через шину данных.

На первый взгляд все выглядит просто: билет оформлен — транзакция отправлена.

Однако если рассмотреть этот процесс детальнее, становится очевидно, что путь транзакции включает несколько микросервисов внутри Alfa. Они последовательно собирают данные, дополняют их и преобразуют в формат, ожидаемый системой Beta. Уже на этом этапе возникает асинхронное взаимодействие между двумя-тремя сервисами через внутреннюю шину сообщений.

01

Дополнительно между Alfa и Beta используется межсистемная шина RabbitMQ — отдельная зона ответственности, которая обычно настраивается DevOps-инженерами. Внутри такой шины может существовать достаточно сложная конфигурация связей exchange и queue. Большое количество точек взаимодействия неизбежно порождает множество сценариев отказа. Потенциальные точки отказа можно условно разделить на несколько типов:

  • Внутренние сбои (точки 1 и 3) — временная недоступность внутренней шины Alfa, например из-за сетевых проблем, что может привести к потере сообщения.
  • Ошибки обработки (точки 2 и 4) — consumer может содержать баг и, например, выбросить NullPointerException при обработке сообщения.
  • Межсистемные проблемы — после отправки сообщения в межсистемную шину возможны любые сбои: недоступный consumer, ошибки конфигурации очередей, проблемы с инфраструктурой.

Изначально в системе Alfa использовался оптимистичный подход: транзакция собиралась и отправлялась, после чего система никак не отслеживала ее дальнейшую судьбу. Данные, необходимые для повторной отправки, не сохранялись.

В результате при любом сбое транзакция терялась без возможности восстановления.

Ситуацию усугубляло то, что не существовало простого способа понять, какие транзакции были успешно доставлены, а какие — потеряны. Обратного канала связи не было.

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

В такой архитектуре мы не могли:

  • быстро обнаруживать проблемы
  • понимать их масштаб
  • повторно отправлять потерянные транзакции

Система не обеспечивала Eventual Consistency — согласованность в конечном счете. Alfa оформила билет, но в Beta данные могли так и не появиться.

Наша цель — Eventual Consistency

Eventual Consistency допускает временную несогласованность данных между системами, но предполагает, что благодаря определенным механизмам (например, повторной отправке) согласованность будет восстановлена. В нашем случае «в конечном счете» означало: до ночного планового процессинга. Если транзакция не дошла, она должна быть доставлена в течение дня.

Перед нами стояли три задачи:

  • обеспечить возможность повторной отправки транзакций
  • понимать, какие именно транзакции не были доставлены
  • настроить мониторинг недоставки

Сохранение транзакций перед отправкой

В идеальном мире процесс восстановления должен быть полностью автоматизирован. Однако на этапе MVP мы осознанно оставили шаг повторной отправки под контролем человека — сначала нужно было отладить корректное выявление недоставленных транзакций.

Для повторной отправки транзакцию необходимо сохранить. При этом хранить такие данные долго не требуется: либо мы переотправляем транзакцию сегодня, либо она теряет актуальность.

Мы начали сохранять данные, необходимые для сборки транзакции, в базе данных сразу после оформления билета и настроили автоматическую очистку через три дня.

Поиск недоставленных транзакций

Далее предстояло решить более сложную задачу — поиск недоставленных транзакций.

Можно было бы реализовать канал обратной связи: например, чтобы система‑получатель Beta в ответ на полученную транзакцию отправляла подтверждение (ack). Но здесь возникает логичный вопрос: что делать, если ack тоже потеряется из-за сбоя где-то «посередине»? В этот момент появилась идея стороннего наблюдателя. В обеих системах уже использовался ELK-стек. Факт отправки транзакции из Alfa и факт ее приёма в Beta отражались в логах. Это позволило реализовать независимый сервис, который сопоставляет логи двух систем и выявляет недоставленные транзакции.

02 (1)

Наблюдатель

Наблюдатель с заданной периодичностью выполняет запросы к ELK Alfa и Beta. В логах используются заранее согласованные паттерны, например:

  • tx 1 sent
  • tx 1 received from streaming

Сообщения сопоставляются в заданном временном окне. При этом окно для сообщений об отправке делается немного меньше, чем окно для сообщений о приеме — с учетом задержки доставки.

Дополнительно используется сдвиг в прошлое: анализируются транзакции, отправленные не менее чем за 5 минут до текущего момента. Это гарантирует, что такие транзакции уже либо доставлены, либо окончательно потеряны.

Ключевое преимущество подхода — отсутствие необходимости дорабатывать существующие системы. Наблюдатель не создает дополнительной нагрузки и работает исключительно с уже имеющимися логами.

Мониторинг

При обнаружении недоставленной транзакции наблюдатель пишет сообщение уровня ERROR в лог. На это сообщение срабатывает мониторинг в Grafana, который отправляет уведомление в службу поддержки.

Повторная отправка транзакций

Как упоминалось ранее, на этапе MVP повторная отправка выполнялась вручную по инструкции для инженеров сопровождения. Этот этап позволил выявить ряд проблем в логике наблюдателя и скорректировать его поведение.

Следующим шагом стала автоматизация: наблюдатель начал отправлять событие о недоставке в модуль resend service, который принимает решение о повторной отправке.

В нем реализованы дополнительные проверки, например, отсутствие ошибок от RabbitMQ, связанных с переполнением очередей. Возможны ситуации с отключенным consumer’ом, когда сообщения накапливаются в очереди, и повторная отправка не только не имеет смысла, но и может создать дополнительную неконтролируемую нагрузку, ещё более увеличив количество накопленных сообщений в очередях.

Также было введено ограничение: автоматический повтор возможен только один раз. После этого требуется ручное вмешательство.

Обработка дублей

В реальных системах семантика exactly-once (доставка строго один раз и не более) практически недостижима. Даже в хорошо настроенной инфраструктуре возможны сбои.

Например, лог о приеме транзакции может не попасть в ELK, и наблюдатель ошибочно сочтет ее недоставленной. В результате транзакция будет отправлена повторно.

Чтобы корректно обрабатывать такие случаи, каждое сообщение снабжается уникальным идентификатором отправки. На стороне consumer’а реализуется проверка: обрабатывался ли этот идентификатор ранее.

Так достигается более практичная семантика at-least-once - доставка не менее одного раза.

03 (2)

Outbox-паттерн

Описанное решение является вариацией outbox-паттерна, реализованного через три ключевых компонента:

  • сохранение транзакции перед отправкой
  • наблюдение за доставкой
  • повторная отправка и обработка дублей

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

Заключение В этой статье мы рассмотрели, как обеспечить Eventual Consistency, опираясь на существующие инструменты. Использование логов в качестве инструмента контроля может показаться непривычным, однако в нашем случае они позволяют отследить доставку сообщений end-to-end — независимо от состояния RabbitMQ или сетевых проблем.

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

Дополнительное преимущество подхода — отсутствие необходимости «вживлять» контроль доставки в существующие бизнес-процессы. Минимальные доработки потребовались только в Alfa (сохранение транзакций) и Beta (обработка дублей). Все остальные компоненты были вынесены за пределы основных систем.