База данных на сервис
Первое, что ломается при распиле монолита, — не код, а база. Разбираемся, почему у каждого сервиса должно быть своё хранилище и как после этого жить без 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 composition | 2–3 сервиса, немного записей на экран | задержки складываются, N+1, нет сортировки по чужому полю |
| Событийная репликация | частое чтение, редкое изменение чужих данных | eventual consistency, надо поддерживать подписчика |
| Самодостаточные события | много подписчиков на один факт | событие «толстеет», сложнее эволюционировать |
| Отдельная read-модель или индекс | сложные экраны, поиск, отчёты | ещё одно хранилище и его синхронизация |
Как это работает
На практике базу разрезают не одним движением, а по шагам — и порядок здесь важнее, чем кажется:
- Находят границы. Что такое «заказ», а что — «отгрузка», где заканчивается зона ответственности каталога. Это работа в терминах предметной области (bounded context), а не в терминах таблиц.
- Разделяют логически внутри одной СУБД. Каждому сервису — своя схема и свой пользователь БД. А дальше самый недооценённый шаг:
REVOKEправ на чужие схемы. Пока у сервиса технически естьSELECTна таблицы соседа, кто-нибудь им обязательно воспользуется — «на пять минут, потом уберём». - Убирают кросс-сервисные внешние ключи и JOIN. FK между схемами заменяют проверкой при записи: сервис заказов спрашивает у каталога «такой SKU существует?» — либо доверяет событию, которое сам же и получил.
- Физически разводят по разным инстансам. Только после того, как код перестал ходить в чужое.
Целостность после этого обеспечивает не СУБД, а приложение. Инструментов два:
- Проверка на входе — не создаём заказ на несуществующий товар, не резервируем больше, чем есть.
- Сверка (reconciliation) — фоновая задача, которая раз в час ищет расхождения: заказы без платежей, платежи без заказов, «сироты» после сбоев. В распределённой системе такой уборщик — не признак плохого кода, а штатный элемент архитектуры. Он же обычно и находит ваши настоящие баги.
А что делать, когда операция всё-таки должна изменить данные в двух сервисах «атомарно» — деньги списать и товар зарезервировать? Об этом следующий урок: Saga.
Частые ошибки
- «Общая база, но только на чтение». Обманчивый компромисс: схема всё равно становится жёстким контрактом, просто вы узнаете об этом в день миграции.
- Базы разделили, а вьюхи и FK оставили. «Мостики» между схемами — это та же общая база, вид сбоку. Отберите права доступа, и мостики отвалятся сами.
- Пишут в копию. Сервис заказов «поправил» у себя название товара. Теперь источников правды два, и расхождение уже не сойдётся.
- Синхронная композиция из пяти сервисов на горячем пути. Задержки складываются, а доступности перемножаются: пять звеньев по 99,9% дают 99,5% — это лишние примерно четыре часа простоя в год, которых в монолите не было.
- Сортировка и пагинация по чужому полю через композицию. Работает на демо, умирает на проде. Нужна read-модель.
- Аналитику гоняют по боевым базам сервисов. Тяжёлые запросы конкурируют с пользовательскими. Данные для BI выгружают отдельно (CDC в DWH).
- Слишком мелкая нарезка. Если «пользователь» разрезан на три сервиса, вы будете дублировать и синхронизировать вообще всё. Границы идут по бизнесу, а не по таблицам.
Итоги
- Своя база у каждого сервиса — не деталь, а условие независимости: без этого деплой, отказы и масштабирование остаются общими.
- Чужие данные берут через API или события, но никогда — напрямую из чужой базы.
- Дублирование данных — норма. Снимок факта (цена в чеке) не обновляется вообще; справочная копия обновляется по событию владельца.
- У каждого факта один владелец; все копии — только на чтение.
- Вместо JOIN: композиция с batch-ручками, событийная репликация, самодостаточные события, отдельная read-модель.
- Целостность теперь на вас: проверки при записи плюс фоновая сверка расхождений.