MAATRIX / Блог / Антипаттерн: одна база данных на все проекты компании

Антипаттерн: одна база данных на все проекты компании

MAATRIX

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

Как компания приходит к общей базе

Обычно это не единовременное архитектурное решение, а результат постепенного накопления. Первый проект поднимает СУБД с запасом ресурсов, который на старте занимает лишь часть мощности сервера. Второй проект логично заводится там же: зачем платить за отдельный сервер, если у первого явно есть свободные ресурсы. К третьему-четвёртому проекту это уже негласное правило: «новые базы поднимаем на общем инстансе, если явно не заявлена высокая нагрузка».

Технически это чаще всего выглядит одним из двух способов:

  • Отдельная база (CREATE DATABASE) на проект внутри одного инстанса СУБД — самый частый вариант для PostgreSQL и MySQL/MariaDB. Логическая изоляция есть, физическая — нет: все базы делят один и тот же процесс postgres/mysqld, одну и ту же память, один и тот же диск.
  • Общая база с разделением по схемам или префиксам таблиц — ещё более плотный вариант, где проекты формально различаются schema.table или префиксом project_a_users, а de facto могут выполнять запросы друг к другу без ограничений на уровне СУБД.

К моменту, когда в компании 8–10 проектов на одном инстансе, картина обычно такая: часть — активный продакшн с платящими клиентами, часть — внутренние инструменты, часть — заброшенные MVP, которые никто не выключил. Все они конкурируют за один и тот же shared_buffers, один и тот же max_connections, один и тот же дисковый том. Именно эта конкуренция создаёт четыре конкретных проблемы, которые не видны на этапе «у нас же есть запас по ресурсам».

Проблема №1: noisy neighbor — чужой запрос кладёт ваш проект

Это самая частая и самая болезненная проблема shared-инстанса. Все базы на одном сервере СУБД используют общий пул CPU, общую память под кеш страниц и shared_buffers, общую пропускную способность диска и общий набор фоновых процессов (WAL writer, autovacuum, checkpointer в PostgreSQL; InnoDB buffer pool в MySQL). Тяжёлая операция в одной базе физически забирает ресурсы у всех остальных, даже если между проектами нет ни одной строчки общего кода.

Типичные источники проблемы:

  • Запрос без индекса, уходящий в full scan на многомиллионной таблице — съедает CPU и дисковый I/O, вытесняет из буферного кеша данные всех остальных проектов.
  • Долгая незакоммиченная транзакция — в PostgreSQL она держит горизонт для autovacuum по всей базе, а не только по своей таблице, из-за чего мёртвые строки не чистятся нигде на инстансе.
  • Утечка соединений в коде одного проекта — постепенно съедает max_connections, общий на весь инстанс. Когда лимит исчерпан, новые подключения не может открыть уже никто.
  • Тяжёлая аналитическая выгрузка, запущенная днём вместо ночи в одном из проектов — забирает диск и CPU у соседей на всё время выполнения.

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

-- PostgreSQL: кто сейчас ест ресурсы и с какой базой связан
SELECT pid, datname, usename, state, wait_event_type,
       now() - query_start AS running_for, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY running_for DESC
LIMIT 20;

-- сколько соединений держит каждая база
SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname ORDER BY 2 DESC;

Ограничить соединения можно на уровне роли — ALTER ROLE app_project_b CONNECTION LIMIT 30; — но это лечит только один из источников проблемы. Общий буферный кеш, диск и CPU так не разделить: пока СУБД — один процесс на всех, полной изоляции не добиться, можно только снизить амплитуду взаимного влияния.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Проблема №2: независимое масштабирование проектов невозможно

У разных проектов компании почти никогда не совпадают требования к ресурсам — это не временное расхождение, а нормальная ситуация. Внутренний CRM с двадцатью сотрудниками нагружает базу минимально и предсказуемо. Интернет-магазин на распродаже даёт всплеск в разы. Аналитический проект раз в сутки гоняет тяжёлые агрегирующие запросы, которым нужен большой work_mem. На одном инстансе все эти профили нагрузки вынуждены жить с одними и теми же настройками СУБД — их нельзя выставить по-разному для разных баз в рамках одного процесса.

Из этого вытекают конкретные ограничения:

Что нужноПочему невозможно на общем инстансе
Больше work_mem для аналитических запросов одного проектаwork_mem — параметр уровня сервера/сессии; поднять его глобально — риск OOM при параллельных запросах у всех остальных проектов
Вертикальное масштабирование под пиковую нагрузку одного проектаАпгрейд CPU/RAM сервера — это ресурсы для всех баз сразу, оплачивать и обслуживать приходится избыточные мощности ради одного проекта
Даунтайм на майорный апгрейд версии СУБД под нужды одного проектаАпгрейд СУБД затрагивает все базы на инстансе одновременно, окно обслуживания приходится согласовывать со всеми командами сразу
Разные версии/расширения PostgreSQL (например, pgvector только одному проекту)Расширения ставятся на уровень инстанса; часть команд получает то, что им не нужно, часть — блокируется чужими зависимостями

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

Проблема №3: один инцидент — простой для всех проектов сразу

Третий риск — каскадный отказ. Инстанс СУБД физически один, а значит, у него одна точка отказа на всю компанию, даже если бизнес-логика проектов никак не связана. Причины могут быть любыми:

  • Диск заполнен — разросшийся WAL из-за незавершённой репликации в одном проекте, забытые дампы от другого, бесконтрольный рост таблицы логов в третьем. Как только df -h показывает 100% на разделе с данными СУБД, писать не может уже никто — падает вся компания разом, а не тот проект, который довёл диск до предела.
  • OOM killer выбирает жертву по эвристике потребления памяти, а не по «чей это был запрос» — под удар обычно попадает сам процесс postgres/mysqld, и рестарт задевает все базы на инстансе одновременно, включая те, что вообще не участвовали в скачке потребления памяти.
  • Повреждение файлов на уровне инстанса (сбой диска, некорректное отключение) — даже если физически повреждена только одна база, часто проще и безопаснее поднимать весь сервер из резервной копии, а значит простаивают все проекты, а не один.
  • Плановое обслуживание — рестарт СУБД под параметр, требующий перезапуска, или минорный апгрейд — приходится согласовывать разом со всеми командами, потому что окно обслуживания общее.

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

# быстрая проверка, что именно съело место перед тем, как база откажет писать
df -h /var/lib/postgresql
du -sh /var/lib/postgresql/*/base/* 2>/dev/null | sort -rh | head -10

Проблема №4: аудит прав доступа между командами теряет смысл

Четвёртая проблема менее заметна на старте, но именно она чаще всего всплывает при первом серьёзном аудите безопасности. На общем инстансе с десятком проектов и разными командами роли и права накапливаются годами и почти никогда не убираются, а не добавляются заново по чёткому шаблону.

Типичная эволюция: сначала для каждого проекта заводят отдельную роль — разумно. Потом подрядчику для отладки одного проекта на скорую руку выдают доступ «ко всей базе, чтобы не разбираться с грантами прямо сейчас» — и это временное решение никто не отменяет. Потом сотрудник переходит из команды проекта A в команду проекта B, и его старая роль в A не отзывается, потому что «вдруг понадобится». Через два-три года на инстансе:

  • часть ролей имеют GRANT ALL ON DATABASE, выданный «для скорости» и не пересмотренный;
  • часть учётных данных принадлежат людям, которые уже не работают над этим проектом или вовсе уволились;
  • никто в моменте не может быстро ответить на вопрос «кто из внешних подрядчиков технически может прочитать таблицу с персональными данными клиентов проекта X».

Формальный аудит на общем инстансе выглядит так:

-- PostgreSQL: какие роли имеют доступ к конкретной базе и с какими правами
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_catalog = 'project_a_db'
ORDER BY grantee;

-- список ролей и их атрибутов на уровне всего инстанса
SELECT rolname, rolsuper, rolcreatedb, rolcanlogin FROM pg_roles ORDER BY rolname;

Проблема не в том, что такой запрос нельзя выполнить — можно. Проблема в том, что при росте числа проектов и команд объём ролей и грантов растёт нелинейно, и поддерживать его в актуальном состоянии вручную становится отдельной постоянной работой, а не разовой настройкой. Это прямое продолжение темы из статьи как раздать доступ команде без выдачи root: чем шире по умолчанию выданные права «для простоты сейчас», тем дороже потом восстановить порядок.

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

Разумный компромисс: изоляция без раздувания инфраструктуры

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

Критерии для решения:

  1. Критичность для дохода и репутации — проект приносит деньги напрямую или на нём завязана публичная репутация компании?
  2. Профиль нагрузки — предсказуемая низкая нагрузка или регулярные всплески/тяжёлые аналитические запросы?
  3. Категория данных — персональные данные клиентов, финансовая информация, коммерческая тайна — или технические данные без регуляторных требований?
  4. Число внешних участников — подрядчики, партнёры, временные команды, которым нужен доступ именно к этому проекту и не нужен ко всем остальным?

По этим критериям на практике складывается три уровня, а не бинарный выбор «всё вместе или всё раздельно»:

УровеньЧто этоКогда достаточно
Отдельная база + отдельная роль на общем инстансеCREATE DATABASE, CREATE ROLE с правами только на неё, REVOKE ALL ... FROM PUBLICВнутренние инструменты, MVP без чувствительных данных, низкая и предсказуемая нагрузка
Отдельный контейнер СУБД с лимитами ресурсов на общем физическом сервереСвой процесс postgres/mysqld в Docker с --cpus и --memory, свой том, но общее железоПроекты с разным профилем нагрузки, где нужна изоляция ресурсов, но не оправдан отдельный сервер
Физически отдельный сервер или VPSПолная изоляция CPU, RAM, диска и сетиПродакшн с прямым доходом, регулируемые персональные данные, нагрузка, реально требующая собственного масштабирования

Минимальный шаг, который снимает большую часть рисков практически бесплатно — даже оставаясь на одном физическом сервере, перестать смешивать проекты в одной базе или схеме и выдавать роли по принципу наименьших привилегий:

CREATE DATABASE project_b_db OWNER project_b_owner;
REVOKE ALL ON DATABASE project_b_db FROM PUBLIC;
GRANT CONNECT ON DATABASE project_b_db TO project_b_owner;
ALTER ROLE project_b_owner CONNECTION LIMIT 40;

Следующий шаг без покупки нового сервера — контейнеризация СУБД по проектам с явными лимитами, чтобы один процесс физически не мог выесть ресурсы у другого:

docker run -d \
  --name pg-project-b \
  --cpus="2.0" \
  --memory="4g" \
  -v pg_project_b_data:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=... \
  postgres:16

Это не убирает общий диск и сетевую карту хоста полностью — граница остаётся мягче, чем у отдельного сервера, подробнее эти рамки разобраны в статье про лимиты CPU и памяти в Docker. Но она снимает главную боль noisy neighbor на уровне CPU и памяти и даёт понятную единицу для аудита: один контейнер — один проект — один набор ролей, вместо расползающихся грантов на общем инстансе. Когда проект начинает упираться в лимит соединений, стоит свериться с материалом сколько соединений к PostgreSQL до деградации — это практический сигнал, что пора выносить базу на отдельный сервер, а не подкручивать max_connections ещё раз.

Логика та же, что и в разборе антипаттерна с одним сервером под все окружения: экономия на инфраструктуре имеет смысл, пока она не стоит дороже часа простоя критичного проекта или утечки данных из-за размытых прав доступа.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

С чего начать, если сейчас у нас реально одна база на все проекты?

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

Отдельная схема в одной базе — это уже достаточная изоляция?

Нет, это самый слабый уровень: схемы всё ещё делят процесс СУБД, память, диск и лимит соединений. Отдельная база с отдельной ролью — минимальный рабочий уровень, контейнер или сервер — следующий шаг по мере роста критичности.

Как понять, что пора выносить проект на отдельный физический сервер?

Когда совпадают минимум два признака: проект приносит доход или содержит регулируемые данные, и его нагрузка либо непредсказуема, либо уже заметно влияет на соседей на общем инстансе.

Контейнеризация СУБД полностью решает проблему noisy neighbor?

Она решает её для CPU и памяти за счёт --cpus/--memory, но не для диска и сети — они у контейнеров на одном хосте общие. Для критичной нагрузки это остаётся промежуточным, а не финальным решением.

Что делать с накопившимися за годы правами доступа?

Провести разовый аудит через pg_roles и information_schema.role_table_grants (или аналоги для вашей СУБД), отозвать доступ у ролей, не привязанных к активным сотрудникам, и перейти на принцип: новая роль получает права только на свою базу.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →