Предел числа таблиц в базе: когда простое открытие таблицы становится проблемой
«Сколько таблиц можно создать в одной базе?» — вопрос, на который формально нет числа: ни PostgreSQL, ни MySQL не откажут вам на условной десятитысячной таблице кодом ошибки «лимит превышен». Но если у вас паттерн «таблица на клиента» или «таблица на тенанта» и счётчик таблиц растёт вместе с бизнесом, в какой-то момент простое SELECT * FROM information_schema.tables начинает выполняться заметно дольше, бэкап тянется часами, а обычный \dt в psql подвисает. Разберём, где именно проходит эта граница на практике и чем заменить архитектуру, которая в неё упирается.
Содержание
- Почему формального предела на число таблиц не существует
- Системный каталог: где растут метаданные и как это мерить
- MySQL/InnoDB: файл на таблицу и упор в лимиты ОС
- Обслуживание, которое утыкается в число объектов, а не их размер
- Где деградируют инструменты и клиенты
- Паттерн «таблица на тенанта»: откуда берётся и чем заменить
Почему формального предела на число таблиц не существует
У PostgreSQL и MySQL нет параметра вроде max_tables. Таблица — это в первую очередь запись в системном каталоге (в PostgreSQL — строки в pg_class, pg_attribute, pg_index и смежных таблицах; в MySQL — записи во внутреннем data dictionary) плюс файлы на диске для хранения данных и индексов. Формально это ограничено только доступным местом на диске и объёмом самого каталога — а это огромные цифры, недостижимые в обычной практике.
Проблема в том, что «формально не ограничено» не значит «работает одинаково быстро при любом количестве». Системный каталог — это такие же таблицы, как ваши собственные: у них есть свой размер, свои индексы, они так же подвержены раздуванию (bloat) в PostgreSQL, но вы обычно не мониторите каталог отдельно — а зря, если таблиц у вас тысячи.
Второй источник проблем — физический, а не логический: в MySQL с InnoDB и включённым innodb_file_per_table (поведение по умолчанию уже много лет) каждая таблица — отдельный файл .ibd на диске. Тысячи таблиц — тысячи файлов, и дальше в игру вступают лимиты файловой системы и ОС, а не только СУБД.
Третья категория — не про архитектуру СУБД, а про то, что администрирование, рассчитанное на десятки-сотни таблиц, плохо масштабируется на десятки тысяч: обслуживание, бэкапы, инструменты писались с расчётом на определённый порядок величины.
Системный каталог: где растут метаданные и как это мерить
Начните с того, чтобы просто посчитать, сколько у вас таблиц. В PostgreSQL:
SELECT count(*) FROM pg_class WHERE relkind IN ('r', 'p');
-- 'r' — обычные таблицы, 'p' — партиционированные родители
В MySQL:
SELECT table_schema, count(*) AS tables
FROM information_schema.tables
WHERE table_schema NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')
GROUP BY table_schema;
Сама по себе цифра ничего не говорит без контекста, но у неё есть практические последствия. В PostgreSQL каждая таблица добавляет строки сразу в несколько системных каталогов: одну в pg_class, по одной на каждый столбец в pg_attribute, по одной на каждый индекс в pg_index плюс сам индекс тоже становится записью в pg_class. Таблица с десятком столбцов и парой индексов — это уже полтора-два десятка строк метаданных. При десятках тысяч таблиц каталог сам по себе становится объёмной структурой, а не мелочью, которую можно игнорировать.
Это ощущается двояко. Во-первых, планировщику нужно читать метаданные при разборе каждого запроса — кэши каталога (relcache в PostgreSQL, dictionary cache в MySQL) сглаживают это для «горячих» таблиц, но при первом обращении после старта сессии расходы растут вместе с размером каталога. Во-вторых, каталог — обычная таблица, которая тоже участвует в VACUUM и тоже может раздуваться при интенсивном создании и удалении объектов — типичная ситуация для мультитенантных схем.
Проверить размер самого каталога в PostgreSQL можно так:
SELECT relname, pg_size_pretty(pg_total_relation_size(oid)) AS size
FROM pg_class
WHERE relname IN ('pg_class', 'pg_attribute', 'pg_index', 'pg_depend')
ORDER BY pg_total_relation_size(oid) DESC;
Если pg_attribute или pg_depend весят сотни мегабайт на, казалось бы, обычной базе — это верный признак, что число объектов в схеме вышло далеко за типичный масштаб, и каталог стоит держать в поле зрения так же, как обычные таблицы: смотреть на bloat, не забывать, что VACUUM (в том числе автовакуум) применяется и к нему.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверMySQL/InnoDB: файл на таблицу и упор в лимиты ОС
В MySQL проблема числа таблиц гораздо более физическая, чем в PostgreSQL. При innodb_file_per_table=ON каждая таблица — это отдельный .ibd-файл в директории данных:
find /var/lib/mysql/имя_базы -name '*.ibd' | wc -l
Если у вас 20 000 таблиц — это 20 000 файлов в одной директории (или в поддиректориях, если используются схемы/базы для группировки). Это сразу упирается в несколько независимых лимитов:
- Дескрипторы файлов. MySQL держит открытыми файлы активно используемых таблиц. Параметр
innodb_open_files(и общийtable_open_cache) ограничивает, сколько файлов сервер держит открытыми одновременно — при превышении он закрывает и переоткрывает файлы по LRU, добавляя накладные расходы на каждое обращение к «вытесненной» таблице. Если активны сразу несколько тысяч таблиц, стоит явно подниматьinnodb_open_filesи системный лимит дескрипторов процесса черезlimits.confи systemd-юниты — подробно об этом в статье про настройку лимитов открытых файлов. - Inode файловой системы. Каждый
.ibd-файл — ещё и запись метаданных на файловой системе (inode на ext4/xfs). При очень большом числе таблиц (особенно с партиционированием — у каждой партиции свой файл) можно упереться в исчерпание inode при формально свободном месте:df -hпокажет процент занятого пространства, а не занятых inode. Диагностика — в статье про inode и «место есть, а файл не создаётся». - Расход дескрипторов на запрос. Если один запрос затрагивает несколько таблиц (JOIN, подзапросы к разным таблицам-клиентам), суммарный расход дескрипторов растёт нелинейно относительно числа таблиц в схеме. Методика замера — в статье сколько дескрипторов тратит один запрос.
Порядок величины, при котором это становится ощутимо, сильно зависит от железа и конфигурации — конкретную цифру стоит замерять на своей нагрузке, а не брать из чужой статьи.
Обслуживание, которое утыкается в число объектов, а не их размер
Часть операций обслуживания рассчитана линейно на объём данных — но есть и операции, накладные расходы которых растут именно с числом таблиц, независимо от того, насколько они малы.
ANALYZE и автовакуум по всей базе. Автовакуум в PostgreSQL проходится по всем таблицам базы, проверяя для каждой, не пора ли её обработать по порогу мёртвых строк и возрасту транзакций. Сам этот обход — накладные расходы, растущие вместе с числом таблиц, а не с их размером: 30 000 крошечных таблиц по паре клиентов каждая всё равно требуют регулярной проверки состояния каждой из них. Число одновременных воркеров автовакуума (autovacuum_max_workers) ограничено, и при большом количестве объектов, требующих обработки, вакуум начинает отставать — таблицы, которым он реально нужен, ждут очереди дольше. Причины и настройки разобраны в статье про медленный VACUUM в PostgreSQL.
Логические бэкапы. pg_dump и mysqldump обходят объекты схемы последовательно: для каждой таблицы — свой блок метаданных, своя блокировка на старте транзакции (в PostgreSQL — ACCESS SHARE на каждую таблицу при формировании снапшота), свой COPY/INSERT-поток. При десятках тысяч крошечных таблиц заметная часть времени дампа уходит не на выгрузку данных, а на сам перебор объектов. Дамп базы с 50 000 почти пустых таблиц может занимать заметно больше времени, чем дамп той же по объёму базы с полусотней крупных таблиц — просто из-за количества операций на объект.
Миграции схемы. Flyway, Liquibase или встроенные миграторы ORM при старте сверяют состояние схемы в базе с ожидаемым — тоже проход по каталогу. С ростом числа таблиц растёт и время сверки, а в паттерне «таблица на клиента» миграция «шаблона» на практике превращается в цикл из тысяч отдельных ALTER TABLE — с соответствующим временем выполнения и риском, что часть упадёт по причинам, не связанным с самой миграцией (заблокированная клиентом таблица, вручную разошедшаяся схема у конкретного тенанта).
Массовое удаление. Симметричная проблема: при переходе с «таблицы на клиента» на общую схему соблазн удалить все старые таблицы одним циклом создаёт всплеск нагрузки на каталог в один момент времени. Практичнее удалять пачками по несколько сотен с паузами, отслеживая нагрузку.
Где деградируют инструменты и клиенты
Отдельная категория проблем — не в самой СУБД, а в инструментах вокруг неё, которые исторически не рассчитывались на десятки тысяч объектов в одной схеме.
- GUI-клиенты и админки. phpMyAdmin, Adminer, pgAdmin, DBeaver и подобные инструменты по умолчанию строят дерево объектов схемы или выпадающий список таблиц целиком. При нескольких тысячах таблиц дерево схемы становится практически бесполезным: прокрутка, поиск нужной таблицы, автодополнение имён в SQL-редакторе заметно тормозят, а иногда клиент просто зависает на загрузке метаданных при подключении.
- Автодополнение и интроспекция ORM. ORM и
psqlс автодополнением по табу подтягивают список объектов схемы для подсказок. С ростом числа таблиц это заметно замедляет первое подключение или первый запрос после старта приложения — кэш метаданных «прогревается» дольше. - Мониторинг и дашборды. Инструменты, опрашивающие статистику по всем таблицам (размеры, число строк, попадания в кэш), делают это через агрегирующие запросы к
pg_stat_user_tablesилиinformation_schema— и сами эти запросы становятся заметно небыстрыми, когда строк в системных вьюхах десятки тысяч. \d,\dt,SHOW TABLES. Даже базовая операция — посмотреть список таблиц из консоли — начинает занимать заметное время и выдаёт список, по которому безgrepничего не найти.
Ни один из этих пунктов не критичен сам по себе, но вместе они означают, что повседневная работа с базой — посмотреть структуру, найти таблицу, подключиться из нового клиента — становится медленнее с каждой добавленной тысячей таблиц. Такую деградацию не покажет ни один алерт мониторинга, но команда чувствует её на себе каждый день.
Паттерн «таблица на тенанта»: откуда берётся и чем заменить
Чаще всего к упору в число таблиц приводит один и тот же архитектурный выбор в мультитенантных SaaS-системах: у каждого клиента (тенанта) — своя копия таблиц, а иногда и своя схема целиком. Логика понятна: полная изоляция данных клиента на уровне СУБД, простой бэкап и удаление отдельного клиента, отсутствие риска утечки данных между тенантами через забытый WHERE. Проблема в том, что это решение хорошо работает до определённого масштаба, а дальше начинает стоить дороже, чем экономит.
Таблица на тенанта (одна общая база, у каждого клиента — свой набор одноимённых таблиц с префиксом или суффиксом) — самый прямолинейный вариант и самый быстрый способ упереться во всё описанное выше: 1000 клиентов × 20 таблиц в схеме — уже 20 000 объектов, и это без роста самого числа клиентов.
Схема на тенанта (отдельная схема tenant_123 на клиента внутри одной базы PostgreSQL) немного мягче: администрировать чуть удобнее (права на уровне схемы, визуальная группировка), но часть проблем с каталогом никуда не уходит — все схемы всё равно живут в одном pg_class. При тысячах клиентов упор в те же узкие места наступает почти так же, просто чуть позже.
Практическая альтернатива, которая закрывает подавляющее большинство сценариев мультитенантности, — общая таблица с колонкой tenant_id и обязательным условием изоляции на уровне запросов или политик безопасности:
CREATE TABLE orders (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
customer_name text,
amount numeric,
created_at timestamptz DEFAULT now()
);
CREATE INDEX idx_orders_tenant ON orders (tenant_id, created_at);
Изоляцию на уровне СУБД, а не только в коде приложения, в PostgreSQL можно закрыть через Row-Level Security — это снимает главный аргумент за «таблицу на клиента» (риск, что кто-то забудет WHERE tenant_id = ...):
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Приложение перед выполнением запросов устанавливает текущего тенанта на сессию (SET app.current_tenant = '...'), и дальше все запросы к таблице автоматически фильтруются политикой — без ручного добавления условия в каждый SQL-запрос и без риска, что кто-то его пропустит.
Если есть небольшое число по-настоящему крупных тенантов (десятки, а не тысячи) с сильно разным объёмом данных, разумный компромисс — партиционирование по tenant_id: крупные клиенты получают отдельную партицию, мелкие складываются в общую партицию по умолчанию или группируются хэш-партиционированием. Это даёт изоляцию по производительности без взрывного роста числа объектов в каталоге — но у партиционирования тоже есть свой практический предел по числу партиций, и наращивать их до тысяч не стоит по тем же причинам, что описаны выше для обычных таблиц.
Миграция с «таблицы на клиента» на общую таблицу — отдельная по объёму работа: завести целевую таблицу, перелить данные пакетно (INSERT INTO orders (tenant_id, ...) SELECT '<id>', ... FROM tenant_123_orders), сверить количество строк по каждому клиенту, переключить приложение на чтение из общей таблицы и только после этого поэтапно удалять старые таблицы небольшими пачками.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько таблиц — это уже много?
Универсального числа нет: одна база живёт с тысячами таблиц при достаточных ресурсах, другая тормозит раньше — из-за меньшей памяти под кэш каталога или интенсивного создания/удаления таблиц. Ориентируйтесь на симптомы: растущее время pg_dump/mysqldump, отставание автовакуума, ошибки Too many open files, зависающие GUI-клиенты.
В PostgreSQL с этим лучше, чем в MySQL?
По файлам на диске — отчасти да: PostgreSQL не держит все файлы данных открытыми так агрессивно, как MySQL с innodb_file_per_table. Но рост каталога (pg_class, pg_attribute) и нагрузка на автовакуум — проблема, специфичная для PostgreSQL и не имеющая прямого аналога в MySQL.
Можно ли просто поднять лимиты и жить с таблицей на клиента дальше?
Частично — поднять ulimit, innodb_open_files, table_open_cache. Это отодвигает границу, но не убирает её: каталог всё равно растёт, а обслуживание и инструменты всё равно деградируют с числом объектов. Это временная мера, а не архитектурное решение.
Не потеряю ли я изоляцию данных клиентов при переходе на общую таблицу?
Нет, если изоляция обеспечена на уровне СУБД — Row-Level Security в PostgreSQL или обязательная проверка tenant_id в единой точке доступа к данным дают сравнимую защиту от утечки, не создавая тысяч отдельных объектов схемы.
С чего начать, если у меня уже тысячи таблиц-клиентов?
Сначала измерьте воздействие: время дампа, отставание автовакуума, реальный расход дескрипторов под нагрузкой. Если симптомов пока нет — хотя бы прекратите создавать новые таблицы по этому паттерну для новых клиентов и спланируйте миграцию существующих.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →