<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
SQL · JUNIOR

Нормальні форми: до якої нормалізувати схему і коли свідомо денормалізувати?

Робочий стандарт - 3НФ: кожен факт живе в одному місці, решта таблиць посилається на нього ключем. Денормалізують після 3НФ і під конкретний вимір читання, залишаючи нормалізовані дані джерелом істини, з якого дублікат можна перерахувати.

До якої нормальної форми ви доводите схему в реальному проєкті?
Чому не можна зберігати список товарів замовлення в одному текстовому полі?
У нас на картці товару є поле reviews_count, а ще є таблиця відгуків. Це помилка проєктування?
Що поламається, якщо клієнт змінить назву компанії, а вона записана в кожному замовленні?
нормалізація 3НФ денормалізація проєктування схеми

Нормалізація вирішує одну задачу: щоб кожен факт був записаний у базі рівно один раз. Таблиця orders_flat з коду вище провалює вже 1НФ, бо в колонці products лежить список, і база не вміє ні порахувати проданих клавіатур, ні поставити зовнішній ключ на товар. Ремонт через колонки product_1, product_2, product_3 того самого рівня: він просто розтягує список по горизонталі й ламається на четвертій позиції. 2НФ прибирає залежність від частини складеного ключа: у позиціях замовлення з ключем (order_id, product_id) колонці product_name не місце, бо назва визначається лише товаром. А 3НФ прибирає залежність неключової колонки від неключової: місто, що виводиться з поштового індексу, або line_total, обчислений з quantity і unit_price.

В експлуатації дублі перетворюються на аномалії. Клієнт змінює назву компанії, запит оновлює 900 рядків із 1000, і схема більше не має способу відповісти, яка назва правильна: обидві однаково легальні. Це аномалія оновлення. Поряд живуть дві її родички: аномалія вставки, коли нове місто не можна завести в довідник, бо міста існують лише в замовленнях, і аномалія видалення, коли з видаленням останнього замовлення клієнта зникають його адреса й телефон. Зовнішній ключ на clients.id знімає всі три, бо змінювати стає нічого: є один рядок.

Лишається окремий клас колонок, який часто помилково записують у дублі. order_items.unit_price виглядає як копія products.price, але фіксує інший факт: не «скільки товар коштує», а «скільки за нього заплатили тоді». Коли ціна в каталозі зміниться, вчорашні чеки мусять залишитись як були. Той самий критерій працює для адреси доставки й ставки податку: якщо копія має змінюватись разом із джерелом, її треба прибрати як дубль; якщо мусить залишитись незмінною, це знімок, і його місце саме тут.

Денормалізацію додають зверху на нормалізовану схему і під конкретний запит, який уже виміряли. Типові випадки - сума замовлення в списку, reviews_count на картці товару, денормалізоване імʼя автора в ленті: без них кожен рядок списку тягне агрегат по дочірній таблиці. Для звітів краще не чіпати робочі таблиці взагалі, а виносити агрегати окремо: у PostgreSQL це матеріалізоване представлення з REFRESH MATERIALIZED VIEW CONCURRENTLY, у MySQL - таблиця-зведення, яку наповнює задача за розкладом. Плата однакова: дані застарілі на відомий інтервал, і цей інтервал треба назвати вголос, а не виявити з багрепорту.

Узгодженість дублікатів тримається трьома речами. Дубль оновлюється в тій самій транзакції, що й джерело, і рівно з одного місця: або з одного сервісу в коді, або з тригера на таблиці-джерелі; два незалежних місця запису розійдуться. Арифметика робиться атомарно, UPDATE ... SET reviews_count = reviews_count + 1, а не читанням значення в PHP і записом назад. І має існувати запит, який перераховує колонку з нуля, як UPDATE ... FROM (SELECT ... GROUP BY ...) у прикладі: він же виступає звіркою на розкладі. Немає такого запиту - колонка перестала бути кешем і стала другою версією правди.

-- Не в 1НФ: список товарів в одній колонці, дані клієнта в кожному рядку
CREATE TABLE orders_flat (
    id          bigserial PRIMARY KEY,
    client_name text,               -- дублюється в усіх замовленнях клієнта
    client_city text,               -- залежить від клієнта, а не від замовлення
    products    text                -- 'Клавіатура x2, Мишка x1' - SQL це не прочитає
);

-- 3НФ: кожен факт в одному місці, звʼязок через ключ
CREATE TABLE clients (
    id   bigserial PRIMARY KEY,
    name text NOT NULL,
    city text NOT NULL
);

CREATE TABLE orders (
    id         bigserial   PRIMARY KEY,
    client_id  bigint      NOT NULL REFERENCES clients (id),
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
    order_id   bigint        NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
    product_id bigint        NOT NULL REFERENCES products (id),
    quantity   int           NOT NULL CHECK (quantity > 0),
    unit_price numeric(12,2) NOT NULL,   -- ціна на момент продажу, не копія products.price
    PRIMARY KEY (order_id, product_id)
);

-- Свідома денормалізація: сума замовлення для списків і сортування
ALTER TABLE orders ADD COLUMN total numeric(12,2) NOT NULL DEFAULT 0;

-- Перерахунок, який робить дубль ремонтованим і звіряє його з джерелом
UPDATE orders o
SET    total = COALESCE(i.sum_total, 0)
FROM  (SELECT order_id, SUM(quantity * unit_price) AS sum_total
       FROM order_items GROUP BY order_id) i
WHERE i.order_id = o.id AND o.total <> i.sum_total;
Що 1НФ забороняє список значень в одній колонці й повторювані колонки product_1, product_2, а не «просто вимагає первинний ключ».
Що 2НФ про частковy залежність від частини складеного ключа, а 3НФ про залежність неключової колонки від іншої неключової: city від zip, total від quantity і price.
Формулювання аномалій через сценарій: оновили назву клієнта в 900 рядках з 1000, і база більше не знає, яка назва правильна.
Що ціна в order_items - це не порушення 3НФ, а зафіксований на момент продажу факт, і його треба відрізняти від дубля довідникової колонки.
Що денормалізація виправдана числом, а не відчуттям: показали план і час запиту до і після, назвали, хто й коли перераховує дубль.
Плутати 1НФ з «має бути id»: таблиця з колонкою tags = 'php,sql,laravel' має первинний ключ і все одно не в 1НФ.
Називати 2НФ і 3НФ, але не вміти показати аномалію оновлення на конкретних рядках.
Вважати будь-яку повторену колонку денормалізацією, зокрема price_at_purchase у позиціях замовлення.
Денормалізувати «про запас», ще до першого повільного запиту й без EXPLAIN.
Оновлювати кеш-колонку в PHP через читання, додавання одиниці й запис замість атомарного UPDATE ... SET reviews_count = reviews_count + 1.
Тримати денормалізовану колонку без жодного способу її перерахувати: коли числа розійшлись, правильне значення нізвідки взяти.
ПОРАДА

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

оновлено 30 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

3НФ прибирає дублі, через які виникають аномалії оновлення. Денормалізацію додають зверху й свідомо, разом зі способом перерахувати дубль з джерела.