Как мы разделили монолит на 12 микросервисов за 6 месяцев

Как мы разделили монолит на 12 микросервисов за 6 месяцев

Исходная ситуация

К нам обратились ребята из «ФинСтрим» с задачей, которую мы между собой называем «легаси, которое страшно трогать». Представьте: огромный Spring Boot-монолит на Java 11, в котором 1,2 миллиона строк кода. Он крутился на трёх серверах, спрятанных за балансировщиком, а вся их база данных — одна PostgreSQL 13 на 340 таблиц. А как они деплоились? Просто: сборка WAR, ручное копирование на серверы, и каждый раз рестарт Tomcat. Конечно, в 2026-м это звучит как что-то из прошлого века, но, поверьте, таких систем в проде до сих пор встречается добрая половина рынка.

Основные проблемы были следующими:

  • Время деплоя — 4 часа с даунтаймом от 15 до 40 минут. Деплоили раз в месяц, накапливая изменения.
  • Время сборки — полная сборка с тестами занимала 47 минут. Разработчики запускали её 2-3 раза в день.
  • Связанность — изменение в модуле отчётности могло сломать обработку платежей. Инцидент в ноябре 2025 стоил компании 12 часов простоя.
  • Масштабирование — для обработки пиковой нагрузки (Чёрная пятница) приходилось масштабировать всё приложение целиком, хотя узким местом был только модуль проверки фрода.

Команда и подход

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

  • Platform Team (2 человека) — инфраструктура, CI/CD, service mesh, мониторинг.
  • Migration Team (5 человек) — выделение сервисов, переписывание кода, миграция данных.
  • Integration Team (3 человека) — API gateway, межсервисное взаимодействие, тестирование.

Самое важное правило, которое мы зафиксировали в первый же день, звучало так: никакого big bang. Монолит должен был продолжать работать в продакшене как ни в чём не бывало, а мы бы потихоньку «отрезали» от него функциональность кусочек за кусочком. Это классический Strangler Fig, или, как мы его называем, паттерн «удушающего плюща».

Strangler Fig: как это работает на практике

Логика здесь, на самом деле, довольно простая. Рядом с нашим старым монолитом мы поднимаем новый микросервис, который постепенно начинает перехватывать часть трафика. При этом сам монолит, бедняга, даже не подозревает, что его потихоньку заменяют. Сначала мы разводили трафик через Nginx, но потом решили переехать на Istio.

Хотите посмотреть, как это выглядело? Вот так была настроена маршрутизация в Istio, когда мы постепенно, по частям, переключали трафик модуля уведомлений:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: notification-route
  namespace: finstream
spec:
  hosts:
    - notification-service
  http:
    - match:
        - headers:
            x-migration-phase:
              exact: "canary"
      route:
        - destination:
            host: notification-svc-v2
            port:
              number: 8080
          weight: 20
        - destination:
            host: monolith-gateway
            port:
              number: 8080
          weight: 80
    - route:
        - destination:
            host: monolith-gateway
            port:
              number: 8080
          weight: 100

Мы начинали с 5% трафика на новый сервис, мониторили ошибки и латентность в Grafana, и за 1-2 недели доводили до 100%. После чего удаляли соответствующий код из монолита.

Карта сервисов

Мы не стали выдумывать границы сервисов, что называется, «из головы». Вместо этого мы провели трёхдневный Event Storming, собрав вместе бизнес-аналитиков и разработчиков. В итоге у нас получилось 12 bounded contexts, и каждый из них превратился в отдельный сервис:

  1. payment-gateway — приём платежей от мерчантов (Go)
  2. payment-processor — маршрутизация к платёжным провайдерам (Go)
  3. fraud-detector — проверка транзакций на мошенничество (Python + ML)
  4. account-service — управление счетами и балансами (Java)
  5. merchant-service — управление мерчантами и тарифами (Java)
  6. notification-service — email, SMS, push-уведомления (Go)
  7. reporting-service — формирование отчётов (Python)
  8. reconciliation-service — сверка транзакций (Java)
  9. settlement-service — расчёт выплат мерчантам (Java)
  10. auth-service — аутентификация и авторизация (Go)
  11. audit-service — журнал операций для комплаенса (Go)
  12. config-service — централизованная конфигурация (Go)

Несколько сервисов мы сознательно решили написать на Go, а не на Java. Почему? Там, где нам нужна была высокая пропускная способность, но при этом скромный аппетит к ресурсам, Go показал себя отлично. Вот вам показательная цифра: наш payment-gateway на Go потреблял всего 120 MB памяти, в то время как Java-версия «съедала» 1.5 GB. Разница, как видите, больше чем в десять раз!

Database per Service

А вот самое больное место всей миграции — разрезать единую базу. Все 340 таблиц лежали в одной PostgreSQL, и просто так их не растащишь. Шли поэтапно:

Этап 1 — логическое разделение. Для каждого сервиса завели отдельную схему в той же базе. Так мы хотя бы начали контролировать, кто куда лезет:

-- Создаём схему для payment-processor
CREATE SCHEMA IF NOT EXISTS payment_processor;

-- Переносим таблицы
ALTER TABLE public.transactions
  SET SCHEMA payment_processor;
ALTER TABLE public.payment_routes
  SET SCHEMA payment_processor;

-- Создаём пользователя с ограниченным доступом
CREATE USER svc_payment_processor WITH PASSWORD '...';
GRANT USAGE ON SCHEMA payment_processor TO svc_payment_processor;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES
  IN SCHEMA payment_processor TO svc_payment_processor;

-- Запрещаем доступ к чужим схемам
REVOKE ALL ON SCHEMA merchant FROM svc_payment_processor;

Этап 2: Физическое разделение. Поднимали отдельную PostgreSQL для каждого сервиса, настраивали logical replication для синхронизации данных на переходный период, а затем переключали сервис на собственную БД.

И вот мы подошли к Этапу 3 — это замена прямых JOIN-ов на вызовы API. Признаюсь честно, это была самая муторная часть работы. В монолите мы насчитали больше 60 запросов, которые объединяли таблицы из совершенно разных доменов. Каждый такой запрос нам пришлось переписывать в оркестрацию через API, и да, делали мы это вручную, по одному.

Распределённые транзакции и Saga Pattern

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

Именно поэтому мы решили создать оркестрационную сагу, используя для этого отдельный сервис-координатор. Вот как выглядела упрощённая логика нашего оркестратора, написанного на Go:

type PaymentSaga struct {
    sagaID    string
    steps     []SagaStep
    completed []SagaStep
}

type SagaStep struct {
    Name       string
    Execute    func(ctx context.Context, data *PaymentData) error
    Compensate func(ctx context.Context, data *PaymentData) error
}

func (s *PaymentSaga) Run(ctx context.Context, data *PaymentData) error {
    for _, step := range s.steps {
        if err := step.Execute(ctx, data); err != nil {
            log.Error("saga step failed",
                "saga_id", s.sagaID,
                "step", step.Name,
                "error", err,
            )
            // Запускаем компенсацию в обратном порядке
            return s.compensate(ctx, data)
        }
        s.completed = append(s.completed, step)
    }
    return nil
}

func (s *PaymentSaga) compensate(ctx context.Context, data *PaymentData) error {
    for i := len(s.completed) - 1; i >= 0; i-- {
        step := s.completed[i]
        if err := step.Compensate(ctx, data); err != nil {
            // Компенсация не удалась — помещаем в dead letter queue
            log.Error("compensation failed, sending to DLQ",
                "saga_id", s.sagaID,
                "step", step.Name,
            )
            return s.sendToDLQ(ctx, step, data)
        }
    }
    return nil
}

// Определение шагов саги для платежа
func NewPaymentSaga(sagaID string) *PaymentSaga {
    return &PaymentSaga{
        sagaID: sagaID,
        steps: []SagaStep{
            {
                Name:       "reserve_balance",
                Execute:    reserveBalance,
                Compensate: releaseBalance,
            },
            {
                Name:       "process_payment",
                Execute:    sendToProvider,
                Compensate: cancelPayment,
            },
            {
                Name:       "update_transaction",
                Execute:    markAsCompleted,
                Compensate: markAsFailed,
            },
            {
                Name:       "send_notification",
                Execute:    notifyMerchant,
                Compensate: func(_ context.Context, _ *PaymentData) error {
                    return nil // Уведомление не требует компенсации
                },
            },
        },
    }
}

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

Service Mesh с Istio

Istio в качестве service mesh мы выбрали не просто так, это было вполне обдуманное решение. Во-первых, там сразу «из коробки» был mTLS между сервисами, а это, между прочим, было требование PCI DSS, а не наша прихоть. Во-вторых, нас очень порадовал адекватный traffic management для канареечных деплоев. И в-третьих, Istio предлагает circuit breaker, что очень важно для общей устойчивости системы.

Так настраивался circuit breaker для payment-processor:

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: payment-processor-cb
spec:
  host: payment-processor
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
      http:
        h2UpgradePolicy: DEFAULT
        http1MaxPendingRequests: 50
        http2MaxRequests: 200
        maxRequestsPerConnection: 10
    outlierDetection:
      consecutive5xxErrors: 3
      interval: 30s
      baseEjectionTime: 60s
      maxEjectionPercent: 50

Важный урок: Istio добавляет около 2-3 мс латентности на каждый hop из-за sidecar proxy. Для цепочки из 4 сервисов это 8-12 мс. Мы минимизировали этот эффект, пересмотрев некоторые синхронные вызовы и заменив их на асинхронные через Kafka.

Мониторинг: без него микросервисы — хаос

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

  • Метрики: Prometheus + Grafana. Каждый сервис экспортирует метрики через /metrics. Ключевые дашборды: RED metrics (Rate, Errors, Duration) для каждого сервиса, USE metrics (Utilization, Saturation, Errors) для инфраструктуры.
  • Логи: Loki + Promtail. Структурированные JSON-логи со сквозным trace_id. Один запрос на логи в Grafana показывает всю цепочку вызовов.
  • Трейсы: Jaeger с OpenTelemetry SDK. Каждый сервис инструментирован, и мы видим полный водопад вызовов для любой транзакции.

Пример инструментации в Go-сервисе:

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/trace"
)

func (s *PaymentService) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) {
    ctx, span := otel.Tracer("payment-gateway").Start(ctx, "ProcessPayment",
        trace.WithAttributes(
            attribute.String("merchant_id", req.MerchantID),
            attribute.Float64("amount", req.Amount),
            attribute.String("currency", req.Currency),
        ),
    )
    defer span.End()

    // Валидация
    if err := s.validate(ctx, req); err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, "validation failed")
        return nil, err
    }

    // Проверка фрода — вызов другого сервиса
    fraudResult, err := s.fraudClient.Check(ctx, req)
    if err != nil {
        span.RecordError(err)
        return nil, fmt.Errorf("fraud check: %w", err)
    }

    span.AddEvent("fraud_check_passed", trace.WithAttributes(
        attribute.Float64("risk_score", fraudResult.Score),
    ))

    // ... обработка платежа
}

Алертинг мы настроили через Alertmanager с маршрутизацией в Telegram-бот дежурной команды. Критичные алерты (ошибки платежей > 1%) уходят немедленно, информационные — агрегируются за 15 минут.

Порядок миграции

Параллельно всё не мигрировали — порядок продумали заранее:

  1. Месяц 1: notification-service и audit-service — наименее связанные модули, идеальные для обкатки процесса.
  2. Месяц 2: auth-service и config-service — инфраструктурные сервисы, от которых зависят остальные.
  3. Месяц 3: merchant-service и reporting-service — средняя сложность, но много зависимых данных.
  4. Месяц 4: fraud-detector — отдельная Python-модель, которую давно хотели вынести для независимого масштабирования.
  5. Месяц 5: payment-gateway и payment-processor — ядро системы, самое рискованное, поэтому в конце.
  6. Месяц 6: account-service, reconciliation-service, settlement-service + финальное удаление монолита.

Что пошло не так

Было бы не совсем честно рассказывать только о наших победах, правда? Поэтому я хочу поделиться тремя серьёзными проблемами, на которые мы, к сожалению, «налетели» по ходу дела:

1. На третьей неделе случился каскадный сбой. Наш Fraud-detector вдруг начал сильно тормозить, потому что в фоне как раз переобучалась модель. В итоге Payment-processor выжрал весь пул подключений, ожидая ответа, и перестал обрабатывать новые запросы. А дальше всё посыпалось как домино: платёжный конвейер «лёг» на целых 8 минут. Что помогло? Circuit breakers, правильно настроенные таймауты и асинхронные вызовы там, где это было возможно.

2. Проблема расхождения данных. Когда мы мигрировали account-service, вдруг выяснилось, что 0.02% балансов каким-то образом расходятся между старой и новой системой. Это был классический race condition, который случается при параллельной записи. Пришлось срочно писать reconciliation-скрипт и запускать его каждые 5 минут в течение двух недель подряд, пока мы наконец не вычислили и не «убили» корневую причину.

3. И, конечно, сетевая латентность. Сценарий, который в монолите выполнялся всего за 15 мс (это были три вызова методов внутри одного процесса), в микросервисах вдруг «раздулся» до 120 мс. Почему? Потому что это уже три сетевых вызова, каждый из которых идёт через sidecar. Что нас спасло? Агрегация! Мы разработали composite-эндпоинт, который умеет собирать данные сразу из нескольких сервисов за один «поход».

Результаты

Что в итоге через полгода? Цифры говорят сами за себя:

МетрикаДо (монолит)После (микросервисы)
Среднее время обработки платежа850 мс280 мс
P99 латентность3200 мс650 мс
Частота деплоев1 раз в месяц5-8 раз в день
Время деплоя4 часа12 минут
Время простоя при деплое15-40 минут0 (zero downtime)
Время восстановления после сбоя45 минут3 минуты
Потребление RAM (пиковое)24 GB8.5 GB

Но, по моему мнению, самое главное тут даже не впечатляющие цифры, а та невероятная скорость, с которой команда теперь может «катить» новые фичи. Ребята из «ФинСтрим» теперь выпускают изменения совершенно независимо в каждом сервисе. Например, Fraud-detector может обновлять свои модели хоть каждый день, и это никак не затронет платёжный конвейер. А notification-service в период Чёрной пятницы легко масштабируется до 50 инстансов, пока все остальные сервисы продолжают спокойно работать в своём обычном режиме.

Выводы

Если перед вами та же задача — вот что я советую в первую очередь:

  • Не начинайте с ядра системы. Начните с переферийных сервисов, чтобы набить руку.
  • Инвестируйте в мониторинг до начала миграции. Без трейсинга и метрик дебажить распределённую систему невозможно.
  • Strangler Fig — ваш лучший друг. Постепенная миграция безопаснее и позволяет откатиться на любом этапе.
  • Планируйте время на разделение базы данных — это займёт 40% всей миграции.
  • Тестируйте отказоустойчивость с первого дня. Chaos engineering — не роскошь, а необходимость.

Нужна помощь с проектом?

Мы, специалисты АйТи Фреш, всегда готовы помочь вам с архитектурой, DevOps, безопасностью и разработкой. За нашими плечами — более 15 лет опыта.

📞 Связаться с нами
#database#devops#fraud-detector#integration team#istio#mesh#migration team#pattern
Комментарии 0

Оставить комментарий

загрузка...
📄
Скачайте подробный разбор в PDF Кейсы, статистика, типовые ошибки и чек-лист самопроверки — 12 страниц
Скачать PDF

Подпишитесь на рассылку ITfresh

Раз в неделю — практические гайды для руководителя IT и сисадмина: безопасность, 1С, миграции, резервные копии, лайфхаки из реальных проектов.

Реквизиты оператора персональных данных

ООО «АЙТИ-ФРЕШ», ИНН 7719418495, КПП 771901001. Юридический адрес: 105523, г. Москва, Щёлковское шоссе, д. 92, корп. 7. Контакт: info@itfresh.ru, +7 903 729-62-41. Оператор обрабатывает e-mail подписчика в целях рассылки информационных и рекламных материалов до момента отзыва согласия.