11.2Монолит vs микросервисы
Сравни монолит и микросервисы по командам, независимому выпуску, масштабированию и отказам. Перед распилом проверь, можно ли провести модульные границы внутри одного процесса и какие сетевые обязанности появятся у каждого нового сервиса.
Честное сравнение по осям
Сравнивать «монолит vs микросервисы» в целом бессмысленно, только по конкретным осям, и почти на каждой оси ответ зависит от размера команды. Ни на одной оси один вариант не выигрывает всухую.
| Ось | Монолит | Микросервисы | Где перелом |
|---|---|---|---|
| Скорость разработки фичи | Высокая: один репозиторий, рефакторинг через границы одним коммитом, компилятор проверяет всё | Низкая на старте: одна фича = правки в 2–4 сервисах, согласование контрактов, порядок выката | Когда в один код лезут 5+ команд и merge-конфликты съедают больше, чем сеть |
| Деплой | Один артефакт. Но: любая правка выкатывает всё, откат - тоже всё. Релиз становится событием | Независимые релизы - главный и часто единственный настоящий выигрыш | Когда очередь на релиз мешает командам работать |
| Масштабирование | Только целиком: чтобы вытянуть тяжёлый отчёт, копируешь весь процесс со всей его памятью | Точечное: 40 подов на поиск, 3 на биллинг. Разные профили CPU/RAM/GPU | Когда 5 % кода потребляет 80 % ресурсов |
| Отказоустойчивость | Всё падает вместе; зато нет частичных отказов и «половина системы работает, половина нет» | Изоляция отказов - если есть таймауты, circuit breaker и деградация. Без них хуже монолита | Изоляция не бесплатна: её надо явно построить |
| Отладка | Стектрейс, дебаггер, go test ./.... Всё воспроизводится локально |
Нужны distributed tracing, корреляционные id, агрегация логов; локально система не поднимается | Всегда в пользу монолита. Это чистая цена |
| Найм и онбординг | Новичок должен осилить всё; чем больше монолит, тем дольше вход | Новичок входит в один сервис за неделю, но не понимает системы целиком | Зависит от того, есть ли документация границ |
| Инфраструктура | Бинарь + БД. Хватит systemd и одной машины | Оркестратор, реестр образов, service discovery, брокер, трейсинг, CI на N пайплайнов, дежурства | Это тот самый «налог», который платится до первой пользы |
| Консистентность данных | ACID-транзакция покрывает весь сценарий. Foreign keys работают | Транзакций между сервисами нет: саги, компенсации, eventual consistency, дубликаты | Самая недооценённая статья расходов |
| Технологическая свобода | Один стек на всё | Свобода выбора языка - и зоопарк, который потом некому поддерживать | Чаще ловушка, чем польза |
«Микросервисы не про производительность и не про чистоту кода. Они про то, чтобы N команд могли релизиться независимо друг от друга. Остальное идёт побочными эффектами: что-то приятное, что-то приходит счётом к оплате. Если у меня одна команда из шести человек, я покупаю сетевые вызовы, eventual consistency и дежурства, не получая взамен главного.»
Стоит ли новый продукт делать монолитом
Почти всегда да, и у подхода есть имя: monolith first (Мартин Фаулер, 2015).
- Границы неизвестны. В новом продукте предметная область меняется каждую неделю. Граница внутри монолита переносится рефакторингом за час; между сервисами она обойдётся в миграцию данных, версионирование API и несколько недель координации релизов. Ошибиться в границах на старте нормально.
- Продукт может не выжить. Инфраструктурный налог платится сразу, а окупается через год-два. Тратить первые три месяца на Kubernetes и трейсинг вместо проверки гипотезы не стоит.
- Обратный путь дороже прямого. Разрезать монолит по живой границе неприятно, но реально. Склеивать обратно десять сервисов, у каждого из которых своя БД и свой контракт, мучительно. Segment схлопнул полторы сотни таких сервисов в один, Istio слил управляющие компоненты в istiod, Prime Video собрал свой конвейер анализа видео обратно в один процесс - и каждый раз это стоило дорого.
Обратный аргумент существует, и назвать его стоит самому: если компания заранее знает предметную область (второй похожий продукт), уже имеет платформу и опыт эксплуатации, а команд сразу несколько, то начинать с нескольких сервисов разумно. Это исключение, и оно про зрелую организацию, а не про стартап.
Модульный монолит: как его строить в Go
У модульного монолита один процесс и одна база, но внутренние границы соблюдаются так, как будто это уже сервисы. Смысл: получить дешёвый рефакторинг границ и ACID-транзакции сейчас, а возможность распила оставить на потом, без переписывания.
Практические правила, которые делают монолит модульным (а не «просто монолитом с папками»):
- У модуля публичный API и приватные потроха.
internal/order/экспортируетorder.Serviceи доменные типы, всё остальное пишется с маленькой буквы или прячется в подпакетinternal/order/internal/…, куда компилятор просто не пустит чужих. - Модули общаются только через публичные интерфейсы модулей, а не через чужие
репозитории и таблицы. Хочешь пользователя - зови
user.Service, а неSELECT * FROM users. - Схема БД разделена по модулям. Отдельные схемы Postgres (
orders.*,users.*) или хотя бы явное соглашение о префиксах. Запрет на JOIN между схемами разных модулей соблюдать тяжелее всего, и он же ценнее всего: именно совместные JOIN-ы потом делают распил невозможным. - Внутримодульные события вместо прямых вызовов там, где связь «уведомить». Простая шина в памяти сегодня превращается в Kafka завтра, а код модулей не меняется.
- Границы проверяются автоматически. Линтер импортов (
depguard,go-arch-lint) в CI: правило «никто, кромеorder, не импортируетorder/internal». Без такой проверки границы разъезжаются за квартал.
// internal/order/service.go - публичный контракт модуля
package order
type Service struct {
repo repository // приватный интерфейс модуля
users UserProvider // порт к соседнему модулю: только то, что нужно
events EventPublisher // сегодня шина в памяти, завтра Kafka
}
// Порт: order не знает про пакет user, только про свой узкий интерфейс.
type UserProvider interface {
ByID(ctx context.Context, id string) (Customer, error)
}
// main.go - единственное место, где модули встречаются
orderSvc := order.NewService(orderRepo, userAdapter{userSvc}, bus)
Задай себе один вопрос: сколько времени займёт вынести модуль billing в отдельный
процесс? Если ответ «заменить вызов метода на HTTP-клиент и перенести таблицы», монолит
модульный. Если «непонятно, потому что половина запросов джойнит billing.invoices
с orders.items, а user.Service напрямую пишет в чужую таблицу»,
то это обычный монолит, просто разложенный по папкам.
Когда монолит действительно упирается в потолок
«Стало много кода» ни о чём не говорит. Есть монолиты на миллионы строк, которые прекрасно живут. Настоящие признаки касаются людей и эксплуатации, и все они измеримы:
| Признак | Как измерить | Что это значит |
|---|---|---|
| Очередь на релиз | Время от «готово» до «на проде» > нескольких дней; релизный поезд раз в неделю | Команды блокируют друг друга - главный аргумент за распил |
| Один упавший модуль роняет всё | Утечка в отчётах кладёт приём платежей | Нет изоляции ресурсов внутри процесса |
| Профиль нагрузки разошёлся | Поиск требует 32 ГБ RAM, всё остальное - 2 ГБ; платим за максимум ×N подов | Масштабировать целиком стало дорого буквально в деньгах |
| Конфликты в общих файлах | Каждый мерж - ручное разрешение в одних и тех же местах | Границы модулей не соблюдаются |
| CI перестал помещаться | Сборка и тесты > 30–40 минут, флаки, «прогоню ещё раз» | Обратная связь порвалась; лечится в том числе кэшем и селективными тестами |
| Разные требования к доступности | Приём платежей - 99.99 %, админка - 99 %, а деплоятся вместе | Разные SLA просят разных жизненных циклов |
| Технологический тупик | Нужен ML-инференс на Python или библиотека, которой нет в Go | Отдельный сервис оправдан точечно, без общего распила |
«Код грязный» лечится рефакторингом, сеть его не почистит; грязный код, размазанный по сети, становится грязным распределённым кодом. «Хотим микросервисы в резюме» причина честная, но не архитектурная. «Медленно работает» разбирают профилированием: сетевой вызов дороже вызова функции на пять порядков, и распил почти всегда делает латентность хуже. «У нас 30 разработчиков» само по себе ничего не значит; значит только то, мешают ли они друг другу релизиться.
Стратегии распила
Strangler fig по шагам
- Поставить фасад. Reverse proxy (nginx/Envoy/API Gateway) перед монолитом, который сначала проксирует 100 % трафика без изменений. Шаг важнее всех остальных: появляется точка, где можно переключать маршруты, не трогая клиентов.
- Выбрать первый кусок. Не самый важный, а самый изолированный: мало данных, мало связей, понятный контракт. Обычно это уведомления, экспорт отчётов, загрузка файлов. Первый распил тренирует инфраструктуру, а не приносит бизнес-ценность.
- Разделить данные до кода. Таблицы будущего сервиса перестают участвовать в JOIN-ах монолита; вместо прямых запросов появляются вызовы модуля. Это самая долгая часть, и её делают внутри монолита, пока откат бесплатный.
- Написать сервис и включить двойную запись/чтение. Запросы идут в оба места, результаты сравниваются в фоне (shadow / dark launch). Расхождения это найденные баги, а не повод останавливаться.
- Переключить маршрут на фасаде. Сначала 1 % трафика, потом 10 %, потом 100 %. Откат мгновенный.
- Удалить код из монолита. Шаг, который пропускают чаще всего. Если старый код остался «на всякий случай», через полгода у тебя два источника правды и никто не помнит, какой живой.
Branch by abstraction
Альтернатива фасаду для случая, когда заменяемый кусок сидит внутри кода и наружу не торчит (нельзя переключить по URL). Порядок: (1) вводим абстракцию - интерфейс над старой реализацией, все вызовы идут через него; (2) пишем вторую реализацию, которая ходит в новый сервис; (3) переключаем по фиче-флагу, можно по проценту пользователей; (4) удаляем старую реализацию и, при желании, саму абстракцию. Всё это живёт в главной ветке, ничего не висит в долгоживущем бранче, отсюда и название.
type PricingEngine interface {
Price(ctx context.Context, cart Cart) (Money, error)
}
// Флаг-переключатель: обе реализации живут одновременно
type switching struct {
old, new PricingEngine
flags Flags
log *slog.Logger
}
func (s *switching) Price(ctx context.Context, c Cart) (Money, error) {
if !s.flags.Enabled(ctx, "pricing_v2") {
return s.old.Price(ctx, c)
}
// «тёмный» прогон: считаем новым, но отдаём старое, пока не совпадёт
got, err := s.new.Price(ctx, c)
want, errOld := s.old.Price(ctx, c)
if err == nil && errOld == nil && got != want {
s.log.Warn("pricing mismatch", "new", got, "old", want, "cart", c.ID)
}
return want, errOld
}
Антипаттерн: распределённый монолит
Признаки, по которым его узнают:
- Общая база данных. Два сервиса пишут в одну таблицу, значит это один сервис, разложенный по двум процессам. Миграция схемы становится общесистемным событием.
- Релиз «поездом». Чтобы выкатить фичу, нужно выкатить три сервиса в правильном порядке, и откат тоже по порядку.
- Длинные синхронные цепочки. Один клиентский запрос порождает 5–10 последовательных вызовов; латентность складывается, доступности перемножаются.
- Общая библиотека с доменными типами. Изменил структуру в shared-пакете - обязан пересобрать и выкатить всех потребителей. Это компиляционная связность, просто спрятанная в go.mod.
- Нельзя протестировать сервис в одиночку. Для локального запуска нужен docker-compose на восемь контейнеров.
Монолит даёт транзакции, дешёвый рефакторинг и простую отладку, а распределённый монолит всё это теряет. Микросервисы дают независимый релиз и изоляцию отказов, а он не получает и этого. Остаётся чистый минус: сетевые ошибки, сериализация, версии контрактов, десять пайплайнов, трейсинг ради того, чтобы понять, где упало. Именно поэтому фраза «мы распилили монолит, но релизимся всё равно всем вместе» звучит как диагноз, а не как этап.
Как проводить границы сервисов
Рабочие критерии границы, по убыванию надёжности:
- Bounded context из DDD. Граница проходит там, где меняется смысл слова. «Заказ» в корзине это черновик с товарами; в доставке - адрес, вес и маршрут; в бухгалтерии - сумма и НДС. Три разных модели одного слова дают три кандидата в сервисы. Общая «универсальная» модель заказа, которая устраивает всех, не устраивает никого и намертво связывает команды.
- По данным, а не по действиям. Сервис эксклюзивно владеет своими таблицами и остаётся для них единственным источником правды. Если для одной операции нужна транзакция по таблицам двух сервисов, значит граница проведена неверно, и это самый сильный сигнал.
- По причине изменения. Смотришь историю git: какие файлы меняются вместе. То, что всегда правится одним коммитом, должно жить в одном сервисе. Это буквально SRP, поднятый на уровень системы.
- По команде (закон Конвея). Система копирует структуру коммуникаций организации. Если границы сервисов не совпадают с границами команд, побеждают команды: код всё равно расползётся по чужим сервисам. При «обратном манёвре Конвея» сначала строят команды так, как хотят видеть архитектуру.
- По требованиям к нагрузке и SLA. Кусок с другим профилем трафика или другой требуемой доступностью тянет на отдельный сервис, даже если доменно он невелик.
Из DDD при нарезке пригодятся ещё два правила. Агрегат задаёт границу транзакции, поэтому он никогда не должен пересекать границу сервиса. А легаси-монолит при распиле обычно отгораживают anticorruption layer, чтобы его модель не протекала в новые сервисы.
Полная цена микросервисов
- Сеть вместо вызова функции. Вызов метода занимает единицы наносекунд, вызов по сети внутри датацентра сотни микросекунд плюс сериализация. Это пять-шесть порядков. Плюс сеть ненадёжна: таймауты, ретраи, дубликаты, частичные отказы становятся частью бизнес-логики.
- Консистентность. Транзакции больше нет. Всё, что раньше было одним
BEGIN … COMMIT, превращается в сагу с компенсациями, outbox и идемпотентными обработчиками. Это не «немного кода», а отдельная подсистема со своим состоянием и багами. - Наблюдаемость обязательна, а не желательна. Без distributed tracing, единого сбора логов с trace_id и метрик по каждому вызову отладка превращается в гадание. Это отдельный стек (OpenTelemetry, Jaeger/Tempo, Prometheus, Loki) со своей стоимостью хранения.
- Инфраструктура и эксплуатация. Оркестратор, реестр образов, service discovery, секреты, N пайплайнов CI, N дашбордов, N алертов, дежурства и рантбуки. Плюс средства локальной разработки, чтобы человек мог поднять кусок системы на ноутбуке.
- Версионирование контрактов. Ни один сервис нельзя выкатить, сломав контракт: нужны обратная совместимость, дедлайны миграции и contract-тесты.
- Дублирование данных и кода. Копии справочников в нескольких сервисах, их синхронизация, повторяющаяся обвязка (аутентификация, логирование, конфиг). Дальше или копипаста, или общая платформенная библиотека со своей проблемой версий.
- Когнитивная нагрузка. Понять один сервис легко, понять систему тяжело. Нужна карта сервисов и владельцев, иначе появляются «сервисы-сироты», которые все боятся трогать.
Вопросы
6Разложить по осям, а не «лучше/хуже»
Монолит выигрывает в скорости разработки, отладке, консистентности и стоимости инфраструктуры. Микросервисы выигрывают в независимости деплоя, точечном масштабировании и изоляции отказов, причём изоляция не появляется сама, её надо построить таймаутами, circuit breaker и деградацией. Есть оси, где выигрыш неоднозначен: онбординг (в монолит вход дольше, но система понятна целиком), технологическая свобода (чаще зоопарк, чем польза).
Почему monolith first
- Границы неизвестны. В новом продукте домен переопределяют каждую неделю. Ошибка в границе внутри монолита стоит рефакторинга; между сервисами миграции данных, версий API и координации релизов.
- Налог платится сразу, польза приходит потом. Оркестратор, трейсинг, брокер, пайплайны съедают месяцы, которые продукт может не пережить.
- Склеивать обратно дороже, чем резать. Публичных историй возврата к монолиту (Segment, Amazon Prime Video) больше, чем историй «мы жалеем, что начали с монолита».
Когда исключение оправдано
Если организация уже знает домен (делает второй похожий продукт), у неё есть платформа и опыт эксплуатации, и сразу несколько команд, то стартовать несколькими сервисами разумно. Законен и отдельный сервис под чужой стек (ML на Python) или под другой профиль нагрузки: это точечное решение, а не общий распил.
«Микросервисы лечат организационную боль, а не техническую. Если команды не мешают друг другу релизиться, я плачу за микросервисы, не получая главного, что они дают.» Дальше полезно добавить компромисс: модульный монолит, один процесс с жёсткими внутренними границами, из которого распил делается механически, когда боль появится.
Пять правил
- Модуль = пакет с узким публичным API. В Go за это отвечает сам язык:
internal/order/internal/…компилятор не даст импортировать никому, кроме поддереваorder. Наружу торчатorder.Serviceи доменные типы, остальное закрыто. - Общение через порты, а не через чужие репозитории.
orderобъявляет у себяUserProviderс одним методом; адаптер кuser.Serviceсобирается вmain. - Данные разделены. Отдельные схемы Postgres на модуль и запрет на JOIN между ними. Это самое неудобное и самое ценное правило: именно совместные JOIN-ы делают распил невозможным через два года.
- События внутри процесса. Там, где связь по смыслу «уведомить, а не запросить», модуль публикует событие в шину в памяти. При распиле шину меняют на Kafka, а модули править не приходится.
- Границы проверяются в CI.
go-arch-lintилиdepguardс правилами импортов. Без автоматической проверки границы разъезжаются за квартал.
Что это даёт при распиле
Вынос модуля превращается в механическую операцию: реализация порта заменяется на HTTP/gRPC-клиент, таблицы модуля переезжают в свою базу, шина в памяти - в брокер. Никакого «сначала полгода распутываем» не требуется. Плюс можно резать по одному модулю за раз и останавливаться в любой момент, многие команды так и живут: два-три вынесенных сервиса и большое модульное ядро.
«Сколько времени займёт вынести billing в отдельный процесс?» Если ответ
измеряется днями, монолит модульный. Если «непонятно, там всё джойнится», то это обычный
монолит с красивыми папками, и его модульность существует только в презентации.
Признаки, которые считаются
- Организационный. Время от «готово» до «на проде» измеряется днями; команды ждут релизного поезда и друг друга. Это единственный признак, который прямо указывает на микросервисы.
- Ресурсный. Один модуль требует 32 ГБ, остальным нужно 2, и платим за максимум, умноженный на число подов. Или один модуль на GPU, а остальные нет.
- Надёжностный. Утечка памяти в отчётах кладёт приём платежей; нет способа изолировать ресурсы внутри одного процесса.
- SLA. Платежи хотят 99.99 %, админка - 99 %, а деплоятся вместе, значит, по факту у обоих SLA админки.
- Инженерный. Сборка и тесты дольше получаса, флаки, «прогоню ещё раз». Обратная связь порвалась.
Что делать, и не всегда это распил
- Сначала дешёвое. Вертикальное масштабирование, кэш, индексы, профилирование. Часто «потолок» оказывается одним неудачным запросом. Распил медленного кода делает его медленным и распределённым.
- Разделить деплой одного бинаря по ролям. Один и тот же артефакт запускается
с разными флагами:
--role=api,--role=worker,--role=cron. Это даёт независимое масштабирование и изоляцию отказов почти бесплатно, без распила кода и без сети между модулями. - Навести модульность внутри. Разделить схемы БД, убрать межмодульные JOIN-ы, поставить линтер импортов. Это подготовка к распилу, которая полезна, даже если распила не будет.
- Вынести первый сервис strangler-фигом. Самый изолированный кусок, ради тренировки инфраструктуры.
- Резать дальше только под конкретную боль. Каждый следующий сервис должен закрывать названный признак из списка выше, а не «продолжать начатое».
Интервьюер ждёт, что ты назовёшь «много кода» или «стало сложно». Ответ «сложно» не инженерный: сложность лечится модульностью, а не сетью. Сильнее сработает встречный вопрос: «какая боль? релизы, ресурсы или отказы?», и дальше отвечаешь под неё. Именно это поведение и проверяют вопросом.
Strangler fig
Метафора фикуса-душителя: новое обвивает старое и постепенно его вытесняет. Шаги: ставим reverse proxy перед монолитом (сначала он просто проксирует всё) → выбираем самый изолированный кусок → разделяем данные внутри монолита, убирая JOIN-ы → пишем сервис и гоняем его в тени, сравнивая результаты → переключаем маршрут на фасаде по проценту трафика → удаляем старый код. Последний шаг пропускают чаще всего, и тогда получаются два источника правды.
Выделение по bounded context
Это ответ на вопрос «что резать», а strangler fig - на вопрос «как резать». Ищем места, где меняется смысл терминов и где данные не пересекаются; первым выносим кусок с минимальным числом связей, а не самый бизнес-важный. Порядок «данные → код → трафик» здесь обязателен: пока таблицы общие, вынесенный сервис не самостоятелен.
Branch by abstraction
Нужен, когда заменяемый кусок не торчит наружу и переключить его по URL нельзя. Вводим интерфейс над старой реализацией → пишем вторую реализацию, ходящую в новый сервис → переключаем фиче-флагом, можно по проценту пользователей и с параллельным сравнением результатов → удаляем старую. Всё в главной ветке: успешных долгоживущих веток рефакторинга не бывает.
Распределённый монолит: признаки
- Общая база: два сервиса пишут в одну таблицу.
- Релиз только «поездом», в определённом порядке, и откатывать надо по порядку.
- Длинные синхронные цепочки: один запрос порождает 5–10 последовательных вызовов.
- Общая библиотека доменных типов: правка в ней требует пересборки всех.
- Сервис нельзя поднять и протестировать в одиночку.
Почему это хуже обоих вариантов: от монолита потеряли транзакции, дешёвый рефакторинг и простую отладку; от микросервисов не получили независимый релиз и изоляцию отказов. Остаются только издержки. Выход не в том, чтобы «доводить распил»: сначала надо разделить данные и убрать синхронные цепочки, заменить часть вызовов событиями, добавить кэш/реплику данных вместо запроса к соседу, ввести версионирование контрактов, чтобы релизы расцепились.
Bounded context
Идея DDD в том, что универсальной модели предметной области не существует, существуют
контексты, внутри каждого из которых термин значит ровно одно. В корзине «заказ» это черновик
с позициями, в доставке - адрес, вес, маршрут, в бухгалтерии - сумма, НДС, документ.
Попытка сделать одну структуру Order на всех даёт объект с сорока полями,
половина которых всегда пустые, и намертво связывает три команды. Между контекстами модели
переводятся явно, этим занимается anticorruption layer; им же при распиле отгораживают
новые сервисы от модели легаси-монолита.
Практические критерии, по убыванию надёжности
- По данным. Сервис эксклюзивно владеет своими таблицами и остаётся единственным источником правды. Если сценарий требует транзакции по таблицам двух сервисов, граница неверна. Это самый сильный и самый проверяемый сигнал.
- По причине изменения. Смотри историю git: что всегда меняется вместе, должно жить вместе. SRP на уровне системы.
- По языку. Меняется словарь бизнеса - меняется контекст. Если аналитики двух отделов под одним словом понимают разное, это готовая граница.
- По команде (закон Конвея). Архитектура повторит структуру коммуникаций. Если границы сервисов не совпали с границами команд, победят команды. При «обратном манёвре Конвея» команды строят сначала, под желаемую архитектуру.
- По нагрузке и SLA. Другой профиль трафика или другая требуемая доступность дают законный повод, даже для небольшого куска домена.
Ubiquitous language - общий язык кода и бизнеса внутри контекста. Aggregate собирает объекты вокруг корня и меняется атомарно; практическое правило: агрегат равен границе транзакции и никогда не пересекает границу сервиса. Context map описывает отношения: shared kernel (общий код связывает релизы, опасно), customer/supplier, conformist, anticorruption layer. Если добавить, что размер сервиса не критерий («микро» про границу, а не про строки кода), ответ звучит как опыт, а не как пересказ книги Эванса.
Сеть
Вызов метода занимает единицы наносекунд. RPC внутри датацентра сотни микросекунд плюс сериализация: разница в пять-шесть порядков. Но главное не скорость, а то, что сеть ненадёжна: появляются таймауты, ретраи, дубликаты и частичные отказы, и всё это просачивается в бизнес-логику. Отдельно бьёт умножение доступностей: цепочка из пяти сервисов по 99.9 % даёт 99.5 %, и это без учёта латентности хвостов.
Консистентность
Транзакции между сервисами нет. Каждый сценарий, который раньше был одним
BEGIN … COMMIT, становится сагой с компенсирующими действиями, transactional
outbox для надёжной публикации событий и идемпотентными обработчиками для защиты от дублей.
Это отдельная подсистема с собственным состоянием, мониторингом и багами, самая
недооценённая статья расходов.
Наблюдаемость
Без distributed tracing, единого сбора логов с общим trace_id и метрик по каждому
исходящему вызову отладка становится гаданием: «медленно» может быть в любом из десяти мест.
Это отдельный стек (OpenTelemetry + Jaeger/Tempo + Prometheus + Loki) со своей стоимостью
хранения и своей эксплуатацией. В монолите то же самое даёт обычный стектрейс.
Инфраструктура и люди
- Оркестратор, реестр образов, service discovery, управление секретами, брокер.
- N пайплайнов CI/CD, N наборов дашбордов и алертов, дежурства и рантбуки.
- Средства локальной разработки, иначе человек не может воспроизвести баг.
- Версионирование контрактов и contract-тесты, чтобы релизы не ломали соседей.
- Дублирование справочных данных между сервисами и их синхронизация.
- Когнитивная нагрузка и владение: без карты сервисов появляются «сироты», которые все боятся трогать.
«Всё это оплачивается независимостью релизов. Если независимость релизов не нужна, счёт приходит, а товар не поставляется.» И полезно добавить, что цена не разовая: она платится каждый спринт, каждым дежурством и каждым новым человеком в команде.
Что стоит запомнить
- Модульный монолит позволяет держать границы доменов без сетевых отказов и независимых хранилищ.
- Микросервис оправдан, когда есть конкретная организационная или эксплуатационная потребность, например независимый выпуск/масштабирование.
- Распределённый монолит сохраняет синхронную связанность, но добавляет latency, частичные отказы и сложный деплой.
Частые ошибки
- Не считай отдельный deployment автоматически независимым сервисом, если без остальных он не запускает сценарий.
- Не выделяй сервис только по сущности или желанию использовать технологию.
- Не запускай новый продукт с N сервисами без необходимости независимых команд и эксплуатационной готовности.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Найди независимость в модульном монолите
Что именно скрывает интерфейс UserProvider и почему сборка обоих модулей в одном composition root сама по себе не требует отдельного deployment?
type UserProvider interface {
ByID(ctx context.Context, id string) (Customer, error)
}
userProvider := user.NewService(userRepo)
orderSvc := order.NewService(repo, userProvider, eventPublisher)
server := httpapi.NewHandler(orderSvc)
Ответ и разбор
Заказ зависит только от нужного поведения получения Customer, а не от реализации user-модуля. Создание обоих модулей в одном main показывает composition root, но не означает, что они обязаны быть отдельными процессами; такой контракт работает и внутри модульного монолита.
Код-ревью
2. Проверь распределённый вызов в hot path
У запроса общий budget 500 ms. Три downstream вызова идут последовательно и могут ждать неопределённо долго. Promo можно пропустить при отказе, цена и остаток нужны для ответа. Предложи разбиение deadline и fallback.
price, err := pricing.Get(ctx, sku)
if err != nil { return err }
stock, err := inventory.Get(ctx, sku)
if err != nil { return err }
promo, err := promotion.Get(ctx, sku)
if err != nil { return err }
return writeResponse(price, stock, promo)
Ответ и разбор
Создай общий deadline 500 ms от parent ctx и дочерние бюджеты, например до 150 ms на price, 150 ms на inventory, 100 ms на promo, оставив около 100 ms на сериализацию и ответ. Проверь, что каждый клиентский вызов получает дочерний ctx. Ошибку/timeout promo обработай как отсутствие optional promotion с метрикой; цену или остаток без которых нельзя ответить верни как контролируемую ошибку. Конкретные лимиты надо сверить с p99 downstream.
Применение
3. Выбери время распила
У 4 разработчиков один сервис, изменяемый раз в день; общий релиз занимает 8 минут. Их беспокоят общий Go module и длинный CI. Какие два вопроса надо измерить до микросервисного распила?
Ответ и разбор
Измерь, сколько времени релизная очередь реально блокирует независимую работу и какая доля CI/сборки вызвана несвязанными модулями. Также выясни, есть ли отдельная команда-владелец и нужна ли автономная масштабируемость. При четырёх разработчиках и 8-минутном выпуске модульный монолит или ускорение CI может решить конкретную боль дешевле сети.
Применение
4. Спроектируй границу по владению данным
Команды каталога и биллинга часто меняют одни и те же таблицы и выкатываются вместе. Какой сигнал это даёт о границе сервиса и какой вариант проверить раньше полного распила?
Ответ и разбор
Совместное изменение таблиц и синхронный выпуск показывают, что граница владения данными не проведена. Сначала раздели модули и владение таблицами в монолите, договорись о внутренних контрактах и убери прямые чужие записи. Если после этого командам всё ещё нужна независимость выпуска/масштаба, выделение сервиса станет менее рискованным.