Современные комплексы бизнес-приложений отличаются высокой сложностью, из-за чего могут происходить сбои - сообщения теряются, 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 может содержать баг и, например, выбросить NullPointerException при обработке сообщения.
- Межсистемные проблемы — после отправки сообщения в межсистемную шину возможны любые сбои: недоступный consumer, ошибки конфигурации очередей, проблемы с инфраструктурой.
Изначально в системе Alfa использовался оптимистичный подход: транзакция собиралась и отправлялась, после чего система никак не отслеживала ее дальнейшую судьбу. Данные, необходимые для повторной отправки, не сохранялись.
В результате при любом сбое транзакция терялась без возможности восстановления.
Ситуацию усугубляло то, что не существовало простого способа понять, какие транзакции были успешно доставлены, а какие — потеряны. Обратного канала связи не было.
Только анализируя логи, уже после обращения клиента в службу поддержки, можно было постфактум установить, что конкретный билет не попал в Beta. Проактивного контроля и оперативного реагирования не существовало.
В такой архитектуре мы не могли:
- быстро обнаруживать проблемы
- понимать их масштаб
- повторно отправлять потерянные транзакции
Система не обеспечивала Eventual Consistency — согласованность в конечном счете. Alfa оформила билет, но в Beta данные могли так и не появиться.
Наша цель — Eventual Consistency
Eventual Consistency допускает временную несогласованность данных между системами, но предполагает, что благодаря определенным механизмам (например, повторной отправке) согласованность будет восстановлена. В нашем случае «в конечном счете» означало: до ночного планового процессинга. Если транзакция не дошла, она должна быть доставлена в течение дня.
Перед нами стояли три задачи:
- обеспечить возможность повторной отправки транзакций
- понимать, какие именно транзакции не были доставлены
- настроить мониторинг недоставки
Сохранение транзакций перед отправкой
В идеальном мире процесс восстановления должен быть полностью автоматизирован. Однако на этапе MVP мы осознанно оставили шаг повторной отправки под контролем человека — сначала нужно было отладить корректное выявление недоставленных транзакций.
Для повторной отправки транзакцию необходимо сохранить. При этом хранить такие данные долго не требуется: либо мы переотправляем транзакцию сегодня, либо она теряет актуальность.
Мы начали сохранять данные, необходимые для сборки транзакции, в базе данных сразу после оформления билета и настроили автоматическую очистку через три дня.
Поиск недоставленных транзакций
Далее предстояло решить более сложную задачу — поиск недоставленных транзакций.
Можно было бы реализовать канал обратной связи: например, чтобы система‑получатель Beta в ответ на полученную транзакцию отправляла подтверждение (ack). Но здесь возникает логичный вопрос: что делать, если ack тоже потеряется из-за сбоя где-то «посередине»? В этот момент появилась идея стороннего наблюдателя. В обеих системах уже использовался ELK-стек. Факт отправки транзакции из Alfa и факт ее приёма в Beta отражались в логах. Это позволило реализовать независимый сервис, который сопоставляет логи двух систем и выявляет недоставленные транзакции.
Наблюдатель
Наблюдатель с заданной периодичностью выполняет запросы к ELK Alfa и Beta. В логах используются заранее согласованные паттерны, например:
tx 1 senttx 1 received from streaming
Сообщения сопоставляются в заданном временном окне. При этом окно для сообщений об отправке делается немного меньше, чем окно для сообщений о приеме — с учетом задержки доставки.
Дополнительно используется сдвиг в прошлое: анализируются транзакции, отправленные не менее чем за 5 минут до текущего момента. Это гарантирует, что такие транзакции уже либо доставлены, либо окончательно потеряны.
Ключевое преимущество подхода — отсутствие необходимости дорабатывать существующие системы. Наблюдатель не создает дополнительной нагрузки и работает исключительно с уже имеющимися логами.
Мониторинг
При обнаружении недоставленной транзакции наблюдатель пишет сообщение уровня ERROR в лог. На это сообщение срабатывает мониторинг в Grafana, который отправляет уведомление в службу поддержки.
Повторная отправка транзакций
Как упоминалось ранее, на этапе MVP повторная отправка выполнялась вручную по инструкции для инженеров сопровождения. Этот этап позволил выявить ряд проблем в логике наблюдателя и скорректировать его поведение.
Следующим шагом стала автоматизация: наблюдатель начал отправлять событие о недоставке в модуль resend service, который принимает решение о повторной отправке.
В нем реализованы дополнительные проверки, например, отсутствие ошибок от RabbitMQ, связанных с переполнением очередей. Возможны ситуации с отключенным consumer’ом, когда сообщения накапливаются в очереди, и повторная отправка не только не имеет смысла, но и может создать дополнительную неконтролируемую нагрузку, ещё более увеличив количество накопленных сообщений в очередях.
Также было введено ограничение: автоматический повтор возможен только один раз. После этого требуется ручное вмешательство.
Обработка дублей
В реальных системах семантика exactly-once (доставка строго один раз и не более) практически недостижима. Даже в хорошо настроенной инфраструктуре возможны сбои.
Например, лог о приеме транзакции может не попасть в ELK, и наблюдатель ошибочно сочтет ее недоставленной. В результате транзакция будет отправлена повторно.
Чтобы корректно обрабатывать такие случаи, каждое сообщение снабжается уникальным идентификатором отправки. На стороне consumer’а реализуется проверка: обрабатывался ли этот идентификатор ранее.
Так достигается более практичная семантика at-least-once - доставка не менее одного раза.
Outbox-паттерн
Описанное решение является вариацией outbox-паттерна, реализованного через три ключевых компонента:
- сохранение транзакции перед отправкой
- наблюдение за доставкой
- повторная отправка и обработка дублей
Отличие от канонического подхода в том, что отправка не вынесена в отдельный фоновый процесс. Сообщение сохраняется в базе и сразу же отправляется тем же процессом. Поскольку отправка асинхронная, это не создает заметных задержек в бизнес-логике оформления билета.
Заключение В этой статье мы рассмотрели, как обеспечить Eventual Consistency, опираясь на существующие инструменты. Использование логов в качестве инструмента контроля может показаться непривычным, однако в нашем случае они позволяют отследить доставку сообщений end-to-end — независимо от состояния RabbitMQ или сетевых проблем.
Фактически наблюдатель выполняет роль независимого контролера, фиксируя простой факт: доставлено сообщение или нет. При этом важную роль играет этап ручной обработки — он позволяет накопить опыт и избавиться от ложных повторов.
Дополнительное преимущество подхода — отсутствие необходимости «вживлять» контроль доставки в существующие бизнес-процессы. Минимальные доработки потребовались только в Alfa (сохранение транзакций) и Beta (обработка дублей). Все остальные компоненты были вынесены за пределы основных систем.