Таблицы базы данных: чек-лист подготовки схемы перед запуском
Схема реляционной БД — это не код, который можно без последствий переписать под нагрузкой. Это контракт между приложением и хранилищем, рассчитанный на годы эксплуатации.

Ошибки проектирования, заложенные на старте, проявляются не сразу: сначала появляются дублирующиеся сущности и сложные запросы, затем — расхождения между сервисами, долгие миграции и операции, которые уже нельзя безопасно выполнить в рабочее время.
Таблицы базы данных должны быть понятны не только автору первой версии проекта. Через несколько месяцев с ними будут работать другие разработчики, аналитики, инженеры сопровождения и автоматические инструменты миграций. Поэтому подготовка схемы — это не проверка отдельных колонок, а ревизия всей модели: ключей, связей, именования, индексов, ограничений и способа внесения изменений.
Архитектурная чистота: нормализация и целостность данных
Базовая единица реляционной модели — таблица. Для большинства прикладных сущностей у неё есть несколько опорных элементов: первичный ключ, внешние ключи при наличии связей и структура, приведённая к подходящей нормальной форме.
Первичный ключ как точка идентификации
Первичный ключ (Primary Key) однозначно идентифицирует строку. Без него нельзя надёжно задать уникальность сущностей и сделать таблицу предсказуемой точкой ссылки для других таблиц. Внешние ключи технически могут ссылаться не только на первичный ключ, но и на подходящее UNIQUE-ограничение — это зависит от правил конкретной СУБД. На практике именно PK чаще всего используется ORM и инструментами миграций по умолчанию.
JOIN, UPDATE и DELETE могут выполняться и для таблицы без первичного ключа. Проблема возникает на уровне гарантий: если в таблице есть полные дубли, массовое обновление становится менее безопасным, а отдельную строку трудно адресовать без дополнительного условия. Не всякая таблица без PK автоматически сломана — временная staging-таблица или поток сырых событий может быть устроен иначе, — но для основных бизнес-сущностей отсутствие ключа должно быть обосновано.
В качестве PK обычно используют синтетический ключ: целочисленный идентификатор или UUID. Натуральный ключ вроде email, ИНН или комбинации серии и номера документа уместен, когда его значение действительно стабильно, уникально и не меняет смысл за пределами конкретной предметной области. Даже тогда часто сохраняют отдельный технический идентификатор, а натуральное значение фиксируют уникальным ограничением.
Внешние ключи и границы ответственности
Связи между сущностями лучше фиксировать на уровне схемы, а не оставлять исключительно в коде приложения. Если приложение само следит за ссылочной целостностью, это правило придётся одинаково реализовать во всех сервисах, фоновых заданиях, скриптах импорта и административных инструментах. Рано или поздно один из путей записи обойдёт проверку.
Внешний ключ в DDL позволяет СУБД отклонить запись, которая ссылается на несуществующую сущность, или не дать удалить родительскую строку, пока дочерние записи ещё зависят от неё. Поведение при удалении нужно выбирать явно: RESTRICT, CASCADE, обнуление ссылки или отдельная процедура архивирования решают разные задачи. ON DELETE CASCADE удобен для технических дочерних сущностей, но опасен там, где удаление одной строки может затронуть большой объём исторических данных.
Отказ от FK бывает оправдан в отдельных сценариях: при загрузке сырых данных перед очисткой, в высокопроизводительных логах, во временных таблицах или в системах, где целостность контролируется другим специализированным контуром. Но для обычных бизнес-сущностей отказ должен быть архитектурным решением с понятной компенсацией, а не способом обойти ошибку миграции.
Нормализация как безопасная отправная точка
Для большинства транзакционных схем разумно начинать с третьей нормальной формы (3NF). Это не универсальный закон и не требование доводить любую модель до академической чистоты. 3NF — рабочая отправная точка, которая помогает отделить сущности друг от друга и уменьшить число аномалий при вставке, обновлении и удалении.
В упрощённом виде схема в 3NF:
- не содержит повторяющихся групп атрибутов;
- хранит каждый факт в подходящей таблице, а не копирует его по множеству строк;
- связывает неключевые атрибуты со всем ключом, а не с его частью;
- не заставляет один неключевой атрибут зависеть от другого неключевого атрибута.
Например, если у заказа хранится название категории товара, хотя категория уже определяется через product_id, изменение названия категории потребует обновлять несколько мест. При пропущенной строке или частично выполненной операции данные начнут расходиться. Нормализованная модель хранит категорию отдельно, а запрос получает её через связь.
Распространённый аргумент против нормализации — якобы неизбежное замедление запросов. На практике само разделение данных на связанные таблицы не определяет производительность. На неё влияют условия соединения, объём данных, распределение значений, индексы, статистика и выбранный план выполнения. Для одного запроса оптимальным может оказаться Nested Loop, для другого — Hash Join или Merge Join. Поэтому нельзя заранее утверждать, что нормализованная схема обязательно будет медленной или что любой JOIN требует одинакового набора индексов.
3NF — стартовая точка, а не финиш. Денормализация оправдана, когда её необходимость подтверждают профиль нагрузки и измерения, а не тревога перед JOIN.Связи нужно проверять не только на уровне структуры, но и на уровне жизненного цикла данных. Если заказ нельзя удалить из-за истории платежей, это должно быть отражено в правилах приложения и миграциях. Если вместо физического удаления используется deleted_at, то индексы, уникальные ограничения и запросы должны учитывать статус записи. Иначе soft delete превратится в набор разрозненных исключений.
Стандартизация именования как основа поддержки проекта
Соглашение об именовании — это встроенная документация схемы. В проекте, где одна таблица называется users, поле — UserID, а соседняя таблица — OrderList, разработчик тратит время не на предметную область, а на расшифровку локальных правил. Непоследовательные имена также усложняют ORM-маппинг, генерацию миграций и ручную работу с запросами.
Рабочий минимум можно зафиксировать до создания первой бизнес-таблицы:
- Таблицы и столбцы — в
snake_case. Строчные буквы и подчёркивания уменьшают зависимость от правил цитирования идентификаторов в разных СУБД. Кириллица, пробелы и смешениеCamelCaseсsnake_caseпочти всегда усложняют переносимость. - Единственное или множественное число — по одному правилу. Варианты
userиusersоба встречаются в реальных проектах. Проблема не в самом выборе, а в его непоследовательности. Если таблицы именуются в единственном числе, это правило должно применяться ко всем сущностям и быть согласовано с ORM. - Первичный ключ —
id, если в проекте нет другой принятой конвенции. Контекст таблицы уже сообщает, что именно идентифицирует поле. Внешний ключ в другой таблице тогда выглядит какuser_id,order_idилиproduct_id. - Внешний ключ — имя сущности плюс
_id. Такое имя сразу показывает направление связи. Для нескольких связей с одной таблицей нужны уточнения: например,created_by_user_idиapproved_by_user_id. - Булевы поля — в предикативной форме.
is_active,has_access,is_deletedдают понять, что значение логическое. Для поляactiveэто не всегда очевидно: оно может оказаться строковым статусом, числом или флагом с нестандартной семантикой. - Даты и время получают понятные суффиксы. Для временной точки используют
_at:created_at,updated_at; для календарной даты —_date:birth_date,payment_date. Если колонка хранит интервал или часовую зону, это лучше зафиксировать в документации и типе данных. - Служебные таблицы маркируются по назначению. Префиксы
audit_,tmp_и суффиксы*_log,*_archiveпомогают отличать аудит, временные данные и архивы от основных сущностей. Но одних имён недостаточно: для таких таблиц также нужны собственные правила хранения, очистки и резервного копирования.
Зарезервированные слова и статусы
Имена вроде user, order, group, select и table могут конфликтовать с ключевыми словами SQL или особенностями конкретной СУБД. Иногда конфликт можно снять экранированием, но постоянное использование кавычек повышает вероятность ошибок в ручных запросах и усложняет перенос схемы. Обычно лучше выбрать понятную альтернативу: account, purchase, app_user.
Состояния сущностей стоит ограничивать на уровне схемы. Строковое поле без CHECK, справочника или перечисления со временем заполняется вариантами вроде pending, Pending, PENDING, pending_approval и случайными опечатками. Выбор между ENUM, справочной таблицей и CHECK зависит от того, как часто меняется набор состояний и требуется ли хранить их дополнительные свойства.
Справочник удобнее, если у статусов есть порядок, локализованное название, разрешённые переходы или настройки. CHECK подходит для небольшого стабильного набора значений. ENUM может быть удобен в СУБД и стеке, где изменение перечисления проходит безопасно через миграции. Важен не сам механизм, а наличие единого источника допустимых значений.
Стратегия индексации: баланс между скоростью чтения и записи
Индекс ускоряет поиск не бесплатно. Он занимает место, требует обновления при изменении затронутых строк и может увеличивать стоимость INSERT, UPDATE и DELETE. Набор индексов, созданный «на всякий случай», иногда ускоряет один редкий запрос, но постоянно добавляет нагрузку на запись и обслуживание.
Индексацию лучше связывать с реальными паттернами доступа:
1. Фильтрация в WHERE. Колонка, по которой регулярно ищут записи, может быть хорошим кандидатом, особенно если условие достаточно селективно. Однако универсального порога вроде «возвращается меньше 5–10% строк» нет: решение зависит от размера таблицы, стоимости чтения, распределения значений и статистики оптимизатора. Индекс по полю с двумя значениями иногда полезен для частого запроса по редкому значению, а иногда полный скан действительно дешевле.
2. Соединение в JOIN. Индекс на внешнем ключе часто помогает находить дочерние строки после выбора родительской записи. Но нельзя утверждать, что каждая колонка соединения всегда должна быть проиндексирована с обеих сторон. Первичный ключ или уникальный индекс на одной стороне связи обычно уже существует, а оптимизатор может выбрать план без дополнительного индекса — например, хеш-соединение или последовательное чтение. Нужный индекс проверяют по фактическим запросам и планам.
3. Сортировка в ORDER BY. Индекс может избавить СУБД от отдельной сортировки, если порядок столбцов и условия запроса позволяют использовать его напрямую. Особенно полезны составные индексы, которые одновременно отбирают строки и возвращают их в нужном порядке.
4. Уникальность. Уникальный индекс решает не только задачу производительности, но и задачу целостности: не позволяет создать две активные записи с одним email, внешний номер документа или другой бизнес-идентификатор.
| Тип индекса | Когда применяют | Что учитывать |
|---|---|---|
| B-Tree | Равенство, диапазоны, сортировка, поиск по префиксу строки | Универсальный вариант по умолчанию, но не замена полнотекстовому поиску |
| Hash | Точное равенство | Возможности и ограничения зависят от СУБД; диапазоны и сортировка не поддерживаются как основной сценарий |
| Partial, или условный | Подмножество строк, например только активные записи | Уменьшает размер индекса, но условие запроса должно позволять его использовать |
| Составной | Фильтрация и сортировка по нескольким колонкам | Порядок столбцов критичен; индекс не универсален для всех перестановок условий |
| BRIN | Очень большие таблицы с естественной корреляцией данных и физического порядка | Компактен, но малоэффективен при случайном распределении значений |
Составные индексы и планы выполнения
Индекс по (user_id, created_at) обычно подходит для запроса, который выбирает записи конкретного пользователя и сортирует их по времени. Но это не означает, что тот же индекс будет столь же полезен для запроса, который фильтрует только по created_at. Правило левого префикса — полезная ориентировочная модель, однако окончательное решение принимает оптимизатор с учётом статистики и стоимости операций.
Проверять индекс нужно на запросах, похожих на рабочие. EXPLAIN ANALYZE в PostgreSQL, EXPLAIN в MySQL и EXPLAIN QUERY PLAN в SQLite показывают, какой план выбран для конкретной СУБД и конкретного набора данных. Тестовая таблица из ста строк мало что говорит о поведении на большой базе: при изменении объёма, распределения значений и актуальности статистики оптимизатор может выбрать другой план.
Следует смотреть не только на наличие индекса в плане, но и на фактическое число прочитанных строк, время ожидания, количество обращений к диску, сортировки и оценку кардинальности. Если СУБД ожидала несколько строк, а получила миллионы, проблема может быть не в отсутствии индекса, а в устаревшей статистике или неудачной форме запроса.
Индекс без обращений — это расход на обслуживание каждого изменения данных. Аудит неиспользуемых и перекрывающихся индексов должен быть частью эксплуатации схемы.
Избыточность тоже нужно проверять. Индексы по user_id и (user_id, created_at) действительно могут пересекаться, но удалять первый автоматически нельзя: у них могут различаться размер, стоимость обновления, покрывающие столбцы или планы для конкретных запросов. Решение принимают после анализа рабочих планов и статистики использования. В PostgreSQL для этого применяют представления статистики индексов, в MySQL — соответствующие инструменты и представления конкретной версии. Важно учитывать период наблюдения: индекс, который не использовался за короткое окно, может быть нужен для ежемесячной операции.
Версионирование схемы: управление изменениями через миграции
Схема меняется вместе с приложением. Если разработчик добавил колонку вручную через psql или GUI-клиент, а изменение не попало в репозиторий, среда начинает жить по собственным правилам. Позже staging и production расходятся, а восстановление становится расследованием: нужно выяснять, кто, когда и зачем изменил таблицу.
Миграции как часть поставки
Файлы миграций стоит хранить в Git рядом с кодом приложения или в репозитории, который проходит тот же процесс ревью и доставки. В коммите должна быть понятна не только структура новой колонки, но и причина изменения, влияние на существующие данные и порядок включения новой логики.
Инструмент миграций — Liquibase, Flyway, Alembic, Prisma Migrate, Rails Migrations или другой компонент стека — фиксирует применённые версии и помогает воспроизвести схему. Конкретная модель зависит от инструмента: одни используют последовательные номера, другие — хеши или временные метки. Существенно, чтобы команда могла определить текущую версию, увидеть пропуск и сопоставить изменение с кодом.
Требования к миграциям также не стоит формулировать слишком механически:
- Идемпотентность полезна, но не означает, что любой файл нужно запускать повторно. Миграция должна иметь предсказуемое поведение при сбое и повторном запуске инструмента. В некоторых системах каждая версия выполняется один раз и отмечается в служебной таблице.
- Откат должен быть продуман заранее. Для простого добавления индекса или колонки обратная операция обычно очевидна. Для удаления данных, объединения значений и изменения типа полноценный
downможет быть невозможен без потери информации. В таком случае безопаснее сделать отдельную резервную копию, двухфазное изменение или явно зафиксировать необратимость. - Применение должно быть автоматизировано. Ручной SQL иногда нужен для аварийной операции, но он не должен быть обычным способом изменения production-схемы. Иначе drift между средами станет практически неизбежным.
- Миграции должны учитывать объём таблицы. Добавление простой nullable-колонки и построение уникального индекса на большой таблице — операции разного риска. Для некоторых СУБД важны блокировки, время построения и возможность создавать индекс без длительной остановки записи.
- Изменение схемы должно быть наблюдаемым. В логах доставки фиксируют версию, время выполнения, длительность и ошибку. Без этого долгую миграцию легко принять за зависший деплой.
Совместимость старой и новой версии приложения
Самые опасные изменения — не обязательно самые большие. Удаление колонки, переименование поля или ужесточение ограничения могут сломать ещё работающую версию приложения. Поэтому часто применяют расширение и последующее сужение схемы:
1. сначала добавляют новую структуру, не ломая старый код;
2. выпускают приложение, которое умеет писать в новый формат или читать оба варианта;
3. переносят данные и проверяют результат;
4. переключают чтение на новую колонку;
5. после подтверждения удаляют старую структуру отдельным изменением.
Это не означает, что для любого переименования нужны ровно три коммита или три миграции. Число этапов зависит от СУБД, размера таблицы, способа развёртывания, наличия нескольких версий приложения и требований к обратной совместимости. В небольшом приложении изменение может пройти одной атомарной миграцией. В системе с несколькими инстансами и rolling deploy безопаснее разделить его на несколько совместимых шагов.
Переименование name в full_name — хороший пример. Вариант «добавить новую колонку, перенести данные, начать читать и писать её, затем удалить старую» часто снижает риск простоя. Но конкретная реализация может включать фоновую миграцию, двойную запись, триггер, временное представление или иной механизм. Количество коммитов и миграций определяется процессом, а не самим фактом переименования.
Версия схемы — часть CI/CD, а не локальная договорённость команды. Чем ближе изменение к данным, тем важнее воспроизводимость и возможность понять, на каком шаге возникла несовместимость.
Удаление колонки также требует проверки всех потребителей: приложения, отчёты, ETL-задачи, ad hoc-скрипты и внешние интеграции могут обращаться к ней напрямую. Внутренний поиск по репозиторию недостаточен, если к базе подключаются сторонние инструменты.
Когда оправдана денормализация: нагрузочное тестирование и реальные кейсы
Денормализация — контролируемое исключение из нормализованной модели. Она оправдана, когда измерения показывают проблему конкретного запроса или контура чтения, а более простые способы оптимизации уже проверены.
Сначала стоит проверить форму запроса, индексы, статистику, ограничения выборки и план соединения. Иногда запрос, который считают «слишком сложным из-за нормализации», ускоряется после исправления условия, устранения лишнего JOIN или добавления составного индекса. Если же узкое место сохраняется, денормализация становится одним из вариантов, а не автоматическим решением.
Счётчики и агрегаты
Запрос вида:
COUNT(*) FROM orders WHERE user_id =?
не обязательно при каждом вызове сканирует всю таблицу. СУБД может использовать индекс, выполнить index-only scan или выбрать другой план. Но при большом числе заказов и высокой частоте обращений даже чтение большого диапазона индекса становится заметной стоимостью. Поэтому агрегированный orders_count в таблице пользователей иногда оправдан.
Такой счётчик переводит чтение в более дешёвую операцию, но добавляет сложность записи. Нужно определить, когда увеличивать значение, как обрабатывать отмену заказа, повторную доставку события, откат транзакции и параллельные обновления. Наивная последовательность «прочитать счётчик, прибавить единицу, записать обратно» теряет изменения при конкурирующих операциях. Нужны атомарный UPDATE, блокировка, транзакция или другой механизм, соответствующий модели согласованности.
Для критичных значений полезна периодическая сверка счётчика с исходными данными. Если агрегат можно восстановить, ошибка остаётся операционной проблемой. Если он становится единственным источником истины, цена рассинхронизации резко возрастает.
Материализованные представления
Отчётный запрос, который соединяет несколько больших таблиц и пересчитывает агрегаты при каждом открытии страницы, может быть кандидатом на материализованное представление. В PostgreSQL его можно обновлять через REFRESH MATERIALIZED VIEW; в других СУБД используются собственные механизмы.
Выигрыш появляется за счёт переноса работы с чтения на обновление. Но данные в представлении могут быть не самыми свежими, а само обновление потребляет ресурсы и иногда требует отдельной стратегии блокировок. Поэтому нужно заранее определить допустимую задержку, расписание обновления, поведение при сбое и способ проверить полноту результата.
Документы для специальных сценариев чтения
JSONB или другой документный столбец может быть удобен, если приложение почти всегда получает связанный набор данных целиком, структура меняется независимо от основной схемы, а фильтрация по вложенным атрибутам не является центральным сценарием. Это не бесплатная замена нормализованным таблицам: ограничения целостности становятся слабее, частичные обновления сложнее, а индексы по вложенным ключам также увеличивают стоимость записи.
Если данные в документе должны участвовать в сортировке, уникальности или сложных фильтрах, их часто лучше оставить отдельными колонками или таблицами. Денормализация оправдана не потому, что JSONB выглядит гибче, а потому что конкретный сценарий чтения действительно выигрывает от такой формы.
Что измерить до решения
Перед изменением модели полезно зафиксировать:
1. фактический запрос и его план выполнения;
2. объём данных, распределение значений и частоту вызовов;
3. время чтения в обычном режиме и под конкурентной нагрузкой;
4. эффект от индексов, переписывания запроса и обновления статистики;
5. допустимую задержку данных после введения агрегата или материализованного слоя;
6. процедуру восстановления, сверки и исправления рассинхронизации.
Нагрузочный тест должен быть похож на реальную эксплуатацию. Синтетические данные из тысячи строк могут показать, что запрос работает, но не объяснят его поведение на большой таблице, при перекосе распределения или одновременной записи. При этом копирование production-данных не всегда допустимо: для теста используют обезличенную выборку или генератор, который воспроизводит нужные свойства данных.
Денормализация без замера — преждевременная оптимизация. Каждый продублированный факт становится ещё одной точкой, где возможен конфликт. Чем больше таких точек, тем важнее автоматические проверки, транзакционные гарантии и понятный владелец процесса синхронизации.
Перед запуском
Перед первым production-развёртыванием схему полезно проверить как набор взаимосвязанных решений:
- у основных бизнес-таблиц есть первичные ключи, а исключения для временных или загрузочных таблиц документированы;
- внешние связи и правила удаления соответствуют жизненному циклу данных;
- нормализованная структура не содержит очевидных повторов, а каждое отклонение от неё связано с измеренной задачей;
- соглашения об именовании одинаковы для таблиц, колонок, ключей, дат, статусов и служебных сущностей;
- ограничения
UNIQUE,CHECK,NOT NULLи внешние ключи находятся на том уровне, где их действительно может гарантировать СУБД; - индексы привязаны к рабочим запросам, а их планы проверены на данных, близких к реальному объёму;
- составные индексы соответствуют порядку фильтрации и сортировки, а перекрывающиеся индексы не удаляются без анализа использования;
- схема хранится в системе контроля версий, а миграции применяются одним воспроизводимым инструментом;
- деструктивные изменения проверены на совместимость со старой версией приложения и внешними потребителями;
- для крупных операций известны блокировки, длительность, план отката и способ продолжить работу после сбоя;
- денормализованные поля имеют владельца, механизм поддержания и процедуру сверки с исходными данными.
Таблицы базы данных — это не просто контейнеры для полей. Они определяют, какие ошибки система сможет остановить сама, какие запросы останутся быстрыми при росте данных и насколько безопасно будет развивать приложение через год. Час ревизии схемы до первого коммита миграции не гарантирует отсутствие проблем, но заметно снижает вероятность того, что архитектурное решение придётся принимать уже во время инцидента.