Грамотно спроектированная архитектура микросервисов крайне важна для оптимальной работы системы, отказоустойчивости и возможностей ее масштабирования.
В этой статье я расскажу, как оптимизировать сложную систему микросервисов через брокер сообщений, в данном случае RabbitMQ. Мы рассмотрим вариант решения проблемы высокой связанности между сервисами через переход на событийно-ориентированную архитектуру (EDA). В нашем примере в качестве центрального звена для обмена сообщениями между микросервисами мы оставили RabbitMQ, который уже использовался в системе, но можно использовать и другие.
В чем заключалась проблема?
У нас было большое количество сервисов, работающих на Node.js и написанных на JavaScript, плюс несколько новых сервисов, написанных на TypeScript. В отличие от JavaScript, TypeScript имеет строгую типизацию — это упрощает разработку, помогает избежать ошибок и облегчает поддержку в интегрированных средах разработки (IDE).
Основной проблемой, с которой мы столкнулись, была высокая связанность сервисов между собой. При высокой связанности проблемы в одном сервисе могут привести к сбоям во всей системе, например, сбой в службе генерации отчетов может привести к остановке всего проекта.
Проблемы высокой связанности появились из-за ряда ошибок, допущенных на этапе создания архитектуры системы и еще более ранних этапах разработки:
- Микросервисы были разделены по их функциональности, а не по продуктам. Из-за этого сервисы, которые должны были взаимодействовать только в рамках одного продукта, оказались в разных местах. Например, некоторые функции продукта были в одном сервисе, а работа над настройками этих функций и кастомизация тарифов под конкретного пользователя — в другом.
- У команды накопился бэклог, который было трудно разобрать. В период активной разработки и быстрого роста продукта команде не всегда удавалось выделить время на рефакторинг и оптимизацию кода. Из-за этого одни временные решения наслаивались на другие, и кодовая база превратилась в «спагетти-код», что усугубило проблемы связанности и негативно повлияло на отказоустойчивость системы.
- Дополнительную путаницу в архитектуру вносило использование нескольких разных очередей и брокера сообщений RabbitMQ для обмена сообщениями между сервисами. RabbitMQ — мощный инструмент, поддерживающий сложные сценарии обмена сообщениями, но он эффективен только, если система сообщений спроектирована грамотно. В нашем случае это было не так, и требовалась значительная переработка этой логики.
Например, для выполнения трейдинговой операции служба A неоднократно запрашивала параметры у службы B, затем формировала на их основе запрос и отправляла его обратно в службу B через вызов удаленной процедуры (RPC). После этого служба A ожидала ответа в специальной очереди. Со временем, чтобы избежать дублирования кода, другие службы стали обращаться к службе A для формирования запросов к службе B. Это сильно усложнило взаимодействие между службами.
Как мы решили проблему
Событийно-ориентированная архитектура (EDA)
Чтобы решить проблему чрезмерной связанности сервисов и повысить отказоустойчивость, мы решили перейти на событийно-управляемую архитектуру (EDA). EDA — это подход к проектированию ПО, при котором компоненты системы взаимодействуют через асинхронные события. Эти события представляют собой значительные изменения или события в системе, которые запускают определенные рабочие процессы. События создаются компонентами системы, которые называются «издатели», и обрабатываются получателями через брокеры сообщений. Такой подход позволяет обеспечить слабую связанность компонентов и тем самым повысить устойчивость и масштабируемость системы.
Библиотека для RabbitMQ
Поскольку в проекте уже был задействован RabbitMQ в качестве брокера сообщений, мы решили оставить его, но использовать его возможности более эффективно. Для этого мы обратились к Exchange, инструменту для маршрутизации сообщений. Exchange принимает сообщения от издателей и, в соответствии с определенными правилами, направляет их в одну или несколько очередей, оптимизируя работу брокера. RabbitMQ предлагает несколько типов Exchange, которые подходят для различных случаев использования, например, Direct Exchange, Fanout Exchange, Topic Exchange и Headers Exchange.
Чтобы упростить работу с RabbitMQ, мы разработали специальную библиотеку. За основу мы взяли каталог возможных ключей маршрутизации, route.keys, и соответствующих им объектов передачи данных (DTO). Это позволило нам четко определить, какие события и данные генерируются каждым каналом и помогло уменьшить путаницу при взаимодействии между сервисами.
Например, служба аутентификации теперь занимается только аутентификацией, не получая сообщений о реакции других служб на аутентификацию пользователя. Аналогично, служба, отвечающая за транзакции, ничего не знает о действиях программы лояльности, которая начисляет бонусы за эти транзакции.
Внедрение механизма Exchange непосредственно в новых сервисах еще больше упростило интеграцию. Например, нам удалось без проблем настроить подписку и транслировать события через веб-сокет непосредственно во фронтенд. Также мы смогли подключить внешние аналитические сервисы, тем самым расширив возможности нашего проекта без увеличения связанности.
Благодаря такому подходу, любая служба, которой нужно уведомить о важном событии, теперь просто публикует это событие в Exchange. Даже если получатели временно неактивны или заняты, они обработают все входящие события при первой возможности. Все это позволило нам реализовать принцип Fire & Forget, значительно повысив надежность и гибкость системы.
Особенности внедрения
Перейти к использованию Exchange в системе оказалось не так просто, как мы предполагали. Нам не сразу пришла идея привязать каждый ключ маршрута к строго определенной очереди — изначально мы предполагали, что некоторые сервисы будут подписываться через wildcard, но на практике оказалось, что это встречается редко.
Также мы столкнулись с проблемой устаревшего кода, который был настолько запутанным, что нам несколько раз даже пришлось пересобрать функциональность заново с использованием Exchange, чтобы сделать ее более управляемой и читаемой.
Хотя мы старались избежать ошибок конкуренции, некоторые наши сервисы, работающие с репликами баз данных, доступными только для чтения, все же сталкивались с этой проблемой. Например, иногда обработка событий в RabbitMQ происходила быстрее, чем синхронизация реплик. Такие ситуации могли привести к непредсказуемым изменениям в базе данных при обработке последующих сообщений.
Достоинства и недостатки решения
Результаты
Переход к событийно-ориентированной архитектуре с использованием RabbitMQ стал ключевым решением для нашего проекта. Это дало нам возможность организовать асинхронное взаимодействие между сервисами через систему обмена сообщениями, ориентированную на события. Внедрение новых функций почти не увеличило связанность между микросервисами, так как многие задачи решались простым добавлением обработчика на Exchange. Благодаря этому, издатель не знал о конкретных подписчиках при разработке новых функций, а подписчики зависели только от интерфейса издателя.
Такой подход также сильно упростил постановку задач в проекте. Задачи разработчиков во многих случаях требовали всего лишь обработать определенные события или сгенерировать новые. Это позволило выпускать релизы без необходимости согласования с другими службами продукта.
Еще одно важное преимущество — независимость издателей от производительности и доступности подписчиков. Это позволяет экономить ресурсы подписчиков, обрабатывая события, полученные во время пиковой нагрузки, в периоды низкой активности. Это также повышает надежность издателей, которые действуют по принципу Fire & Forget, и им не нужно ждать ответа. Это особенно важно, потому что издатели зачастую являются более критичными сервисами, чем подписчики, а это позволяет им не зависеть от доступности подписчиков.
Дальнейшие шаги
Несмотря на успех проекта, многое в нем все еще можно улучшить. Например, мы хотим настроить непрерывную интеграцию (CI) для обнаружения критических изменений в изменениях DTO. Это позволит предупреждать сервисы, использующие этот Exchange, о необходимости обновлений. Из-за особенностей работы с пакетами Node.js разные сервисы могут использовать разные версии пакетов, что может привести к некорректной обработке сообщений при изменении критических элементов.
Мы также рассматриваем возможность добавления runtime-проверки, которая будет проверять, что структура сообщений соответствует заданной до того, как они будут опубликованы в очереди. Сейчас проверка происходит только на уровне компиляции TypeScript, и этого бывает недостаточно.
Кроме того, мы рассматриваем возможность сделать архитектуру нашего продукта независимой от вендора. Хотя пока мы продолжаем использовать RabbitMQ, с развитием проекта может потребоваться миграция на более мощное решение. Независимость системы поможет обеспечить большую гибкость и устойчивость архитектуры в долгосрочной перспективе.
Комментарии