Нормализация и проектирование схемы
Нормализация — это дисциплина проектирования базы данных, которая устраняет дублирование и аномалии через разбиение данных на связанные таблицы. Каждая нормальная форма (1NF, 2NF, 3NF) решает конкретный класс проблем: неатомарные значения, частичные зависимости, транзитивные зависимости. Хорошо нормализованная схема — это основа надёжного backend-приложения: данные консистентны, изменения предсказуемы, запросы понятны. Знание нормализации отличает разработчика, который 'просто добавляет колонки', от инженера, который проектирует системы, рассчитанные на годы эксплуатации.
Почему это важно: Плохо спроектированная схема приводит к трём классам проблем, каждая из которых становится дороже со временем. Аномалии обновления: чтобы изменить название города, нужно обновить тысячи строк — пропустишь хоть одну, данные становятся несогласованными. Аномалии вставки: нельзя записать информацию о департаменте пока в нём нет ни одного сотрудника, потому что department_name хранится в таблице employees. Аномалии удаления: удаляешь последнего сотрудника департамента — теряешь информацию о самом департаменте. Нормализация систематически устраняет эти проблемы на уровне структуры данных, а не через код приложения.
Главная идея
Нормализация строится на концепции функциональных зависимостей. Атрибут B функционально зависит от A (A → B), если для каждого значения A существует ровно одно значение B. Нормальные формы определяют правила для этих зависимостей. 1NF: каждая ячейка содержит одно атомарное значение — никаких массивов, списков через запятую, повторяющихся групп столбцов. 2NF: каждый неключевой атрибут полностью зависит от всего первичного ключа (проблема актуальна при составном PK). 3NF: нет транзитивных зависимостей — неключевой атрибут не должен зависеть от другого неключевого атрибута. Практическое правило: каждый факт хранится ровно в одном месте. Денормализация — осознанное нарушение этого правила ради измеренного прироста производительности.
Как это выглядит на практике
- Ты проектируешь систему управления сотрудниками. Начинаешь с одной таблицы employees: id, name, email, phone1, phone2, phone3, department_name, manager_name. Кажется удобным — всё в одном месте.
- Через месяц появляется требование хранить произвольное количество телефонов. Столбцы phone1, phone2, phone3 не справляются — нарушение 1NF. Создаёшь отдельную таблицу employee_phones(employee_id, phone, type). Первый урок нормализации.
- HR просит переименовать отдел 'Engineering' в 'Software Engineering'. Пишешь UPDATE employees SET department_name = 'Software Engineering' WHERE department_name = 'Engineering' — обновляется 847 строк. На следующий день видишь в данных 'Engineering' у троих — пропустили строки с опечатками в исходном названии. Аномалия обновления в действии.
- Решаешь вынести departments в отдельную таблицу: departments(id, name), в employees остаётся department_id FK. Теперь переименование — одна строка в departments. Это 3NF.
- Проектируешь систему курсов: студент может записаться на много курсов, курс может иметь много студентов. Создаёшь enrollments(student_id, course_id, enrolled_at, grade). При удалении студента — ON DELETE CASCADE убирает записи. Правильная many-to-many через join-таблицу.
- Аналитик просит добавить в список заказов столбец 'сумма заказа'. Текущий запрос требует JOIN order_items + SUM + GROUP BY — медленно при 1M заказов. Добавляешь total_amount в orders с триггером для поддержания актуальности. Осознанная денормализация с документированием.
- Коллега предлагает хранить теги товара в JSON-массиве внутри products.tags = ['электроника', 'смартфоны']. Спрашиваешь: нужно ли искать товары по тегу? Нужно ли считать количество товаров в каждом теге? Если да — создаёшь отдельную таблицу tags и product_tags. Если только отображение — JSONB может быть оправдан.
- Добавляешь UUID как первичный ключ для таблицы событий аналитики — нужна глобальная уникальность между сервисами. Через неделю замечаешь что INSERT в эту таблицу замедлился. EXPLAIN ANALYZE показывает random I/O при обновлении индекса. Переходишь на UUID v7 (time-ordered) — проблема исчезает.
- На code review видишь таблицу: invoices(id, client_id, client_name, client_email, client_address). Объясняешь: client_name, client_email, client_address зависят от client_id (транзитивно) — нарушение 3NF. Предлагаешь вынести в clients или хранить снимок данных клиента на момент выставления счёта (что может быть оправдано для юридических документов).
- Проектируешь схему для OLAP-аналитики (ClickHouse или BigQuery). Нормализация здесь вредна: аналитические БД оптимизированы для широких денормализованных таблиц. Создаёшь fact_orders с денормализованными dimension-полями: user_country, product_category, etc. OLAP и OLTP — разные требования к нормализации.
- Пишешь миграцию для добавления NOT NULL колонки в таблицу с 5M строк. Понимаешь что ALTER TABLE заблокирует таблицу. Правильный подход: ADD COLUMN с DEFAULT, затем UPDATE батчами, затем NOT NULL constraint. Нормализованная схема облегчает такие операции — изменений меньше мест.
- На собеседовании просят спроектировать схему для системы бронирования отелей. Начинаешь с сущностей: hotels, rooms, guests, bookings. Проговариваешь функциональные зависимости вслух, определяешь составные ключи, объясняешь почему room_price хранится в bookings (снимок цены), а не только в rooms. Понимание нормализации позволяет принимать обоснованные решения.
Примеры кода
Диагностика нарушений нормальных форм
-- НАРУШЕНИЕ 1NF: неатомарные значения
-- Плохо: телефоны в одной строке
CREATE TABLE users_bad (
id SERIAL PRIMARY KEY,
name TEXT,
phones TEXT -- '79001234567, 79009876543'
);
-- Плохо: повторяющиеся группы столбцов
CREATE TABLE orders_bad (
id SERIAL PRIMARY KEY,
product1 TEXT,
qty1 INT,
product2 TEXT,
qty2 INT
);
-- Хорошо 1NF: отдельные таблицы
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE user_phones (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
phone TEXT NOT NULL,
type TEXT CHECK (type IN ('mobile', 'work', 'home'))
);
-- НАРУШЕНИЕ 2NF: частичная зависимость от составного ключа
-- Плохо: product_name зависит только от product_id, не от (order_id, product_id)
CREATE TABLE order_items_bad (
order_id INT,
product_id INT,
product_name TEXT, -- нарушение 2NF!
quantity INT,
PRIMARY KEY (order_id, product_id)
);
-- Хорошо 2NF: product_name в таблице products
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2)
);
CREATE TABLE order_items (
order_id INT REFERENCES orders(id),
product_id INT REFERENCES products(id),
quantity INT NOT NULL,
unit_price NUMERIC(10,2) NOT NULL, -- снимок цены на момент заказа
PRIMARY KEY (order_id, product_id)
);
1NF требует атомарности: один факт — одна ячейка. Повторяющиеся группы (product1, product2, product3) — классическое нарушение. 2NF применимо только при составном первичном ключе: все неключевые столбцы должны зависеть от всего ключа, а не только от его части. Обрати внимание на unit_price в order_items — это правильно, это снимок цены на момент заказа, а не нарушение нормализации.
3NF, BCNF и практические паттерны
-- НАРУШЕНИЕ 3NF: транзитивная зависимость
-- employees.department_name зависит от department_id (неключевого),
-- а не напрямую от id сотрудника
CREATE TABLE employees_bad (
id SERIAL PRIMARY KEY,
name TEXT,
department_id INT,
department_name TEXT, -- нарушение 3NF!
department_head TEXT -- нарушение 3NF!
);
-- Хорошо 3NF: вынести зависимые данные в отдельную таблицу
CREATE TABLE departments (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
head_employee_id INT -- circular reference — осторожно с FK
);
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
department_id INT REFERENCES departments(id),
salary NUMERIC(10,2),
hired_at DATE NOT NULL DEFAULT CURRENT_DATE
);
-- MANY-TO-MANY через join-таблицу с атрибутами
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE courses (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
max_capacity INT DEFAULT 30
);
-- Join-таблица с дополнительными атрибутами — не просто связь
CREATE TABLE enrollments (
student_id INT REFERENCES students(id) ON DELETE CASCADE,
course_id INT REFERENCES courses(id) ON DELETE CASCADE,
enrolled_at TIMESTAMPTZ DEFAULT NOW(),
grade NUMERIC(3,1) CHECK (grade BETWEEN 0 AND 10),
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'dropped')),
PRIMARY KEY (student_id, course_id)
);
-- Проверка аномалии обновления: ПРАВИЛЬНО (3NF)
-- Переименовать департамент — одна строка
UPDATE departments SET name = 'Engineering & Data' WHERE id = 5;
-- Сотрудники автоматически видят новое название через JOIN
-- Проверка аномалии обновления: НЕПРАВИЛЬНО (нарушение 3NF)
-- Переименовать департамент — тысячи строк
UPDATE employees_bad SET department_name = 'Engineering & Data'
WHERE department_id = 5;
-- Если пропустишь хоть одну строку — данные несогласованны
3NF: неключевые атрибуты должны зависеть от ключа, только от ключа, и ничего кроме ключа (по Кенту). Join-таблица для many-to-many часто содержит дополнительные атрибуты — это не нарушение нормализации, это обогащение связи данными. Практическое преимущество нормализации наглядно: переименование департамента — одна строка вместо тысяч.
Денормализация: когда и как
-- Нормализованная схема (правильно для OLTP)
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
status TEXT DEFAULT 'pending'
-- НЕТ total_amount — считаем через SUM от order_items
);
-- Получение суммы заказа (нормализованный запрос)
SELECT
o.id,
o.created_at,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.created_at;
-- Денормализация: кэшируем total_amount в orders
ALTER TABLE orders ADD COLUMN total_amount NUMERIC(10,2);
-- Поддерживаем кэш через триггер
CREATE OR REPLACE FUNCTION update_order_total()
RETURNS TRIGGER AS $$
BEGIN
UPDATE orders
SET total_amount = (
SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_items
WHERE order_id = COALESCE(NEW.order_id, OLD.order_id)
)
WHERE id = COALESCE(NEW.order_id, OLD.order_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER order_items_total_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW EXECUTE FUNCTION update_order_total();
-- Теперь список заказов без JOIN
SELECT id, created_at, total_amount FROM orders ORDER BY created_at DESC LIMIT 20;
-- UUID vs BIGSERIAL: практические компромиссы
-- UUID v4 (случайный) — плохо для первичного ключа в B-tree индексе
CREATE TABLE events_uuid (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY, -- фрагментация индекса
data JSONB
);
-- BIGSERIAL — последовательный, эффективный для B-tree
CREATE TABLE events_serial (
id BIGSERIAL PRIMARY KEY,
data JSONB
);
-- UUID v7 (time-ordered) — компромисс: глобально уникален + последователен
-- Доступен через расширение или приложение
Денормализация через триггер — контролируемый компромисс: пишем чуть медленнее (триггер при каждом изменении order_items), читаем значительно быстрее (не нужен JOIN и агрегация). Документируй такие решения в схеме через COMMENT ON COLUMN. UUID vs BIGSERIAL: случайные UUID фрагментируют B-tree индекс, что замедляет INSERT на 2-3x для больших таблиц. Используй BIGSERIAL или UUID v7 если нужна глобальная уникальность.
Что происходит под капотом
- Функциональная зависимость (A → B): для каждого значения A существует ровно одно значение B. Нормальные формы определяют допустимые функциональные зависимости между атрибутами таблицы.
- 1NF: атомарность ячеек означает что СУБД может индексировать, сортировать и фильтровать по значению без парсинга строк. JSON-массив в PostgreSQL — технически атомарен с точки зрения хранения, но логически нарушает 1NF если нужна фильтрация по элементам.
- 2NF актуальна при составном первичном ключе. При суррогатном BIGSERIAL PK нарушение 2NF невозможно по определению — один атрибут не может быть 'частью' скалярного ключа.
- 3NF и BCNF: BCNF (Boyce-Codd Normal Form) строже 3NF — требует что каждый детерминант является потенциальным ключом. На практике разница проявляется в схемах с несколькими перекрывающимися кандидатными ключами, что редко.
- Аномалии обновления (update anomaly): при изменении данных нужно обновить несколько строк. Если операция прервётся — данные становятся несогласованными. Нормализация делает такие операции атомарными по природе.
- Аномалия вставки (insert anomaly): невозможно вставить одну сущность без другой. В ненормализованной employees(id, name, department_name) нельзя записать информацию о новом пустом департаменте.
- Аномалия удаления (delete anomaly): удаление одной сущности уничтожает информацию о другой. Удаление последнего сотрудника из ненормализованной таблицы уничтожает информацию о департаменте.
- B-tree индекс и UUID: B-tree требует вставки в отсортированном порядке для эффективности. Случайные UUID (v4) вызывают random write — страница индекса читается с диска, модифицируется, записывается обратно. При высокой нагрузке это thrashing. UUID v7 monotonic решает проблему.
- Денормализация через триггер vs через приложение: триггер гарантирует консистентность при любом источнике изменений (прямой SQL, миграции, другие сервисы). Денормализация через приложение — риск рассинхронизации при нескольких точках записи.
- JSONB для гибкой схемы: PostgreSQL JSONB позволяет хранить структурированные данные с переменными полями. Можно индексировать (GIN индекс), фильтровать (->>, @>), но нельзя ссылаться через FK. Это компромисс между нормализацией и гибкостью — правильный выбор для полиморфных данных.
Типичные ошибки и заблуждения
- Ошибка: нормализация всегда правильный выбор для любой базы данных. OLAP-системы (аналитические хранилища) намеренно денормализованы — широкие таблицы с повторяющимися данными сканируются быстрее для агрегации. Нормализация оптимальна для OLTP (транзакционные системы).
- Ошибка: больше таблиц и JOIN после нормализации — это медленно. JOIN между двумя таблицами с индексами по FK выполняется за миллисекунды. Аномалии обновления и несогласованность данных в ненормализованной схеме гораздо дороже.
- Ошибка: 3NF — максимум нормализации, дальше бессмысленно. Существуют BCNF, 4NF (многозначные зависимости), 5NF (зависимости соединения). На практике для OLTP достаточно 3NF, но знание более высоких форм помогает в сложных схемах.
- Ошибка: UUID всегда лучше BIGSERIAL для первичного ключа. UUID v4 (случайный) фрагментирует B-tree индекс и замедляет INSERT на больших таблицах. Используй BIGSERIAL для локальных таблиц, UUID v7 или ULID если нужна глобальная уникальность.
- Ошибка: денормализация — это всегда ошибка проектирования. Денормализация с документированием и поддержкой консистентности (триггеры, приложение) — это инженерное решение с измеренными trade-offs. Неосознанная денормализация без поддержки консистентности — ошибка.
- Ошибка: хранить unit_price в order_items — нарушение нормализации (дублирование price из products). Наоборот — это правильно. Цена на момент заказа должна быть зафиксирована независимо от изменений в price товара. Это снимок (snapshot), а не нарушение нормализации.
- Ошибка: join-таблица для many-to-many содержит только два внешних ключа. Join-таблица часто несёт дополнительные атрибуты: enrolled_at, grade, status в enrollments. Это обогащение связи данными, а не нарушение схемы.
Ключевые выводы
- Каждый факт хранится ровно в одном месте — золотое правило нормализации, которое устраняет все три класса аномалий.
- 3NF — практический стандарт для OLTP: нет частичных зависимостей (2NF), нет транзитивных зависимостей (3NF).
- Many-to-many всегда через join-таблицу, которая может содержать дополнительные атрибуты связи.
- Денормализация — осознанный компромисс при измеренной проблеме производительности, с документированием и поддержкой консистентности.
- UUID v4 фрагментирует B-tree индексы при высоких нагрузках — используй BIGSERIAL или UUID v7.
Термины урока
Связь с работой backend-разработчика
Проектируй схему нормализованной с самого начала — это дешевле чем исправлять аномалии данных в продакшне. Денормализуй только при измеренной проблеме производительности: сначала добавь индексы, потом проанализируй планы запросов, и только потом рассматривай денормализацию. Каждое нарушение нормализации документируй как технический долг с обоснованием: 'orders.total_amount денормализован для производительности страницы списка заказов, поддерживается триггером order_items_total_trigger'.
Мини-разбор реальной ситуации
E-commerce команда хранит категорию товара прямо в таблице products: category_name TEXT. Всё работает пока категорий мало. Маркетинг решает переименовать 'Электроника' в 'Бытовая электроника' перед праздниками. Запрос UPDATE products SET category_name = 'Бытовая электроника' WHERE category_name = 'Электроника' затрагивает 200 000 строк, блокирует таблицу на 8 секунд в прайм-тайм, и всё равно оставляет 340 строк с вариантами 'Электроника ' (с пробелом) и 'электроника' (строчная). После инцидента: создают таблицу categories(id, name, slug), добавляют category_id FK в products, мигрируют данные. Теперь переименование — одна строка: UPDATE categories SET name = 'Бытовая электроника' WHERE id = 3. Мгновенно, без блокировок, без несогласованности.
Что запомнить
- Каждый факт — в одном месте. Изменение — в одном месте. Нет аномалий.
- 3NF — начальная точка для OLTP. Денормализуй только при измеренной необходимости и документируй.
- Join-таблица many-to-many несёт атрибуты связи: дата, статус, результат — это данные, не нарушение.
- unit_price в order_items — это снимок, не дублирование. Цена заказа не должна меняться при изменении прайса.
Итог
Нормализация — это инвестиция в будущее схемы. Ненормализованная схема кажется проще в начале, но каждое изменение бизнес-логики превращается в экспедицию по обновлению данных в десятках мест. Нормализованная схема растёт линейно: новые требования добавляют новые таблицы и связи, не ломая существующие. Освоив принципы нормализации, ты начинаешь видеть данные как связанные сущности с чёткими зависимостями — это фундамент для любого серьёзного backend-проекта.