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

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

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

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

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

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

Мы сформировали смешанную команду из 4 наших инженеров и 6 разработчиков клиента. Разделили людей на три стрима:

  • 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 — там, где требовалась высокая пропускная способность при минимальном потреблении ресурсов. Payment-gateway на Go потреблял 120 MB RAM против 1.5 GB у Java-версии.

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 для канареечных деплоев, 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 + таймауты + fallback на упрощённую проверку фрода.

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

3. Сетевая латентность. Один сценарий, который в монолите работал за 15 мс (три вызова методов в одном процессе), в микросервисной архитектуре стал занимать 120 мс (три сетевых вызова через sidecar). Решили агрегацией: создали composite API endpoint, который собирает данные из нескольких сервисов за один запрос клиента.

Результаты

Спустя 6 месяцев цифры говорят сами за себя:

МетрикаДо (монолит)После (микросервисы)
Среднее время обработки платежа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

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

загрузка...