ARTICLE / GO BACKEND

Все статьи

Границы микросервисов, которые не превращают систему в распределённый монолит

Практический подход к границам микросервисов: владение данными, бизнес-инварианты, синхронные зависимости и признаки распределённого монолита.

ArchitectureDistributed SystemsHighloadIntegrations

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

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

Контекст и ограничения: зачем системе вообще нужны границы

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

Количество сущностей в предметной области ничего не говорит о количестве сервисов. Order, Store и Delivery могут быть тремя таблицами одного модуля, а могут относиться к разным областям ответственности. Решение зависит от инвариантов, частоты изменений, требований к согласованности и характера отказов.

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

Рабочая модель: разделять решения, данные и причины изменений

Я начинаю не со списка существительных и не с диаграммы таблиц. Сначала фиксирую бизнес-решения, за которые система отвечает. Например: можно ли принять заказ сейчас, допустим ли переход заказа в новое состояние, нужно ли отправить изменение во внешнюю платформу. Затем для каждого решения задаю четыре вопроса:

  1. Какие данные нужны, чтобы принять решение?
  2. Какие правила должны выполняться атомарно?
  3. Кто имеет право изменить исходное состояние?
  4. Какой факт должен увидеть остальной мир после решения?

Если правило требует одной транзакции, связанные данные обычно должны оставаться внутри одной границы. Разносить их по сервисам, а затем возвращать согласованность через SAGA имеет смысл только при более сильной причине, чем желание получить «правильную» микросервисную схему.

Владелец границы принимает решение, а не просто хранит запись

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

Это не готовое разбиение для любого e-commerce. Оно показывает критерий: у каждой части есть решение, которое можно назвать одним предложением, состояние для этого решения и причина меняться независимо от соседей.

flowchart LR
    Client[Клиентский сценарий] -->|команда| Orders[Заказы<br/>переходы состояний]
    Orders -->|запрос решения| Availability[Доступность<br/>правила и ограничения]
    Orders --> OrdersDB[(База заказов)]
    Availability --> AvailabilityDB[(Данные и проекции<br/>для расчёта)]
    Orders -->|OrderAccepted| Bus[(Kafka)]
    Bus --> Adapter[Интеграционный адаптер<br/>контракт и повторы]
    Adapter --> AdapterDB[(Состояние доставки<br/>и дедупликация)]
    Adapter -->|DeliveryStatusChanged| Bus
    Bus --> Orders

На схеме синхронный запрос используется там, где без ответа нельзя продолжить текущую операцию. Событие передаёт уже состоявшийся факт и позволяет получателю обработать его со своей скоростью. Это не правило «команды — по gRPC, события — по Kafka». Если решение допускает отложенный результат, команда тоже может быть асинхронной. Важно честно зафиксировать момент принятия решения и поведение при недоступности участника.

Данные принадлежат одному сервису

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

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

Контракт выражает намерение или факт

Граница с методами Create, Update и SetStatus часто скрывает отсутствие владельца правил. Контракт устойчивее, когда сообщает намерение (ReserveCapacity) или факт (CapacityReserved) и не позволяет соседнему сервису произвольно переписывать внутреннее состояние.

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

Проверка границы через сценарий изменения

Перед выделением сервиса полезно провести мысленный релиз. Добавить новое бизнес-условие, переименовать внутреннее поле, заменить способ хранения, пережить недоступность зависимости. Хорошая граница позволяет сделать основную часть работы внутри одного владельца. Если почти каждый сценарий требует менять производителей, потребителей и общий пакет типов, линия на диаграмме проведена слишком рано или не там.

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

Типичные поломки и как их ловят

Общая база с формальными владельцами

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

Длинная синхронная цепочка

Сервис вызывает следующий сервис, тот ещё два, а повтор запроса запускает цепочку заново. Локальная ошибка превращается в исчерпание пулов соединений и каскад тайм-аутов. Такое обнаруживают по распределённым трассировкам: глубине критического пути, разветвлению, доле времени в зависимостях и повторным вызовам. Укоротить цепочку можно локальной проекцией или асинхронным процессом, но только если сценарий принимает задержку и промежуточное состояние.

Бизнес-правило в нескольких местах

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

События как удалённый журнал таблиц

События EntityCreated и EntityUpdated с полным набором полей привязывают потребителей к внутренней модели производителя. Это проявляется при любой миграции: формально совместимое поле меняет смысл, и приходится синхронно обновлять несколько сервисов. Семантические события уменьшают связанность, если фиксируют состоявшийся факт и содержат только данные, необходимые обещанному контракту. Обратная сторона — их нужно проектировать и версионировать как публичный интерфейс.

Центральный оркестратор знает всё

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

Общая библиотека предметных типов

Общий пакет удобен до первой независимой эволюции. Если обновление структуры требует одновременно выпускать всех потребителей, библиотека стала скрытым монорепозиторием контрактов. Совместно использовать безопаснее технические примитивы: трассировку, формат идентификатора события, базовый клиент. Предметные команды и модели лучше генерировать из версионируемого контракта или определять на стороне владельца API.

Что измерять после разделения

Граница не становится хорошей из-за отдельного дашборда, но наблюдаемые сигналы быстро показывают связанность.

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

Когда разделение на сервисы вредно

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

Разделение также сомнительно, когда:

Асинхронность не исправляет неверную границу. Она может замаскировать связанность очередью, добавив промежуточные состояния и более сложное восстановление. И наоборот, один синхронный вызов не делает систему монолитом, если контракт устойчив, зависимость осознана, а отказ укладывается в обещание сценария.

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

Вывод для архитектурного ревью

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

Короткий список вопросов помогает вернуть обсуждение от технологий к ответственности:

Если ответы требуют перечислить половину системы, граница пока существует только на диаграмме.