Нормалізація вирішує одну задачу: щоб кожен факт був записаний у базі рівно один раз. Таблиця 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;
Скажіть коротку формулу 3НФ: «кожна неключова колонка залежить від ключа, від усього ключа й ні від чого крім ключа». Потім назвіть свій робочий порядок: спершу 3НФ, далі денормалізація під конкретний запит, який ви вже зміряли.