Монолит, модульный монолит или микросервисы: как выбрать архитектуру

Разберём, как устроены монолит, модульный монолит и микросервисы, когда каждый подход помогает справиться с ростом проекта и какие баги добавляет разработчикам.

Обложка: Монолит, модульный монолит или микросервисы: как выбрать архитектуру

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

Чем отличаются монолит, модульный монолит и микросервисы

Представим приложение магазина. В нём есть заказы, каталог товаров и биллинг, который рассчитывает скидки и выставляет счета. Эти части связаны бизнес-логикой: для персональной скидки нужна сумма прошлых заказов, а при покупке нужно знать, есть ли товар на складе.

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

Такое устройство удобно на старте. Команда работает с общим приложением, и для проверки взаимодействия заказов с биллингом достаточно запустить его вместе с необходимой инфраструктурой. Обновление связанных частей можно включить в один релиз. Разработчикам проще проследить выполнение операции, когда её логика находится рядом.

По мере роста проекта становится важнее то, как устроен код внутри приложения. Если расчёт скидок, обработка заказов и работа с товарами связаны, изменение одной функции может затронуть соседние. Разработчику приходится учитывать всё больше связей, а проверка последствий занимает больше времени.

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

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

В микросервисной архитектуре части системы работают как самостоятельные сервисы. Заказы и биллинг можно развёртывать отдельно, выделять им разные ресурсы и разрабатывать силами разных команд. Для получения суммы заказов биллинг обращается к сервису заказов через API, получает ответ и использует его в расчёте.

Здесь появляется возможность выпускать изменения одного сервиса независимо от остальных, пока сохраняется совместимость взаимодействия. Вместе с ней возникает ответственность за контракт: сервис заказов должен возвращать данные в том виде, который понимает биллинг. Если изменение затрагивает обе стороны, командам нужно согласовать обновление.

Что меняется, когда вызов функции становится сетевым запросом

Вернёмся к персональной скидке. Биллинг должен узнать, на какую сумму пользователь сделал заказы за год. Пока логика находится в одном приложении, он может получить результат через внутренний вызов. В варианте с общей БД нужные данные доступны через запрос к ней.

После выделения заказов в сервис путь становится длиннее. Биллинг отправляет запрос к его API. Сервис заказов обращается к своей базе, формирует ответ и передаёт его обратно. Затем биллинг разбирает полученные данные и рассчитывает скидку.

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

Например, страница профиля может собирать данные из нескольких сервисов. Тогда разработчику приходится выяснять, на каком участке задерживается ответ. Медленная работа одного сервиса способна задержать весь сценарий, который от него зависит. Физическое разделение компонентов сохраняет логические связи между ними.

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

Кроме кода прикладных задач, команда поддерживает инфраструктуру каждого сервиса. Нужно организовать сборку и развёртывание, наблюдать за работой компонентов, собирать их логи. При отладке приходится восстанавливать ход операции по нескольким частям системы. Для локальной разработки также нужно продумать, какие сервисы и базы потребуются, чтобы проверить изменение.

Стоимость взаимодействия покажем на примере системы мониторинга качества видео Amazon Prime Video. Сначала она работала как распределённое решение на AWS Lambda и Step Functions. При увеличении нагрузки обмен данными и управление выполнением компонентов потребовали слишком больших расходов. Команда объединила систему в монолитное приложение, после чего расходы на неё снизились примерно на 90%.

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

Как масштабировать монолит и микросервисы

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

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

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

Воркеры и очереди задач

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

В нашем магазине таким вычислением может быть расчёт персональной скидки по прошлым заказам. Воркер получает задачу, считает результат и сохраняет его в БД. Приложение затем использует подготовленное значение. Возможность вынести конкретный расчёт в фон зависит от того, в какой момент его результат нужен пользователю.

У монолита воркеры могут использовать ту же кодовую базу, что и веб-приложение, но выполнять разные группы задач. Один занимается расчётами биллинга, другой обрабатывает задачи, связанные с товарами. Для каждого направления можно организовать свою очередь и подобрать число исполнителей.

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

Ресурсы воркеров тоже могут различаться. Расчётам, которым нужно держать в памяти большой объём данных, выделяют больше памяти. Более лёгким задачам достаточно меньших ресурсов. Такой подбор применим и к монолиту, и к отдельным сервисам.

Чтение и запись в базе данных

Для распределения нагрузки на чтение можно использовать реплики БД. Основная база принимает изменения, а её копии получают эти изменения через механизм репликации. Часть запросов на чтение направляют к копиям, разгружая основную базу.

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

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

Возможен и шардинг: распределение данных между несколькими базами, например по пользователям или клиентам. Такой подход можно применять в разных архитектурах. Он требует продумать, как приложение найдёт нужные данные и как будет работать с информацией, относящейся сразу к нескольким группам.

Что усложняется при разделении данных между сервисами

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

Когда каждая часть системы управляет своими данными и предоставляет их через API, получение общей информации требует обращений к соответствующим сервисам. Результаты нужно собрать и обработать. В системе появляется код, который организует эти обращения и учитывает доступность участников.

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

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

При выполнении связанных изменений внутри одной БД можно использовать общую транзакцию. Она позволяет применить предусмотренные изменения вместе или отменить их при неудаче. Разработчик описывает операцию с учётом возможностей и правил работы выбранной базы.

После разделения данных между сервисами согласование становится отдельной задачей. Один участник может успеть выполнить свою часть, пока другой столкнулся с ошибкой. Команде нужно определить, что произойдёт с уже внесёнными изменениями и какой итог получит пользователь.

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

Здесь возрастает объём работы по обеспечению согласованного результата. Чем больше участников и связей между ними, тем больше сценариев должна учитывать команда. Поэтому частые операции, которые тесно связывают данные разных частей приложения, влияют на выбор границ сервисов.

По каким признакам выбирать архитектуру для проекта

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

Для такой ситуации монолит даёт простой старт. По мере того как проясняются обязанности частей приложения, их можно оформлять в модули. Это помогает ограничивать зависимости в коде и сохранять понятную структуру, пока общее развёртывание подходит команде.

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

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

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

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

Потребность в разных технологиях может стать ещё одним аргументом за разделение. Для отдельной задачи бывает удобнее другой язык или стек. Тогда сервис даёт возможность использовать его локально. Вместе с этим потребуется поддерживать соответствующие знания в команде и учитывать особенности новой технологии при эксплуатации.

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

Чтобы обсудить решение на конкретных примерах, команда может ответить на несколько вопросов:

  • Какие части приложения обычно меняются вместе и почему им нужен общий релиз?
  • Какая функциональность требует самостоятельного масштабирования и где именно возникает нагрузка?
  • Какие данные потребуются выделенному сервису для выполнения обычной операции?
  • Кто будет отвечать за его развёртывание и разбирать проблемы в эксплуатации?
  • Можно ли разрабатывать и проверять этот сервис, запуская только необходимые ему компоненты?

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

Как понять, что компонент готов стать отдельным сервисом

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

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

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

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

Затем нужно сопоставить ожидаемую пользу с работой по переносу. У компонента появится собственное развёртывание, его взаимодействие с остальным приложением потребуется организовать и проверить. Если выделяются данные, отдельно планируют согласование операций, которые раньше выполнялись внутри одной БД.

Для существующего проекта такие изменения входят в более широкую работу по модернизации ПО. Centicore Group занимается обновлением легаси-кода, баз данных и инфраструктуры; в услугу входят рефакторинг и обновление технологического стека. Если команда планирует переработку работающей системы, можно обсудить её модернизацию с Centicore Group.

После выделения компонента у команды должна появиться конкретная возможность: выпускать его изменения в собственном темпе или подбирать ему ресурсы отдельно от остального приложения. Эту возможность полезно сформулировать ещё до переноса, тогда результат можно оценивать по исходной задаче, а дальнейшие изменения архитектуры планировать по мере появления новых потребностей.

Итого

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

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

Рекомендуем