Целевая топология: четыре сервиса и ни одной распределённой транзакции
Перед началом: Модуль 1 — границы контекстов проведены, боли монолита измерены («до»). Инструменты: репозиторий трека (boilerplate), docker, docker-compose, nginx. Трудоёмкость: ~5 ч.
🔥 Инцидент: «один заход за именем — и доставка встала»
В первом прототипе переезда event-service поступил честно, но наивно: получив
событие из шины, он перед отправкой WS-кадра ходил в user-service за
display_name, а карточку диалога брал у messenger — на каждый кадр и на
каждое соединение. Вечерний пик: сотни сообщений в группах, тысячи кадров
в минуту — и вся эта волна синхронных вызовов ударила в REST-сервисы.
p99 messenger вырос втрое, клиентам начало казаться, что «чат отстал», они
переподключали WS, волна реконнектов добавила нагрузки — каскадная деградация
из-за одного лишнего хопа.
Разбор показал главное: на пути клиента появилась синхронная цепочка из трёх сервисов. В целевой топологии такой цепочки нет ни одной: имена и карточки event-service берёт из кэша-каталога, а данные для каталога приходят событиями, не запросами. Этот модуль фиксирует топологию, в которой такой инцидент невозможен по устройству: кто чем владеет, кто кого вызывает и где заканчиваются синхронные вызовы.
📖 Теория
2.1 Четыре сервиса и владение данными
| Сервис | Контекст | Владеет данными | Хранилище |
|---|---|---|---|
| user-service | Identity & Access | users, contacts, blocks, сессии/токены | users_db |
| messenger-service | Messaging (core) | conversations, conversation_participants, messages, conversation_invites, outbox_events | messenger_db |
| event-service ×N | Realtime Delivery | ничего доменного: реестр соединений, подписки, presence, кэши каталога | память + Redis |
| notification-worker | Notifications | push_subscriptions, окна агрегации | notifications_db |
Правило владения: одна БД на сервис, доступов к чужим БД нет. Один инстанс
PostgreSQL допустим — базы разделены, доступы тоже. Чужие данные сервис видит
только через события шины и read-model каталоги. Общая схема — это скрытый
контракт: ALTER TABLE в одном сервисе ломает другой в рантайме, и независимые
деплои превращаются в distributed monolith с двумя деплоями и нулевой
независимостью.
Отсюда же — ни одной распределённой транзакции. Запись сообщения — одна
транзакция внутри messenger_db: сообщение и outbox-событие коммитятся
атомарно, и это вся транзакция. Между сервисами согласованность только
итоговая (eventual) — через события шины. Ни на одном пути клиента сервис не
открывает вторую БД, потому что не имеет к ней доступа.
2.2 nginx: роутинг по путям вместо api-gateway
Входная точка — nginx из deploy-схемы курса. Он роутит по префиксам пути:
| Путь | Куда |
|---|---|
/api/v1/auth/*, /api/v1/users/* | user-service |
/api/v1/conversations/* | messenger-service |
/api/v1/ws | event-service (upstream-пул реплик) |
/ | статика SPA |
nginxupstream user_api { server user:8080; } upstream messenger_api { server messenger:8080; } upstream event_ws { least_conn; server event-1:8080; server event-2:8080; } server { location /api/v1/auth/ { proxy_pass http://user_api; } location /api/v1/users/ { proxy_pass http://user_api; } location /api/v1/conversations/ { proxy_pass http://messenger_api; } location /api/v1/ws { proxy_pass http://event_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # без этого WS не апгрейдится proxy_set_header Connection "upgrade"; } }
Отдельного «api-gateway-сервиса» на Go нет — и это решение, а не лень: пока клиент один, роутинг по путям делает nginx. Gateway (точнее BFF) появится, когда придут внешние клиенты с другими экранами, — не раньше.
2.3 Матрица «кто кого вызывает»
| Вызов | Тип | Причина |
|---|---|---|
| клиент → user (login, profile) | REST через nginx | стандартный CRUD |
| клиент → messenger (диалоги) | REST через nginx | ядро домена |
| клиент → event (WS) | WS через nginx | realtime |
| messenger → user | ничего на запросном пути | только события + каталог по user.updated |
| event → messenger | GET /internal/conversations/{id}/card | прогрев кэша при холодном старте (редко) |
| notification → event | GET /internal/presence | правило «кого будить» |
| event ⇄ event | pub/sub presence | кросс-реплические события присутствия |
Правило матрицы: синхронных цепочек на пути клиента нет — максимум один сервис за запрос (кроме WS-кадра, который event-service собирает из своего кэша). Каждый исходящий синхронный вызов — потенциальный каскад, как в инциденте выше, поэтому синхронных вызовов всего два, оба internal и оба не на клиентском пути. Аутентификация в эту матрицу не попадает вовсе: токены проверяются локально (модуль 3), данные — приходят событиями (модуль 4).
2.4 Внутренние API и Redis
Internal-API ровно два, и оба обслуживают кэши, а не бизнес-логику:
GET /internal/conversations/{id}/card— messenger отдаёт карточку диалога для прогрева кэша event-service при холодном старте;GET /internal/presence— event-service отдаёт presence пользователей notification-worker'у (офлайн-получатель пуша).
Redis в топологии один инстанс с логическим разделением ролей: шина
(Redis Streams, топик messenger.events), presence (TTL-ключи +
pub/sub-канал) и rate-limit. Ни один из этих сценариев не требует
транзакционности между собой и Postgres: шина — очередь с ACK, presence —
эфемерное состояние, потеря которого — норма (клиент догонит по seq).
2.5 Конвенции сервисов
Чтобы четыре сервиса не превратились в четыре стиля, общие конвенции фиксируются до первого Pull Request:
- язык Go, структура
cmd/+internal/— как в boilerplate трека; - свой контракт — OpenAPI + тест «роуты ↔ спека» (
make validate-api); - свои
/health,/ready,/metricsу каждого сервиса — набор и SLO из модуля про эксплуатацию; - между сервисами — только события шины плюс узкий internal-API для кэшей;
- миграции — у каждого сервиса свои, в его репозитории/папке.
Проверка конвенций дешёвая: тест «роуты ↔ спека» переезжает в каждый сервис как есть — тот же механизм, что в монолите.
💻 Практика
-
Нарисуйте схему своего стенда (45 мин). Прямоугольники сервисов, их БД, Redis с тремя ролями, nginx с путями из §2.2. Стрелки: REST/WS — сплошные, события — пунктир. Проверка: на схеме не должно существовать пути «клиент → сервис → сервис». Если существует — это будущий инцидент.
-
Поднимите топологию на docker-compose (1.5 ч). Три сервиса: user (можно заглушку с
/health), messenger (пока — ваш монолит), event (заглушка WS). nginx с upstream-пулами как в §2.2; для WS-locationUpgrade/Connectionобязательны — проверьте реконнектом клиента. PostgreSQL и Redis — общие контейнеры, логические БД разделены. -
Докажите, что клиент не заметил (30 мин). Фронтенд из boilerplate не меняется вовсе: он ходит по тому же OpenAPI. Меняется только базовый URL в dev-конфиге. Прогоните
make validate-apiи сценарий «login → список диалогов → сообщение по WS». Переезд, который видит пользователь, — это не переезд, а релиз с миграцией.
Чек-лист модуля: nginx роутит четыре префикса пути; WS апгрейдится через
upstream-пул; на клиентском пути нет вызова сервис-в-сервис; у каждого сервиса
свои /health и OpenAPI-тест; OpenAPI для клиента не изменился.
🧩 Quiz
/api/v1/conversations/42/messages?GET /internal/conversations/{id}/card?✅ Итоги модуля
- Назвать четыре сервиса, контекст каждого и список таблиц, которыми он владеет.
- Объяснить правило «своя БД у сервиса» и чем грозит общая схема.
- Настроить nginx-роутинг по путям с upstream-пулом для WS.
- Прочитать матрицу вызовов и доказать отсутствие синхронных цепочек на клиентском пути.
- Показать, что клиент переживает переезд без изменений — на том же OpenAPI.
🔗 Мостик к Модулю 3
Топология стоит, но в матрице спрятано обещание: «messenger → user: ничего на запросном пути». Чтобы оно выполнялось, проверка токена обязана быть локальной криптографией, а не звонком по сети. В следующем модуле выносим user-service и переводим аутентификацию на RS256 + JWKS: подписывает один, валидируют все офлайн — и падение user-service перестаёт быть падением чата.