Распределённый монолит
Сервисы, деплоящиеся синхронно, вызывающие друг друга через N синхронных хопов и разделяющие базу данных — это distributed monolith: все операционные затраты распределённости без какой-либо автономности.
Через восемнадцать месяцев после разделения B2B биллинговой платформы на «микросервисы» дежурство стало невыносимым. Один запрос на размещение заказа проходил через пять сервисов последовательно: ApiGateway вызывал OrderService, который вызывал InventoryService, который вызывал BillingService, который вызывал NotificationService. Каждый хоп добавлял 20–40 мс задержки и новый режим отказа. Если NotificationService тормозил, размещение заказа получало таймаут. Задеплоить BillingService без координации с OrderService было невозможно: оба сервиса ожидали одну версию схемы Order — которая жила в общей базе данных, в которую оба писали. Каждый деплой требовал треда в Slack для подтверждения. Постмортем выявил первопричину: монолит разделили по техническому слою (API, логика, данные как отдельные сервисы), а не по бизнес-возможности. Получилось пять деплойных юнитов, пять дежурных ротаций, пять наборов пайплайнов деплоя и в пять раз больше сетевых точек отказа — при нулевой независимой развёртываемости. Это и был distributed monolith.
Что делает систему distributed monolith
Distributed monolith — набор сервисов, выглядящих независимыми, но тесно связанных в рантайме и во время деплоя. Он объединяет худшие свойства обеих архитектур:
- От монолита: изменение одного сервиса требует изменений и скоординированных деплоев в нескольких сервисах; кодовую базу сложно понять целиком, потому что зацепление неявное (сетевые вызовы и общая схема, а не видимые импорты).
- От распределённых систем: сетевая задержка на каждом межсервисном вызове, режимы частичного отказа сложнее для отладки, чем исключения уровня процесса, и операционные накладные расходы нескольких деплойных юнитов.
Диагностический вопрос — не «сколько у нас сервисов?» — а «может ли каждый сервис деплоиться и отказывать независимо?». Если нет — distributed monolith, сколько бы сервисов ни показывала архитектурная диаграмма.
Три структурных симптома определяют паттерн:
1. Lockstep-деплой. Сервисы должны деплоиться вместе или в определённой последовательности, потому что разделяют схему данных или API-контракт без версионирования. Изменение таблицы orders требует координации OrderService, BillingService и InventoryService в одном деплойном окне.
2. Болтливые синхронные цепочки вызовов. Один клиентский запрос разворачивается в N синхронных хопов через сервисы, не способные ответить, пока не ответят нижестоящие. Каждый хоп добавляет задержку и вероятность отказа. Под нагрузкой медленный сервис в конце цепочки производит таймауты, каскадирующиеся вверх.
3. Shared database. Несколько сервисов читают и пишут в одни и те же таблицы через общую ORM или общий пул соединений. Никакой сервис не владеет своими данными эксклюзивно; изменения схемы затрагивают каждый сервис, обращающийся к изменённой таблице.
Проблема dual-write
Shared database производит структурный дефект, проявляющийся как несогласованность данных: dual-write problem.
Рассмотрим создание BillingService записи о списании и обновление статуса заказа последовательно:
1. INSERT INTO charges (order_id, amount) VALUES (123, 450.00)
2. UPDATE orders SET status = 'charged' WHERE id = 123Оба оператора успешно выполняются локально. Но в orders также пишет OrderService. Если OrderService конкурентно пишет status = 'cancelled' после шага 1, но до шага 2, заказ оказывается в состоянии charged, хотя был отменён. BillingService и OrderService каждый написал своё локальное представление истины в одну и ту же таблицу без владельца, принудительно соблюдающего согласованность.
Проблему dual-write нельзя полностью решить транзакцией, охватывающей записи обоих сервисов: такая транзакция является распределённой — она требует координатора для блокировки записей через соединения обоих сервисов одновременно. Это 2PC, который повторно вводит единственную точку отказа координатора, удержание блокировок между сервисами и зависимость доступности от всех участников (урок 09). Правильное исправление — дать каждому сервису эксклюзивное владение его таблицами, структурно исключив конкурентные записи от разных сервисов в одну запись.
▸Why this works
Проблема dual-write — не баг в коде приложения, это структурное следствие разделённого владения данными. Никакая тщательная последовательность операций не устраняет её при двух сервисах, пишущих в одну таблицу, потому что гарантия последовательности требует распределённой блокировки — то есть 2PC под другим именем. Единственное структурное исправление — эксклюзивное владение данными на сервис.
BillingService и OrderService биллинговой платформы оба пишут в общую таблицу `orders`. Разработчик предлагает исправить несогласованность dual-write, всегда выполняя записи в фиксированном порядке: сначала пишет OrderService, затем BillingService читает запись OrderService перед своей записью. В чём структурный изъян этого подхода?
Временно́е зацепление: скрытое зацепление синхронных цепочек
Temporal coupling (временно́е зацепление) — форма рантаймового зацепления, возникающего когда один сервис может завершить свою работу только при доступности другого сервиса в тот же момент. Каждый синхронный вызов сервиса создаёт temporal coupling.
В пятисервисной цепочке биллинговой платформы OrderService временно́ связан с InventoryService, BillingService и NotificationService. Если любой из них недоступен во время размещения заказа — даже ненадолго, во время деплоя или GC-паузы — вся операция размещения заказа завершается с ошибкой. Отказ сервиса уведомлений (низкий бизнес-приоритет) вызывает отказ размещения заказа (высокий бизнес-приоритет). Это инвертированный профиль риска temporal coupling: критически важная бизнес-операция падает при недоступности наименее критичного сервиса.
Temporal coupling также усложняет независимый деплой. Если OrderService должен вызывать InventoryService синхронно на каждом запросе, их нельзя тестировать независимо. Поведение OrderService при сбоях InventoryService нужно тестировать с живым InventoryService или тщательно созданными тест-дублями. На практике команды пропускают такое тестирование из-за сложности, производя характерную хрупкость distributed monolith.
Дежурная команда биллинговой платформы наблюдает, что сбои NotificationService (email/SMS) вызывают сбои размещения заказов. Первопричина — синхронная цепочка вызовов. Инженер предлагает: «Нужно повысить SLA NotificationService до уровня OrderService — если уведомления станут надёжнее, цепочка станет надёжнее». В чём структурная проблема такого подхода?
Диагностика distributed monolith
Контрольный список архитектурного ревью биллинговой платформы для distributed monolith:
Изменил один сервис — нужно менять три. Если добавление поля в доменный объект Order требует изменений кода в OrderService, BillingService и InventoryService — даже если поле вводит только один из них — сервисы структурно связаны. Настоящая граница сервиса означает, что изменение поля внутреннее для кодовой базы одного сервиса.
Один запрос разворачивается в N sync-хопов. Если диаграмма последовательностей для одного пользовательского действия показывает каскад синхронных вызовов через четыре сервиса, каждый из которых должен завершиться до ответа предыдущего, сервисы — деталь реализации одного воркфлоу, а не автономные возможности.
Деплои требуют координационного треда. Если деплой BillingService требует треда в Slack для подтверждения с командой OrderService совместимости миграции общей схемы, зацепление деплоев явное. Автономные сервисы деплоятся в любое время без межкомандной координации.
Ни один сервис не может отказать изолированно. Если отключение InventoryService на обслуживание приводит к сбою размещения заказов, размещение заказов не изолировано от инвентаря. Автономные сервисы деградируют gracefully при недоступности зависимостей — они не распространяют сбой вверх.
Архитектурная команда биллинговой платформы проверяет пятисервисную систему. Находки: (1) Каждый запрос на размещение заказа делает четыре синхронных вызова последовательно. (2) Все пять сервисов разделяют один инстанс PostgreSQL с таблицами без чёткого владельца. (3) BillingService деплоится в продакшн три раза в неделю; OrderService — раз в месяц. (4) При деплое BillingService OrderService продолжает корректно работать. Инженер аргументирует: «Находки (3) и (4) доказывают независимость сервисов — разные частоты деплоев и деплои BillingService не ломают OrderService». Убедителен ли аргумент?
Как исправить distributed monolith
Distributed monolith исправляется тем же подходом, что его предотвращает: декомпозиция по бизнес-возможности по швам bounded context (урок 02), принудительное соблюдение владения данными, замена болтливых синхронных цепочек асинхронным событийным взаимодействием там, где бизнес допускает eventual consistency (урок 09).
Инкрементальное исправление:
- Определить болтливейшую синхронную цепочку. Цепочка с наибольшим числом хопов — самое срочное temporal coupling для разрыва.
- Определить, какие вызовы блокирующие, а какие — уведомления. Отправка уведомления-подтверждения не должна блокировать транзакцию заказа. Резервирование инвентаря должно — бизнес требует знать о наличии товара до фиксации заказа.
- Заменить уведомления событиями. OrderService публикует событие
OrderPlaced; NotificationService подписывается и отправляет email асинхронно. Транзакция размещения заказа завершается без ожидания уведомлений. - Адресовать владение данными. Определить, какими таблицами фактически владеет каждый сервис. Перенести эксклюзивный write-доступ к владеющему сервису. Заменить весь остальной доступ API-вызовами к владеющему сервису.
- Версионировать API, не схемы. Когда двум сервисам нужно разделить концепцию (статус заказа), они делают это через API с версионированным контрактом — не через общую таблицу, в которую оба пишут.
- 01Каковы три структурных симптома, определяющих distributed monolith?
- 02Что такое проблема dual-write и почему её нельзя исправить транзакцией, охватывающей записи обоих сервисов?
- 03Что такое temporal coupling и чем оно отличается от зацепления деплоев?
Пятисервисная система биллинговой платформы была distributed monolith во всех значимых смыслах: изменение домена заказов требовало изменений в трёх сервисах, размещение заказа блокировалось на отправке уведомлений, а shared database делала каждое изменение схемы скоординированным событием. Пять пайплайнов деплоя и пять дежурных ротаций — при нулевой независимой развёртываемости.
Distributed monolith появляется, когда команды разделяют по техническому слою вместо бизнес-возможности — производя фрагменты воркфлоу вместо автономных возможностей — или когда маршрутизируют API-трафик к новым сервисам без передачи владения данными. Сетевая граница косметическая, если зацепление по данным и temporal coupling остаются.
Диагностика проста: может ли каждый сервис деплоиться и отказывать независимо? Если деплой биллинга требует координационного треда с командой заказов — нет. Если простой сервиса уведомлений останавливает размещение заказов — нет. Количество сервисов на архитектурной диаграмме не имеет значения.
Исправление совпадает с предотвращением: декомпозиция по шву bounded context, эксклюзивное владение данными каждым сервисом, замена синхронных цепочек асинхронными событиями там, где бизнес принимает eventual consistency. С трудом усвоенный урок: распределённость — не цель. Цель — автономная развёртываемость.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.