Как разрезать монолит пошагово
Монолит не переписывают за выходные — его душат: медленно, по кусочку, не выключая свет.
Strangler Fig (паттерн «удушающего фикуса») — способ миграции, при котором новая система постепенно обрастает вокруг старой и перехватывает её функции по одной, пока от старой не остаётся пустая оболочка, которую можно спокойно удалить.
Название пришло из биологии. Фикус-душитель прорастает на ветке дерева-хозяина, спускает корни вниз, оплетает ствол — и через годы дерево внутри отмирает, а фикус стоит сам, повторяя его форму. Ровно так и мигрируют системы, которые нельзя выключить ни на день: интернет-магазин, банк, биллинг оператора.
Почему «перепишем с нуля» почти всегда заканчивается плохо
Соблазн понятен: старый код неприятен, новая архитектура в голове красива, кажется, что за полгода команда сделает то же самое, но правильно. На практике переписывание «большим взрывом» (big bang rewrite) упирается в три вещи.
Бизнес не останавливается. Пока вы полгода пишете новую систему, в старую продолжают лить фичи — иначе продукт умрёт. Значит, вы гоняетесь за движущейся мишенью и переписываете один и тот же функционал дважды.
В старом коде спрятано знание. Тот странный if с датой 2019 года — не мусор, а обход бага у платёжного провайдера, который до сих пор не починили. Такие вещи не задокументированы нигде: они живут только в коде и в памяти одного человека, который уже уволился.
Нет отката. В день переключения либо всё работает, либо вы откатываетесь на полгода назад. Промежуточного состояния не предусмотрено, а значит, любая ошибка — катастрофа.
| Критерий | Переписывание с нуля | Strangler Fig |
| Когда бизнес увидит первую пользу | через 6–18 месяцев (или никогда) | через 2–6 недель, на первом же вынесенном куске |
| Откат | только «назад на полгода» | переключение маршрута обратно за секунды |
| Риск | один огромный в конце | много маленьких, каждый проверяем |
| Судьба легаси | живёт параллельно, поддерживается двумя командами | усыхает и удаляется по кускам |
| Что происходит при остановке проекта | всё выброшено | вынесенные сервисы остаются и приносят пользу |
Главное свойство фикуса: миграцию можно прервать в любой момент, и система останется в рабочем состоянии. Это не «мягкий» подход — это единственный подход, который переживает смену приоритетов, а она случится обязательно.
Три обязательных элемента паттерна
- Фасад-перехватчик. Точка, через которую проходит весь входящий трафик: реверс-прокси, API Gateway, иногда просто слой роутинга внутри самого монолита. Клиенты (мобильное приложение, фронтенд) не знают, что за фасадом что-то меняется — и это ключ ко всему.
- Новый сервис. Полноценная реализация одной бизнес-возможности: со своим деплоем, своими метриками, желательно со своим хранилищем.
- Правило маршрутизации. Настройка вида «этот путь — в новый сервис, остальное — в монолит». Она обязана меняться без деплоя: конфиг, фичефлаг, запись в базе. Если для переключения нужно собрать релиз — у вас нет рубильника, а без рубильника нет и права на эксперимент.
# Правила на фасаде. Читается сверху вниз, первое совпадение выигрывает.
POST /api/notifications/* -> notification-service 100% # вынесено полностью
GET /api/search -> search-service 10% # канарейка
GET /api/search -> monolith 90%
ANY /api/* -> monolith # всё остальное — по-старому
Обратите внимание: фасад — это не «ещё один сервис, который мы напишем потом». Он появляется первым, ещё до первого вынесенного сервиса, и поначалу тупо проксирует 100% трафика в монолит. Так вы заранее убеждаетесь, что фасад ничего не ломает, и получаете место, куда потом добавлять правила.
Что выносить первым
Самая частая ошибка новичка — начать с самого интересного, то есть с ядра: с заказа, корзины, счёта. Это худший из возможных выборов: ядро связано со всем, у него самая высокая цена ошибки, и провал первого же шага похоронит всю затею политически.
Хороший первый кандидат скучный. Его выбирают по признакам:
- мало связей с остальным кодом — считайте общие таблицы, это самая честная метрика связанности;
- у него понятная граница по бизнес-возможности («отправка уведомлений», а не «слой отправки писем»);
- у него своя нагрузка, отличная от остальных, — есть что выиграть от независимого масштабирования;
- цена ошибки терпимая: письмо ушло с задержкой — неприятно; деньги списались дважды — катастрофа;
- результат можно измерить и показать бизнесу.
| Кандидат | Почему удачный первый шаг | Подводный камень |
| Уведомления (email, push, SMS) | почти нет чтения чужих данных: на вход приходит событие с готовым текстом | шаблоны писем тянут за собой половину доменной модели, если не отрезать их сразу |
| Отчёты и выгрузки | только чтение, можно работать с репликой базы; тяжёлые запросы уезжают с основной БД | отчёты любят JOIN-ить всё со всем — придётся заранее решить, что читать |
| Поиск | отдельное хранилище (индекс), своя нагрузка, легко сравнить с монолитом по выдаче | нужен механизм наполнения индекса — обычно события или CDC |
| Загрузка и обработка файлов | изолированная задача, ест CPU и диск — приятно отселить | ссылки на файлы разбросаны по всему монолиту |
| Платежи, корзина, заказ | Плохие первые кандидаты. Ядро домена выносят, когда конвейер уже отлажен на скучном. | |
И ещё одна вещь, которую редко проговаривают вслух: первый сервис стоит в пять-десять раз дороже второго. Вы платите не за сервис — вы платите за конвейер: пайплайн сборки, шаблон репозитория, логирование в общий сборщик, метрики, алерты, gateway, стенды. Считайте это инвестицией. Если после первого выноса второй дался так же тяжело, как первый, — конвейера у вас так и нет, и дробить дальше рано.
Общая база на переходный период
Правило «одна база на сервис» — это цель, а не стартовая позиция. Разорвать данные в первый же день невозможно: в монолите сотни таблиц, связанных внешними ключами, и половина запросов ходит по ним джойнами. Поэтому базу режут в три этапа.
Этап 0. Общая база, но разделённое владение
Новый сервис физически ходит в ту же самую базу, что и монолит, — но только в свои таблицы. Всё остальное он получает вызовом API монолита. Данные ещё общие, но дисциплина владения уже введена: у каждой таблицы появляется ровно один хозяин.
# Карта владения (заводится в первый же день, живёт в репозитории)
notification-service ВЛАДЕЕТ: notifications, notification_templates, delivery_log
monolith ВЛАДЕЕТ: users, orders, order_items, payments, ...
Правило: писать в чужую таблицу нельзя. Читать — только на этапе 0 и только по списку:
notification-service ЧИТАЕТ users.email (временно, до этапа 1)
Этот этап неприятен ревьюерам («мы же договорились не лезть в общую базу!»), но он единственный, который можно сделать за спринт и без риска. Важно лишь одно: временный доступ должен быть записан в явном списке, иначе он станет вечным.
Этап 1. Своя база и синхронизация
Сервис заводит собственное хранилище и копит в нём нужные ему данные. Копию наполняют событиями: монолит при изменении пользователя публикует событие UserEmailChanged, сервис обновляет свою локальную копию.
Здесь подстерегает ловушка двойной записи: код монолита сначала пишет в базу, потом шлёт событие в брокер. Между этими двумя действиями процесс может упасть — база обновлена, событие потеряно, копии разъехались навсегда. Лечится это паттерном Transactional Outbox: событие пишется в таблицу outbox в той же транзакции, что и изменение данных, а отдельный процесс вычитывает эту таблицу и отправляет в брокер. Либо тем же добиваются через CDC (Change Data Capture) — чтением журнала транзакций базы.
Этап 2. Разрыв
Внешние ключи между доменами удаляются, монолит перестаёт читать чужие таблицы и начинает ходить в сервис по API. Именно на этом шаге микросервис становится микросервисом: теперь его можно выкатывать, откатывать и мигрировать независимо.
| Этап | Где данные | Независимый деплой? | Главный риск |
| 0. Общая база | одна БД, разделённое владение | частично: миграция схемы всё ещё общая | «временный» доступ к чужим таблицам становится постоянным |
| 1. Своя база + синхронизация | две БД, копия наполняется событиями | да, но данные догоняют с лагом | двойная запись → расхождение копий (лечится outbox / CDC) |
| 2. Разрыв | две БД, связь только через API и события | да, полностью | забыли удалить старый код и старые таблицы |
Отдельно стоит упомянуть anti-corruption layer — тонкий слой-переводчик на входе нового сервиса. Он превращает кривые структуры монолита («поле status = 7, что означает „оплачен частично“») в чистую модель нового сервиса. Без него легаси-модель просачивается в новый код, и через год вы обнаружите, что построили тот же монолит, только по сети.
Как это работает: переключение на живом трафике
Сервис написан. Дальше — самое интересное: как убедиться, что он не хуже монолита, до того как на него пойдут реальные пользователи.
- Теневой трафик (dark launch). Фасад зеркалирует запросы в новый сервис, но ответ пользователю по-прежнему отдаёт монолит. Ответ сервиса выбрасывается — или, что гораздо полезнее, сравнивается с ответом монолита, а расхождения пишутся в лог.
- Канарейка. Переключаем 1% трафика, смотрим сутки. Потом 5%, 25%, 50%, 100%. Между шагами обязательно проходит хотя бы один суточный цикл нагрузки: ночной джоб или утренний пик легко ломает сервис, который днём вёл себя идеально.
- Четыре метрики на каждом шаге: доля ошибок (5xx), задержка p95 и p99, число расхождений с монолитом, нагрузка на базу. Ухудшилось что-то одно — откат.
- Рубильник. Возврат на монолит — изменение конфига, а не релиз. Проверьте, что он работает, до того как он понадобится в три часа ночи.
- Удаление старого кода. Скучный, но обязательный шаг. Пока старая реализация жива, любой баг чинят в двух местах, а «временный» флаг живёт вечно.
# Зеркалирование: пользователю отвечает монолит, копия запроса уходит в новый сервис
location /api/notifications {
proxy_pass http://monolith; # ответ идёт отсюда
mirror /shadow; # копия запроса — в тень
}
location /shadow {
internal;
proxy_pass http://notification-service; # ответ отбрасывается, но пишется в лог
}
Расхождения из теневого прогона — золото. Именно там всплывает, что монолит округлял сумму «в пользу клиента», а новый сервис — математически, и на 40 тысячах заказов это уже деньги.
Частые ошибки
- Начали с ядра домена. Заказ или платёж выносят последними: они связаны со всем и стоят дороже всего при ошибке.
- Переносят код «как есть». Копипаст модуля вместе с его моделью данных даёт микросервис, который всё так же зависит от чужих таблиц. Перенос без переосмысления границ — это переезд, а не декомпозиция.
- Ветка миграции живёт три месяца. Любой шаг фикуса должен вливаться в основную ветку минимум раз в неделю, пусть и за фичефлагом. Долгие ветки не мержатся — они умирают.
- Старый код не удалён. Проект считается завершённым не тогда, когда новый сервис работает, а тогда, когда старый модуль и его таблицы стёрты, а флаг выпилен.
- Двойная запись без outbox. «Сохранили в базу и отправили в брокер» — это два действия, между ними процесс падает. Рано или поздно копии разъедутся, и никто не поймёт почему.
- Выносят пять кусков одновременно. Ни один не доведён до этапа 2, зато система теперь наполовину распределённая и целиком нестабильная. Один вынос за раз — до конца.
- Нет критерия успеха. Если заранее не записано «время выкатки уведомлений сократится с 40 минут до 5», через полгода никто не сможет ответить, зачем всё это было.
Итоги
- Big bang rewrite проигрывает по всем осям: длинный срок до первой пользы, отсутствие отката, гонка за движущейся мишенью.
- Strangler Fig = фасад-перехватчик + новый сервис + правило маршрутизации, переключаемое без деплоя.
- Первый кандидат на вынос — скучный, слабо связанный, с терпимой ценой ошибки: уведомления, отчёты, поиск. Ядро домена — в последнюю очередь.
- Первый сервис дорогой, потому что вы оплачиваете конвейер (CI/CD, логи, метрики, gateway), а не сам сервис.
- База режется в три этапа: общая БД с разделённым владением → своя БД + синхронизация через outbox/CDC → полный разрыв и связь только по API.
- Переключение трафика: тень → канарейка → 100%, с рубильником на каждом шаге и обязательным удалением старого кода в конце.