Монолит документооборота, менеджмент говорит: «пора пилить на микросервисы». Мы нарезали шесть сервисов, сверху повесили 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 │ │ (почтовые │ │ ящики) │ └──────────────┘
NSI — нормативно-справочная информация. Тянет данные из внешних систем (1С, финансовые заявки и т.п.) и отдаёт остальным сервисам справочники: подразделения, контрагенты, типы, статусы.
Authorization — аутентификация и контроль доступа.
Workflow Engine — общий движок процессов и для документов, и для инцидентов, где сам процесс описывается Groovy-скриптами.
Core Incident Module — хранилище инцидентов и вся работа с ними: сотрудники создают, назначают, ведут по статусам.
Parsing Service — разбирает письма из нескольких почтовых ящиков, чтобы находить и заводить инциденты из входящей почты.
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. Варианта было три, и все так себе:
Тащить кусок доменных данных инцидента в Auth → Auth начинает знать про инциденты (утечка домена).
Auth синхронно дёргает Core Incident, чтобы принять решение → круговая зависимость Auth ↔ Incident.
Дублировать правила доступа в каждом сервисе → правила расходятся, и «кто что видит» становится нигде-не-описанным.
Мы честно потоптались по всем трём, пока не разделили два разных вопроса, которые сначала свалили в один сервис:
Аутентификация и грубые роли (кто ты, к каким сервисам вообще имеешь доступ) — да, это 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 называть не буду, но направление и порядок величин именно такие. И ни одну из этих цифр мы не закладывали как цель заранее — они были симптомами криво проведённых границ и вылечились, когда границы поехали на место.
Если бы я перезапускал этот распил, начал бы не с «на сколько сервисов пилим», а с «где реально проходят швы домена и что обязано быть согласованным одномоментно». Число сервисов — это следствие, а не цель.
И если после всего этого кажется, что уж у вас-то границы проведены нормально — просто проверьте, нет ли у вас сервиса, который знает про чужой домен больше, чем ему положено. У нас таких было два. Мы просто не сразу были готовы это признать.