Проектирование схемы таблиц: 10 ошибок новичков и как их исправить
Разберём типовые проблемы моделирования: неверные ключи, денормализация без причины, смешивание сущностей и хранение вычисляемых значений. Дадим чек-лист проектирования.
Содержание
Проектирование схемы таблиц: 10 ошибок новичков и как их исправить
Проектирование схемы таблиц — это не «рисование сущностей ради красивой базы». Это дисциплина: как хранить данные так, чтобы изменения в бизнесе не превращались в вечный ремонт запросов, а целостность данных держалась на ограничениях, а не на дисциплине пользователей.
Новички чаще всего ломают схему не из‑за отсутствия теории, а из‑за типовых привычек: «сделаю так, чтобы работало», «заведу одну таблицу на всё», «вычислю на стороне приложения», «добавлю поле, потому что нужно в отчёте». В итоге схема либо быстро деградирует, либо начинает требовать сложных костылей.
Ниже — 10 самых распространённых ошибок моделирования, что именно в них опасно, и как исправить с примерами (с учётом типичной реляционной базы, где есть PK/FK/UNIQUE/CHECK и нормализация).
1) Неправильные ключи: отсутствие PK или «случайный» идентификатор
Как выглядит ошибка
- В таблице нет первичного ключа.
- PK — это логическое поле, которое может измениться (например,
email,name). - Используется составной ключ без причины, когда есть простой суррогатный.
- «ID генерируется на приложении» и периодически ломается в гонках.
Почему это проблема
PK — это фундамент ссылочной целостности и оптимизаций. Когда ключ нестабилен, вы получаете:
- невозможность корректно ссылаться на запись,
- дубликаты,
- дорогие операции обновления и перепривязки,
- сложные индексы и плохие планы запросов.
Как исправить
- Для ссылок на сущность используйте стабильный идентификатор:
BIGINT/UUID(в зависимости от требований). - Естественные ключи (например,
email) можно делатьUNIQUE, но не использовать как единственный PK. - Для связей используйте FK на PK родителя.
Пример: исправление схемы пользователей
-- Ошибка: email как PK
-- CREATE TABLE users(email TEXT PRIMARY KEY, ...);
-- Исправление:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
full_name TEXT
);
2) Денормализация «на всякий случай»
Как выглядит ошибка
Новички часто добавляют дублирующиеся поля в дочерние таблицы:
- в
ordersхранятcustomer_name, хотя естьcustomers, - в
order_items—product_price, хотя цена берётся изproducts, - в
logs— «удобные» поля вместо нормальных ссылок.
Почему это проблема
Денормализация допустима, но должна быть осознанной:
- Если данные меняются (имя клиента, категория товара), вы получаете несогласованность.
- Если обновления редки, но чтения часты — можно думать о денормализации, но тогда нужен регламент синхронизации.
Как исправить
- Если значение вычисляется из текущего состояния другой сущности — храните ссылку и вычисляйте в запросе.
- Если значение должно отражать исторический факт (например, «сколько стоил товар на момент покупки») — тогда храните значение в момент события, но фиксируйте это явно.
Пример: историческая цена в заказе
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
current_price NUMERIC(12,2) NOT NULL
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0),
price_at_purchase NUMERIC(12,2) NOT NULL
);
Здесь price_at_purchase — действительно факт события, а не «текущее состояние».
3) Смешивание сущностей в одной таблице
Как выглядит ошибка
Одна таблица содержит поля, принадлежащие разным типам сущностей:
transactionsсодержит и чеки, и возвраты, и списания, и штрафы, причём набор полей сильно разный;user_dataхранит и профиль, и настройки, и история — со множествомNULL.
Иногда это выглядит «удобно», но быстро становится невозможным:
- валидация превращается в набор сложных правил,
- запросы начинают содержать
CASE WHEN, - индексы становятся непредсказуемыми.
Почему это проблема
Разные сущности имеют разные инварианты:
- разные обязательные поля,
- разные правила целостности,
- разные связи.
Смешивание ломает эти инварианты — вы вынуждены выражать логику на уровне приложения.
Как исправить
Выносите сущности в отдельные таблицы, а если нужна полиморфность — используйте отдельные таблицы или модель «тип + подтаблица».
Пример: заказы и возвраты Вместо одной «transactions» с множеством необязательных полей:
orders— заказ,refunds— возврат, с FK наorders.
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE refunds (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
refund_amount NUMERIC(12,2) NOT NULL CHECK (refund_amount > 0),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
Если требуется «привязка к нескольким типам событий», можно думать о таблице событий events, но это отдельная тема.
4) Хранение вычисляемых значений как «истины»
Как выглядит ошибка
В БД хранят:
- итоги
total_amount, age(который вычисляется из даты рождения),status(который можно вывести из дат и флагов),full_text_search_vectorбез обновления.
Иногда это делается для скорости, но часто сопровождается тем, что значения забывают пересчитывать.
Почему это проблема
Вычисляемые значения должны быть либо:
- выводимыми (вычисление в запросе),
- либо материализованными с понятным механизмом обновления (триггеры, задания, materialized views).
Если хранить вычисляемое без механизма обновления — появляется рассинхрон, который трудно диагностировать: «вроде всё обновляется, но отчёт показывает другое».
Как исправить
- Если это не «исторический факт» — не храните, вычисляйте.
- Если нужно хранить для производительности — делайте материализацию осознанно и создайте правила обновления.
Пример: не хранить age
CREATE TABLE people (
id BIGSERIAL PRIMARY KEY,
birth_date DATE NOT NULL
);
-- В запросе:
SELECT id,
EXTRACT(YEAR FROM AGE(birth_date)) AS age_years
FROM people;
Пример: материальный итог по событию
Если total_amount — итог конкретного заказа и должен фиксироваться, можно хранить total_amount, но тогда фиксируйте его как результат на момент закрытия заказа, и не используйте его как «текущий» пересчитываемый показатель.
5) Отсутствие ограничений целостности (FK/UNIQUE/CHECK)
Как выглядит ошибка
- Нет внешних ключей (
FOREIGN KEY) или отключена проверка. - Нет
UNIQUEна естественных идентификаторах. - Нет
CHECKдля доменных инвариантов: количество не может быть отрицательным, цена не может быть нулевой и т. п.
Почему это проблема
Без ограничений БД превращается в «хранилище строк». Тогда:
- дубликаты и «битые ссылки» создаются случайно,
- ошибки проявляются в приложении поздно,
- миграции становятся сложнее, потому что данные уже загрязнены.
Как исправить
Добавляйте ограничения:
- PK: определяет запись.
- FK: обеспечивает связность.
- UNIQUE: предотвращает дубликаты.
- CHECK: защищает инварианты.
Пример: ограничения в справочнике статусов и заказах
CREATE TABLE order_statuses (
id SMALLINT PRIMARY KEY,
code TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES users(id),
status_id SMALLINT NOT NULL REFERENCES order_statuses(id),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
ALTER TABLE orders
ADD CONSTRAINT orders_status_created_not_null
CHECK (created_at IS NOT NULL);
Хотя created_at и так NOT NULL — пример показывает подход: ограничения должны быть осмысленными и проверяемыми.
6) Плохая модель связей: один-ко-многим превращают в много-ко-многим (и наоборот)
Как выглядит ошибка
- Встречаются схемы, где
userиroleреализованы как полеrole_idвusers, хотя у пользователя может быть несколько ролей. - Или наоборот: сделали таблицу
user_roles, хотя пользователь имеет ровно одну роль по доменной логике.
Почему это проблема
Ошибка приводит к:
- невозможности выразить бизнес-правила,
- неверным запросам и сложным условиям,
- ошибкам в миграциях (а миграции — самые дорогие изменения).
Как исправить
Определите кардинальности:
- один к одному (1:1),
- один ко многим (1:N),
- многие ко многим (M:N).
Для M:N используйте связующую таблицу с составным PK или UNIQUE на паре FK.
Пример: M:N “пользователи — роли”
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
7) Неаккуратное проектирование NULL: «пусть будет NULL везде»
Как выглядит ошибка
- Поля обязательны по смыслу, но сделаны nullable.
- Поля nullable используются как “флаги” состояния (например,
closed_at= NULL означает открыто, а непустое — закрыто). - Иногда вместо справочника статусов делают кучу nullable полей.
Почему это проблема
NULL в SQL — особое значение: оно означает «неизвестно/отсутствует», а не «логический false». Это ломает:
- простые фильтры,
- уникальные ограничения,
- агрегации,
- простоту чтения кода.
Как исправить
- Если поле действительно обязательно — делайте
NOT NULL. - Если это состояние — лучше хранить явный
status_idилиstate(enum/справочник), либо отдельные поля, но с явными ограничениями.
Пример: статус заказа вместо разрозненных NULL
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES users(id),
status TEXT NOT NULL CHECK (status IN ('new', 'paid', 'shipped', 'closed'))
);
Если модель требует временных маркеров, можно хранить closed_at как nullable, но тогда status должен быть явным и согласованным с closed_at через CHECK/триггеры (в зависимости от возможностей СУБД).
8) Игнорирование нормализации там, где она нужна (но и не понимают, где денормализация допустима)
Как выглядит ошибка
Обычно это проявляется в двух крайностях:
- Либо «всё в одну таблицу», где повторяются группы полей (
address_line1,address_line2,address_line3в нескольких местах, если пользователь может иметь несколько адресов). - Либо «справочники развели до абсурда», не понимая стоимость JOIN и сложности миграций.
Почему это проблема
Нормализация помогает избежать:
- аномалий обновления (меняется один факт — нужно менять в десятках строк),
- аномалий вставки/удаления,
- дубликатов по смыслу.
Но чрезмерная нормализация может быть неоправданной, особенно если данные действительно принадлежат одной сущности и обновляются вместе.
Как исправить
Ориентируйтесь на инварианты:
- Если атрибут зависит только от части ключа — выносите.
- Если атрибуты группы обновляются и живут вместе — они могут оставаться в одной сущности.
- Денормализацию делайте, когда понимаете, что это оптимизация под конкретные запросы, а не «на будущее».
9) Неправильная уникальность: “UNIQUE на что попало” или отсутствие UNIQUE там, где он обязателен
Как выглядит ошибка
UNIQUEустановлен на поле, которое может повторяться (например,orders.created_at).- Нет
UNIQUEтам, где требуется доменная уникальность (например,products.sku). - Уникальность нужна в контексте (например,
emailуникален внутри одного тенанта/организации), но модель игнорирует контекст.
Почему это проблема
Неправильные UNIQUE приводят либо к блокировкам и ошибкам вставки, либо к появлению дублей. И то и другое быстро превращается в инциденты.
Как исправить
Определяйте уникальность по доменной логике:
- глобальная уникальность:
UNIQUE(email) - составная уникальность:
UNIQUE(tenant_id, email) - частичная уникальность: уникальность только при определённом статусе — тогда нужны частичные индексы (в PostgreSQL) или дополнительные ограничения/обновление логики.
Пример: email уникален внутри компании
CREATE TABLE tenants (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
UNIQUE (tenant_id, email)
);
10) Пренебрежение временем и историей: «поля даты» без понимания, что именно моделируется
Как выглядит ошибка
- Нет
created_at,updated_at, а аудит нужен. - Даты хранятся, но не ясно: это “момент события” или “период действия”.
- Нет версионности: когда меняется договор/цена, вы затираете прошлое.
Почему это проблема
В реальных системах время — это не просто атрибут, а часть бизнес-семантики. Особенно в продажах, подписках, тарифах, прайс-листах, кадровых изменениях.
Стирание истории делает невозможным расследование:
- почему отчёт за прошлый месяц другой,
- какая ставка была действительна тогда,
- кто и когда изменил данные.
Как исправить
Разделяйте:
- факт события (
paid_at,refunded_at), - период действия (
valid_from,valid_to), - последнюю версию (таблицы версионирования или таблицы “срезов”).
Пример: версионирование тарифов
CREATE TABLE plans (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE
);
CREATE TABLE plan_prices (
id BIGSERIAL PRIMARY KEY,
plan_id BIGINT NOT NULL REFERENCES plans(id),
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
price NUMERIC(12,2) NOT NULL,
CHECK (valid_to > valid_from),
UNIQUE (plan_id, valid_from)
);
Если нужна бизнес-уникальность на интервалах (например, не пересекать периоды) — это уже отдельные ограничения и, возможно, триггеры/исключающие индексы (зависит от СУБД).
Чек-лист проектирования схемы таблиц (перед тем как “создать базу”)
Ниже — практический список вопросов, который помогает быстро поймать 70% ошибок новичков.
На уровне сущностей
- Какая сущность? Опишите её инварианты: что всегда верно для записи.
- Что является идентификатором? Есть ли стабильный PK? Естественные ключи — как UNIQUE?
- Где границы сущностей? Не смешаны ли атрибуты разных концепций в одной таблице?
На уровне связей
- Какая кардинальность? 1:1, 1:N или M:N? Если M:N — есть ли таблица-связка?
- Есть ли FK и правила удаления?
ON DELETE CASCADE/RESTRICT/SET NULLвыбран осознанно. - Есть ли индексы под FK? И под поля, по которым чаще всего джоится таблица.
На уровне атрибутов
- Это факт или вычисление?
- Факт события — храните (и фиксируйте момент).
- Текущее значение — чаще вычисляйте в запросах.
- NULL отражает отсутствие/неизвестность или просто “флаг”? Если поле по смыслу обязательно — делайте
NOT NULL. - Уникальность — доменная? Где нужны
UNIQUE, а где — индексы без уникальности. - Есть ли ограничения CHECK? Количество/цена/даты должны иметь доменные границы.
На уровне целостности и сопровождения
- Можно ли нарушить целостность без правок приложения? Если да — значит, нет нужных ограничений.
- Что произойдёт при изменениях? Подумайте, как будет выглядеть миграция, если поменяется бизнес-правило.
- Как будет выглядеть типичный запрос? Если схема заставляет писать
Комментарии
Пока нет комментариев