lenec ru

← все посты

Database per service: критерии, компромиссы и где это правда работает

13K

«У каждого сервиса своя база» — одно из тех правил, которые звучат настолько разумно, что хочется применить везде сразу. На бумаге всё аккуратно: сервисы независимы, релизятся отдельно, схему меняет одна команда. На практике через полгода ты обнаруживаешь, что половина новых эндпоинтов агрегирует данные из четырёх баз, отчёт за квартал требует ETL-процесса, а у DBA вместо двух Postgres-ов в проде стоит шесть, плюс две Mongo и один Redis для кешей. И всё это нужно бэкапить, мониторить, обновлять и расследовать инциденты.

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

Что вообще даёт database per service

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

Второе следствие — независимость по отказам. Падение базы одного сервиса не валит остальных, если они не зависят от него синхронно. Это работает только в комплекте с правильно поставленными API-вызовами и таймаутами, иначе изоляция остаётся только в схеме, а в проде всё ляжет каскадом.

Третье, менее очевидное — возможность выбрать подходящее хранилище под задачу. Один сервис на Postgres, другой на ClickHouse для аналитики, третий на key-value для кешей. Это плюс, но он же создаёт операционную нагрузку: команда инфраструктуры теперь поддерживает зоопарк.

Когда правило не оправдано

В стартапе на двух разработчиках и трёх сервисах — почти никогда. Цена изоляции (миграции, копирование данных, отдельная админка для каждой БД) не окупается, потому что согласовывать всё равно негде: все за одним столом. На таком этапе одна общая БД с явными схемами под каждый сервис — нормальный компромисс.

В системах, где много отчётов из разных доменов — тоже не везде. Если ваш бизнес строится на больших аналитических запросах, разделение на десять баз превратит каждый отчёт в задачу интеграции данных. Тогда лучше явно отделить операционные базы от аналитического хранилища, в которое события сливаются единообразно.

В тонких сервисах-обёртках — сомнительно. Если сервис делает прокси к API партнёра и хранит мизер информации, отдельная БД для него — лишняя коробка в инфраструктуре.

Что значит «своя база» на самом деле

Вариантов больше двух. Реальная шкала выглядит так.

  • Общий инстанс БД, общая схема. Все сервисы пишут в одни и те же таблицы. Это не database per service ни в каком смысле. Жить можно, но любое изменение схемы — это event с участием всех.
  • Общий инстанс, разные схемы. В одном Postgres есть схемы orders, billing, catalog. Каждая команда владеет своей. Доступ к чужой — только через API, не через SQL. Самый прагматичный компромисс на средних проектах.
  • Разные инстансы (логически). Каждый сервис подключается к своему URL, своему пользователю, своим бэкапам. Может быть на одном или разных физических серверах.
  • Разные физические серверы. Полная изоляция. Окупается только при значительной разнице в нагрузке или требованиях.

Я обычно рекомендую начинать с уровня «общий инстанс, разные схемы» и переходить выше, когда появляется явная причина: разные требования к доступности, разная нагрузка, разные бэкапные политики. Прыгать сразу в «разные физические серверы» — лишняя инфраструктурная боль на старте.

Чего нельзя делать ни в каком из вариантов

Главное правило, которое отделяет настоящую изоляцию от показухи — никаких прямых запросов в чужую базу. Никаких JOIN через границу сервиса. Никаких отчётных читаемых вьюшек, которые «просто читают, ничего не пишут». Если такой канал появился, он перестаёт быть аномалией и становится зависимостью, которую сложно убрать.

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

Из этой же области: общие staged/temp таблицы, в которые пишут несколько сервисов. Это шаринг состояния под другим именем. Если данные нужны нескольким сервисам — они должны передаваться явно: через API, через события, через копию в каждой БД.

Как делиться данными

Когда сервису нужны данные соседа, есть три варианта в порядке убывания сложности.

API-вызов на чтение

Самый простой. Сервис A зовёт API сервиса B и получает то, что нужно. Подходит для нечастых запросов, для свежих данных, для ситуаций, когда количество вызовов невелико.

Минусы: связность по доступности (B упал — A не работает), задержка (каждый вызов — сетевой round-trip), невозможность сложных запросов (фильтрация и агрегация в API соседа обычно ограничены).

Локальная копия (read model)

Сервис A держит у себя копию нужных данных из B, обновляемую через события или CDC. Свои запросы делает по своей копии.

Плюсы: производительность, независимость по доступности (B упал — A работает на старой копии). Минусы: согласованность (копия отстаёт), сложность поддержки (если схема в B меняется, копия требует обновления), удвоенное хранилище.

Я применяю этот вариант, когда A часто читает данные из B и любая задержка/сбой влияет на пользователя. Цена — внимательное проектирование событий и контрактов.

Событийная архитектура с фактами

B публикует события (OrderPlaced, CustomerUpdated), любой сервис собирает свою проекцию. Это масштабируемый вариант для системы с множеством потребителей одних и тех же фактов.

Сложнее в реализации: нужны брокер, схема событий, контракт, обработка дубликатов и порядка. Но окупается на системах, где данные одного домена нужны пяти-десяти другим.

Транзакции через несколько баз

Самая болезненная тема. Прямой ответ: не пытайтесь. Распределённая транзакция через несколько БД (XA, two-phase commit) технически возможна, но в современных стеках это либо плохо работает, либо плохо поддерживается, либо сильно бьёт по доступности.

Если бизнес-операция трогает данные нескольких сервисов, есть два пути.

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

Второй — пересмотреть границы сервисов. Если операция действительно атомарная и без неё бизнес ломается — может быть, эти данные должны быть в одном сервисе. Не каждое разделение, которое выглядело логичным, выживает столкновение с реальной бизнес-логикой.

Эксплуатационная стоимость

Часто недооценивают, что отдельные базы — это не только разные строки в коде. Это:

  • Отдельные миграции, версионируемые с релизом каждого сервиса.
  • Отдельные бэкапы и тесты восстановления для каждой БД.
  • Отдельные мониторинги: размер, медленные запросы, replication lag, размер WAL.
  • Отдельные процедуры обновления версии (Postgres 14 → 16 на десяти инстансах).
  • Часто — отдельные пулы соединений и отдельные secrets.

Если у вас два сервиса — это терпимо. Если десять — у вас уже половина рабочего времени DBA уходит на координацию обновлений. Это не аргумент против database per service, это аргумент за то, чтобы не множить базы без явной выгоды.

Аналитика и отчёты

В системах с раздельными базами вопрос «как сделать отчёт по всему» поднимается на третий-четвёртый месяц. Решений по сути два.

Один — отдельное аналитическое хранилище (DWH, lakehouse, ClickHouse), куда попадают данные из всех сервисов. Через CDC, через события, через batch-выгрузку. Все аналитические запросы — там, не в операционных БД. Отчёты не нагружают прод, аналитики не ждут разрешения у каждой команды.

Второй — read-сервис для конкретного отчёта, собирающий данные из нескольких источников по событиям. Это вариант для случаев, когда DWH ещё не построен, а отчёт нужен сейчас.

Что точно не работает — собирать SQL из десяти БД через приложение, агрегируя в памяти. Если вижу такое в проде, это первое, что переписываю.

Чек-лист перед выделением отдельной БД

  • Сервис меняет свою схему достаточно часто, чтобы согласование с соседями стоило боли.
  • Есть хотя бы один сценарий, где независимость по отказам критична.
  • Нет частых отчётов, которые требуют объединения этой БД с другими.
  • Команда готова поддерживать дополнительный инстанс (или хотя бы схему) с миграциями, бэкапами, мониторингом.
  • Понятно, как другие сервисы будут получать нужные данные: API, события, проекция.

Если хотя бы половина пунктов сомнительна — пока оставляйте общую БД и фиксируйте границы на уровне схем и кода. Перейти на отдельные инстансы потом несложно. Свести их обратно — заметно дороже.

Что запомнить

Database per service — это не догма, а инструмент с конкретной ценой. Платите за изоляцию релизами, доступностью, гибкостью схемы. В обмен получаете операционную сложность, проблему распределённых транзакций, необходимость думать о согласованности данных. На зрелых системах с большим количеством команд оно окупается. На маленьких и средних — чаще нет, и спокойная общая БД с разными схемами оказывается лучшим выбором, который не стыдно показать архитектору через два года.

Комментарии 1

  • Дмитрий Орлов

    Из практики добавлю про оконные функции с PARTITION BY — на ClickHouse они работают чуть иначе, и LAG/LEAD в больших партициях могут конкретно тормозить. Если возможно, я их выношу в подзапрос с LIMIT BY. У тебя в посте про lateral join хорошо написано, я её часто применяю для top-N per group, но на Postgres 16 наблюдаю, что планировщик иногда выбирает hash join вместо nested, и приходится подсказывать через CTE с MATERIALIZED.

Войдите, чтобы оставить комментарий.