Монолит документооборота, менеджмент говорит: «пора пилить на микросервисы». Мы нарезали шесть сервисов, сверху повесили API Gateway, Load Balancer и Circuit Breaker и какое-то время искренне считали, что теперь у нас взрослая распределённая система.

По факту мы собрали распределённый монолит. Те же зависимости, что и в монолите, только теперь по сети — с таймаутами, ретраями и без общей транзакции. Оно работало, спорить не буду. Просто каждая вторая проблема в итоге упиралась в одно и то же: границы сервисов мы провели не там, где надо.

Это не туториал «как правильно резать монолит на DDD-bounded-context за 10 шагов» — таких на Хабре хватает. Это разбор конкретных мест, где границы у нас поехали, в порядке от «это все знают, но всё равно наступают» до «поняли только в проде под нагрузкой». Если вы сейчас режете свой монолит — читайте как чеклист. Если уже прошли — сверьте, сколько совпало.

Дисклеймер: проект под NDA. Названия сервисов, домен и детали обобщены и изменены, часть цифр округлена. Сами проблемы и порядок, в котором мы на них натыкались, — настоящие.

Что было на входе

Монолит корпоративного документооборота. Внутри — обработка документов и работа с инцидентами (заявки, обращения, разбор проблемных ситуаций по SLA). Всё это жило в одной кодовой базе, ходило в одну БД и деплоилось одним куском.

Чтобы дальше было понятно, о каком масштабе речь (цифры порядковые и изменены под NDA, но соотношения настоящие): внутренняя система на ~2–3 тысячи сотрудников, дневной пик по инцидентному модулю в районе 120–150 RPS, порядка 1,5–2 тысяч инцидентов в день, из них заметная часть заводится автоматически из почты — парсер разгребал ~5 тысяч писем в сутки с нескольких ящиков. Не хайлоад в интернет-смысле, но система, в которой SLA по инцидентам считается в минутах, а «полежать 40 минут в рабочее время» — это уже разбор с руководством.

Вводные по команде тоже важны, потому что они многое объясняют дальше: над распилом работала небольшая команда из трёх человек, которую я вёл и в которой отвечал за домен инцидентов. Команда была немолодой по возрасту, но молодой по опыту в распределённых системах — а проектировать границы шести сервисов предстояло именно нам. Часть архитектурных решений на старте мы обкатывали в обсуждениях с более опытными коллегами, но проводить границы и жить с их последствиями в проде дальше приходилось команде.

Менеджмент сформулировал цель просто: «разделите на два модуля — документы и инциденты». Дальше эту фразу мы, по сути, и закодировали. И вот это была ошибка №0, из которой выросла половина остальных.

Как мы нарезали систему

Монолит мы разложили на шесть сервисов:

                    ┌───────────────┐
   клиенты ───────▶ │  API Gateway  │
                    │  + LoadBalancer│
                    └───────┬───────┘
                            │  (Circuit Breaker на всех вызовах)
      ┌─────────────┬───────┼────────────┬──────────────┐
      ▼             ▼       ▼            ▼              ▼
 ┌─────────┐  ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌──────────────┐
 │  Auth   │  │   NSI    │ │Workflow │ │  Core    │ │ Notification │
 │         │  │ (справоч-│ │ Engine  │ │ Incident │ │  (email/tg)  │
 │         │  │  ники)   │ │(Groovy) │ │  Module  │ │              │
 └─────────┘  └──────────┘ └────┬────┘ └────▲─────┘ └──────▲───────┘
                                │           │              │
                                └───────────┤              │
                                            │       ┌──────┴───────┐
                                            └───────┤   Parsing    │
                                                    │  Service     │
                                                    │ (почтовые    │
                                                    │  ящики)      │
                                                    └──────────────┘
  1. NSI — нормативно-справочная информация. Тянет данные из внешних систем (1С, финансовые заявки и т.п.) и отдаёт остальным сервисам справочники: подразделения, контрагенты, типы, статусы.

  2. Authorization — аутентификация и контроль доступа.

  3. Workflow Engine — общий движок процессов и для документов, и для инцидентов, где сам процесс описывается Groovy-скриптами.

  4. Core Incident Module — хранилище инцидентов и вся работа с ними: сотрудники создают, назначают, ведут по статусам.

  5. Parsing Service — разбирает письма из нескольких почтовых ящиков, чтобы находить и заводить инциденты из входящей почты.

  6. Notification System — рассылка уведомлений по email, Telegram и другим каналам.

На бумаге — красиво и «по учебнику». Проблемы начались там, где эти шесть коробочек должны были работать вместе.

Проблемы, по порядку — от «очевидно» до «больно»

1. Границы провели по оргструктуре, а не по домену

Команда услышала «два модуля: документы и инциденты» — и сделала ровно это как физическую границу. Но при этом четыре сервиса из шести (Auth, NSI, Workflow, Notification) оказались общими для обоих модулей. То есть заявленная граница «документы | инциденты» на самом деле проходила только через два сервиса, а остальные были размазаны поперёк.

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

Правильнее было сначала найти честные bounded context’ы (что меняется вместе, что читается вместе, где транзакционные границы), а уже потом смотреть, совпадают ли они с «двумя модулями». В нашем случае реальные швы были другими: «приём и классификация обращений», «жизненный цикл инцидента», «справочные данные», «доставка уведомлений». Оргструктура — плохой источник границ сервисов.

2. Один Workflow Engine на два домена: как shared-сервис превратился в god-сервис

Идея звучала разумно: и у документов, и у инцидентов есть процесс со статусами, переходами и условиями. Зачем два движка? Сделаем один, а конкретику вынесем в Groovy-скрипты.

Сразу оговорюсь, чтобы не создавать ложного впечатления: один workflow-движок на несколько доменов — сам по себе не «распределённый монолит» и вообще не приговор. Нормальный shared-сервис так и живёт. Нас убило не количество доменов на движок, а то, что в одном сервисе сошлись сразу четыре вещи: runtime процессов + доменная логика обоих доменов + общее состояние в одной схеме + перекрёстные зависимости между ними. Вот эта комбинация и превратила безобидный shared-сервис в god-сервис, от которого зависят все.

В одном движке лежали скрипты двух доменов:

// один движок, два мира в одном классе процессов
process("incident_default") {
    on("email_received")   { createIncident(); assignBySla() }
    on("status_changed")   { notify(); if (isOverdue()) escalate() }
}

process("document_approval") {
    on("submitted")        { routeToApprovers() }
    on("approved")         { archive() }
}

Что из этого выросло:

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

  • Общее состояние процессов в одной таблице. Инциденты и документы делили один engine и, по сути, одну схему выполнения процессов. Блокировки и деградация одного домена задевали другой.

  • Groovy как способ спрятать связанность. Скрипты выглядели как «конфигурация», но на деле это был код с доступом к внутренностям обоих доменов, без нормальных тестов и версионирования.

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

Как надо было: движок процессов у нас скорее просился в библиотеку, а не в отдельный сетевой сервис. Или у каждого домена — свой процессный слой внутри своего сервиса. Ключевое — не «сколько доменов на движок», а не смешивать в одном сервисе runtime и доменную логику разных доменов: как только скрипты одного домена начинают знать про внутренности другого, переиспользование заканчивается и начинается связывание.

Кстати, первое, что мы попробовали, — не разносить домены, а навести порядок внутри скриптов: договорились о структуре, вынесли общее в «хелперы». Стало только хуже: хелперы, которые знали про оба домена, связали их ещё сильнее. Разнести домены по разным процессным слоям додумались только после этого — и то не с первого захода.

3. NSI как синхронная зависимость на каждый запрос

Справочники нужны везде: открыть инцидент — подтяни подразделение и тип из NSI, показать список — снова NSI, распарсить письмо — опять NSI. Мы честно сделали синхронный REST-вызов в NSI на каждый такой случай.

Дошло до нас это, как водится, в пятницу вечером. NSI подтормаживал из-за очередной выгрузки из 1С — latency у него подскочил на порядок, — Circuit Breaker честно открылся, и вместе с NSI у нас перестали открываться списки инцидентов, потому что каждый список сначала шёл в NSI за справочниками. Формально «сработала защита». Фактически легло то, что к NSI и близко не должно было быть привязано так намертво, и легло минут на сорок, пока дежурный не увёл трафик на закэшированную копию руками. Circuit Breaker тут сработал как надо — просто он громко подсветил, что мы сделали синхронной зависимостью то, что ей быть не должно.

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

Что происходило на самом деле, стало видно только когда полезли в трейсы. На дашборде в Grafana RPS на NSI был ровным, но подозрительно жирным — он никак не бился с числом живых пользователей. А в Jaeger оказалось, что один открытый экран инцидента — это 8–12 отдельных вызовов в NSI: каждая строка списка тянула свой справочник по типу и подразделению. Классический N+1, только не по БД, а по сети, и поэтому его не видно в привычных местах. Пока не открыли конкретный трейс — грешили на что угодно, кроме собственной архитектуры.

Справочные данные читаются постоянно, а меняются редко — это же почти определение того, что кэшируется, а не дёргается синхронно. В итоге прикрутили Redis-кэш с TTL и фоновым обновлением, а часть справочников стали получать событиями: NSI публикует «справочник обновился» — потребители обновляют свою копию. Точных цифр под NDA не приведу, но порядок такой: пиковый RPS на NSI просел примерно на порядок (горячие повторные чтения справочников почти перестали доходить до сервиса — те, что мы вообще кэшировали), p95 на том самом экране со списком инцидентов ушёл из «секунд» в «сотни миллисекунд», а Circuit Breaker на NSI после этого месяцами не открывался, потому что синхронной зависимости на горячем пути просто не осталось.

Но вот важная оговорка, которой мне самому не хватало в подобных статьях: кэшировать удалось не всё. Часть «справочных» данных на самом деле влияла на права доступа (кто к какому подразделению приписан) — и стухший на минуту кэш там означал бы, что человек на минуту видит чужие инциденты. Это неприемлемо, поэтому конкретно эти справочники мы оставили синхронными, сознательно приняв и связность, и просадку по latency. То есть правило «справочники — в кэш» у нас звучит не как «всегда», а как «по умолчанию да, кроме данных, где устаревание меняет решение о доступе или деньгах». Синхронный reference-data-сервис — это нормально, если данные требуют свежести; проблема была не в синхронности как таковой, а в том, что мы влепили её на всё подряд, не разделив «показать подсказку» и «решить, что тебе можно видеть».

4. Authorization, которой нужно знать про инциденты

Auth-сервис мы задумали чистым: логин, токены, роли. Но реальные требования доступа звучали так: «руководитель видит инциденты только своего подразделения», «исполнитель — только назначенные ему», «аудитор — все, но без персональных данных заявителя».

И тут граница поехала. Чтобы ответить «может ли этот пользователь видеть этот инцидент», нужно знать данные инцидента: подразделение, назначенного, статус. А эти данные живут в Core Incident, а не в Auth. Варианта было три, и все так себе:

  1. Тащить кусок доменных данных инцидента в Auth → Auth начинает знать про инциденты (утечка домена).

  2. Auth синхронно дёргает Core Incident, чтобы принять решение → круговая зависимость Auth ↔ Incident.

  3. Дублировать правила доступа в каждом сервисе → правила расходятся, и «кто что видит» становится нигде-не-описанным.

Мы честно потоптались по всем трём, пока не разделили два разных вопроса, которые сначала свалили в один сервис:

  • Аутентификация и грубые роли (кто ты, к каким сервисам вообще имеешь доступ) — да, это Auth.

  • Доменная авторизация (можешь ли ты видеть/менять конкретный инцидент) — это ответственность владельца данных, то есть Core Incident. Auth отдаёт identity и роли, а решение «виден ли инцидент X» принимает тот сервис, который этим инцидентом владеет.

Правило, которое я вынес: право на объект принадлежит тому, кто владеет объектом. Как только авторизация начинает требовать чужих доменных данных — граница сервиса, скорее всего, проведена неправильно.

5. Parsing и Notification знали слишком много про инцидент

Два «инфраструктурных» на вид сервиса на самом деле оказались набиты доменной логикой инцидентов.

Parsing Service. Его задача звучала невинно: «читай почту и заводи инциденты». Но чтобы завести инцидент, надо знать, что такое валидный инцидент: обязательные поля, правила классификации по теме письма, дедупликация (одно и то же письмо в трёх ящиках — это один инцидент, а не три). Всё это заползло в парсер. В итоге правила создания инцидента жили в двух местах — в Core Incident и в Parsing, — и они разъезжались.

Notification System. Должен был быть «тупой трубой»: пришла команда «отправь текст T на канал C» — отправил. А стал местом, куда утекли правила «на смену статуса на Просрочен уведомить исполнителя и его руководителя, кроме ночного времени». То есть бизнес-правила инцидента поселились в сервисе доставки.

Общий симптом один: доменная логика инцидента растеклась по сервисам, которые по названию инцидентов «не касаются». Признак неправильной границы — когда «вспомогательный» сервис нельзя изменить, не зная правил чужого домена.

По уму, Parsing должен извлекать из письма структурированные данные и отдавать их Core Incident событием/командой, а решение «это валидный инцидент, вот его классификация, это дубль» пусть принимает владелец домена. Notification получает готовое «кому/что/каким каналом», а «когда и кого уведомлять» решает Core Incident. Принцип, к которому мы пришли: логику владения инцидентом держим у владельца, инфраструктурные сервисы — тонкие.

Но и тут без догмы. Одно место мы сознательно оставили «умным»: базовую эвристику «это вообще похоже на инцидент или это спам/автоответ» мы держали в Parsing, а не гоняли каждое письмо в Core Incident. Причина прагматичная — из ~5 тысяч писем в сутки заметная доля была мусором, и тащить весь этот шум через шину и домен, чтобы там его отсеять, было дороже, чем отфильтровать на входе. Да, это утечка чуть-чуть доменного знания в парсер. Мы решили, что дешёвый фильтр «очевидно не инцидент» на входе того стоит, а вот всё, что влияет на сам инцидент (классификация, дедуп, валидация), должно жить у владельца. Граница «тонкий парсер» — не абсолют, а место, где мы осознанно провели черту по стоимости.

6. Создание инцидента из письма стало распределённой транзакцией без транзакции

Это та проблема, которую мы прочувствовали уже в проде. В монолите «завести инцидент из письма» было одной транзакцией: распарсили → создали инцидент → запустили процесс → поставили уведомление в очередь, и всё это либо целиком коммитилось, либо целиком откатывалось.

После распила один бизнес-акт растянулся на четыре сервиса:

Parsing → Core Incident (создать) → Workflow (запустить процесс) → Notification (уведомить)

Никакого общего коммита между ними нет. И вылезло всё, что вылезает у всех:

  • Частичные отказы. Инцидент создан, но Workflow не ответил → инцидент есть, а процесс не стартовал, инцидент «висит». Таких «подвисших» было немного — единицы в день, — но для системы с SLA в минутах каждый такой это потенциально просроченный тикет.

  • Дубли. Parsing отправил команду, не получил ответ по таймауту, повторил → два инцидента из одного письма. В плохие дни (когда Core Incident тормозил) дубли шли уже десятками, и их потом руками разгребала поддержка.

  • Порядок и повторы. Notification иногда отрабатывал раньше, чем Core Incident успевал зафиксировать финальное состояние, — и человеку прилетало письмо про статус, которого он ещё не видел в интерфейсе.

Лечили это уже нормальными средствами распределённых систем, а не «ещё одним ретраем»:

  • перевели цепочку на события через Kafka вместо синхронных вызовов по цепочке;

  • сделали шаги идемпотентными через ключ идемпотентности на письме (messageId + хэш содержимого), чтобы повтор не плодил инциденты;

  • оформили процесс как сагу: каждый шаг умеет компенсироваться, «зависшие» инциденты добираются фоновым процессом.

// ключ идемпотентности вместо надежды на «повторов не будет»
val idempotencyKey = sha256("$mailboxId:$messageId:${hash(body)}")
if (incidentRepo.existsByIdempotencyKey(idempotencyKey)) return  // дубль письма — выходим

Почему именно Kafka, а не что-то другое — вопрос честный, и выбор был не «потому что модно». RabbitMQ у нас в контуре тоже был, и для простой доставки команд он бы справился. Но нам нужны были две вещи: durable-лог с возможностью переиграть события (когда после багфикса надо перепрогнать вчерашние письма) и несколько независимых потребителей одного потока — со временем к «инцидент создан» подключились и Notification, и аналитика. Под это Kafka с её ретеншеном и партиционированием ложилась лучше, чем очередь, где сообщение исчезает после ack. Вариант «REST + retry» мы отмели, потому что он вообще не решает главную проблему — durability частичного отказа: если получатель лежит, ретраить некуда. Про transactional outbox мы знали и он был бы правильнее всего для «атомарно записать инцидент и факт события», но на тот момент честно не потянули бы его аккуратную реализацию по всем сервисам, поэтому пошли на компромисс: идемпотентность на стороне потребителя вместо строгого outbox. Это осознанный долг, а не «так правильно».

Ordering решался тем, что все события по одному инциденту летят в одну партицию (ключ — incidentId), так что порядок в пределах инцидента сохраняется; глобальный порядок нам был не нужен. Duplicate delivery Kafka не исключает by design (at-least-once), поэтому дедуп всё равно живёт на потребителе — тот самый ключ идемпотентности. При падении consumer’а сообщения просто ждут в партиции, а offset коммитится только после успешной обработки, так что «пропустить» инцидент нельзя, максимум — обработать с задержкой.

И вот здесь — главный trade-off, который мы приняли сознательно. Перейдя на события, мы разменяли строгую согласованность на eventual consistency: между «письмо пришло» и «инцидент виден в списке» появилось окно, обычно доли секунды, в плохие моменты — пара секунд. Для создания инцидента это оказалось допустимо: пользователь видит его в статусе PROCESSING и понимает, что система в работе. А вот где eventual consistency была неприемлема — это закрытие инцидента и переходы, влияющие на SLA и аудит: тут «через секунду досогласуется» означало бы, что отчёт по SLA на мгновение врёт, а для аудита это критично. Эти операции мы оставили синхронными (а позже — с transactional outbox), сознательно пожертвовав их независимостью ради согласованности. Грубо говоря, событиями поехало то, что терпит задержку, а то, что не терпит, осталось синхронным — и это разделение оказалось важнее выбора брокера.

Главный урок про границы здесь: мы провели границу сервиса ровно поперёк того, что было атомарной бизнес-транзакцией. Прежде чем резать, стоило спросить «что здесь обязано быть согласованным одномоментно, а что потерпит секунду?». Ответ на этот вопрос — и есть граница, по которой можно резать безопасно.

7. Три человека на шесть сервисов

Организационная проблема, но она напрямую влияет на границы. Шесть сервисов, каждый со своим репозиторием, деплоем, контрактами и дежурствами — на команду из трёх человек. Проектировать контракты между сервисами и договариваться о границах, по сути, приходилось на ходу и без большого опыта в распределёнке за плечами.

Что из этого вышло:

  • Контракты между сервисами получались «как удобнее сейчас написать вызов», а не «как правильно провести границу». Отсюда и синхронный NSI, и умный Notification.

  • Когнитивная нагрузка «кто вообще отвечает за этот сервис на этой неделе» съедала больше, чем экономия от «независимых» сервисов.

  • Настоящей независимости деплоя мы всё равно не получили — из-за общих Workflow и NSI релизы приходилось координировать.

Как надо было: число сервисов должно соответствовать не только доменной декомпозиции, но и способности команды ими владеть. Дело не в арифметике «сколько сервисов на человека» — пять человек могут спокойно тянуть и двадцать мелких сервисов, если ownership выстроен: понятно, кто за что отвечает, есть шаблоны, автоматика, наблюдаемость. У нас ничего этого не было, поэтому честнее было начать с двух-трёх сервисов по реальным доменным швам и дробить дальше только там, где боль от связности уже реально мешает. Микросервисы — это в том числе организационное решение (закон Конвея никто не отменял), и «шесть сервисов на троих без выстроенного ownership» — это чаще ошибка проектирования, чем зрелость.

Чем я теперь мучаю каждую новую границу

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

  • Откуда вообще взялась эта граница — из домена или из фразы на планёрке? Если из второго, стоит перепроверить.

  • Что здесь меняется и релизится вместе? Если два куска всегда правятся синхронно — это, скорее, один сервис, а не два.

  • Нет ли в одном сервисе одновременно общего runtime и доменной логики нескольких доменов? Сам по себе shared-сервис — нормально; беда начинается, когда он ещё и знает бизнес двух доменов и держит их общее состояние. Тогда, может, ему лучше быть библиотекой.

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

  • Кто принимает решение о доступе к объекту? Владелец объекта. Если авторизации нужны чужие доменные данные — граница проведена криво.

  • Не режу ли я границей то, что было одной транзакцией? Если да — саги и идемпотентность придётся закладывать осознанно, а не встречать их в проде.

Circuit Breaker, Gateway и Load Balancer, кстати, никуда не делись и работают. Просто теперь открывшийся Circuit Breaker я читаю не как «защита сработала, красавцы», а как «здесь, кажется, была лишняя синхронная граница».

Итог

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

Если свести к измеримому, разгребание этих границ дало примерно следующее: пиковый RPS на NSI просел на порядок, p95 списка инцидентов ушёл из секунд в сотни миллисекунд, дубли из почты — из десятков в сутки в единичные случаи, а Circuit Breaker на NSI перестал открываться. Точные значения под NDA называть не буду, но направление и порядок величин именно такие. И ни одну из этих цифр мы не закладывали как цель заранее — они были симптомами криво проведённых границ и вылечились, когда границы поехали на место.

Если бы я перезапускал этот распил, начал бы не с «на сколько сервисов пилим», а с «где реально проходят швы домена и что обязано быть согласованным одномоментно». Число сервисов — это следствие, а не цель.

И если после всего этого кажется, что уж у вас-то границы проведены нормально — просто проверьте, нет ли у вас сервиса, который знает про чужой домен больше, чем ему положено. У нас таких было два. Мы просто не сразу были готовы это признать.

Комментарии (0)