Монолит против микросервисов
Честное сравнение: что монолит делает лучше, сколько стоит переход через сеть и почему первой архитектурой почти всегда должен быть монолит.
Монолит — приложение, которое собирается в один артефакт, деплоится целиком и работает в одном процессе. Микросервисы — набор небольших самостоятельных приложений, у каждого своя база данных и свой цикл релиза, а общаются они друг с другом по сети.
На конференциях и в вакансиях микросервисы звучат как признак зрелости: раз у вас монолит — вы отстали. Это неправда. Микросервисы — не следующая ступень эволюции, а обмен: вы отдаёте простоту и получаете независимость команд. Обмен бывает выгодным, а бывает разорительным, и разница почти целиком в том, сколько у вас команд и как часто они мешают друг другу. Этот урок — про честный курс обмена, без хайпа.
Зачем разбираться в этом до первой строчки кода
Архитектурное решение живёт дольше, чем код, который его реализует. Неудачный фреймворк меняется за спринт, неудачно нарезанные сервисы — за год-полтора, и всё это время команда платит налог: каждая фича трогает три репозитория, каждый релиз требует созвона, каждый баг ищут по четырём логам. Поэтому «монолит или микросервисы» — не вопрос вкуса и не вопрос моды, а вопрос цены, которую вы согласны платить каждый рабочий день.
Что монолит делает лучше
Список короткий, но каждый пункт бьёт по больному.
| Аспект | Монолит | Микросервисы |
| Вызов соседнего модуля | вызов функции: десятки наносекунд, не может «не дойти» | сетевой запрос: 1–10 мс внутри дата-центра, может отвалиться, зависнуть, прийти дважды |
| Согласованность данных | одна транзакция БД: либо всё, либо ничего | сага и компенсации: промежуточные состояния видит пользователь |
| Сдвинуть границу модуля | rename в IDE, один коммит | переговоры двух команд, версия API, обратная совместимость, два релиза |
| Отладка | один стек-трейс от контроллера до SQL | трассировка, correlation-id, логи пяти сервисов |
| Локальный запуск | приложение плюс база | docker compose на десяток контейнеров или гора моков |
| Деплой | один артефакт | N пайплайнов, N версий, матрица совместимости |
| Инфраструктура | сервер и БД | оркестратор, реестр сервисов, брокер, сбор логов, трассировка, дежурство |
Транзакция против саги
Это самая недооценённая потеря. В монолите «создать заказ, списать деньги, зарезервировать товар» — три вызова внутри одной транзакции: упал третий — база откатила первые два, пользователь ничего странного не увидел. Разнесите те же три шага по трём сервисам с тремя базами — и общей транзакции больше нет. Деньги списаны, товара нет, надо возвращать: писать компенсирующее действие, обрабатывать случай, когда возврат тоже упал, и объяснять пользователю, почему у него пять минут висел статус «заказ в обработке». Задача решаемая, но это целая подсистема, которой в монолите просто не существует.
Сдвинуть границу стоит копейки
Пока код в одном репозитории, неудачно проведённая граница между модулями чинится за час: перенесли класс, IDE поправила импорты, тесты позеленели. Ровно та же ошибка между сервисами чинится неделями: нужно менять контракт, какое-то время поддерживать обе версии, дожидаться, пока обновятся все потребители. Отсюда главный вывод урока: микросервисы дороже всего наказывают за ошибку в границах — а границы вы почти наверняка проведёте неправильно, пока продукт ещё ищет себя.
Цена распределённости
Сеть — это не «чуть медленнее»
Вызов функции внутри процесса не может частично выполниться, не может «дойти дважды» и не может ответить через 30 секунд. Сетевой вызов может всё это. И надёжность синхронной цепочки сервисов не равна надёжности каждого звена — вероятности перемножаются. Запустите этот код и посмотрите на цифры сами:
# Доступность синхронной цепочки: вероятности перемножаются
for single in (0.999, 0.9999):
print(f'Каждый сервис: {single * 100:.2f}%')
for n in (1, 3, 5, 10):
chain = single ** n
print(f' длина цепочки {n:2} → {chain * 100:.2f}%')
print()
Результат:
Каждый сервис: 99.90%
длина цепочки 1 → 99.90%
длина цепочки 3 → 99.70%
длина цепочки 5 → 99.50%
длина цепочки 10 → 99.00%
Каждый сервис: 99.99%
длина цепочки 1 → 99.99%
длина цепочки 3 → 99.97%
длина цепочки 5 → 99.95%
длина цепочки 10 → 99.90%
Пять «очень надёжных» сервисов по 99,9% дают на выходе 99,5% — это почти 44 часа недоступности в год вместо 9. И заметьте: ничего не сломалось, каждый сервис работает ровно так, как обещал. Просто вы сложили их в цепочку. Именно поэтому в микросервисах так много говорят про таймауты, ретраи, circuit breaker (предохранитель, который перестаёт долбиться в упавший сервис) и асинхронное взаимодействие — всё это лекарства от болезни, которой в монолите нет.
Отладка превращается в расследование
В монолите пользователь пишет «не оформился заказ» — вы открываете лог, находите исключение, видите стек-трейс до строки. В микросервисах у вас пять логов, в каждом свой формат времени, и вопрос «а какой из этих запросов был мой?» становится нетривиальным. Ответ на него — сквозной идентификатор запроса (correlation-id), который надо аккуратно прокидывать через все вызовы и очереди, и распределённая трассировка. Это не «настроить за вечер», это постоянная дисциплина: забыли прокинуть заголовок в одном месте — и трасса рвётся ровно там, где нужнее всего.
Деплой: было одно, стало двадцать
Один артефакт превращается в двадцать. Двадцать пайплайнов, двадцать наборов секретов, двадцать дашбордов и, главное, двадцать версий, которые как-то должны быть совместимы между собой. Появляются вопросы, которых раньше не было: что случится, если сервис A уже выкатили, а B ещё нет? Как выкатывать несовместимое изменение поля? Кого будят ночью, если упал сервис уведомлений? Ответы существуют (версионирование API, expand-and-contract при миграциях, контрактные тесты, дежурства), но каждый ответ — это работа, которую кто-то должен делать вместо фич.
Как это работает: один сценарий в двух мирах
Сравните два куска кода. Логика одинаковая — цена разная.
# МОНОЛИТ: три модуля в одном процессе, одна транзакция
def place_order(user_id, items):
with db.transaction(): # атомарность даёт база
order = orders.create(user_id, items)
payments.charge(order) # упало здесь — откатится всё
inventory.reserve(order)
return order
# МИКРОСЕРВИСЫ: те же три шага, но по сети
def place_order(user_id, items):
order = orders_api.post('/orders', {'user': user_id, 'items': items})
try:
payments_api.post('/charge', {'order': order['id']}) # таймаут? дубль? 500?
except Timeout:
# деньги могли списаться, а могли и нет — узнать нельзя
saga.schedule_compensation('cancel_order', order['id'])
raise
try:
inventory_api.post('/reserve', {'order': order['id']})
except Exception:
saga.schedule_compensation('refund', order['id']) # возврат тоже может упасть
saga.schedule_compensation('cancel_order', order['id'])
raise
return order
Обратите внимание: во втором примере бизнес-логики столько же, а кода — втрое больше, и почти весь новый код посвящён не заказам, а тому, что сеть ненадёжна. Это и есть налог на распределённость. Платить его имеет смысл только тогда, когда взамен вы получаете что-то очень ценное — например, возможность пяти командам выкатываться независимо и не ждать друг друга.
Почему стартапу почти всегда нужен монолит
Три причины, каждая достаточна сама по себе.
- Границы ещё неизвестны. Пока продукт ищет рынок, предметная область меняется каждый месяц: то, что вчера было «доставкой», сегодня стало частью «заказа». В монолите такой поворот стоит рефакторинга, в микросервисах — переезда данных между базами и переписывания контрактов.
- Некому владеть сервисами. Микросервисы решают проблему координации людей. Если людей пятеро и они сидят в одной комнате — проблемы нет, а лекарство вы уже купили и пьёте.
- Инфраструктурный налог съест всю скорость. Оркестратор, трассировка, контрактные тесты, дежурства — это месяцы работы, которых у стартапа нет.
Мартин Фаулер сформулировал это как принцип «сначала монолит»: почти все успешные микросервисные системы, которые он видел, выросли из монолита, который начал трещать по конкретным швам. А вот системы, начатые сразу с микросервисов, обычно попадали в неприятности. Заметьте: Shopify, Stack Overflow и Basecamp годами работают на крупных монолитах и чувствуют себя прекрасно.
Разумный компромисс — модульный монолит: один процесс и один деплой, но внутри строгие модули, которые общаются только через явные публичные интерфейсы и не лезут в чужие таблицы. Вы получаете простоту монолита и заранее размечаете швы. Когда какой-то модуль действительно начнёт мешать (своя нагрузка, своя команда, свой цикл релиза) — его вынимают в отдельный сервис почти механически.
Частые ошибки
- Выбирать архитектуру под резюме, а не под задачу. «Хочу потрогать Kubernetes» — плохое обоснование для дробления системы, за которое потом расплачивается вся команда.
- Считать монолит синонимом плохого кода. Спагетти бывает и в монолите, и в микросервисах — просто во втором случае оно ещё и по сети. Модульность — свойство кода, а не способа деплоя.
- Верить, что микросервисы автоматически дают надёжность. Как показал расчёт выше, наивная синхронная цепочка сервисов надёжнее не становится — она становится хуже.
- Забыть про наблюдаемость. Разрезать систему и оставить логи в файлах на подах — верный способ ослепнуть в первый же инцидент.
- Игнорировать стоимость локальной разработки. Если новичок не может поднять систему на ноутбуке за час, скорость команды падает необратимо.
Итоги
- Микросервисы — не апгрейд монолита, а обмен: простота в обмен на независимость команд.
- Монолит бесплатно даёт то, за что в микросервисах платят кровью: атомарные транзакции, дешёвый рефакторинг границ, один стек-трейс, один деплой.
- Синхронная цепочка сервисов перемножает вероятности: пять звеньев по 99,9% — это уже 99,5%.
- Микросервисы жёстче всего наказывают за ошибку в границах, а границы неизбежно ошибочны, пока продукт меняется.
- Стартапу нужен модульный монолит: простой деплой, но заранее размеченные швы.