MAATRIX / Блог / Чужая база без схемы и документации: как понять, что в ней ещё живое

Чужая база без схемы и документации: как понять, что в ней ещё живое

MAATRIX

Открываете SHOW TABLES или \dt на унаследованной базе и видите двести с лишним имён вроде tmp_export_2, usr_data_old, orders_v2_bak, queue_legacy — и ни единой ER-диаграммы, ни README, ни человека, который помнит, зачем это всё. Часть таблиц наверняка держит на себе продакшен прямо сейчас, часть не трогали три года, а часть вообще создавалась для миграции, которую забыли завершить. Удалить не глядя страшно — а разбираться на глаз можно месяцами. Ниже — методичный способ понять, что в базе живое, без гадания и без документации, которой никогда не было.

Что говорит сама структура таблицы

Прежде чем лезть в логи запросов, стоит выжать максимум из того, что уже лежит в базе — из системного каталога information_schema, который есть и в PostgreSQL, и в MySQL. Это не заменит документацию, но восстанавливает значительную часть контекста без единого обращения к разработчикам.

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

-- PostgreSQL
SELECT
  relname AS table_name,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
  n_live_tup AS live_rows
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;
-- MySQL
SELECT table_name, table_rows,
       ROUND((data_length + index_length) / 1024 / 1024, 1) AS size_mb
FROM information_schema.tables
WHERE table_schema = 'ваша_база'
ORDER BY (data_length + index_length) DESC;

Таблица на 40 МБ с миллионом строк и таблица на 16 КБ с тремя строками — это очевидно разные истории: вторая почти наверняка либо служебная (справочник, конфиг), либо давно заброшенная заготовка. Пустые таблицы (n_live_tup = 0 или table_rows = 0) — отдельная категория: если в них никогда не было данных, вероятно, это результат недокатившейся миграции или экспериментальной фичи, которую так и не включили.

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

SELECT table_name, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'ваша_база'
ORDER BY table_name, ordinal_position;

Обращайте внимание на несколько устойчивых паттернов:

  • ***_id без физического внешнего ключа** — почти всегда ссылка на другую таблицу, просто оформленная только на уровне приложения (ORM без миграции constraint'а, или FK когда-то сняли ради скорости массовой загрузки и не вернули).
  • created_at / updated_at / deleted_at — если такие колонки есть, они станут вашим главным инструментом на следующем шаге: возраст последней записи и последнего изменения расскажет о жизни таблицы больше, чем что угодно.
  • status, state, type с ограниченным набором значений — загляните в реальные данные (SELECT DISTINCT status, COUNT(*) FROM ... GROUP BY status), это часто восстанавливает бизнес-логику лучше любого комментария в коде.
  • Суффиксы _old, _bak, _v2, _tmp, _deprecated в именах самих таблиц — не воспринимайте их как окончательный вердикт (иногда _v2 как раз и есть текущая рабочая версия, а без суффикса — брошенная), но это подсказка, откуда начинать проверку в первую очередь.

Если таблиц очень много, есть смысл сгенерировать визуальную схему автоматическим инструментом (например, SchemaSpy) — не потому что диаграмма сама всё объяснит, а потому что зрительно проще увидеть кластеры связанных таблиц, чем прокручивать список из двухсот строк.

Внешние ключи и связи, которых нет в схеме

Формальные внешние ключи — самый надёжный источник карты связей, если они вообще объявлены:

-- PostgreSQL: все FK-связи в базе одним запросом
SELECT
  tc.table_name AS from_table,
  kcu.column_name AS from_column,
  ccu.table_name AS to_table,
  ccu.column_name AS to_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
  ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY';
-- MySQL: то же самое через REFERENTIAL_CONSTRAINTS
SELECT table_name, column_name, referenced_table_name, referenced_column_name
FROM information_schema.key_column_usage
WHERE referenced_table_name IS NOT NULL
  AND table_schema = 'ваша_база';

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

  1. Составьте список всех колонок вида *_id по всем таблицам (запрос из предыдущего раздела с фильтром column_name LIKE '%\_id').
  2. Для каждой такой колонки поищите таблицу, где id с похожим по смыслу именем — customer_id почти наверняка ссылается на customers.id или users.id, даже если constraint'а нет.
  3. Проверьте гипотезу на данных: SELECT COUNT(*) FROM orders o LEFT JOIN customers c ON o.customer_id = c.id WHERE c.id IS NULL. Если сирот много — либо связь неверна, либо (что тоже важный сигнал) в таблице реально накопились битые ссылки на удалённых клиентов, и это само по себе диагноз для дальнейшей архивации.

Отдельно стоит смотреть на таблицы-связки (many-to-many) вроде user_roles или post_tags — обычно это просто table1_id + table2_id без содержательных полей. Такие таблицы легко восстановить по названию, но именно они чаще всего оказываются мёртвыми первыми: связь могла относиться к фиче, которую убрали из интерфейса, а таблицу — нет.

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

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

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

Кто на самом деле обращается к таблице: slow query log и general log

Структура базы говорит, для чего таблица *может* использоваться. Ответ на вопрос, используется ли она *реально*, даёт только одно — журнал запросов, которые приложение фактически выполняет. Это самый надёжный источник во всём разборе, потому что он не зависит от догадок и комментариев — он показывает факт обращения.

В PostgreSQL проще всего включить расширение pg_stat_statements, если оно ещё не подключено:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

Оно накапливает статистику по всем выполненным запросам с момента подключения (или последнего pg_stat_statements_reset()), включая текст запроса и число вызовов. Для точной картины «кто и когда» полезнее на пару дней включить полное логирование в postgresql.conf:

log_min_duration_statement = 0   # логировать вообще все запросы (временно!)
log_statement = 'all'

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

SELECT relname AS table_name,
       seq_scan, idx_scan,
       n_tup_ins, n_tup_upd, n_tup_del,
       last_vacuum, last_autovacuum
FROM pg_stat_user_tables
ORDER BY (seq_scan + idx_scan) ASC;

seq_scan + idx_scan, близкое к нулю с момента последнего сброса статистики (pg_stat_reset() или перезапуска сервера) — это прямое свидетельство, что к таблице никто не обращался на чтение за весь период наблюдения. Учтите нюанс: если сервер перезапускали недавно, эти счётчики уже обнулены, и низкое значение может означать не «таблица не используется», а «сервер работает без перезапуска всего неделю» — сверяйтесь с pg_postmaster_start_time().

В MySQL аналог — slow query log и general log:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0;   -- временно, как и в Postgres — логирует всё
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-full.log';

Общий лог (general_log) фиксирует вообще все запросы независимо от длительности — включайте его так же осторожно и ненадолго. Разобрать накопленный лог по именам таблиц удобнее не руками, а инструментом вроде pt-query-digest (Percona Toolkit) для MySQL или pgBadger для PostgreSQL — оба группируют запросы и строят отчёт с разбивкой по таблицам, что незаменимо, когда логировали несколько дней и текст уже не осилить вручную:

pt-query-digest /var/log/mysql/slow-full.log > digest-report.txt

Важная оговорка про фоновые задачи. Не полагайтесь на разовое суточное наблюдение, если в системе есть отчёты, которые запускаются раз в неделю или раз в месяц (закрытие периода, годовая сверка, ежеквартальная выгрузка). Прежде чем считать таблицу мёртвой, проверьте расписание задач на предмет более редкой периодичности и по возможности наблюдайте не меньше одного полного бизнес-цикла — обычно это месяц. Если логирование в базе вообще не настроено, а решение нужно быстрее, чем накопится наблюдение, параллельно прогоните grep по репозиторию приложения на все имена таблиц — то, чего нет ни в одном файле кода (моделях, миграциях, сериализаторах), уже сильный кандидат на архивацию ещё до анализа логов. Исключение — динамически собранные имена таблиц (f"orders_{year}", шардирование по префиксу), которые простым grep не найти.

Отличаем мёртвую таблицу от редко используемой

Один сигнал редко даёт полную уверенность — нужна связка из нескольких признаков. Вот на что стоит смотреть в совокупности:

  • Отсутствие обращений в логах или в pg_stat_user_tables/performance_schema за весь период наблюдения — прямой, но не единственный сигнал (см. оговорку выше про периодичность).
  • Дата последней записи по created_at/updated_at. Если максимальный created_at в таблице — год-два назад, а не «вчера», это сильный аргумент даже без анализа логов. Запрос простой: SELECT MAX(created_at) FROM table_name.
  • Отсутствие новых строк при растущем id (SERIAL/AUTO_INCREMENT) в других таблицах. Если во всей базе счётчики растут, а конкретно в этой таблице id застыл на месте несколько месяцев — это она стоит, а не вся база простаивает.
  • Внешние ключи, которые на неё ссылаются, тоже не растут. Если ни одна активная таблица не создаёт новых строк, ссылающихся на эту — она изолирована от текущей бизнес-логики, даже если формально связана constraint'ом.
  • Наличие в коде приложения. Здесь логи БД не помогут — нужен grep по репозиторию (grep -rn "имя_таблицы" src/), потому что таблица может быть мёртвой не потому, что её забыли, а потому что соответствующий код давно удалили, оставив только данные.

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

Если после всех проверок остаётся сомнение — это нормально, и решать его нужно не техническим гаданием, а выяснением: короткое сообщение в компании («кто-нибудь помнит, что такое legacy_promo_codes?») часто закрывает вопрос быстрее, чем ещё неделя анализа логов. Здесь же полезно свериться с общей методологией разбора незнакомой инфраструктуры — она применима не только к базе: с чего начинать разбор чужого сервера без документации.

Безопасная архивация вместо немедленного удаления

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

Шаг 1. Переименовать, не удаляя.

ALTER TABLE legacy_promo_codes RENAME TO legacy_promo_codes_archived_20260828;

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

Шаг 2. Отозвать права на запись, оставив чтение.

REVOKE INSERT, UPDATE, DELETE ON legacy_promo_codes_archived_20260828 FROM app_user;

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

Шаг 3. Выгрузить в холодное хранилище и держать план восстановления.

Параллельно с переименованием сделайте отдельный дамп именно этой таблицы — не полагайтесь только на общий бэкап всей базы, из которого доставать одну таблицу спустя полгода неудобно и долго:

# PostgreSQL — дамп одной таблицы
pg_dump -t legacy_promo_codes_archived_20260828 mydb | gzip > legacy_promo_codes_20260828.sql.gz

# MySQL — аналогично
mysqldump mydb legacy_promo_codes_archived_20260828 | gzip > legacy_promo_codes_20260828.sql.gz

Такой дамп можно унести в холодное хранилище — объектное хранилище с архивным тарифом или отдельный диск, где хранение стоит заметно дешевле, чем на боевом NVMe рядом с рабочей базой; сравнение вариантов холодного хранения разобрано отдельно: что выбрать для холодного хранения архивов.

Шаг 4. Установить срок и только потом DROP.

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

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

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

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

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

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

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

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

С чего начать, если база огромная и времени объективно мало?

С размера и с pg_stat_user_tables/information_schema.tables, отсортированных по объёму — крупные таблицы всегда стоит понять первыми, просто потому что они дороже всего обходятся в бэкапах, дисках и памяти, даже если решение по ним окажется «оставить как есть».

Можно ли доверять только структуре базы без анализа логов?

Нет — структура подскажет, для чего таблица могла создаваться, но не подтвердит, используется ли она сейчас. Таблица с говорящим именем active_sessions вполне может быть мёртвой, если фичу давно заменили на что-то другое, просто не убрав таблицу.

Сколько по времени наблюдать за логами, прежде чем делать выводы?

Минимум несколько дней, а лучше — один полный бизнес-цикл (обычно месяц), если в системе есть периодические отчёты или пакетные задачи с редкой частотой.

Что делать с таблицами без единого текстового следа — ни в коде, ни в логах, ни в комментариях?

Это не повод удалять их немедленно, а повод спросить у команды напрямую. Если ответа нет и данные некритичны по объёму — переименование и выгрузка в холодное хранилище с длинным сроком хранения безопаснее прямого DROP.

Стоит ли включать логирование всех запросов на постоянной основе?

Не рекомендуется на нагруженной боевой базе — это заметная дополнительная нагрузка на диск и I/O. Используйте полный лог точечно, на несколько дней ради разведки, а для постоянного мониторинга переключитесь на встроенные счётчики вроде pg_stat_user_tables, которые не пишут построчный лог вообще.

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

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

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