Нормальные формы БД: разбор 1НФ, 2НФ и 3НФ на примерах

Наводим марафет в таблицах

Нормальные формы БД: разбор 1НФ, 2НФ и 3НФ на примерах

Нормализация базы данных избавляет от странных ситуаций. Например, когда один город в таблице записан тремя разными способами — «Казань», «г. Казань», «Казань, Татарстан». В таком случае отчёт по городам показывает три города вместо одного, и группировка SQL этого не исправит.

Ещё пример: вам нужно сменить номер телефона клиента. Значит, нужно обновить все строки с его заказами, а пропуск хотя бы одной позиции создаёт два разных номера у одного человека. Рассказываем, как избавиться от путанницы в базе данных и пошагово перейти к формам нормализации 1НФ, 2НФ, 3НФ.

ВАМ ПРИШЛО ПРИГЛАШЕНИЕ 💌
Приходите к нам в соцсети поделиться своим мнением и почитать, что пишут другие. А ещё там выходит дополнительный контент, которого нет на сайте — шпаргалки, опросы и разная дурка. В общем, вот тележка, вот ВК — велком!

Что решает нормализация

Нормализация базы данных отвечает на вопрос, как хранить сведения так, чтобы каждый факт был записан один раз. Пока факт повторяется в нескольких строках, любое действие с ним превращается в правку всех копий, и любая пропущенная копия становится противоречием.

Из-за избыточности данных происходят сбои — так называемые аномалии. Разберём их на примере таблицы orders_flat, где заказы, клиенты и товары лежат вместе.

order_idorder_datecustomer_namephone_1phone_2phone_3cityregionskuproduct_namecategorypricequantity
10012026-03-04Иванова Мария+7 917 200-11-45+7 843 555-01-01NULLКазаньТатарстанTWK3A011Чайник Bosch TWK3A011Кухонная техника29901
10012026-03-04Иванова Мария+7 917 200-11-45+7 843 555-01-01NULLКазаньТатарстанHD2581Тостер Philips HD2581Кухонная техника34901
10022026-03-05Сидоров Алексей+7 912 330-77-19NULLNULLЕкатеринбургСвердловская областьKT-1329Кофемолка Kitfort КТ-1329Кухонная техника25902
10032026-03-06Иванова Мария+7 917 200-11-45+7 843 555-01-01NULLКазаньТатарстанPRO-3MУдлинитель Pilot PRO 3 мЭлектрика11903
10042026-03-07Гареев Ильдар+7 987 410-22-08NULLNULLНабережные ЧелныТатарстанMJTD01SYLЛампа настольная Xiaomi Mi LED Desk Lamp 1SОсвещение42901

Аномалия вставки. Магазин закупил микроволновую печь Samsung ME88SUG с артикулом MW-2088 и ценой 8990. Записать её некуда: колонка order_id обязательна, заказа на этот товар пока нет. Приходится либо ждать первого покупателя, либо вставлять строку с фиктивным заказом.

Аномалия изменения. Категорию Кухонная техника решили переименовать в Техника для кухни. Название встречается в трёх строках, в двух позициях заказа 1001 и в заказе 1002, и все три нужно обновить, иначе часть товаров окажется в старой категории, а часть в новой.

Аномалия удаления. Гареев Ильдар отменил заказ 1004. Строка удаляется, и вместе с ней из базы исчезают сам клиент, его телефон и город Набережные Челны, ведь других строк с этими сведениями нет.

Функциональные зависимости и ключи

Функциональная зависимость означает, что значение одного набора полей однозначно задаёт значение другого. Если в таблице orders_flat известен sku, то product_name, category и price уже не могут быть какими угодно: артикулу HD2581 в любой строке соответствует тостер Philips за 3490 рублей, и другого варианта нет. Зависимость записывается стрелкой — слева стоит поле или группа полей, справа то, что от них зависит.

Левую часть зависимости называют детерминантом. Учтите, что в одной таблице детерминантов обычно несколько, и не все они уникальны построчно. Скажем, customer_name задаёт телефоны и город, однако Иванова Мария встречается в трёх строках, так что отдельной строки это поле не определяет.

Набор полей, который однозначно определяет строку целиком и не содержит лишних полей, называется потенциальным ключом. В orders_flat один order_id для роли ключа не годится, поскольку заказ 1001 занимает две строки с разными sku. Один sku тоже не подходит, ведь чайник TWK3A011 может оказаться и в других заказах. Зато пара (order_id, sku) уникальна, это единственный потенциальный ключ в нашем примере.

Зависимости orders_flat:

  • order_id → order_date, customer_name
  • customer_name → phone_1, phone_2, phone_3, city
  • city → region
  • sku → product_name, category, price
  • (order_id, sku) → quantity

Детерминанты:

  • order_id
  • customer_name
  • city
  • sku
  • пара (order_id, sku)

Первая нормальная форма

По правилам первой нормальной формы (1НФ) в каждой ячейке должно храниться одно неделимое значение — это называется атомарностью. Также в таблице не должно быть колонок, которые повторяют друг друга по смыслу.

order_idcustomer_namephone_1phone_2phone_3products
1001Иванова Мария+7 917 200-11-45+7 843 555-01-01NULLЧайник Bosch TWK3A011 x1, Тостер Philips HD2581 x1
1002Сидоров Алексей+7 912 330-77-19NULLNULLКофемолка Kitfort КТ-1329 x2

Сжатая версия orders_flat нарушает оба правила 1НФ:

  • Найти заказы с тостером HD2581 получится только через LIKE по строке. Посчитать сумму проданных штук SUM не сможет, потому что количество зашито в текст рядом с названием.
  • Поиск клиента по номеру требует трёх условий через OR. Четвёртый телефон добавлять некуда, придётся использовать ALTER TABLE.

Приведём таблицу к 1НФ — для этого позиции развернём в отдельные строки, тогда заказ 1001 превращается в две строки с ключом (1001, TWK3A011) и (1001, HD2581). Телефоны уходят в отдельную таблицу, где на каждый номер приходится своя строка.

customer_namephone
Иванова Мария+7 917 200-11-45
Иванова Мария+7 843 555-01-01
Сидоров Алексей+7 912 330-77-19
Гареев Ильдар+7 987 410-22-08
-- PostgreSQL 16
CREATE TABLE customer_phones (
    customer_name text NOT NULL,
    phone         text NOT NULL,
    PRIMARY KEY (customer_name, phone)
);

После изменений поиск по номеру сводится к одному условию WHERE phone = ‘+7 843 555-01-01’. К тому же количество телефонов у клиента теперь ничем не ограничено. Впрочем, дублирование данных о товаре и клиенте в orders_flat пока остаётся.

Вторая нормальная форма

Следуя требованиям второй нормальной формы (2НФ), каждое неключевое поле должно определяться всем составным ключом. Иначе говоря, если знание одного лишь артикула уже даёт значение поля, и номер заказа для этого не нужен, такое поле нарушает 2НФ.

Так выглядит orders_flat после 1НФ:

order_idorder_datecustomer_namecityregionskuproduct_namecategorypricequantity
10012026-03-04Иванова МарияКазаньТатарстанTWK3A011Чайник Bosch TWK3A011Кухонная техника29901
10012026-03-04Иванова МарияКазаньТатарстанHD2581Тостер Philips HD2581Кухонная техника34901
10022026-03-05Сидоров АлексейЕкатеринбургСвердловская областьKT-1329Кофемолка Kitfort КТ-1329Кухонная техника25902

Обратите внимание на строки order_id 1001. Название чайника TWK3A011, его категория и цена 2990 не имеют отношения к номеру заказа: закажи тот же чайник Сидоров Алексей в заказе 1002, все три значения повторились бы без изменений. Значит, product_name, category и price зависят только от sku, то есть от половины ключа.

Та же проблема есть у order_date с customer_name, которые зависят только от order_id. От всего ключа целиком зависит, по сути, одно поле quantity.

Чтобы привести базу данных к 2НФ, нужно разложить данные на три таблицы. Товары уходят в products, шапка заказа в orders, а в order_items остаётся ровно то, что определяется парой (order_id, sku).

products

skuproduct_namecategoryprice
TWK3A011Чайник Bosch TWK3A011Кухонная техника2990
HD2581Тостер Philips HD2581Кухонная техника3490
KT-1329Кофемолка Kitfort КТ-1329Кухонная техника2590
MW-2088Микроволновая печь Samsung ME88SUGКухонная техника8990

orders

order_idorder_datecustomer_namecityregion
10012026-03-04Иванова МарияКазаньТатарстан
10022026-03-05Сидоров АлексейЕкатеринбургСвердловская область

order_items

order_idskuquantity
1001TWK3A0111
1001HD25811
1002KT-13292
-- PostgreSQL 16
CREATE TABLE products (
    sku          text PRIMARY KEY,
    product_name text NOT NULL,
    category     text NOT NULL,
    price        integer NOT NULL
);

CREATE TABLE orders (
    order_id      integer PRIMARY KEY,
    order_date    date NOT NULL,
    customer_name text NOT NULL,
    city          text NOT NULL,
    region        text NOT NULL
);

CREATE TABLE order_items (
    order_id integer NOT NULL REFERENCES orders(order_id),
    sku      text NOT NULL REFERENCES products(sku),
    quantity integer NOT NULL CHECK (quantity > 0),
    PRIMARY KEY (order_id, sku)
);

Если первичный ключ состоит из одного поля, частичная зависимость невозможна по построению, ведь у такого ключа нет частей, и таблица в 1НФ автоматически оказывается во 2НФ. Колонки city и region в orders пока остались как есть, скоро ими займёмся.

Большая скидка — 16% на все курсы Практикума

Если вы читаете эту статью, тогда вы точно разбираетесь в технологиях. Стать лучше и зарабатывать больше можно после курсов Практикума — по программированию, анализу данных и искусственному интеллекту.

До 30 сентября на все курсы действует скидка 16%, она применится автоматически при оплате. Потом цены станут выше, поэтому не откладывайте!

Ещё проектируете таблицы, но не уверены, где заканчивается ваш SQL, — берите «Python-разработчик»; уже пишете запросы, но раскладывать схему на нормальные формы пока приходится с нуля каждый раз — «SQL для разработки»; а если задача — не поддерживать чужую базу, а проектировать архитектуру данных под бизнес-процесс целиком — «Инженер данных».

Третья нормальная форма

Третья нормальная форма (3НФ) требует, чтобы неключевые поля не зависели друг от друга. Каждое из них должно определяться только первичным ключом.

Зависимость через посредника называют транзитивной: order_id определяет customer_name, customer_name определяет city, а city определяет region. Регион, выходит, привязан к заказу через два промежуточных поля.

orders после 2НФ:

order_idorder_datecustomer_namecityregion
10012026-03-04Иванова МарияКазаньТатарстан
10022026-03-05Сидоров АлексейЕкатеринбургСвердловская область
10032026-03-06Иванова МарияКазаньТатарстан
10042026-03-07Гареев ИльдарНабережные ЧелныТатарстан

Заказы 1001 и 1003 повторяют город и регион Ивановой Марии целиком. Значение Татарстан встречается трижды, хотя на самом деле оно принадлежит городу, а не заказу: стоит переименовать регион, и править придётся строки 1001, 1003 и 1004.

По правилам 3НФ, каждое звено цепочки перенесём в свой справочник. Связь между ними будет держаться на внешних ключах.

regions

region_idregion_name
1Татарстан
2Свердловская область

cities

city_idcity_nameregion_id
1Казань1
2Екатеринбург2
3Набережные Челны1

customers

customer_idfull_namecity_id
1Иванова Мария1
2Сидоров Алексей2
3Гареев Ильдар3

orders

order_idorder_datecustomer_id
10012026-03-041
10022026-03-052
10032026-03-061
10042026-03-073

categories

category_idcategory_name
1Кухонная техника
2Электрика
3Освещение

products

skuproduct_namecategory_idprice
TWK3A011Чайник Bosch TWK3A01112990
HD2581Тостер Philips HD258113490
KT-1329Кофемолка Kitfort КТ-132912590
PRO-3MУдлинитель Pilot PRO 3 м21190
MJTD01SYLЛампа настольная Xiaomi Mi LED Desk Lamp 1S34290
MW-2088Микроволновая печь Samsung ME88SUG18990
-- PostgreSQL 16
CREATE TABLE regions (
    region_id   integer PRIMARY KEY,
    region_name text NOT NULL UNIQUE
);

CREATE TABLE cities (
    city_id   integer PRIMARY KEY,
    city_name text NOT NULL,
    region_id integer NOT NULL REFERENCES regions(region_id)
);

CREATE TABLE customers (
    customer_id integer PRIMARY KEY,
    full_name   text NOT NULL,
    city_id     integer NOT NULL REFERENCES cities(city_id)
);

CREATE TABLE customer_phones (
    customer_id integer NOT NULL REFERENCES customers(customer_id),
    phone       text NOT NULL,
    PRIMARY KEY (customer_id, phone)
);

CREATE TABLE categories (
    category_id   integer PRIMARY KEY,
    category_name text NOT NULL UNIQUE
);

CREATE TABLE products (
    sku          text PRIMARY KEY,
    product_name text NOT NULL,
    category_id  integer NOT NULL REFERENCES categories(category_id),
    price        integer NOT NULL
);

CREATE TABLE orders (
    order_id    integer PRIMARY KEY,
    order_date  date NOT NULL,
    customer_id integer NOT NULL REFERENCES customers(customer_id)
);

CREATE TABLE order_items (
    order_id integer NOT NULL REFERENCES orders(order_id),
    sku      text NOT NULL REFERENCES products(sku),
    quantity integer NOT NULL CHECK (quantity > 0),
    PRIMARY KEY (order_id, sku)
);

Исходное плоское представление собирается командной:

SELECT o.order_id, o.order_date, c.full_name, ci.city_name, r.region_name,
       p.sku, p.product_name, cat.category_name, p.price, oi.quantity
FROM orders o
JOIN customers c    ON c.customer_id = o.customer_id
JOIN cities ci      ON ci.city_id = c.city_id
JOIN regions r      ON r.region_id = ci.region_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p     ON p.sku = oi.sku
JOIN categories cat ON cat.category_id = p.category_id
ORDER BY o.order_id, p.sku;

Первые строки результата совпадают с orders_flat, только без телефонов:

order_idorder_datefull_namecity_nameregion_nameskuproduct_namecategory_namepricequantity
10012026-03-04Иванова МарияКазаньТатарстанHD2581Тостер Philips HD2581Кухонная техника34901
10012026-03-04Иванова МарияКазаньТатарстанTWK3A011Чайник Bosch TWK3A011Кухонная техника29901
10022026-03-05Сидоров АлексейЕкатеринбургСвердловская областьKT-1329Кофемолка Kitfort КТ-1329Кухонная техника25902

Смена региона у города или переименование категории правит одну строку, а микроволновка MW-2088 спокойно живёт в products без единого заказа. Один уникальный факт хранится только в одном месте.

Формы выше третьей

Следом за 3НФ идёт нормальная форма Бойса-Кодда (BCNF). Она закрывает аномалию, когда неключевое поле определяет часть составного ключа. Формально левая часть каждой функциональной зависимости обязана быть ключом таблицы.

group_namesubjectteacher
ИС-21Базы данныхПетров
ИС-22Базы данныхПетров
ИС-21СетиКузнецова

Зависимость teacher → subject здесь есть, а ключом teacher не является, поэтому таблица нарушает BCNF, хотя 3НФ соблюдена. Если Петрова во второй строке по ошибке запишут на Сети, база этого не заметит. Исправляется разбиением на teachers(teacher, subject) и schedule(group_name, teacher), чтобы предмет преподавателя был записан один раз.

Четвёртая форма разбирается с многозначными зависимостями, когда в одной таблице лежат два независимых списка. Например, языки сотрудника, его проекты, и строки множатся перекрёстно. Пятая, к слову, встречается ещё реже и требует, чтобы таблицу нельзя было собрать из более мелких без потери или появления строк.

В прикладной разработке обычно останавливаются на 3НФ. Нарушение BCNF предполагает составной ключ и особую зависимость внутри него, что в схемах магазинов, CRM и складского учёта встречается редко. Ещё разложение до BCNF порой мешает проверить исходную зависимость одним ограничением. До BCNF доходят там, где такие связи появляются естественно: расписания, распределение врачей по кабинетам, логистика с закреплёнными за водителями маршрутами.

Когда денормализация оправдана

Денормализацией называют осознанное возвращение избыточности в схему, которая уже приведена к 3НФ. Она уместна там, где чтение происходит намного чаще записи:

  • Витрина для аналитики. Отчёту по продажам не нужен запрос с шестью JOIN, поэтому его результат хранят отдельной таблицей, по структуре совпадающей с orders_flat, и пересобирают по расписанию. Минус в том, что до следующей пересборки отчёт показывает устаревшие цифры.
  • Кэш агрегата. Количество заказов клиента можно считать каждый раз, а можно держать в колонке orders_count таблицы customers.

-- PostgreSQL 16
ALTER TABLE customers ADD COLUMN orders_count integer NOT NULL DEFAULT 0;

В таком случае каждый INSERT или DELETE в orders обязан обновить счётчик, иначе у Ивановой Марии будет два заказа в orders и, скажем, единица в orders_count.

  • Исторический снимок цены. Если чайник TWK3A011 подорожает с 2990 до 3490, старый заказ 1001 не должен измениться, так что цену на момент покупки фиксируют в order_items.
ALTER TABLE order_items ADD COLUMN unit_price integer NOT NULL;

Тут нет нарушения 3НФ: unit_price зависит от пары (order_id, sku). Однако разработчик обязан помнить про два поля с похожим смыслом.

  • Слабоструктурированные характеристики. У лампы есть цветовая температура, у чайника мощность, и отдельная колонка под каждый признак раздула бы products. Вместо этого используют JSONB.
ALTER TABLE products ADD COLUMN attributes jsonb; -- диалект PostgreSQL
UPDATE products SET attributes = '{"power_w": 2400, "color": "белый"}' WHERE sku = 'TWK3A011';

Нужно помнить, что СУБД не проверит ни тип, ни наличие поля power_w. Опечатка в ключе приведёт к пропуску товара в фильтре.

  • Документная модель. Когда заказ всегда читается целиком, вместе с позициями, его можно хранить одним JSONB-документом, вложив содержимое order_items внутрь orders. Однако есть нюанс: обновить цену товара одним UPDATE по products сразу во всех заказах уже нельзя.

В результате приходим к выводу, что избыточность допустима, пока за согласованность отвечает конкретный код, триггер или регламент пересборки, а не память разработчика.

Как нормализация влияет на скорость запросов

Разложенная схема хранит каждое значение один раз, поэтому объём данных на диске меньше и строк в таблицах тоже. Чтение при этом усложняется: чтобы собрать заказ целиком, PostgreSQL соединяет несколько таблиц через JOIN, каждое соединение стоит времени. Плоская таблица, наоборот, отдаёт результат одним проходом, зато переписывает дубли при каждом изменении и растёт заметно быстрее.

Производительность базы данных зависит от того, стоят ли индексы на колонках внешних ключей. Без них на orders(customer_id) и order_items(order_id) каждый JOIN превращается в полный просмотр, и схема 3НФ проигрывает. Второе условие касается доли записи: чем чаще данные меняются, тем сильнее выигрыш от отсутствия дублей.

Чтобы принять решение, нужно снять план запроса командой EXPLAIN, повторить на объёме данных, близком к рабочему, сравнить время выполнения.

Выборка заказов Ивановой Марии по плоской таблице и по схеме 3НФ.

-- PostgreSQL 16
-- диалект PostgreSQL
EXPLAIN ANALYZE
SELECT order_id, order_date, sku, product_name, price, quantity
FROM orders_flat
WHERE customer_name = 'Иванова Мария'
ORDER BY order_id, sku;

-- диалект PostgreSQL
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
CREATE INDEX order_items_order_id_idx ON order_items (order_id);

EXPLAIN ANALYZE
SELECT o.order_id, o.order_date, p.sku, p.product_name, p.price, oi.quantity
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.sku = oi.sku
WHERE c.full_name = 'Иванова Мария'
ORDER BY o.order_id, p.sku;

В выводе строка Seq Scan означает полный просмотр таблицы, а Index Scan сообщает, что сработал индекс. На пяти строках из примера план в обоих случаях покажет Seq Scan, поскольку планировщик считает чтение маленькой таблицы целиком дешевле обращения к индексу. Разница проявляется на десятках тысяч заказов, и решение о структуре принимается, собственно, только после такого прогона.

Как разложить работающую таблицу

В нашем примере orders_flat достаточно удалить и пересобрать. В боевой базе так нельзя: приложение пишет заказы каждую минуту, и остановка стоит денег. Поэтому миграцию базы данных выполняют поэтапно:

  1. Создать таблицы итоговой схемы 3НФ, включая внешние ключи. Пока они пустые, ошибиться не страшно.
  2. Залить данные из orders_flat через INSERT … SELECT DISTINCT. Справочники заполняются первыми, затем таблицы, которые на них ссылаются.
  3. Включить двойную запись: приложение при каждом новом заказе пишет и в orders_flat, и в orders с order_items.
  4. Перевести чтение на новые таблицы, начиная с отчётов, потом с личного кабинета.
  5. Дождаться проверки. Скажем, неделю сравнивать сумму по quantity и число строк в обеих схемах.
  6. Удалить лишние поля из orders_flat или всю таблицу целиком.

-- PostgreSQL 16
INSERT INTO products (sku, product_name, category_id, price)
SELECT DISTINCT f.sku, f.product_name, c.category_id, f.price
FROM orders_flat f
JOIN categories c ON c.category_name = f.category;

INSERT INTO order_items (order_id, sku, quantity)
SELECT DISTINCT order_id, sku, quantity
FROM orders_flat;

DISTINCT убирает точные дубли. Расхождения он не спасёт: если один sku встречается с двумя ценами, вставка в products упадёт на PRIMARY KEY. Такие строки находят заранее запросом с GROUP BY sku HAVING COUNT(DISTINCT price) > 1 и решают вручную, какая версия верна. Аналогично с клиентом, у которого разные города. Рефакторинг схемы тем и полезен: он вытаскивает наружу мусор, годами копившийся в плоской таблице.

Антипаттерны проектирования

Ошибки проектирования БД повторяются из проекта в проект, и почти все они встречались выше на примере orders_flat:

  • Список значений через запятую в текстовом поле. В схеме: колонка products со значением Чайник Bosch TWK3A011 x1, Тостер Philips HD2581 x1. Посчитать продажи тостеров получится только разбором строки, индекс по такой колонке бесполезен. Исправляется добавлением таблицы order_items, где каждая позиция занимает свою строку.
  • Нумерованные колонки под однотипные значения. В схеме с колонками phone_1, phone_2, phone_3, четвёртый телефон потребует ALTER TABLE, поиск клиента по номеру придётся писать через три условия OR. Исправляется таблицей customer_phones с ключом (customer_id, phone).
  • Таблица сущность-атрибут-значение (EAV). Вместо колонок products хранится тройка sku, имя атрибута, значение текстом, например MW-2088 / power_w / 2400. Типы теряются, NOT NULL и CHECK не работают, выборка товара по двум характеристикам требует двух самосоединений. Обязательные атрибуты уходят в обычные колонки, редкие — в JSONB-колонку attributes.
  • Дублирование справочника в нескольких таблицах. Категория записана текстом и в products, и, скажем, в order_items. Переименование Кухонная техника в Техника для кухни правит две таблицы, которые разъедутся при первой же ошибке. Нужна одна таблица categories и ссылки на category_id.
  • Отсутствие внешних ключей при наличии логических связей. Колонка order_items.sku объявлена как text без REFERENCES. База примет заказ с несуществующим артикулом, JOIN такие строки молча потеряет. Исправляется командой ALTER TABLE order_items ADD FOREIGN KEY (sku) REFERENCES products(sku).
  • Схема от ИИ-ассистента, принятая без ревью. Внешне CREATE TABLE выглядит аккуратно, внутри же, как правило, те самые phone_1..phone_3, текстовая категория в двух таблицах и ни одного REFERENCES. Такую схему стоит прогнать по чек-листу из следующего раздела и сверить каждую колонку со списком зависимостей.

Чек-лист для ревью

  1. У каждой таблицы есть первичный ключ — колонка или набор колонок, по которым строка находится однозначно. Если ключа нет, две одинаковые строки невозможно различить и удалить по отдельности. В таблице позиций заказа order_items ключом служит пара (order_id, sku), поскольку один товар в одном заказе встречается один раз.
  2. В каждой ячейке хранится одно значение. Перечисление через запятую вроде Чайник Bosch TWK3A011 x1, Тостер Philips HD2581 x1 нельзя отфильтровать или посчитать обычным WHERE. Такие данные выносятся в отдельную таблицу по строке на элемент.
  3. Нет колонок с номерами в названии. Набор phone_1, phone_2, phone_3 означает, что четвёртый телефон уже некуда записать, а поиск по номеру требует трёх условий. Вместо этого создаётся таблица customer_phones, где каждый номер лежит в своей строке.
  4. Неключевые поля зависят от всего ключа. Если ключ составной, скажем (order_id, sku), а название товара определяется одним только sku, оно будет повторяться в каждом заказе и разъедется после правки. Такие поля переезжают в таблицу products с ключом sku.
  5. Повторяющиеся текстовые значения заменены ссылками на справочник. Город и регион у каждого клиента записываются один раз в cities и regions, остальные таблицы хранят лишь числовой city_id.
  6. Связи между таблицами объявлены через REFERENCES и покрыты индексами. Ограничение не даёт вставить заказ несуществующего клиента. Индекс нужен потому, что PostgreSQL создаёт его автоматически только для PRIMARY KEY и UNIQUE. Индексы на orders(customer_id) и order_items(order_id) добавляются отдельно.
  7. Каждое намеренное дублирование данных объяснено в COMMENT ON COLUMN или в документации. Например, колонка unit_price в order_items копирует цену из products ради истории, и эта причина должна быть видна следующему разработчику.
  8. У любого дублированного или вычисленного поля описано, кто его обновляет. Счётчик orders_count в customers либо пересчитывается триггером, либо кодом приложения, и решение принимается до появления первых данных.

Схема, прошедшая все восемь пунктов, как правило, готова и к росту данных, и к передаче другой команде.

Ответы на частые вопросы

Обязательно ли доводить схему до третьей формы?

Да, для прикладных систем учёта третья форма считается рабочим минимумом.

Нужна ли нормализация в MongoDB и других документных базах данных?

Нужна, хотя применяется иначе. Документ заказа может вкладывать позиции внутрь себя. Справочники товаров и клиентов, которые меняются отдельно, выгодно держать в собственных коллекциях, иначе смена цены товара затронет тысячи документов.

Замедляют ли соединения работу приложения?

Обычно нет, при наличии индексов на внешних ключах. Выборка заказов одного клиента по схеме 3НФ с индексами на orders(customer_id) и order_items(order_id) читает несколько строк по индексу, тогда как orders_flat вынуждает просматривать таблицу целиком, если индекса по customer_name нет.

Как понять, что база уже в третьей форме?

Проверьте таблицы на два признака: все неключевые колонки зависят от полного ключа, а не от его части, и ни одна неключевая колонка не определяет другую. В products, к примеру, category_name ушла в categories именно потому, что зависела от category_id, а не от sku.

Что делать, если схему проектировали без нормализации и она в проде?

Начните с самого болезненного справочника. Сначала создайте новую таблицу, данные перемещайте через INSERT … SELECT DISTINCT, затем переключайте приложение на неё. Старые колонки удаляйте только после проверки.


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

Советуем дополнительно почитать

5 видов баз данных, которые подходят для разных задач — сравнение реляционных, колоночных, графовых, резидентных и документных СУБД, чтобы понимать, когда задачу вообще не нужно тащить в таблицы.

Redis: что это такое и как им пользоваться — устройство in-memory хранилища, которое на практике решает ту самую задачу кэша агрегата из раздела про денормализацию.

Монолит и микросервисы: как выбрать архитектуру проекта — данные CNCF о том, что 42% компаний вернули часть сервисов обратно в монолит, и чек-лист, когда дробление системы — а вместе с ней и баз данных — оправдано.

Оконные функции SQL: что это такое и как работают — с примерами — OVER, PARTITION BY, ROW_NUMBER и LAG для задач, с которыми обычный GROUP BY уже не справляется.

Карьера backend-разработчика middle и выше: куда перекатываться в 2026–2027 — пять векторов роста для тех, кто разложил свою первую таблицу и хочет понимать, что делать дальше.

Бонус для читателей

Если вам интересно погрузиться в мир IT и при этом немного сэкономить, держите наш промокод на курсы Практикума. Он даст вам скидку при оплате, поможет с льготной ипотекой или безлимитом на маркетплейсах. Ладно, окей, это просто скидка, без остального, но хорошая.

Вам может быть интересно
medium
[anycomment]
Exit mobile version