Монолит против микросервисов

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

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

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

Зачем разбираться в этом до первой строчки кода

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

Что монолит делает лучше

Список короткий, но каждый пункт бьёт по больному.

АспектМонолитМикросервисы
Вызов соседнего модулявызов функции: десятки наносекунд, не может «не дойти»сетевой запрос: 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%.
  • Микросервисы жёстче всего наказывают за ошибку в границах, а границы неизбежно ошибочны, пока продукт меняется.
  • Стартапу нужен модульный монолит: простой деплой, но заранее размеченные швы.
Проверьте себя
1. Три сервиса с доступностью 99,9% каждый вызываются синхронно один за другим. Какова доступность всей цепочки?
AТе же 99,9% — доступность звеньев не складывается
BОколо 99,7% — вероятности успеха перемножаются
C99,99% — разбиение на сервисы повышает надёжность
DОпределяется только самым медленным сервисом
2. Что монолит делает принципиально лучше микросервисов?
AПозволяет разным командам выкатывать релизы независимо друг от друга
BДаёт атомарную транзакцию на несколько модулей и один стек-трейс при отладке
CПозволяет масштабировать каждый модуль отдельно от остальных
DПозволяет писать разные части системы на разных языках