vyvchy
    Теми розділу

    05 · Бази даних і SQL для QA

    Нормалізація і проєктування схеми

    Зміст

    Ти оновлюєш email у профілі користувача, а в старих інвойсах він лишається попереднім. Агрегат «сума замовлення» показує одне число, а сума позицій дає інше. У звіті вилазить замовлення без клієнта — клієнта видалили, а рядок лишився. Усі ці баги виглядають різними, але корінь у них один: як спроєктована схема бази. Нормалізація (normalization) — це саме та теорія, яка наперед каже, де такі баги оселяться.

    Для QA це не абстрактна академічна вправа. Розуміючи нормальні форми, ти дивишся на схему й одразу бачиш ризиковані місця: де дані продубльовані й можуть розсинхронитися, де можлива втрата інформації при видаленні, де на тебе чекають «сирітські» (orphan) записи. А ще нормалізація прямо визначає, скільки JOIN доведеться написати, щоб перевірити один факт з UI — і чи взагалі цей факт зберігається в одному місці. Ключі, зовнішні ключі та зв'язки, на які тут спираємось, розібрані у главі Реляційна модель.

    Аномалії: чому «одна широка таблиця» ламається

    Почнемо з болю. Уяви, що всі дані про замовлення склали в одну плоску таблицю orders:

    order_id | order_date | customer_email    | customer_city | product_sku | product_title | price | qty
    ---------+------------+-------------------+---------------+-------------+---------------+-------+----
    1001     | 2026-07-01 | anna@example.com  | Київ          | SKU-42      | Клавіатура    | 1200  | 1
    1001     | 2026-07-01 | anna@example.com  | Київ          | SKU-77      | Миша          | 400   | 2
    1002     | 2026-07-02 | anna@example.com  | Київ          | SKU-42      | Клавіатура    | 1200  | 1

    На перший погляд зручно: усе в одному місці, жодних JOIN. Але така таблиця породжує три класичні аномалії (anomalies) — ситуації, коли звичайна операція з даними псує їхню цілісність.

    АномаліяЩо стаєтьсяПриклад на таблиці вище
    Вставки (insert)Не можна додати факт, бо бракує «сусіднього»Не додати новий товар у каталог, поки його ніхто не замовив — немає рядка без order_id
    Оновлення (update)Одну зміну треба зробити в багатьох рядкахАнна змінила місто — правити три рядки; пропустиш один → розсинхрон
    Видалення (delete)Видаляючи одне, втрачаєш іншеВидалили останнє замовлення Анни — зникла й сама Анна з її email і містом

    Аномалія оновлення — головний генератор багів, які знаходить QA. Дані про клієнта фізично лежать у кожному рядку замовлення. Код, що оновлює профіль, мусить пройтися по всіх копіях; варто розробнику забути одне місце — і система тримає два різні email для однієї людини. З UI це виглядає як «привид»: у профілі новий email, у чеку — старий.

    Корінь усіх трьох аномалій однаковий — надлишковість (redundancy): один факт («Анна живе в Києві») зберігається більше ніж в одному місці. Нормалізація — це систематичний спосіб цю надлишковість прибрати, розклавши дані так, щоб кожен факт жив рівно в одній клітинці.

    Функціональні залежності — мова нормалізації

    Щоб говорити про нормальні форми точно, потрібне одне поняття. Функціональна залежність (functional dependency) A → B означає: знаючи значення A, ми однозначно знаємо B. Наприклад, customer_id → customer_email: за id клієнта email визначається однозначно. А от зворотне customer_email → order_date не виконується — за email не вгадаєш дату конкретного замовлення.

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

    1НФ, 2НФ, 3НФ на одному прикладі

    Нормальні форми вкладені одна в одну: щоб бути в 2НФ, таблиця вже має бути в 1НФ, і так далі. Ідемо крок за кроком на нашому прикладі із замовленнями. Важливо: нормальні форми — це теорія реляційної моделі (їх увів Едгар Кодд), а не частина стандарту SQL; сама СУБД їх не «вмикає» — це дисципліна проєктувальника.

    Перша нормальна форма (1НФ)

    1НФ вимагає атомарності: у кожній клітинці — одне неподільне значення, жодних списків і повторюваних груп, і в таблиці є ключ, що робить рядки унікальними.

    Порушення 1НФ виглядає так:

    -- НЕ 1НФ: список товарів запханий в один стовпець
    order_id | customer_email   | products
    ---------+------------------+---------------------------
    1001     | anna@example.com | "Клавіатура, Миша, Миша"

    Проблеми очевидні: не порахуєш кількість мишей, не знайдеш замовлення з конкретним товаром без розбору рядка, не порахуєш суму. Приведення до 1НФ — це рядок на кожен товар (як у першій таблиці розділу). Типовий «запах» порушення 1НФ у бойових базах — колонки phone1, phone2, phone3 або comma-separated список id у текстовому полі.

    Друга нормальна форма (2НФ)

    2НФ застосовна там, де ключ складений (складається з кількох стовпців). Вона забороняє часткову залежність (partial dependency) — коли неключовий атрибут залежить лише від частини ключа, а не від усього.

    У нашій таблиці природний ключ рядка — пара (order_id, product_sku): саме вона унікально визначає рядок-позицію. Але подивись на залежності:

    • product_title і price залежать тільки від product_sku (назва товару однакова в будь-якому замовленні) — це часткова залежність;
    • customer_email і order_date залежать тільки від order_id.

    Розв'язання — розщепити таблицю так, щоб кожен атрибут залежав від повного ключа своєї таблиці:

    • orders(order_id, order_date, customer_email, customer_city) — усе про замовлення;
    • products(product_sku, product_title, price) — усе про товар;
    • order_items(order_id, product_sku, qty) — зв'язка «замовлення×товар» з ключем (order_id, product_sku).

    Тепер ціна товару лежить в одному місці. Змінити її — один UPDATE в одному рядку, а не в кожному замовленні, де цей товар колись фігурував. Аномалія оновлення для ціни зникла.

    Третя нормальна форма (3НФ)

    3НФ прибирає транзитивну залежність (transitive dependency) — коли неключовий атрибут залежить від іншого неключового, а не безпосередньо від ключа.

    Глянь на таблицю orders після 2НФ. Ключ — order_id. Але customer_city насправді залежить не від замовлення, а від клієнта:

    order_id

    order_date

    customer_id

    customer_email

    customer_city

    order_id

    order_date

    customer_id

    customer_email

    customer_city

    order_id → customer_id → customer_city: місто залежить від замовлення лише транзитивно, через клієнта. Саме тут ховається початкова аномалія — email і місто Анни досі дублюються в кожному її замовленні. Виносимо клієнта в окрему таблицю:

    • customers(customer_id, email, city);
    • orders(order_id, order_date, customer_id) — замість email/міста тільки посилання на клієнта.

    Ось фінальна схема в 3НФ:

    робить

    містить

    входить у

    CUSTOMERS

    int

    id

    PK

    text

    email

    text

    city

    ORDERS

    int

    id

    PK

    int

    customer_id

    FK

    date

    order_date

    ORDER_ITEMS

    int

    order_id

    FK

    int

    product_id

    FK

    int

    qty

    numeric

    price_at_purchase

    PRODUCTS

    int

    id

    PK

    text

    title

    numeric

    price

    робить

    містить

    входить у

    CUSTOMERS

    int

    id

    PK

    text

    email

    text

    city

    ORDERS

    int

    id

    PK

    int

    customer_id

    FK

    date

    order_date

    ORDER_ITEMS

    int

    order_id

    FK

    int

    product_id

    FK

    int

    qty

    numeric

    price_at_purchase

    PRODUCTS

    int

    id

    PK

    text

    title

    numeric

    price

    У фінальній схемі дві зміни, які варто помітити: ключі стали сурогатними id замість природного product_sku (різницю розібрано у главі про реляційну модель), а в order_items з'явився price_at_purchase. Це не контрабандний дублікат ціни — це знімок на момент покупки; чому він легітимний, розберемо нижче в розділі про денормалізацію.

    Тепер кожен факт живе в одному місці: email клієнта — рядок у customers, ціна товару — рядок у products, кількість — рядок у order_items. Зміна email — один UPDATE. Усі три аномалії зникли за побудовою.

    Коротка мнемоніка 3НФ, яку люблять на співбесідах: кожен неключовий атрибут залежить від ключа, усього ключа й нічого, крім ключа («the key, the whole key, and nothing but the key»). «Усього ключа» — це 2НФ, «нічого, крім ключа» — 3НФ. Для більшості бізнес-схем 3НФ — практичний орієнтир «достатньо нормалізовано»; є суворіші форми (BCNF, 4НФ), але на них рідко натикаються поза спеціальними випадками зі складеними ключами-кандидатами.

    Денормалізація: коли дублювати дані свідомо

    Нормалізація прибирає дублікати ціною JOIN: щоб показати замовлення з іменем клієнта й назвами товарів, треба з'єднати чотири таблиці. Коли таких читань мільйони, з'єднання стають вузьким місцем. Тоді дублікат вводять навмисно — це денормалізація (denormalization).

    Типові приклади: зберегти order_total прямо в orders, щоб не сумувати позиції щоразу; тримати items_count на замовленні; продублювати customer_name у таблиці замовлень заради швидкого пошуку. Це усвідомлений компроміс: платимо ризиком розсинхрону за швидкість читання. Денормалізація — не помилка, а інженерне рішення; помилка — робити її несвідомо. Часто дешевша альтернатива — залишити схему нормальною й прискорити читання індексом.

    Тут критично розрізняти два зовні схожі випадки, бо їх постійно плутають:

    • Знімок на момент часу (snapshot) — це НЕ шкідливий дублікат. price_at_purchase в order_items навмисно копіює ціну товару в момент покупки, бо ціна в каталозі згодом зміниться, а чек мусить лишитися історично правдивим. Тут копія й джерело мають розходитися — це різні факти («ціна тоді» vs «ціна зараз»).
    • Кеш поточного значення — це і є небезпечна денормалізація. order_total має завжди дорівнювати сумі позицій. Якщо позицію додали, а тотал не перерахували — це баг розсинхрону.

    Розсинхрон дублікатів — головний ризик денормалізації й благодатне поле для QA. Кешоване значення оновлюють кодом (тригером, застосунком чи фоновою задачею), і будь-яка діра в цьому коді лишає копію відставати від джерела. Аналогія з вебом: денормалізований тотал — це такий самий кеш, як HTTP-кеш браузера, тільки в базі, і в нього та сама хвороба — застаріле значення, яке розходиться з першоджерелом.

    Запахи схеми (schema smells)

    Кілька ознак, які на рев'ю схеми чи при знайомстві з базою мають вмикати обережність у QA:

    • Список у клітинці — comma-separated значення чи JSON-масив id там, де мала б бути окрема таблиця зв'язку. Порушення 1НФ, пекло для пошуку й цілісності.
    • Нумеровані колонкиphone1, phone2, addr_line_3. Повторювана група, яку не масштабувати; має бути дочірня таблиця.
    • Стовпці, які майже завжди NULL — часто ознака, що в таблиці змішано кілька сутностей (підтипів), і їх варто рознести. Про пастки самих NULL — окрема глава.
    • God-таблиця на 80 стовпців — у неї запхано кілька непов'язаних сутностей; читати й підтримувати боляче.
    • Немає первинного ключа — рядки не адресуються унікально, дублікати заповзають непомітно.
    • Похідне значення без механізму оновлення — збережений total/count, який ніхто не перераховує при зміні джерела. Розсинхрон — питання часу.
    • Дубльований довідник — той самий перелік статусів чи міст, вбитий текстом у двох таблицях, які згодом розійдуться в написанні.

    Запах не завжди означає дефект — денормалізацію могли ввести свідомо. Але кожен такий пункт — це готова гіпотеза для тесту: «а що, якщо ці дві копії розійдуться?».

    Як нормалізація змінює запити QA

    Ступінь нормалізації прямо змінює те, як ти перевіряєш дані.

    Нормалізована схема розкидає один екранний факт по кількох таблицях. Щоб звірити «замовлення №1001 Анни на клавіатуру» з UI, доведеться з'єднати orders, customers, order_items, products — тобто впевнено володіти JOIN. Натомість джерело істини одне: email є тільки в customers, розходитися нема чому. Ціна багів зміщується у бік цілісності зв'язків — сирітські записи й порушені зовнішні ключі. Класична перевірка — пошук order_items, що вказують на неіснуючий товар:

    SELECT oi.order_id, oi.product_id
    FROM order_items oi
    LEFT JOIN products p ON p.id = oi.product_id
    WHERE p.id IS NULL;

    Порожній результат — цілісність ціла. Такі перевірки — хліб глави Тестування БД: цілісність даних і міграції.

    Денормалізована схема читається одним простим запитом без JOIN — але додає окремий клас перевірок: чи не розійшлися дублікати. Це найцінніша QA-навичка навколо цієї теми — вміти написати запит, що ловить розсинхрон. Наприклад, знайти замовлення, де збережений тотал не дорівнює сумі позицій:

    SELECT o.id, o.order_total, SUM(oi.qty * oi.price_at_purchase) AS real_total
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.id
    GROUP BY o.id, o.order_total
    HAVING o.order_total <> SUM(oi.qty * oi.price_at_purchase);

    Порожній результат означає, що кеш і джерело збіглися по всій базі. Той самий прийом HAVING COUNT(*) > 1 ловить дублікати там, де мав би стояти унікальний ключ — деталі у главі Агрегація: GROUP BY і HAVING.

    Таку перевірку зручно винести в автотест як інваріант даних після сценарію, що чіпає замовлення:

    // Playwright + pg: денормалізований total не має розходитися з позиціями
    const { rows } = await db.query(`
      SELECT o.id FROM orders o
      JOIN order_items oi ON oi.order_id = o.id
      GROUP BY o.id, o.order_total
      HAVING o.order_total <> SUM(oi.qty * oi.price_at_purchase)
    `);
    expect(rows, 'знайдено замовлення з розсинхроном total').toEqual([]);

    Найнадійніше проти розсинхрону працює не тест, а сама схема: винести правило в обмеження цілісності (зовнішній ключ, UNIQUE, CHECK) — тоді СУБД не дасть даним зіпсуватися в принципі. Що з цього доступно на рівні DDL — у главі DDL і обмеження цілісності.

    Типові помилки

    Виглядає як «денормалізація завжди погана, треба все розкласти до 3НФ» — а насправді денормалізація буває свідомим і правильним рішенням заради швидкості читання. Питання не «нормалізовано чи ні», а «чи є механізм, що тримає дублікати синхронними».

    Виглядає як розсинхрон даних (баг) — а насправді легітимний знімок на момент часу. Ціна в чеку відрізняється від ціни в каталозі не тому, що щось зламалось, а тому, що товар подорожчав після покупки. Перш ніж заводити баг, з'ясуй, це кеш поточного значення чи історичний факт.

    Виглядає як «схема в 3НФ, отже цілісність гарантована» — а насправді нормалізація прибирає надлишковість, але не створює зв'язки сама. Без оголошених зовнішніх ключів у тебе так само будуть сирітські записи; 3НФ і FOREIGN KEY — різні речі.

    Виглядає як порушення 1НФ у колонці tags з JSON-масивом — а насправді сучасні СУБД мають JSON-типи (JSONB у PostgreSQL) й масиви, і це може бути свідомим рішенням. Але для даних, які фільтруєш і з'єднуєш, окрема таблиця майже завжди виграє; JSON у клітинці лишає ті самі проблеми пошуку, що й comma-separated список.

    Виглядає як «дублікатів немає, бо додали унікальний індекс» — а насправді унікальний індекс порівнює значення, а не сенс. Київ і київ з пробілом на кінці — для бази два різні міста (а чи вважати різницю лише в регістрі різними значеннями — питання колації: у PostgreSQL типове порівняння регістрозалежне); довідник, вбитий текстом у двох місцях, розійдеться саме так.

    Підсумок

    • Три аномалії (вставки, оновлення, видалення) — симптоми однієї хвороби: один факт зберігається більш ніж в одному місці.
    • 1НФ — атомарні значення й ключ; 2НФ — жодних часткових залежностей від складеного ключа; 3НФ — жодних транзитивних залежностей. Мнемоніка: ключ, увесь ключ і нічого, крім ключа.
    • 3НФ — практичний орієнтир «достатньо» для більшості бізнес-схем; глибші форми потрібні рідко.
    • Денормалізація — свідомий компроміс «швидкість читання ↔ ризик розсинхрону»; знімок на момент часу і кеш поточного значення — різні речі, не плутай їх при заведенні багів.
    • Нормалізована схема зміщує баги у бік цілісності зв'язків (сироти, FK); денормалізована — у бік розсинхрону дублікатів. Уміння написати запит, що ловить і те, і те, — базова QA-навичка.

    Що питають на співбесіді

    • «Що таке нормалізація і навіщо вона?» — інтерв'юер хоче почути про усунення надлишковості й три аномалії, а не переказ означень. Сильна відповідь стартує з болю («email оновили в одному місці, а в чеках старий») і виходить на «кожен факт — в одній клітинці».
    • «Поясни 1НФ, 2НФ, 3НФ» — очікують не завчені формули, а приклад. Назви таблицю, покажи конкретне порушення на кожному кроці й що дає його виправлення. Мнемоніка «the key, the whole key, and nothing but the key» доречна, але за нею мусить стояти розуміння часткової й транзитивної залежностей.
    • «Коли б ти денормалізував?» — перевіряють інженерну зрілість. Правильний вектор: заради швидкості читання під конкретне навантаження, усвідомлено, з механізмом підтримки синхронності (тригер, перерахунок, обмеження). Згадка про різницю «знімок vs кеш» додає балів.
    • «Як ти протестуєш, що дані узгоджені?» — тут чекають конкретики: пошук сиріт через LEFT JOIN ... IS NULL, дублікатів через HAVING COUNT(*) > 1, розсинхрону кешованого агрегату проти джерела. Це відрізняє того, хто читав про нормалізацію, від того, хто ловив нею баги.

    Джерела