Целевая топология: четыре сервиса и ни одной распределённой транзакции

Перед началом: Модуль 1 — границы контекстов проведены, боли монолита измерены («до»). Инструменты: репозиторий трека (boilerplate), docker, docker-compose, nginx. Трудоёмкость: ~5 ч.

🔥 Инцидент: «один заход за именем — и доставка встала»

В первом прототипе переезда event-service поступил честно, но наивно: получив событие из шины, он перед отправкой WS-кадра ходил в user-service за display_name, а карточку диалога брал у messenger — на каждый кадр и на каждое соединение. Вечерний пик: сотни сообщений в группах, тысячи кадров в минуту — и вся эта волна синхронных вызовов ударила в REST-сервисы. p99 messenger вырос втрое, клиентам начало казаться, что «чат отстал», они переподключали WS, волна реконнектов добавила нагрузки — каскадная деградация из-за одного лишнего хопа.

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

📖 Теория

2.1 Четыре сервиса и владение данными

Контейнерная схема
СервисКонтекстВладеет даннымиХранилище
user-serviceIdentity & Accessusers, contacts, blocks, сессии/токеныusers_db
messenger-serviceMessaging (core)conversations, conversation_participants, messages, conversation_invites, outbox_eventsmessenger_db
event-service ×NRealtime Deliveryничего доменного: реестр соединений, подписки, presence, кэши каталогапамять + Redis
notification-workerNotificationspush_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/wsevent-service (upstream-пул реплик)
/статика SPA
nginx
upstream 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 через nginxrealtime
messenger → userничего на запросном путитолько события + каталог по user.updated
event → messengerGET /internal/conversations/{id}/cardпрогрев кэша при холодном старте (редко)
notification → eventGET /internal/presenceправило «кого будить»
event ⇄ eventpub/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 для кэшей;
  • миграции — у каждого сервиса свои, в его репозитории/папке.

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

💻 Практика

  1. Нарисуйте схему своего стенда (45 мин). Прямоугольники сервисов, их БД, Redis с тремя ролями, nginx с путями из §2.2. Стрелки: REST/WS — сплошные, события — пунктир. Проверка: на схеме не должно существовать пути «клиент → сервис → сервис». Если существует — это будущий инцидент.

  2. Поднимите топологию на docker-compose (1.5 ч). Три сервиса: user (можно заглушку с /health), messenger (пока — ваш монолит), event (заглушка WS). nginx с upstream-пулами как в §2.2; для WS-location Upgrade/Connection обязательны — проверьте реконнектом клиента. PostgreSQL и Redis — общие контейнеры, логические БД разделены.

  3. Докажите, что клиент не заметил (30 мин). Фронтенд из boilerplate не меняется вовсе: он ходит по тому же OpenAPI. Меняется только базовый URL в dev-конфиге. Прогоните make validate-api и сценарий «login → список диалогов → сообщение по WS». Переезд, который видит пользователь, — это не переезд, а релиз с миграцией.

Чек-лист модуля: nginx роутит четыре префикса пути; WS апгрейдится через upstream-пул; на клиентском пути нет вызова сервис-в-сервис; у каждого сервиса свои /health и OpenAPI-тест; OpenAPI для клиента не изменился.

🧩 Quiz

1. Куда nginx отправит запрос /api/v1/conversations/42/messages?
2. Что делает GET /internal/conversations/{id}/card?
3. Почему у сервисов нет доступов к чужим БД?
4. Почему в топологии пока нет api-gateway-сервиса на Go?
5. Сколько сервисов максимум участвует в одном синхронном клиентском запросе?

✅ Итоги модуля

  • Назвать четыре сервиса, контекст каждого и список таблиц, которыми он владеет.
  • Объяснить правило «своя БД у сервиса» и чем грозит общая схема.
  • Настроить nginx-роутинг по путям с upstream-пулом для WS.
  • Прочитать матрицу вызовов и доказать отсутствие синхронных цепочек на клиентском пути.
  • Показать, что клиент переживает переезд без изменений — на том же OpenAPI.

🔗 Мостик к Модулю 3

Топология стоит, но в матрице спрятано обещание: «messenger → user: ничего на запросном пути». Чтобы оно выполнялось, проверка токена обязана быть локальной криптографией, а не звонком по сети. В следующем модуле выносим user-service и переводим аутентификацию на RS256 + JWKS: подписывает один, валидируют все офлайн — и падение user-service перестаёт быть падением чата.