Современные комплексы бизнес-приложений отличаются высокой сложностью, из-за чего могут происходить сбои - сообщения теряются, 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 (обработка дублей). Все остальные компоненты были вынесены за пределы основных систем.

А как вы решаете проблемы гарантированной доставки в своих проектах? Используете готовые решения или кастомные? Какие по вашему мнению работают лучше?