Зачем вообще нормализовать
Вы получили ТЗ: «нужна таблица заказов». Самое естественное — собрать всё в одну широкую таблицу:
orders (id, customer_name, customer_email, customer_country, product_name, qty, price, placed_at)
Что не так? Через пару месяцев боя обнаружится:
- Аномалия обновления: клиент сменил email — нужно обновить его во всех ста его заказах. Забыл хоть один — данные становятся противоречивыми.
- Аномалия вставки: хотите завести нового клиента, но он ещё не делал заказов — некуда вставить.
- Аномалия удаления: удалили последний заказ клиента — потеряли всё знание о клиенте.
- Дублирование: вы храните
customer_emailтысячу раз вместо одного.
Функциональная зависимость — главная концепция
Примеры:
customer_id → customer_email: зная id клиента, однозначно знаем его email.order_id → customer_id, placed_at: зная id заказа, знаем кто и когда его сделал.product_id → product_name, category_id: id товара определяет имя и категорию.
Когда таблица содержит зависимости, которые не «крутятся вокруг» её первичного ключа — это симптом проблемы. Нормализация формализует «крутятся вокруг ключа» через три правила.
X → Y: одинаковый X гарантирует одинаковый Y. Это не утверждение про данные сейчас, это инвариант, который мы обещаем держать всегда.
1NF — атомарные значения
Первая нормальная форма: каждое значение атрибута — атомарное, никаких «списков внутри ячейки».
Нарушение 1NF:
orders
| id | customer | products |
| 1 | Аня | iPhone, USB-кабель, чехол |
| 2 | Борис | MacBook, USB-хаб |
Здесь products — это список в текстовой ячейке. Чтобы посчитать «сколько раз iPhone заказывали», нужно делать LIKE '%iPhone%' — медленно, хрупко, не индексируется.
В 1NF это превращается в две таблицы:
orders (id, customer)
order_items (order_id, product, qty)
Маленькая оговорка: Postgres поддерживает массивы (TEXT[]) и JSONB. Формально с точки зрения 1NF Кодда массивы — нарушение. На практике это удобный инструмент, когда данные внутри ячейки не нужно индексировать или фильтровать построчно (теги, конфиг, профиль). Если нужно — выносите в отдельную таблицу.
2NF — никаких зависимостей от части ключа
Вторая нормальная форма действует только если у таблицы составной первичный ключ. Правило: ни один не-ключевой атрибут не должен зависеть от части ключа — только от ключа целиком.
Классический пример нарушения — таблица позиций заказа с лишним полем:
order_items (order_id, product_id, qty, product_name, product_price)
PRIMARY KEY (order_id, product_id)
Зависимости:
(order_id, product_id) → qty— норма, qty уникальна на пару.product_id → product_name, product_price— а вот это проблема!product_nameзависит только отproduct_id, то есть от части ключа. Один и тот же продукт в разных заказах будет иметь одно и то же имя — мы дублируем его и теряем гарантию консистентности.
Лечение — вынести product_name и product_price в products:
products (id PK, name, price)
order_items (order_id, product_id, qty) -- price можно сохранить отдельно как unit_price_cents
Заметьте: в order_items мы оставили unit_price_cents — это не нарушение 2NF, потому что это исторический снимок цены на момент заказа, а не «текущая цена товара». Цены меняются, а цена в чеке — нет.
3NF — никаких транзитивных зависимостей
Третья нормальная форма: не-ключевые атрибуты должны зависеть только от ключа, и ни от чего другого.
Самый частый случай нарушения 3NF — наш стартовый пример:
orders (id, customer_name, customer_email, customer_country, placed_at)
Зависимости:
id → customer_email— норма (через id мы знаем email).customer_email → customer_name, customer_country— а это уже транзитивная зависимость: email определяет имя и страну, минуяidзаказа.
Если у клиента «Аня» сто заказов, мы храним её имя и страну сто раз. Когда Аня переезжает — обновлять сто строк.
Лечение — вынести данные клиента в отдельную таблицу:
customers (id PK, email UNIQUE, name, country)
orders (id PK, customer_id FK, placed_at)
Теперь id заказа → customer_id, а customer_id → email/name/country живёт в customers. Транзитивности больше нет.
Слева — нарушение 3NF: имя и страна клиента дублируются в каждом его заказе. Справа — 3NF: имя и страна живут один раз в customers.
Запомните неформальное мнемоническое правило для 3NF:
Every non-key attribute must depend on the key, the whole key, and nothing but the key — so help me Codd.
— это шутка из учебников, но она правда работает: «зависит от ключа» = 1NF, «от всего ключа» = 2NF, «и ни от чего больше» = 3NF.
BCNF — крайний случай 3NF
Boyce-Codd Normal Form — это «3NF, доведённая до конца». Разница важна только когда у таблицы есть несколько перекрывающихся
Канонический пример различия — таблица (student_id, course, instructor), где предполагается, что один инструктор ведёт ровно один курс. Тогда instructor → course, но instructor сам не является ключом. Это валидное 3NF (потому что course — часть candidate key (student_id, course)), но нарушение BCNF.
В реальной работе можно просто всегда стремиться к 3NF и не переживать про BCNF отдельно.
Практика: чиним orders
Попробуем посмотреть, как выглядит «плохая» схема и как её разбить. Мы пишем её с нуля в песочнице, чтобы не задеть основную вселенную курса:
Сначала — анти-паттерн: всё в одной таблице
Теперь нормализованная версия:
3NF-версия: три таблицы вместо одной
Цена нормализации — нужны JOIN-ы, чтобы собрать «полную» картину. На крупных аналитических запросах это может быть медленнее. Об этом — следующий урок про денормализацию.
Чек-лист
- Цель нормализации — убрать аномалии вставки/обновления/удаления и дублирование данных.
- Функциональная зависимость X → Y: «один и тот же X гарантирует один и тот же Y». Это фундамент всех нормальных форм.
- 1NF: атомарные значения, никаких списков в ячейках.
- 2NF: при составном ключе ни один не-ключевой атрибут не должен зависеть только от части ключа.
- 3NF: не-ключевые атрибуты не должны зависеть друг от друга — только от ключа.
- Мнемоника 3NF: «depends on the key, the whole key, and nothing but the key».
- BCNF — более строгая версия 3NF. В реальной работе они почти всегда совпадают.
- Цена нормализации —
JOIN-ы. Когда они становятся дорогими — см. следующий урок про денормализацию.