Проектирование и нормализация баз данных SQL
Проектирование базы данных — процесс преобразования требований предметной области в структуру таблиц, связей, ключей, ограничений и индексов.
Хорошо спроектированная база данных:
- не хранит одни и те же факты в нескольких местах без необходимости;
- поддерживает целостность данных;
- предотвращает появление противоречивых записей;
- позволяет эффективно выполнять основные запросы;
- допускает безопасное изменение схемы;
- отражает правила предметной области;
- остаётся понятной для разработчиков и аналитиков.
Проектирование обычно включает несколько уровней:
- Концептуальная модель — сущности и связи без привязки к конкретной СУБД.
- Логическая модель — таблицы, атрибуты, ключи и нормализация.
- Физическая модель — типы PostgreSQL, индексы, секционирование и параметры хранения.
Содержание
- Предметная область
- Сущности, атрибуты и связи
- ER-модель
- Кардинальность связей
- Обязательные и необязательные связи
- Выбор первичного ключа
- Альтернативные и составные ключи
- Ограничения целостности
- Поведение внешнего ключа при удалении
- Нормализация
- Аномалии данных
- Функциональная зависимость
- Первая нормальная форма — 1NF
- Вторая нормальная форма — 2NF
- Третья нормальная форма — 3NF
- Краткое правило 1NF–3NF
- Нормальная форма Бойса — Кодда
- Четвёртая нормальная форма
- Пошаговая нормализация
- Почему цена хранится и в товаре, и в позиции заказа
- Полный пример нормализованной схемы
- Справочники или `CHECK`
- Имена таблиц и колонок
- Единственное или множественное число
- Выбор типов данных
- `NULL` и отсутствие значения
- Производные данные
- Нормализация истории
- Денормализация
- Индексы и проектирование
- Уникальность с учётом регистра
- Мягкое удаление
- Полиморфные связи
- Наследование сущностей
- Локализация данных
- Аудит изменений
- Представления
- Разделение схем PostgreSQL
- Процесс проектирования
- Типичные ошибки проектирования
- Проверка схемы
- Краткий чек-лист нормализации
- Итоговый пример запроса
- Итог
Предметная область
Проектирование начинается не с команды CREATE TABLE, а с анализа предметной области.
Например, интернет-магазин может включать:
- пользователей;
- адреса;
- товары;
- категории;
- заказы;
- позиции заказа;
- платежи;
- остатки товаров.
До создания схемы необходимо определить:
- какие данные должна хранить система;
- какие действия выполняются с данными;
- какие правила нельзя нарушать;
- какие отчёты и запросы будут использоваться;
- как долго должны храниться данные;
- какие объёмы и нагрузки ожидаются.
Пример бизнес-правил:
- email пользователя должен быть уникальным;
- заказ принадлежит одному пользователю;
- заказ содержит одну или несколько позиций;
- одна позиция относится к одному товару;
- количество товара должно быть положительным;
- итоговая цена позиции фиксируется на момент заказа;
- удаление товара не должно уничтожать историю заказов.
Такие правила затем преобразуются в таблицы, внешние ключи, ограничения и программную логику.
Сущности, атрибуты и связи
Сущность
Сущность — объект предметной области, сведения о котором необходимо хранить.
Примеры сущностей:
- пользователь;
- товар;
- заказ;
- категория;
- платёж.
В реляционной базе сущность обычно представляется таблицей:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
name text NOT NULL
);Таблица users хранит множество пользователей, а каждая строка представляет одного пользователя.
Атрибут
Атрибут — характеристика сущности.
Для пользователя атрибутами могут быть:
- идентификатор;
- email;
- имя;
- дата регистрации;
- статус.
В таблице атрибуты обычно представлены колонками:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
name text NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Связь
Связь показывает, как сущности взаимодействуют друг с другом.
Например:
Пользователь 1 ─── N ЗаказОдин пользователь может иметь много заказов, но каждый заказ принадлежит одному пользователю.
В SQL такая связь создаётся с помощью внешнего ключа:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL
REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now()
);ER-модель
ER-модель — Entity-Relationship Model — представление сущностей, их атрибутов и связей.
Основные элементы ER-модели:
| Элемент | Назначение |
|---|---|
| Entity | Сущность предметной области |
| Attribute | Характеристика сущности |
| Primary Key | Уникальный идентификатор сущности |
| Relationship | Связь между сущностями |
| Cardinality | Количество связанных экземпляров |
| Optionality | Обязательность связи |
Упрощённая модель интернет-магазина:
users
-----
id PK
email
name
orders
------
id PK
user_id FK
status
created_at
order_items
-----------
order_id PK, FK
product_id PK, FK
quantity
unit_price
products
--------
id PK
name
price
category_id FK
categories
----------
id PK
nameСвязи:
users 1 ─── N orders
orders 1 ─── N order_items
products 1 ─── N order_items
categories 1 ─── N productsТаблица order_items одновременно является дочерней таблицей заказа и связующей таблицей между заказами и товарами.
Кардинальность связей
Один к одному
Одной строке первой таблицы соответствует не более одной строки второй таблицы.
Пример: пользователь и профиль.
users 1 ─── 1 user_profilesCREATE TABLE user_profiles (
user_id bigint PRIMARY KEY
REFERENCES users(id)
ON DELETE CASCADE,
bio text,
birth_date date
);user_id одновременно является первичным и внешним ключом. Поэтому у одного пользователя не может быть более одного профиля.
Другой вариант:
CREATE TABLE user_profiles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL UNIQUE
REFERENCES users(id)
ON DELETE CASCADE,
bio text
);Ограничение UNIQUE обеспечивает отношение один к одному.
Один ко многим
Одной строке родительской таблицы может соответствовать много строк дочерней таблицы.
users 1 ─── N ordersCREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL
REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now()
);Внешний ключ располагается на стороне «многие».
Многие ко многим
Каждая строка первой таблицы может быть связана с несколькими строками второй таблицы, и наоборот.
Пример: товары и категории.
products N ─── M categoriesВ реляционной базе такая связь реализуется через промежуточную таблицу:
CREATE TABLE product_categories (
product_id bigint NOT NULL
REFERENCES products(id)
ON DELETE CASCADE,
category_id bigint NOT NULL
REFERENCES categories(id)
ON DELETE CASCADE,
PRIMARY KEY (product_id, category_id)
);Составной первичный ключ запрещает повторное добавление одной и той же связи.
Если связь имеет собственные атрибуты, они также размещаются в промежуточной таблице:
CREATE TABLE product_categories (
product_id bigint NOT NULL
REFERENCES products(id)
ON DELETE CASCADE,
category_id bigint NOT NULL
REFERENCES categories(id)
ON DELETE CASCADE,
assigned_at timestamptz NOT NULL DEFAULT now(),
position integer,
PRIMARY KEY (product_id, category_id)
);Обязательные и необязательные связи
Обязательность связи обычно выражается через NOT NULL.
Обязательная связь:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL
REFERENCES users(id)
);Каждый заказ должен принадлежать пользователю.
Необязательная связь:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
manager_id bigint
REFERENCES employees(id)
);manager_id может быть NULL, если менеджер ещё не назначен.
Следует отличать:
NULLот специального значения:
0или строки:
"не назначен"NULL означает отсутствие известного значения или неприменимость атрибута. Значения 0 и "не назначен" являются обычными данными и не должны использоваться как универсальная замена NULL.
Выбор первичного ключа
Первичный ключ — PRIMARY KEY — колонка или набор колонок, однозначно идентифицирующий строку.
Первичный ключ:
- уникален;
- не может содержать
NULL; - должен оставаться стабильным;
- используется внешними ключами;
- автоматически получает уникальный индекс.
Естественный ключ
Естественный ключ имеет смысл в предметной области.
Примеры:
- код страны;
- VIN автомобиля;
- ISBN книги;
- адрес email, если правила системы гарантируют его постоянство и уникальность.
CREATE TABLE countries (
code char(2) PRIMARY KEY,
name text NOT NULL
);Преимущество естественного ключа — он уже существует в предметной области.
Недостатки:
- значение может измениться;
- ключ может быть длинным;
- правила уникальности иногда меняются;
- составные естественные ключи усложняют связи.
Суррогатный ключ
Суррогатный ключ создаётся специально для базы данных и не имеет самостоятельного бизнес-смысла.
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
name text NOT NULL
);Здесь:
id— суррогатный первичный ключ;sku— естественный альтернативный ключ.
Такой подход позволяет сохранить бизнес-уникальность через UNIQUE, но использовать компактный и стабильный идентификатор для связей.
Числовой идентификатор
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEYПреимущества:
- компактный;
- эффективен для индексов и соединений;
- легко генерируется PostgreSQL;
- удобно сортируется.
UUID
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE
);UUID удобен, когда идентификаторы создаются независимо в нескольких системах или не должны быть легко перебираемыми.
Преимущества UUID:
- можно генерировать до вставки;
- низкая вероятность конфликта;
- подходит распределённым системам;
- не раскрывает приблизительное число записей.
Недостатки:
- занимает больше места, чем
bigint; - индексы крупнее;
- длиннее в URL и журналах;
- случайные UUID могут ухудшать локальность вставок.
Альтернативные и составные ключи
Альтернативный ключ
Атрибут может не быть первичным ключом, но всё равно должен оставаться уникальным:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE
);email является альтернативным ключом.
Составной ключ
Составной ключ состоит из нескольких колонок:
CREATE TABLE order_items (
order_id bigint NOT NULL
REFERENCES orders(id),
product_id bigint NOT NULL
REFERENCES products(id),
quantity integer NOT NULL,
PRIMARY KEY (order_id, product_id)
);Комбинация order_id и product_id уникальна, хотя каждое значение отдельно может повторяться.
Составные ключи удобны для:
- таблиц связей;
- строк, естественно определяемых несколькими значениями;
- предотвращения повторяющихся связей.
Если одна и та же позиция товара может встречаться в заказе несколько раз, потребуется отдельный идентификатор:
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL
REFERENCES orders(id),
product_id bigint NOT NULL
REFERENCES products(id),
quantity integer NOT NULL
);Выбор зависит от правил предметной области.
Ограничения целостности
База данных должна защищать важные правила независимо от кода приложения.
NOT NULL
Запрещает отсутствие значения:
name text NOT NULLUNIQUE
Запрещает повторение значения или комбинации значений:
email text NOT NULL UNIQUEСоставная уникальность:
CONSTRAINT bookings_room_period_key
UNIQUE (room_id, booking_date, start_time)CHECK
Проверяет условие:
price numeric(12, 2) NOT NULL
CHECK (price >= 0)quantity integer NOT NULL
CHECK (quantity > 0)status text NOT NULL
CHECK (status IN (
'new',
'paid',
'shipped',
'cancelled'
))Проверка нескольких колонок:
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
CONSTRAINT events_valid_period_check
CHECK (ends_at > starts_at)
);PRIMARY KEY
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEYПервичный ключ сочетает требования UNIQUE и NOT NULL.
FOREIGN KEY
user_id bigint NOT NULL
REFERENCES users(id)Внешний ключ запрещает ссылку на несуществующего пользователя.
Полная запись:
CONSTRAINT orders_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
ON UPDATE RESTRICT
ON DELETE RESTRICTПоведение внешнего ключа при удалении
ON DELETE RESTRICT
Запрещает удаление родителя при наличии связанных строк:
user_id bigint NOT NULL
REFERENCES users(id)
ON DELETE RESTRICTПодходит, если удаление родительской записи нарушило бы историю или бизнес-правила.
ON DELETE CASCADE
Автоматически удаляет зависимые строки:
order_id bigint NOT NULL
REFERENCES orders(id)
ON DELETE CASCADEНапример, при удалении заказа можно удалить его позиции.
CASCADE следует применять осторожно: одно удаление может затронуть большое число связанных строк.
ON DELETE SET NULL
Сохраняет дочернюю строку и устанавливает внешний ключ в NULL:
manager_id bigint
REFERENCES employees(id)
ON DELETE SET NULLКолонка не должна иметь ограничение NOT NULL.
ON DELETE SET DEFAULT
Устанавливает значение по умолчанию:
category_id bigint DEFAULT 1
REFERENCES categories(id)
ON DELETE SET DEFAULTЗначение по умолчанию должно соответствовать существующей родительской записи.
ON DELETE NO ACTION
Используется по умолчанию. Проверка ограничения может зависеть от того, является ли внешний ключ отложенным.
Для большинства обычных схем выбор делается между RESTRICT, CASCADE и SET NULL.
Нормализация
Нормализация — организация данных, при которой каждый факт хранится в подходящем месте, а нежелательные зависимости и повторы уменьшаются.
Нормализация помогает:
- устранять дублирование;
- предотвращать противоречия;
- избегать аномалий изменения;
- упрощать поддержку целостности;
- разделять независимые сущности.
Нормализация не означает, что любые повторяющиеся значения запрещены. Например, одинаковый статус 'active' у разных пользователей является нормальным повторением значения. Проблемой становится повторное хранение одного и того же факта, который должен изменяться согласованно в нескольких местах.
Аномалии данных
Рассмотрим ненормализованную таблицу:
orders
------------------------------------------------------------------
order_id
order_date
customer_id
customer_name
customer_email
product_id
product_name
product_price
quantityОдин заказ с несколькими товарами занимает несколько строк:
1 | 2026-09-11 | 10 | Анна | a@example.com | 101 | Мышь | 1500 | 1
1 | 2026-09-11 | 10 | Анна | a@example.com | 102 | Клавиатура | 4000 | 1
2 | 2026-09-12 | 10 | Анна | a@example.com | 101 | Мышь | 1500 | 2В таблице повторяются сведения о клиенте, заказе и товаре.
Аномалия обновления
Если пользователь изменил email, его необходимо обновить во всех строках заказов.
Если обновить только часть строк, в базе появятся разные email одного пользователя.
Аномалия вставки
Нельзя добавить товар, пока он не встретился хотя бы в одном заказе, если вся информация хранится только в таблице заказов.
Аномалия удаления
Если удалить последнюю строку с определённым товаром, вместе с историей заказа может исчезнуть единственная информация о товаре.
Нормализация разделяет независимые факты:
customers
products
orders
order_itemsФункциональная зависимость
Функциональная зависимость означает, что значение одного набора атрибутов однозначно определяет значение другого набора.
Запись:
A → Bозначает: если известно значение A, можно однозначно определить значение B.
Пример:
user_id → email
user_id → nameДля одного user_id существует одно текущее значение email и одно текущее значение name.
Другой пример:
product_id → product_name
product_id → current_priceЕсли ключ позиции заказа составной:
(order_id, product_id) → quantityто количество зависит от полной комбинации заказа и товара.
Нормальные формы анализируют зависимости между ключами и неключевыми атрибутами.
Первая нормальная форма — 1NF
Таблица находится в первой нормальной форме, если:
- каждая строка идентифицируема;
- в каждой ячейке хранится одно значение;
- отсутствуют повторяющиеся группы колонок;
- значения одной колонки относятся к одному логическому типу.
Нарушение: список в одной колонке
CREATE TABLE users (
id bigint PRIMARY KEY,
name text NOT NULL,
phone_numbers text
);Данные:
1 | Анна | +79990000001, +79990000002Проблемы:
- сложно искать пользователя по отдельному номеру;
- сложно обеспечить уникальность номера;
- невозможно задать внешний ключ на отдельный элемент списка;
- обновление и удаление одного номера требуют обработки строки.
Нормализованный вариант:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE user_phones (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL
REFERENCES users(id)
ON DELETE CASCADE,
phone text NOT NULL,
CONSTRAINT user_phones_phone_key
UNIQUE (phone)
);Если порядок номеров имеет значение:
ALTER TABLE user_phones
ADD COLUMN position integer NOT NULL
CHECK (position > 0);
ALTER TABLE user_phones
ADD CONSTRAINT user_phones_user_position_key
UNIQUE (user_id, position);Нарушение: повторяющиеся колонки
Неправильно:
CREATE TABLE users (
id bigint PRIMARY KEY,
phone_1 text,
phone_2 text,
phone_3 text
);Такая схема:
- ограничивает количество номеров;
- создаёт множество
NULL; - усложняет поиск;
- требует изменения схемы для четвёртого номера.
Правильно создать отдельную таблицу user_phones.
Массивы и JSON
PostgreSQL поддерживает массивы и jsonb, но их использование не всегда нарушает хороший дизайн.
Массив может быть уместен, если:
- значения рассматриваются как единое целое;
- элементы не участвуют в сложных связях;
- для них не нужны отдельные ограничения и внешние ключи;
- набор редко изменяется поэлементно.
Отдельная таблица обычно предпочтительнее, если элементы:
- нужно искать и индексировать по отдельности;
- имеют собственные атрибуты;
- связаны с другими сущностями;
- должны быть уникальными;
- часто добавляются и удаляются независимо.
jsonb подходит для гибких дополнительных атрибутов, но не должен автоматически заменять реляционную структуру для основных данных.
Вторая нормальная форма — 2NF
Таблица находится во второй нормальной форме, если:
- она находится в 1NF;
- каждый неключевой атрибут зависит от полного составного ключа, а не от его части.
2NF особенно важна для таблиц с составным первичным ключом.
Рассмотрим таблицу:
order_items
------------------------------------------------
order_id
product_id
order_date
product_name
quantityПервичный ключ:
(order_id, product_id)Функциональные зависимости:
order_id → order_date
product_id → product_name
(order_id, product_id) → quantityorder_date зависит только от order_id, а product_name — только от product_id. Это частичные зависимости, нарушающие 2NF.
Нормализованная схема:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_date date NOT NULL
);
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE order_items (
order_id bigint NOT NULL
REFERENCES orders(id)
ON DELETE CASCADE,
product_id bigint NOT NULL
REFERENCES products(id),
quantity integer NOT NULL
CHECK (quantity > 0),
PRIMARY KEY (order_id, product_id)
);Теперь:
orders.id → orders.order_date
products.id → products.name
(order_id, product_id) → order_items.quantityКаждый факт находится в таблице сущности, которую он описывает.
Если первичный ключ состоит из одной колонки, частичная зависимость от части ключа невозможна. Поэтому таблица в 1NF с простым ключом формально автоматически удовлетворяет этому аспекту 2NF.
Третья нормальная форма — 3NF
Таблица находится в третьей нормальной форме, если:
- она находится во 2NF;
- неключевые атрибуты не зависят транзитивно от ключа через другой неключевой атрибут.
Рассмотрим таблицу сотрудников:
employees
-------------------------------------------------
employee_id
employee_name
department_id
department_name
department_phoneЗависимости:
employee_id → employee_name
employee_id → department_id
department_id → department_name
department_id → department_phoneСледовательно:
employee_id
→ department_id
→ department_namedepartment_name и department_phone описывают подразделение, а не сотрудника. Это транзитивная зависимость.
Нормализованный вариант:
CREATE TABLE departments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
phone text
);
CREATE TABLE employees (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
department_id bigint NOT NULL
REFERENCES departments(id)
);Теперь данные подразделения хранятся один раз.
Изменение телефона подразделения выполняется одной командой:
UPDATE departments
SET phone = '+7-999-000-00-00'
WHERE id = 5;Нет необходимости обновлять каждого сотрудника отдельно.
Краткое правило 1NF–3NF
Упрощённое правило нормализации:
1NF: одно значение в одной ячейке;
2NF: зависимость от всего ключа;
3NF: зависимость только от ключа.Более известная формулировка:
Каждый неключевой атрибут должен зависеть
от ключа, от всего ключа и ни от чего,
кроме ключа.Это полезное практическое правило, хотя формальные определения нормальных форм точнее и учитывают функциональные зависимости и кандидатные ключи.
Нормальная форма Бойса — Кодда
BCNF — Boyce-Codd Normal Form — усиленный вариант 3NF.
Таблица находится в BCNF, если для каждой нетривиальной функциональной зависимости:
X → Yдетерминант X является суперключом.
BCNF важна в схемах с несколькими пересекающимися кандидатными ключами.
Пример таблицы:
teacher_subject_room
--------------------
teacher
subject
roomПредположим:
- преподаватель ведёт только один предмет;
- в одной аудитории по предмету работает только один преподаватель.
Возможные зависимости могут привести к ситуации, где таблица удовлетворяет некоторым требованиям 3NF, но всё ещё содержит аномалии. Тогда её разбивают на таблицы, представляющие независимые зависимости.
В большинстве прикладных систем 3NF является разумной базовой целью, а BCNF проверяется для таблиц со сложными кандидатными ключами.
Четвёртая нормальная форма
4NF устраняет независимые многозначные зависимости.
Рассмотрим таблицу:
employee_languages_skills
--------------------------
employee_id
language
skillПредположим, языки и профессиональные навыки сотрудника не зависят друг от друга.
Данные:
1 | English | SQL
1 | English | Docker
1 | German | SQL
1 | German | DockerЧтобы представить два языка и два навыка, приходится хранить все комбинации.
Правильнее создать две таблицы:
CREATE TABLE employee_languages (
employee_id bigint NOT NULL
REFERENCES employees(id)
ON DELETE CASCADE,
language_id bigint NOT NULL
REFERENCES languages(id),
PRIMARY KEY (employee_id, language_id)
);
CREATE TABLE employee_skills (
employee_id bigint NOT NULL
REFERENCES employees(id)
ON DELETE CASCADE,
skill_id bigint NOT NULL
REFERENCES skills(id),
PRIMARY KEY (employee_id, skill_id)
);4NF применяется реже, но полезна, когда одна таблица объединяет несколько независимых отношений многие ко многим.
Пошаговая нормализация
Рассмотрим данные заказа:
order_id
order_date
customer_id
customer_name
customer_email
itemsВ items хранится значение:
101:Мышь:2:1500;102:Клавиатура:1:4000Шаг 1. Приведение к 1NF
Разделим список товаров на отдельные строки:
order_id
order_date
customer_id
customer_name
customer_email
product_id
product_name
quantity
unit_priceТеперь каждая строка описывает один товар в заказе.
Шаг 2. Приведение к 2NF
Если ключ строки:
(order_id, product_id)то:
order_date
customer_idзависят только от order_id.
А:
product_nameзависит только от product_id.
Разделяем данные:
orders
products
order_itemsШаг 3. Приведение к 3NF
В таблице orders могут остаться:
order_id
order_date
customer_id
customer_name
customer_emailНо:
customer_id → customer_name
customer_id → customer_emailПоэтому данные клиента выносятся в отдельную таблицу customers.
Результат:
customers
---------
id
name
email
orders
------
id
customer_id
order_date
products
--------
id
name
current_price
order_items
-----------
order_id
product_id
quantity
unit_priceПочему цена хранится и в товаре, и в позиции заказа
На первый взгляд:
products.current_price
order_items.unit_priceвыглядят как дублирование.
Но это разные факты:
products.current_price— текущая цена товара;order_items.unit_price— цена, по которой товар был добавлен в конкретный заказ.
Если текущая цена изменится, историческая стоимость заказа должна сохраниться.
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
current_price numeric(12, 2) NOT NULL
CHECK (current_price >= 0)
);
CREATE TABLE order_items (
order_id bigint NOT NULL
REFERENCES orders(id)
ON DELETE CASCADE,
product_id bigint NOT NULL
REFERENCES products(id),
quantity integer NOT NULL
CHECK (quantity > 0),
unit_price numeric(12, 2) NOT NULL
CHECK (unit_price >= 0),
PRIMARY KEY (order_id, product_id)
);Нормализация не запрещает хранить похожие значения, если они отражают разные факты или состояние на разные моменты времени.
Полный пример нормализованной схемы
Пользователи
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
name text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_email_key
UNIQUE (email),
CONSTRAINT users_status_check
CHECK (status IN (
'active',
'blocked',
'deleted'
))
);Адреса
CREATE TABLE addresses (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL
REFERENCES users(id)
ON DELETE CASCADE,
country_code char(2) NOT NULL,
city text NOT NULL,
street text NOT NULL,
postal_code text,
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);Если у пользователя может быть только один адрес по умолчанию, обычное ограничение UNIQUE (user_id, is_default) не подходит: оно разрешит только один адрес с false.
В PostgreSQL можно использовать частичный уникальный индекс:
CREATE UNIQUE INDEX addresses_one_default_per_user_idx
ON addresses(user_id)
WHERE is_default;Категории
CREATE TABLE categories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
parent_id bigint
REFERENCES categories(id)
ON DELETE SET NULL,
name text NOT NULL,
CONSTRAINT categories_parent_check
CHECK (parent_id IS NULL OR parent_id <> id)
);Это самоссылочная связь:
categories.parent_id → categories.idОна позволяет создать дерево категорий.
Товары
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL,
name text NOT NULL,
description text,
current_price numeric(12, 2) NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT products_sku_key
UNIQUE (sku),
CONSTRAINT products_price_check
CHECK (current_price >= 0),
CONSTRAINT products_status_check
CHECK (status IN (
'draft',
'active',
'archived'
))
);Связь товаров и категорий
CREATE TABLE product_categories (
product_id bigint NOT NULL
REFERENCES products(id)
ON DELETE CASCADE,
category_id bigint NOT NULL
REFERENCES categories(id)
ON DELETE CASCADE,
PRIMARY KEY (product_id, category_id)
);Заказы
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL
REFERENCES users(id),
delivery_address_id bigint
REFERENCES addresses(id),
status text NOT NULL DEFAULT 'new',
created_at timestamptz NOT NULL DEFAULT now(),
paid_at timestamptz,
cancelled_at timestamptz,
CONSTRAINT orders_status_check
CHECK (status IN (
'new',
'paid',
'processing',
'shipped',
'completed',
'cancelled'
))
);Прямая ссылка на addresses может быть недостаточна для истории: пользователь способен изменить адрес после оформления заказа.
Если адрес заказа должен оставаться неизменным, можно создать отдельную таблицу снимков:
CREATE TABLE order_addresses (
order_id bigint PRIMARY KEY
REFERENCES orders(id)
ON DELETE CASCADE,
recipient_name text NOT NULL,
recipient_phone text NOT NULL,
country_code char(2) NOT NULL,
city text NOT NULL,
street text NOT NULL,
postal_code text
);Это осознанное хранение исторического состояния, а не случайное дублирование.
Позиции заказа
CREATE TABLE order_items (
order_id bigint NOT NULL
REFERENCES orders(id)
ON DELETE CASCADE,
product_id bigint NOT NULL
REFERENCES products(id),
product_name text NOT NULL,
quantity integer NOT NULL,
unit_price numeric(12, 2) NOT NULL,
PRIMARY KEY (order_id, product_id),
CONSTRAINT order_items_quantity_check
CHECK (quantity > 0),
CONSTRAINT order_items_unit_price_check
CHECK (unit_price >= 0)
);product_name здесь может быть сохранён как исторический снимок названия на момент оформления заказа. Если история названий не нужна, его можно получать из products.
Платежи
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL
REFERENCES orders(id),
external_id text,
amount numeric(12, 2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
CONSTRAINT payments_external_id_key
UNIQUE (external_id),
CONSTRAINT payments_amount_check
CHECK (amount > 0),
CONSTRAINT payments_status_check
CHECK (status IN (
'pending',
'completed',
'failed',
'refunded'
))
);У одного заказа может быть несколько попыток оплаты, поэтому связь имеет вид:
orders 1 ─── N paymentsСправочники или CHECK
Статусы можно хранить через CHECK:
status text NOT NULL
CHECK (status IN ('new', 'paid', 'cancelled'))Преимущества:
- простая схема;
- быстрое чтение;
- не требуется соединение со справочником;
- подходит для небольшого стабильного набора значений.
Отдельная таблица подходит, если статус имеет дополнительные атрибуты:
CREATE TABLE order_statuses (
code text PRIMARY KEY,
display_name text NOT NULL,
is_final boolean NOT NULL DEFAULT false,
position integer NOT NULL
);CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status_code text NOT NULL
REFERENCES order_statuses(code)
);Справочник полезен, если:
- значения настраиваются без изменения схемы;
- статус имеет название, описание, цвет или порядок;
- набор расширяется пользователями или администраторами;
- другие таблицы должны ссылаться на те же значения.
Небольшой технический набор состояний часто удобнее защищать CHECK или перечислением, а изменяемые бизнес-справочники — отдельными таблицами.
Имена таблиц и колонок
Для SQL-схемы полезно выбрать единый стиль.
Пример соглашения:
Таблицы: users, order_items
Первичный ключ: id
Внешний ключ: user_id, order_id
Дата создания: created_at
Дата изменения: updated_at
Логическое поле: is_active, has_accessДля PostgreSQL обычно удобно использовать:
- строчные имена;
snake_case;- английские названия;
- понятные слова без неоднозначных сокращений.
Рекомендуется:
CREATE TABLE order_items (
order_id bigint NOT NULL,
product_id bigint NOT NULL,
created_at timestamptz NOT NULL
);Менее удобно:
CREATE TABLE "OrderItems" (
"OrderID" bigint NOT NULL,
"ProdID" bigint NOT NULL
);Идентификаторы в кавычках становятся чувствительными к регистру и требуют постоянного использования кавычек.
Единственное или множественное число
Допустимы оба варианта:
user
order
productили:
users
orders
productsВажнее последовательность во всей схеме.
Следует учитывать, что некоторые слова могут совпадать с ключевыми словами SQL. Например, user имеет специальное значение в PostgreSQL, а order связано с ORDER BY.
Поэтому множественные имена часто удобнее:
users
orders
productsВыбор типов данных
Тип должен соответствовать смыслу данных, а не только текущему формату.
Целые числа
quantity integer
id bigintsmallint— небольшие значения;integer— обычные целые числа;bigint— большие диапазоны и идентификаторы.
Денежные значения
price numeric(12, 2)numeric хранит точные десятичные значения.
Не рекомендуется использовать real или double precision для сумм, где важна точность до копейки.
Альтернативный вариант — хранить сумму в минимальных денежных единицах:
price_minor bigintНапример, 150099 может означать 1500.99. В таком случае единица измерения должна быть явно определена в приложении и документации.
Строки
name text
code varchar(30)В PostgreSQL text и varchar без ограничения подходят для большинства строковых данных.
varchar(100) следует использовать, если ограничение длины является реальным правилом предметной области:
code varchar(30) NOT NULLДлину пользовательского имени часто удобнее проверять явно:
name text NOT NULL
CHECK (char_length(name) BETWEEN 1 AND 200)Дата и время
birth_date date
created_at timestamptzdate— календарная дата без времени;time— время без даты;timestamp— дата и время без часового пояса;timestamptz— момент времени с учётом часового пояса;interval— длительность.
Для момента создания записи обычно используют:
created_at timestamptz NOT NULL DEFAULT now()Для даты рождения:
birth_date dateЛогические значения
is_active boolean NOT NULL DEFAULT trueНе следует заменять boolean строками:
yes
no
true
false
0
1если данные действительно имеют два логических состояния.
Перечисления PostgreSQL
CREATE TYPE order_status AS ENUM (
'new',
'paid',
'shipped',
'cancelled'
);CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status order_status NOT NULL DEFAULT 'new'
);ENUM обеспечивает строгий набор значений, но изменение этого набора требует изменения типа. Для часто изменяющихся бизнес-значений отдельный справочник может быть гибче.
NULL и отсутствие значения
NULL не равно пустой строке, нулю или false.
NULL <> ''
NULL <> 0
NULL <> falseСравнение с NULL выполняется через:
WHERE deleted_at IS NULLа не:
WHERE deleted_at = NULLПри проектировании следует определить, что означает NULL в каждой колонке.
Например:
middle_name textNULL может означать, что отчество отсутствует или неизвестно.
Для колонки:
paid_at timestamptzNULL естественно означает, что заказ ещё не оплачен.
Не следует разрешать NULL без необходимости. Если значение обязательно для корректной записи, нужно использовать NOT NULL.
Производные данные
Производное значение можно вычислить из других данных.
Например:
order_total =
SUM(order_items.quantity * order_items.unit_price)Его можно получать запросом:
SELECT
oi.order_id,
SUM(oi.quantity * oi.unit_price) AS total
FROM order_items AS oi
WHERE oi.order_id = $1
GROUP BY oi.order_id;Хранение orders.total создаёт риск рассинхронизации:
orders.total = 5000
сумма позиций = 5500Однако сохранение производного значения может быть оправдано, если:
- вычисление очень дорогое;
- значение должно быть исторически зафиксировано;
- оно часто используется;
- обновление выполняется централизованно;
- допускается контролируемая денормализация.
Если итог заказа фиксируется при оформлении, можно хранить его как отдельный бизнес-факт:
total_amount numeric(12, 2) NOT NULL
CHECK (total_amount >= 0)При этом приложение или процедура должна гарантировать согласованность суммы с позициями на момент фиксации.
Нормализация истории
Текущие данные и исторические данные имеют разные требования.
Например, пользователь изменил имя:
текущее имя: Анна Смирнова
старое имя: Анна ИвановаЕсли документы должны сохранять имя на момент создания, возможны варианты:
Снимок в документе
CREATE TABLE invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
customer_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Таблица версий
CREATE TABLE user_name_history (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL
REFERENCES users(id),
name text NOT NULL,
valid_from timestamptz NOT NULL,
valid_to timestamptz,
CONSTRAINT user_name_history_period_check
CHECK (
valid_to IS NULL OR valid_to > valid_from
)
);Неизменяемый документ
После создания счёт или заказ может сохраняться как неизменяемая запись, даже если справочные данные изменяются.
Такое дублирование не является ошибкой нормализации, если оно отражает самостоятельный исторический факт.
Денормализация
Денормализация — осознанное добавление повторяющихся или вычисляемых данных для повышения производительности или упрощения чтения.
Примеры:
- сохранение суммы заказа;
- счётчик комментариев у публикации;
- последнее сообщение в записи диалога;
- материализованное представление для отчёта;
- поисковый документ, объединяющий поля нескольких таблиц;
- отдельная аналитическая витрина.
Пример:
ALTER TABLE posts
ADD COLUMN comments_count integer NOT NULL DEFAULT 0
CHECK (comments_count >= 0);При добавлении комментария счётчик должен обновляться согласованно:
BEGIN;
INSERT INTO comments (post_id, author_id, body)
VALUES ($1, $2, $3);
UPDATE posts
SET comments_count = comments_count + 1
WHERE id = $1;
COMMIT;Денормализация оправдана, когда:
- подтверждена проблема производительности;
- определён источник истины;
- существует механизм синхронизации;
- предусмотрена проверка и восстановление данных;
- выгода превышает сложность поддержки.
Сначала обычно проектируют нормализованную модель, измеряют запросы, а затем денормализуют конкретные узкие места.
Индексы и проектирование
Нормализация и индексация решают разные задачи:
- нормализация отвечает за структуру и целостность;
- индексы ускоряют доступ к данным.
Индекс внешнего ключа
PostgreSQL не создаёт индекс на внешнем ключе автоматически:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL
REFERENCES users(id)
);Для запросов по пользователю полезен индекс:
CREATE INDEX orders_user_id_idx
ON orders(user_id);Составной индекс
Для запроса:
SELECT id, created_at
FROM orders
WHERE user_id = $1
AND status = $2
ORDER BY created_at DESC;может подойти:
CREATE INDEX orders_user_status_created_idx
ON orders(user_id, status, created_at DESC);Порядок колонок в составном индексе имеет значение.
Уникальный индекс
Ограничение:
UNIQUE (email)обычно создаёт уникальный индекс автоматически.
Не нужно создавать второй обычный индекс на ту же колонку без отдельной причины.
Частичный индекс
CREATE INDEX orders_unprocessed_idx
ON orders(created_at)
WHERE status = 'new';Такой индекс содержит только новые заказы и может быть компактнее полного индекса.
Индекс следует создавать под реальные запросы, а не для каждой колонки.
Уникальность с учётом регистра
Обычное ограничение:
email text NOT NULL UNIQUEможет считать эти значения разными:
User@example.com
user@example.comЕсли email должен быть уникален без учёта регистра, можно создать функциональный индекс:
CREATE UNIQUE INDEX users_email_lower_key
ON users(lower(email));Поиск должен использовать соответствующее выражение:
SELECT id, email
FROM users
WHERE lower(email) = lower($1);Другой вариант — нормализовать email перед сохранением и хранить его в выбранном регистре.
Для некоторых задач можно использовать тип citext, но правила сравнения и локали всё равно следует выбирать осознанно.
Мягкое удаление
При мягком удалении строка сохраняется, но получает отметку:
ALTER TABLE users
ADD COLUMN deleted_at timestamptz;Активные пользователи:
SELECT id, email
FROM users
WHERE deleted_at IS NULL;Если email должен быть уникален только среди активных пользователей:
CREATE UNIQUE INDEX users_active_email_key
ON users(lower(email))
WHERE deleted_at IS NULL;Мягкое удаление усложняет схему:
- каждый запрос должен учитывать
deleted_at; - внешние ключи продолжают ссылаться на строку;
- уникальные ограничения могут требовать частичных индексов;
- данные продолжают занимать место;
- случайно удалённые записи могут попадать в отчёты.
Его следует использовать, когда действительно нужны восстановление, аудит или юридически значимая история.
Полиморфные связи
Иногда пытаются создать универсальную таблицу:
CREATE TABLE comments (
id bigint PRIMARY KEY,
target_type text NOT NULL,
target_id bigint NOT NULL,
body text NOT NULL
);Записи:
target_type = 'post', target_id = 10
target_type = 'photo', target_id = 25PostgreSQL не может создать обычный внешний ключ target_id сразу на несколько таблиц. Поэтому база не гарантирует существование целевого объекта.
Более строгий вариант — отдельные таблицы связей:
CREATE TABLE post_comments (
comment_id bigint PRIMARY KEY
REFERENCES comments(id)
ON DELETE CASCADE,
post_id bigint NOT NULL
REFERENCES posts(id)
ON DELETE CASCADE
);CREATE TABLE photo_comments (
comment_id bigint PRIMARY KEY
REFERENCES comments(id)
ON DELETE CASCADE,
photo_id bigint NOT NULL
REFERENCES photos(id)
ON DELETE CASCADE
);Другой подход — общая родительская сущность:
CREATE TABLE content_objects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
object_type text NOT NULL
);Тогда комментарий может ссылаться на content_objects(id) обычным внешним ключом.
Полиморфная связь через type + id удобна в коде, но ослабляет ссылочную целостность.
Наследование сущностей
Рассмотрим клиентов двух типов:
- физическое лицо;
- организация.
Одна таблица
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_type text NOT NULL,
first_name text,
last_name text,
company_name text,
tax_number text,
CONSTRAINT customers_type_check
CHECK (customer_type IN (
'person',
'company'
))
);Преимущество — простые запросы.
Недостатки:
- множество
NULL; - сложные
CHECK; - трудно выразить обязательность полей для каждого типа.
Можно добавить проверку:
CONSTRAINT customers_fields_check
CHECK (
(
customer_type = 'person'
AND first_name IS NOT NULL
AND last_name IS NOT NULL
AND company_name IS NULL
AND tax_number IS NULL
)
OR
(
customer_type = 'company'
AND first_name IS NULL
AND last_name IS NULL
AND company_name IS NOT NULL
AND tax_number IS NOT NULL
)
)Общая и специализированные таблицы
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE
);CREATE TABLE person_customers (
customer_id bigint PRIMARY KEY
REFERENCES customers(id)
ON DELETE CASCADE,
first_name text NOT NULL,
last_name text NOT NULL
);CREATE TABLE company_customers (
customer_id bigint PRIMARY KEY
REFERENCES customers(id)
ON DELETE CASCADE,
company_name text NOT NULL,
tax_number text NOT NULL UNIQUE
);Этот вариант лучше нормализован, но требует дополнительных соединений и контроля того, что клиент относится только к одному типу.
Выбор зависит от количества общих и специализированных полей, частоты запросов и сложности правил.
Локализация данных
Не рекомендуется создавать отдельные колонки для заранее неизвестного количества языков:
name_ru text,
name_en text,
name_de textТакой вариант допустим для маленького фиксированного набора языков, но плохо расширяется.
Нормализованный вариант:
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE
);CREATE TABLE product_translations (
product_id bigint NOT NULL
REFERENCES products(id)
ON DELETE CASCADE,
language_code varchar(10) NOT NULL,
name text NOT NULL,
description text,
PRIMARY KEY (product_id, language_code)
);Теперь новый язык добавляется строками, а не колонками.
Аудит изменений
Поля:
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()хранят только время последнего изменения, но не историю.
Если необходимо знать, кто и что изменил, используют журнал:
CREATE TABLE order_status_history (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL
REFERENCES orders(id),
old_status text,
new_status text NOT NULL,
changed_by bigint
REFERENCES users(id),
changed_at timestamptz NOT NULL DEFAULT now(),
reason text
);История статусов представляет самостоятельную сущность и может использоваться для аудита и аналитики.
Представления
Представление — VIEW сохраняет запрос как виртуальную таблицу.
CREATE VIEW order_totals AS
SELECT
oi.order_id,
SUM(oi.quantity * oi.unit_price) AS total_amount
FROM order_items AS oi
GROUP BY oi.order_id;Использование:
SELECT
o.id,
o.created_at,
ot.total_amount
FROM orders AS o
JOIN order_totals AS ot
ON ot.order_id = o.id;Представление:
- не дублирует данные;
- скрывает сложность запроса;
- предоставляет единый интерфейс;
- может ограничивать доступ к колонкам.
Материализованное представление
CREATE MATERIALIZED VIEW daily_sales AS
SELECT
created_at::date AS sale_date,
SUM(total_amount) AS total_amount
FROM orders
WHERE status = 'completed'
GROUP BY created_at::date;Материализованное представление физически хранит результат.
Обновление:
REFRESH MATERIALIZED VIEW daily_sales;Оно является формой контролируемой денормализации и подходит для отчётов, которые не обязаны отражать изменения мгновенно.
Разделение схем PostgreSQL
В одной базе PostgreSQL можно использовать несколько схем:
CREATE SCHEMA app;
CREATE SCHEMA audit;
CREATE SCHEMA reporting;Таблицы:
CREATE TABLE app.users (...);
CREATE TABLE audit.events (...);
CREATE TABLE reporting.daily_sales (...);Схемы помогают:
- разделять области приложения;
- управлять правами;
- избегать конфликтов имён;
- отделять рабочие таблицы от аудита и аналитики.
Однако чрезмерное количество схем может усложнить миграции и запросы.
Процесс проектирования
Шаг 1. Собрать требования
Нужно описать:
- основные сценарии;
- бизнес-правила;
- обязательные данные;
- уникальные значения;
- жизненный цикл сущностей;
- требования к истории;
- ожидаемые запросы;
- объём и скорость роста данных.
Шаг 2. Найти сущности
Существительные в требованиях часто становятся кандидатами на сущности:
пользователь
заказ
товар
категория
платёжНе каждое существительное требует отдельной таблицы. Например, «цвет кнопки» может быть атрибутом настройки, а не самостоятельной сущностью.
Шаг 3. Определить атрибуты
Для каждого атрибута нужно определить:
- смысл;
- тип;
- обязательность;
- допустимый диапазон;
- уникальность;
- значение по умолчанию;
- возможность изменения;
- необходимость хранения истории.
Шаг 4. Определить ключи
Для каждой сущности выбираются:
- первичный ключ;
- кандидатные ключи;
- альтернативные уникальные ключи;
- внешние ключи.
Шаг 5. Определить связи
Для каждой связи устанавливаются:
- кардинальность;
- обязательность;
- направление внешнего ключа;
- поведение при удалении;
- необходимость промежуточной таблицы.
Шаг 6. Проверить нормальные формы
Следует найти:
- списки в одной колонке;
- повторяющиеся группы колонок;
- зависимости от части составного ключа;
- зависимости неключевых атрибутов друг от друга;
- несколько независимых многозначных фактов в одной таблице.
Шаг 7. Добавить ограничения
Правила предметной области закрепляются через:
PRIMARY KEY
FOREIGN KEY
UNIQUE
NOT NULL
CHECKШаг 8. Проанализировать запросы
Для основных операций необходимо проверить:
- какие таблицы соединяются;
- по каким колонкам выполняется фильтрация;
- какие сортировки используются;
- какие индексы необходимы;
- нет ли слишком дорогих запросов;
- нужны ли архивы или секционирование.
Шаг 9. Проверить конкурентный доступ
Следует определить:
- какие операции требуют транзакций;
- какие строки могут обновляться одновременно;
- где нужны блокировки;
- какие ограничения предотвращают гонки;
- какие операции должны быть идемпотентными.
Шаг 10. Создать миграции
Схема должна изменяться версионированными миграциями, а не ручными командами в рабочей базе.
Типичные ошибки проектирования
Несколько сущностей в одной таблице
order_id
customer_name
customer_email
product_name
product_priceТакая таблица смешивает заказ, клиента и товар.
Списки идентификаторов в строке
category_ids = "1,5,12"Следует использовать промежуточную таблицу.
Универсальная таблица атрибутов
entity_id
attribute_name
attribute_valueМодель EAV кажется гибкой, но:
- теряется типизация;
- сложно использовать
NOT NULL; - сложно создавать внешние ключи;
- запросы становятся сложными;
- ухудшается производительность;
- правила переходят в код приложения.
EAV может применяться для действительно динамических свойств, но не должна заменять обычные колонки для основных данных.
Избыточное использование JSON
CREATE TABLE orders (
id bigint PRIMARY KEY,
data jsonb NOT NULL
);Если внутри JSON находятся стабильные поля заказа, связи и суммы, база не сможет полноценно обеспечивать их целостность.
JSON подходит для:
- дополнительных параметров;
- данных внешней системы;
- документов с изменяемой структурой;
- редко используемых необязательных атрибутов.
Основные ключи, статусы, суммы и связи обычно лучше хранить в типизированных колонках.
Отсутствие ограничений
Проверка только в приложении не защищает от:
- ошибок в других сервисах;
- ручных SQL-команд;
- конкурентных запросов;
- дефектов миграций;
- загрузки данных из файлов.
Использование имени как ключа
orders.customer_nameИмя не является надёжным идентификатором: оно может повторяться и изменяться.
Нужна ссылка:
orders.user_idХранение возраста
Возраст изменяется со временем:
age integerЛучше хранить дату рождения:
birth_date dateВозраст вычисляется на нужную дату.
Хранение текущего состояния без истории
Если нужно анализировать переходы статусов, одного поля недостаточно:
orders.statusПотребуется таблица истории.
Создание индекса для каждой колонки
Каждый индекс:
- занимает место;
- замедляет
INSERT; - замедляет
UPDATE; - требует обслуживания.
Индексы проектируются под реальные запросы и ограничения.
Отсутствие индексов внешних ключей
Внешний ключ поддерживает целостность, но не гарантирует быстрый поиск дочерних строк.
Чрезмерная денормализация
Большое количество копий одних данных создаёт сложную систему синхронизации и повышает риск противоречий.
Чрезмерная нормализация
Если каждый небольшой атрибут вынесен в отдельную таблицу без бизнес-причины, схема становится сложной и требует большого количества JOIN.
Нормализация — средство управления зависимостями, а не цель создания максимального числа таблиц.
Проверка схемы
Для каждой таблицы полезно задать вопросы:
- Что представляет одна строка?
- Как строка уникально идентифицируется?
- Все ли колонки описывают именно эту сущность?
- Есть ли списки или повторяющиеся группы?
- Зависит ли каждый атрибут от полного ключа?
- Нет ли зависимости от другого неключевого атрибута?
- Какие значения могут быть
NULLи почему? - Какие ограничения должна проверять база?
- Что происходит при удалении связанных строк?
- Нужна ли история изменений?
- Какие запросы выполняются чаще всего?
- Какие индексы поддерживают эти запросы?
- Можно ли восстановить согласованность денормализованных данных?
- Какие операции должны выполняться в транзакции?
Краткий чек-лист нормализации
Схема близка к 1NF, если:
- в ячейках нет списков;
- нет колонок
phone_1,phone_2,phone_3; - каждая строка имеет ключ;
- однотипные элементы хранятся отдельными строками.
Схема близка к 2NF, если:
- соблюдается 1NF;
- атрибуты таблицы связей зависят от полного составного ключа;
- сведения о родительских сущностях не повторяются в таблице связей.
Схема близка к 3NF, если:
- соблюдается 2NF;
- неключевые атрибуты не определяют другие неключевые атрибуты;
- информация о клиенте хранится в таблице клиентов;
- информация о подразделении хранится в таблице подразделений;
- информация о товаре хранится в таблице товаров.
Итоговый пример запроса
Получение заказов с пользователем, позициями и итоговой суммой:
SELECT
o.id AS order_id,
o.created_at,
o.status,
u.id AS user_id,
u.email,
COUNT(oi.product_id) AS item_count,
SUM(
oi.quantity * oi.unit_price
) AS total_amount
FROM orders AS o
JOIN users AS u
ON u.id = o.user_id
JOIN order_items AS oi
ON oi.order_id = o.id
WHERE o.created_at >= $1
AND o.created_at < $2
GROUP BY
o.id,
o.created_at,
o.status,
u.id,
u.email
ORDER BY o.created_at DESC;Соответствующие индексы:
CREATE INDEX orders_created_at_idx
ON orders(created_at);
CREATE INDEX orders_user_id_idx
ON orders(user_id);
CREATE INDEX order_items_product_id_idx
ON order_items(product_id);Первичный ключ:
PRIMARY KEY (order_id, product_id)уже создаёт индекс, начинающийся с order_id, поэтому дополнительный индекс только на order_items(order_id) обычно не требуется.
Итог
Проектирование базы данных начинается с бизнес-правил и модели предметной области. Сначала определяются сущности, атрибуты, ключи и связи, после чего правила закрепляются ограничениями SQL.
Нормализация помогает разместить каждый факт в подходящей таблице:
1NF — одно значение в одной ячейке;
2NF — зависимость от полного ключа;
3NF — отсутствие зависимостей между неключевыми атрибутами.Хорошая практическая схема обычно стремится к 3NF, но учитывает историю, производительность и реальные сценарии работы. Денормализация допустима, если она выполняется осознанно, измеряется и сопровождается надёжным механизмом синхронизации.
Главный принцип проектирования можно сформулировать так:
База данных должна не только хранить корректные данные, но и по возможности не позволять сохранить некорректные.