База данных на сервис

Первое, что ломается при распиле монолита, — не код, а база. Разбираемся, почему у каждого сервиса должно быть своё хранилище и как после этого жить без JOIN.

Database per Service («база на сервис») — принцип, при котором у каждого микросервиса собственное хранилище, и никто, кроме самого сервиса, не ходит в него напрямую. Данные соседа доступны только через его API или через его события.

Типичная история. Команда полгода пилила монолит: получилось двенадцать сервисов, у каждого свой репозиторий, свой CI, свой деплой. Красиво. Но база осталась одна — «ну не переписывать же всё сразу». Ещё через полгода выясняется, что переименовать колонку нельзя (её читают четыре сервиса, и никто не знает какие), релизы снова выкатывают всей толпой в ночь на субботу, а тяжёлый отчёт из админки укладывает оформление заказов. Это называется распределённый монолит: сложность микросервисов вы уже купили, а выгоду — нет.

Поэтому правило звучит жёстко: сервисы, которые ходят в одну базу, — это не микросервисы. Разделение данных — не «одна из практик», а то, что вообще делает сервис самостоятельным.

Почему общая база убивает микросервисы

Схема таблиц становится публичным API — только без версий и контракта

Когда сервис читает чужую таблицу, структура этой таблицы превращается в интерфейс между командами. Но, в отличие от нормального API, у неё нет ни версии, ни документации, ни обратной совместимости. Разработчик каталога переименовывает price в price_net, прогоняет свои тесты, они зелёные — а ночью падает биллинг, который эту колонку тоже читал. Компилятор об этом не предупредит: связи между репозиториями нет, связь есть только в проде.

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

Общий отказ и общий потолок нагрузки

Одна база — один пул соединений, один диск, один blast radius. Криво написанный запрос в сервисе аналитики забирает все коннекты, и в этот момент «лежит» и корзина, и оплата, и профиль. Масштабировать отдельно тоже нечего: узкое место у всех общее.

Нельзя выбрать хранилище под задачу

Разным данным нужны разные движки. Поиску по товарам — полнотекстовый индекс (Elasticsearch), корзине — быстрый key-value (Redis), биллингу — строгий ACID и транзакции (PostgreSQL), рекомендациям — граф. Общая база означает один компромисс на всех. Своя база у каждого сервиса — это в том числе свобода взять правильный инструмент (такой подход называют polyglot persistence).

Пока транзакция «через таблицы» возможна — её будут писать

Если из кода сервиса заказов физически достижима таблица склада, однажды кто-то обернёт оба UPDATE в одну транзакцию — просто потому, что так проще и быстрее. И всё: сервисы срослись намертво, разделить их теперь стоит человекомесяцев.

КритерийОбщая базаБаза на сервис
Миграция схемысогласуют несколько командрешает одна команда, молча
Деплойфактически совместныйнезависимый
Отказупала база — упало всёупала база заказов — каталог работает
Выбор СУБДодна на всехпод задачу
Целостностьгарантирует СУБД (FK, транзакции)обеспечивает приложение (проверки, сверки, Saga)
Сложность запросовлюбой JOIN бесплатноJOIN между сервисами невозможен

Последняя строка — честная цена вопроса. Вы меняете простоту запросов на независимость команд. Если независимость вам не нужна (небольшой продукт, одна команда) — берите монолит, это совершенно нормальное инженерное решение.

Дублирование данных — не ошибка, а осознанная цена

Первый вопрос новичка: «Если товары в другом сервисе, как заказ покажет название и цену?» Ответ, который поначалу режет глаз: сервис заказов хранит у себя копию названия и цены. Да, одни и те же данные лежат в двух местах. Да, это сделано намеренно.

Причём в случае заказа это даже не компромисс, а прямое требование бизнеса: цена в чеке — это цена на момент покупки. Если завтра маркетинг поднимет цену на клавиатуру, ваш прошлогодний чек не должен «переписаться» задним числом. А ссылка на чужую строчку (product_id плюс JOIN) как раз переписала бы. Копия здесь не костыль, а единственно верная модель.

Полезно различать два вида дублирования — их путают чаще всего:

Вид копииЧто этоОбновляется?Пример
Снимок факта (snapshot)значение, зафиксированное в момент событияНет, никогдацена и название товара в позиции заказа; адрес доставки в накладной
Справочная копия (reference, локальный кэш)копия чужих данных, чтобы не дёргать соседа на каждый чихДа, по событию владельцаимя автора рядом с комментарием; название города в списке филиалов

Поверх этого — железное правило: у каждого факта ровно один владелец (source of truth). Все копии — только на чтение. Как только два сервиса начинают писать в один и тот же факт, вы получаете расхождение, которое уже никогда не сойдётся, и вечный вопрос «а чьё значение правильное?».

Как жить без JOIN между сервисами

Приём 1. API composition — склейка на стороне вызывающего

Самый прямолинейный способ: запросить данные у двух-трёх сервисов и собрать ответ в памяти. Так работает большинство BFF-слоёв и шлюзов. Главная ловушка здесь — N+1 запросов: цикл по заказам, а внутри цикла HTTP-вызов за каждым товаром. В монолите такое стоило бы миллисекунды, по сети — сотни миллисекунд и лишнюю нагрузку на соседа.

orders = [
    {"id": 101, "user_id": 7, "sku": "KB-42", "qty": 2},
    {"id": 102, "user_id": 7, "sku": "MS-11", "qty": 1},
    {"id": 103, "user_id": 7, "sku": "KB-42", "qty": 1},
]
catalog = {  # это чужая база: в реальности сюда мы ходим только через API
    "KB-42": {"title": "Клавиатура", "price": 4990},
    "MS-11": {"title": "Мышь", "price": 1490},
}
calls = 0

def catalog_get(skus):
    # один вызов сервиса каталога — сколько бы SKU ни попросили
    global calls
    calls += 1
    return {sku: catalog[sku] for sku in skus}

calls = 0                      # наивно: вызов на каждый заказ
for o in orders:
    catalog_get([o["sku"]])
print("Наивно:", calls, "вызова каталога")

calls = 0                      # батчем: собрали все SKU и спросили один раз
items = catalog_get({o["sku"] for o in orders})
view = [{"order": o["id"],
         "title": items[o["sku"]]["title"],
         "sum": items[o["sku"]]["price"] * o["qty"]} for o in orders]
print("Батчем:", calls, "вызов каталога")
for row in view:
    print(row)

Результат:

Наивно: 3 вызова каталога
Батчем: 1 вызов каталога
{'order': 101, 'title': 'Клавиатура', 'sum': 9980}
{'order': 102, 'title': 'Мышь', 'sum': 1490}
{'order': 103, 'title': 'Клавиатура', 'sum': 4990}

Отсюда первое практическое правило распределённых данных: у каждого сервиса должна быть batch-ручка вида GET /products?sku=KB-42,MS-11. Без неё композиция превращается в N+1 автоматически, сама собой.

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

Приём 2. Событийная репликация — своя read-модель

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

{
  "event": "ProductPriceChanged",
  "product_id": "KB-42",
  "title": "Клавиатура",
  "old_price": 4990,
  "new_price": 5290,
  "occurred_at": "2026-07-14T10:12:03Z"
}

Заплатить придётся согласованностью в конечном счёте (eventual consistency): между «цена изменилась в каталоге» и «цена обновилась в реплике» проходит от десятков миллисекунд до секунд. Для витрины товаров это допустимо, для списания денег — нет. Выбор за вами, но он должен быть осознанным.

Приём 3. Самодостаточные сообщения

Если заранее известно, какие поля понадобятся потребителю, кладите их прямо в событие или в ответ API. Событие OrderPlaced, в котором уже есть адрес и позиции с ценами, избавляет пятерых подписчиков от пяти обратных вызовов. Обратная сторона — событие «толстеет» и его тяжелее менять.

Приём 4. Отдельная модель для чтения

Для сложных экранов вроде «история заказов с товарами, статусом доставки и бонусами» строят выделенную read-модель или поисковый индекс, который наполняется событиями сразу нескольких сервисов. Это уже CQRS — ему посвящён третий урок раздела. А отчётность и аналитику вообще уносят из боевых баз в хранилище (DWH) через CDC: «джойны ради BI» не должны жить в оперативных сервисах.

ПриёмКогда братьМинус
API composition2–3 сервиса, немного записей на экранзадержки складываются, N+1, нет сортировки по чужому полю
Событийная репликациячастое чтение, редкое изменение чужих данныхeventual consistency, надо поддерживать подписчика
Самодостаточные событиямного подписчиков на один фактсобытие «толстеет», сложнее эволюционировать
Отдельная read-модель или индекссложные экраны, поиск, отчётыещё одно хранилище и его синхронизация

Как это работает

На практике базу разрезают не одним движением, а по шагам — и порядок здесь важнее, чем кажется:

  1. Находят границы. Что такое «заказ», а что — «отгрузка», где заканчивается зона ответственности каталога. Это работа в терминах предметной области (bounded context), а не в терминах таблиц.
  2. Разделяют логически внутри одной СУБД. Каждому сервису — своя схема и свой пользователь БД. А дальше самый недооценённый шаг: REVOKE прав на чужие схемы. Пока у сервиса технически есть SELECT на таблицы соседа, кто-нибудь им обязательно воспользуется — «на пять минут, потом уберём».
  3. Убирают кросс-сервисные внешние ключи и JOIN. FK между схемами заменяют проверкой при записи: сервис заказов спрашивает у каталога «такой SKU существует?» — либо доверяет событию, которое сам же и получил.
  4. Физически разводят по разным инстансам. Только после того, как код перестал ходить в чужое.

Целостность после этого обеспечивает не СУБД, а приложение. Инструментов два:

  • Проверка на входе — не создаём заказ на несуществующий товар, не резервируем больше, чем есть.
  • Сверка (reconciliation) — фоновая задача, которая раз в час ищет расхождения: заказы без платежей, платежи без заказов, «сироты» после сбоев. В распределённой системе такой уборщик — не признак плохого кода, а штатный элемент архитектуры. Он же обычно и находит ваши настоящие баги.

А что делать, когда операция всё-таки должна изменить данные в двух сервисах «атомарно» — деньги списать и товар зарезервировать? Об этом следующий урок: Saga.

Частые ошибки

  • «Общая база, но только на чтение». Обманчивый компромисс: схема всё равно становится жёстким контрактом, просто вы узнаете об этом в день миграции.
  • Базы разделили, а вьюхи и FK оставили. «Мостики» между схемами — это та же общая база, вид сбоку. Отберите права доступа, и мостики отвалятся сами.
  • Пишут в копию. Сервис заказов «поправил» у себя название товара. Теперь источников правды два, и расхождение уже не сойдётся.
  • Синхронная композиция из пяти сервисов на горячем пути. Задержки складываются, а доступности перемножаются: пять звеньев по 99,9% дают 99,5% — это лишние примерно четыре часа простоя в год, которых в монолите не было.
  • Сортировка и пагинация по чужому полю через композицию. Работает на демо, умирает на проде. Нужна read-модель.
  • Аналитику гоняют по боевым базам сервисов. Тяжёлые запросы конкурируют с пользовательскими. Данные для BI выгружают отдельно (CDC в DWH).
  • Слишком мелкая нарезка. Если «пользователь» разрезан на три сервиса, вы будете дублировать и синхронизировать вообще всё. Границы идут по бизнесу, а не по таблицам.

Итоги

  • Своя база у каждого сервиса — не деталь, а условие независимости: без этого деплой, отказы и масштабирование остаются общими.
  • Чужие данные берут через API или события, но никогда — напрямую из чужой базы.
  • Дублирование данных — норма. Снимок факта (цена в чеке) не обновляется вообще; справочная копия обновляется по событию владельца.
  • У каждого факта один владелец; все копии — только на чтение.
  • Вместо JOIN: композиция с batch-ручками, событийная репликация, самодостаточные события, отдельная read-модель.
  • Целостность теперь на вас: проверки при записи плюс фоновая сверка расхождений.
Проверьте себя
1. Сервис заказов хранит у себя копию названия и цены товара на момент покупки. Как это правильнее всего оценить?
AЭто ошибка нормализации: надо хранить только product_id и джойнить с каталогом
BЭто осознанное дублирование: цена в чеке — снимок факта, он не должен меняться задним числом
CЭто допустимо лишь как временный кэш, копию надо обновлять по каждому событию каталога
DЭто нарушает принцип единственного владельца данных
2. Экран показывает заказы, отсортированные по названию товара, страницами по 20. Товары живут в другом сервисе. Что сработает на больших объёмах?
AAPI composition: забрать все заказы, дозапросить товары и отсортировать в памяти
BОткрыть сервису заказов доступ на чтение к базе каталога и сделать обычный JOIN
CДержать в сервисе заказов локальную read-модель с нужными полями товара, обновляемую по событиям
DКэшировать ответы каталога в Redis на пять минут