Современные комплексы бизнес-приложений отличаются высокой сложностью, из-за чего могут происходить сбои - сообщения теряются, consumer’ы падают, очереди переполняются. Поделимся реальным кейсом, в котором Eventual Consistency удалось обеспечить без серьезной переработки существующих систем.
Обеспечение Eventual Consistency в сложных системах
Уже давно стандартом де-факто стали микросервисы, поэтому практически любая система представляет собой набор компонентов, взаимодействующих между собой как синхронно (например, по REST), так и асинхронно — через шины сообщений (RabbitMQ, Kafka).
Если к этому добавить интеграцию между разными системами и наличие общей межсистемной шины, архитектурная картина становится еще более многослойной и уязвимой к сбоям.
Где именно все может сломаться
Предположим, у нас две системы:
Alfa — система оформления авиабилетов
Beta — система расчета прибыли
При каждой продаже билета в Alfa информация о продаже должна отправляться в Beta. Для этого Alfa формирует «транзакцию продажи» и отправляет ее через шину данных.
Казалось бы, ничего сложного: билет оформлен — транзакция отправлена.
Но если рассмотреть этот процесс получше, становится видно, что на пути у транзакции есть еще несколько микросервисов внутри Alfa. Они собирают данные, дополняют их и преобразуют в формат, ожидаемый системой Beta. Уже на этом этапе возникает асинхронное взаимодействие между двумя-тремя сервисами через внутреннюю шину сообщений.

Плюс к этому между Alfa и Beta используется межсистемная шина RabbitMQ — отдельная зона ответственности, которая обычно настраивается DevOps-инженерами. Внутри такой шины может существовать достаточно сложная конфигурация связей exchange и queue. Большое количество точек взаимодействия = множество сценариев отказа. Потенциальные точки отказа можно условно разделить на несколько типов:
Внутренние сбои (точки 1 и 3 на схеме) — падение внутренней шины Alfa, например из-за сетевых проблем, что может привести к потере сообщения.
Ошибки обработки (точки 2 и 4) — баг в consumer’e и, например, выброс NullPointerException при обработке сообщения.
Межсистемные проблемы — после отправки сообщения в межсистемную шину возможны любые сбои: недоступный consumer, ошибки конфигурации очередей, проблемы с инфраструктурой.
Изначально в системе Alfa использовался оптимистичный подход: транзакция собиралась и отправлялась, после чего система никак не отслеживала ее дальнейшую судьбу. Данные для повторной отправки нигде не сохранялись.
Можно легко предугадать результат: при любом сбое восстановить транзакцию невозможно.
Ситуацию усугубляло еще и то, что не существовало простого способа понять, какие транзакции были успешно доставлены, а какие — потеряны. Обратного канала связи попросту не было.
Только анализируя логи после обращения клиента в службу поддержки, можно было постфактум установить, что конкретный билет не попал в Beta.
В такой архитектуре мы не могли:
быстро обнаруживать проблемы,
понимать их масштаб,
повторно отправлять потерянные транзакции.
Система не обеспечивала Eventual Consistency — согласованность в конечном счете. Alfa оформила билет, но в Beta данные могли так и не появиться. Нужен бы механизм, который гарантирует: если что-то пошло не так, мы про это узнаем и исправим.
Наша цель — Eventual Consistency
Eventual Consistency допускает временную несогласованность данных между системами, но предполагает, что благодаря определенным механизмам (например, повторной отправке) согласованность будет восстановлена. В нашем случае «в конечном счете» означало: до ночного планового процессинга. Если транзакция не дошла, она должна быть доставлена в течение дня.
Задачи мы сформулировали так:
Возможность повтора: сохранить данные для повторной отправки.
Обнаружение потерь: понять, какие транзакции не дошли.
Мониторинг: оперативно узнавать о проблемах.
Задача 1: сохранение транзакций перед отправкой
В идеальном мире процесс восстановления должен быть полностью автоматизирован. Но на этапе MVP мы осознанно оставили шаг повторной отправки под контролем человека — сначала нужно было отладить корректное выявление недоставленных транзакций.
Для повторной отправки транзакцию нужно сохранить. При этом хранить такие данные долго не требуется: либо мы переотправляем транзакцию сегодня, либо она теряет актуальность.
Мы начали сохранять данные, необходимые для сборки транзакции, в базе данных сразу после оформления билета и настроили автоматическую очистку через три дня.
Задача 2: поиск недоставленных транзакций
Дальше предстояло решить более сложную задачу — поиск недоставленных транзакций.
Можно было бы реализовать канал обратной связи: например, чтобы система‑получатель Beta в ответ на полученную транзакцию отправляла подтверждение (ack). Но здесь возникает логичный вопрос: что делать, если ack тоже потеряется из-за сбоя где-то «посередине»? В этот момент появилась идея стороннего наблюдателя.
В обеих системах уже использовался ELK-стек. Факт отправки транзакции из Alfa и факт ее приема в Beta отражались в логах. Это позволило реализовать независимый сервис, который сопоставляет логи двух систем и выявляет недоставленные транзакции.

Как работает наблюдатель:
1) С заданной периодичностью выполняет запросы к ELK Alfa и Beta. В логах используются заранее согласованные паттерны, например:
tx 1 sent
tx 1 received from streaming
2) Ищет логи об отправлении транзакций в заданном временном окне (окно отправки).
3) Ищет логи о получении этих транзакций за чуть большее окно (окно получателя), учитывая возможную задержку.
4) Использует сдвиг в прошлое: анализируются только те транзакции, которые были отправлены не менее чем за 5 минут до текущего момента. Это дает гарантию, что такие транзакции уже либо доставлены, либо окончательно потеряны.
5) Если есть запись об отправке, но нет записи о получении – это кандидат на повторную отправку.
Главное преимущество подхода — отсутствие необходимости дорабатывать существующие системы. Наблюдатель не создает дополнительной нагрузки и работает только с уже имеющимися логами.
Задача 3: мониторинг и повторная отправка транзакций
При обнаружении недоставленной транзакции наблюдатель пишет сообщение уровня ERROR в лог. На это сообщение срабатывает мониторинг в Grafana, который отправляет уведомление в службу поддержки.
Как как уже говорили, на этапе MVP повторная отправка выполнялась вручную по инструкции для инженеров сопровождения. Этот этап позволил обнаружить ряд проблем в логике наблюдателя и скорректировать его поведение.
Следующим шагом стала автоматизация: наблюдатель начал отправлять событие о недоставке в модуль resend service, который принимает решение о повторной отправке.
В нем реализованы дополнительные проверки, например, отсутствие ошибок от RabbitMQ, связанных с переполнением очередей. Возможны ситуации с отключенным consumer’ом, когда сообщения накапливаются в очереди, и повторная отправка не только не имеет смысла, но и может создать дополнительную неконтролируемую нагрузку, ещё более увеличив количество накопленных сообщений в очередях.
Также было введено ограничение: автоматический повтор возможен только один раз. После этого требуется ручное вмешательство.
Обработка дублей
В реальных системах семантика exactly-once (доставка строго один раз и не более) практически недостижима. Даже в хорошо настроенной инфраструктуре возможны сбои.
Например, лог о приеме транзакции может не попасть в ELK, и наблюдатель ошибочно сочтет ее недоставленной. В результате транзакция будет отправлена повторно.
Чтобы корректно обрабатывать такие случаи, каждое сообщение снабжается уникальным идентификатором отправки. На стороне consumer’а есть проверка: обрабатывался ли этот идентификатор ранее.
Так достигается более практичная семантика at-least-once - доставка не менее одного раза.

Outbox-паттерн
Описанное решение является вариацией outbox-паттерна, реализованного через три ключевых компонента:
сохранение транзакции перед отправкой
наблюдение за доставкой
повторная отправка и обработка дублей
Отличие от канонического подхода в том, что отправка не вынесена в отдельный фоновый процесс. Сообщение сохраняется в базе и сразу же отправляется тем же процессом. Поскольку отправка асинхронная, это не создает заметных задержек в бизнес-логике оформления билета.
Заключение
В этой статье мы поделились, как обеспечить Eventual Consistency, опираясь на существующие инструменты. Использование логов в качестве инструмента контроля может показаться непривычным, однако в нашем случае они позволяют отследить доставку сообщений end-to-end — независимо от состояния RabbitMQ или сетевых проблем.
Фактически наблюдатель выполняет роль независимого контролера, фиксируя просто�� факт: доставлено сообщение или нет. При этом важную роль играет этап ручной обработки — он позволяет накопить опыт и избавиться от ложных повторов.
Дополнительное преимущество подхода — отсутствие необходимости «вживлять» контроль доставки в существующие бизнес-процессы. Минимальные доработки потребовались только в Alfa (сохранение транзакций) и Beta (обработка дублей). Все остальные компоненты были вынесены за пределы основных систем.
А как вы решаете проблемы гарантированной доставки в своих проектах? Используете готовые решения или кастомные? Какие по вашему мнению работают лучше?