Нормализация: 1НФ-3НФ без боли

Нормализация: 1НФ-3НФ без боли

Нормализация - это процесс организации данных так, чтобы:

  1. Не было дубликатов
  2. Каждое значение было на своём месте
  3. Запросы и обновления были легче

Звучит скучно, но в реальной жизни это означает: нет больше адских колонок типа phones = '111,222,333'.

Нормализация по этапам: 1НФ убирает списки в ячейках, 2НФ выносит зависимости от части ключа, 3НФ убирает транзитивные зависимости

Проблема: неправильная структура

Неправильно:

users( id, name, phones )
1 | Alice | 111,222,333
2 | Bob | 444,555

Проблемы:

  • Как найти все номера Алисы? WHERE phones LIKE '%111%' - медленно и хрупко
  • Как обновить один номер? Нужно парсить строку в приложении
  • Как удалить номер? Нужна сложная логика со строками
  • Легко сломать формат: 111;222;333 (точка с запятой вместо запятой)

1НФ: First Normal Form - одно значение в ячейке

1НФ правило: каждая ячейка содержит только одно значение, не список.

Правильно:

users( id, name )
1 | Alice
2 | Bob

user_phones( id, user_id, phone )
1  | 1      | 111
2  | 1      | 222
3  | 1      | 333
4  | 2      | 444
5  | 2      | 555

Теперь:

  • Легко найти все номера: SELECT phone FROM user_phones WHERE user_id = 1
  • Легко обновить: UPDATE user_phones SET phone = '999' WHERE id = 1
  • Легко удалить: DELETE FROM user_phones WHERE id = 1

Цена за это - лишняя таблица: чтобы показать пользователя вместе с номерами, придётся сделать JOIN.

2НФ: Second Normal Form - зависит от ПОЛНОГО ключа

2НФ правило: каждое поле (не-ключевое) должно зависеть от полного PRIMARY KEY, не от его части.

Пример нарушения 2НФ (составной ключ):

CREATE TABLE order_items (
  order_id BIGINT,
  product_id BIGINT,
  product_name TEXT, - зависит только от product_id, а не от (order_id, product_id)!
  quantity INT, - зависит от пары (order_id, product_id)
  PRIMARY KEY (order_id, product_id)
);

Проблема: product_name зависит только от product_id. Если один товар есть в 100 заказах, мы повторяем название 100 раз!

Правильно:

CREATE TABLE products (
  product_id BIGSERIAL PRIMARY KEY,
  product_name TEXT NOT NULL
);

CREATE TABLE order_items (
  order_id BIGINT NOT NULL,
  product_id BIGINT NOT NULL,
  quantity INT NOT NULL,
  CONSTRAINT order_items_pk PRIMARY KEY (order_id, product_id),
  CONSTRAINT order_items_product_fk FOREIGN KEY (product_id) REFERENCES products(product_id)
);

Теперь product_name хранится в одном месте.

3НФ: Third Normal Form - никаких транзитивных зависимостей

3НФ правило: нет зависимостей между не-ключевыми полями. Если колонка A определяет колонку B, а B определяет C, то это транзитивная зависимость - нарушение 3НФ.

Пример нарушения:

CREATE TABLE orders (
  order_id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL,
  city_id BIGINT NOT NULL,
  city_name TEXT NOT NULL, - зависит от city_id, не от order_id!
  country TEXT NOT NULL - зависит от city_id через city_name!
);

Если есть 1000 заказов из Москвы, мы 1000 раз пишем «Москва» и «Россия».

Правильно:

CREATE TABLE cities (
  city_id BIGSERIAL PRIMARY KEY,
  city_name TEXT NOT NULL,
  country TEXT NOT NULL
);

CREATE TABLE orders (
  order_id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL,
  city_id BIGINT NOT NULL,
  CONSTRAINT orders_city_fk FOREIGN KEY (city_id) REFERENCES cities(city_id)
);

Теперь данные о городе хранятся в одном месте.

Реальный пример: e-commerce

Неправильно (кашель и кровь):

CREATE TABLE products (
  id BIGSERIAL PRIMARY KEY,
  name TEXT,
  price DECIMAL,
  category_name TEXT, - дубликат категории, может быть typo
  category_description TEXT, - дублируется, если категория с разными товарами
  supplier_company TEXT, - дублируется много раз
  supplier_contact_person TEXT,
  supplier_email TEXT,
  supplier_phone TEXT
);

Проблемы:

  • Один товар из категории «Электроника» - значение повторяется 500 раз
  • Обновить описание категории - нужно UPDATE 500 строк
  • Опечатка: одна строка пишет «Elektronika», другая «Электроника»
  • Один поставщик может иметь 1000 товаров - адрес повторяется 1000 раз

Правильно (3НФ):

CREATE TABLE categories (
  category_id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  description TEXT
);

CREATE TABLE suppliers (
  supplier_id BIGSERIAL PRIMARY KEY,
  company TEXT NOT NULL,
  contact_person TEXT,
  email TEXT,
  phone TEXT
);

CREATE TABLE products (
  product_id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  price DECIMAL NOT NULL,
  category_id BIGINT NOT NULL,
  supplier_id BIGINT NOT NULL,
  CONSTRAINT products_category_fk FOREIGN KEY (category_id) REFERENCES categories(category_id),
  CONSTRAINT products_supplier_fk FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id)
);

Теперь:

  • Каждая категория хранится один раз
  • Каждый поставщик хранится один раз
  • UPDATE категории касается одной строки
  • Нет дубликатов, нет typo

Когда нарушать нормализацию (денормализация)

Иногда нормализация замедляет запросы. Тогда денормализируют осознанно - но сначала стоит убедиться через EXPLAIN, что тормозят действительно JOIN, а не отсутствующий индекс:

-- Медленно (много JOIN):
SELECT p.id, p.name, c.category_name, s.company
FROM products p
JOIN categories c ON p.category_id = c.id
JOIN suppliers s ON p.supplier_id = s.id
WHERE p.id = 123;

-- Быстро (denormalized):
CREATE TABLE products_denormalized (
  product_id BIGSERIAL PRIMARY KEY,
  name TEXT,
  category_name TEXT, - копия из categories
  supplier_company TEXT - копия из suppliers
);

SELECT * FROM products_denormalized WHERE product_id = 123;

Но тогда нужна логика для обновления всех копий (очень трудно). Используй только если:

  • Запросы критически медленные
  • Данные обновляются редко
  • Есть materialized views или background jobs для обновления

Materialized Views для 3НФ + скорость

CREATE MATERIALIZED VIEW products_with_details AS
SELECT p.product_id, p.name, c.category_name, s.company
FROM products p
JOIN categories c ON p.category_id = c.id
JOIN suppliers s ON p.supplier_id = s.id;

-- Запрос очень быстро (данные предварительно вычислены)
SELECT * FROM products_with_details WHERE product_id = 123;

-- Обновляем view когда данные меняются
REFRESH MATERIALIZED VIEW products_with_details;

Видов (views) хранят запрос, materialized views хранят результаты. По сути это кеш внутри базы - с теми же вопросами, что и у внешнего кеша: когда обновлять и насколько устаревшие данные ты готов показывать.

Каждый факт (дата рождения, город, должность) должен храниться в одном месте. Если видишь дубликат - вынеси в отдельную таблицу и ссылайся через [FK](./09-constraints.md).

Практические задания

  • Дизайн школы: есть студенты, каждый в классе, каждый класс имеет учителя. Нарисуй 3НФ схему (3-4 таблицы)
  • Возьми неправильную таблицу books(id, title, author_name, author_country, author_birth_year) и нормализуй в 3НФ
  • Создай запрос к 3НФ схеме с двумя JOIN (книга + автор)
  • Обсуди: когда денормализация оправдана? (e.g., поле total_items_count в заказе вместо COUNT)
  • Проектируй блог: посты, комментарии, авторы, категории (минимум 5 таблиц в 3НФ)

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.