Що ламається в таблиці з дублями
Уявіть таблицю відгуків, де в кожному рядку лежить і назва вакансії, і назва компанії, і сайт компанії. Читати її зручно: один SELECT без JOIN. Але компанія змінила назву, і тепер її треба оновити в сотні рядків. Пропустили один, і в базі дві правди. Це оновлення. Є ще дві: не можна додати компанію, поки на неї ніхто не відгукнувся (аномалія вставки), і видалення останнього відгуку стирає все, що ми знали про компанію (аномалія видалення).
Усі три мають одну причину: в одній таблиці зібрано факти про різні речі. Нормалізація шукає ці речі через . Якщо за vacancy_id завжди однозначно визначається назва вакансії, то назва залежить від vacancy_id і має лежати там, де vacancy_id є ключем, тобто в таблиці vacancies.
це сходинки, кожна прибирає свій вид дублювання. 1НФ: у клітинці одне значення, а не список. 2НФ: кожна неключова колонка залежить від усього складеного ключа, а не від його частини. 3НФ: неключова колонка залежить від ключа напряму, а не через іншу неключову колонку. Далі йде , а 4НФ і 5НФ трапляються переважно в підручниках. Для робочої схеми зазвичай досить 3НФ.
Нормалізація не про швидкість. Вона про те, щоб база не могла суперечити сама собі. Ціна в JOIN при читанні, і іноді її свідомо не платять: копіюють поле, рахують лічильник заздалегідь, зберігають знімок. Це денормалізація, і в неї є правило: дубль має бути рішенням з причиною, а не випадковістю.
Одна брудна таблиця розпадається на чисті
Таблиця замовлень магазину, яку зібрали «щоб усе було під рукою». На кожному кроці підсвічено те, що ще може зіпсуватися. Перемикайте кроки: таблиця ділиться за залежностями, і разом з нею зникає аномалія.
order_id → orders.id order_id → orders.id client_id → clients.id Імена, адреси й товари це приклад. Ключ таблиці позначено, стрілка під таблицею показує зовнішній ключ.
Сім схем з домену цього сайту
Щоб знайти порушення, випишіть залежності: що визначає кожна неключова колонка. Якщо щось визначає частина складеного ключа, це 2НФ. Якщо неключова колонка, це 3НФ. Нижче таблиці, які легко зустріти в будь-якому сайті вакансій.
developers (id, name, skills = 'PHP, Laravel, Redis')
не 1НФ
Список у рядку. Розробника з Redis шукають через LIKE, перейменувати навичку можна лише текстовою заміною в кожному рядку.
developer_skills (developer_id, skill), ключ з обох
3НФ
Звʼязок багато-до-багатьох окремою таблицею. Пошук розробників з Redis це WHERE skill = 'redis' з індексом.
applications (vacancy_id, email, vacancy_title, message), ключ (vacancy_id, email)
не 2НФ
vacancy_title залежить лише від vacancy_id, тобто від частини ключа. Назва вакансії повториться в кожному відгуку на неї.
vacancies (id, title, company_id, company_name)
не 3НФ
id → company_id → company_name: транзитивна залежність. Компанія перейменувалась, і треба оновити всі її вакансії.
vacancies (id, title, company_id) + companies (id, name)
3НФ
Назва компанії лежить в одному рядку, вакансія тримає лише ключ. Так зроблено в цьому проєкті.
vacancies (id, title, cities = ['kyiv', 'lviv'])
компроміс
Формально список у клітинці. Але JSON-масив база вміє індексувати й шукати всередині, і коли список читають і пишуть лише цілком, це свідомий компроміс. Так зберігаються міста вакансій на цьому сайті.
applications (id, vacancy_id, job_snapshot)
знімок
Копія вакансії на день відгуку не залежить від поточної вакансії: це окремий факт про минуле. Дублем вона була б, якби її оновлювали разом з вакансією.
Залежності тут взято з предметної області: в однієї вакансії одна назва, в одного розробника багато навичок.
Коли список таки лежить у колонці
Сучасні бази вміють JSON, і список міст чи тегів у JSON-колонці вже не те саме, що рядок через кому: база знає, де закінчується одне значення, і може шукати всередині. Платять за це синтаксисом, який у кожної бази свій, і тим, що звичайний B-tree тут не допомагає.
Ось два запити, які сайт вакансій робить постійно: знайти вакансії у Львові й порахувати вакансії за містами. Для другого масив розгортають у рядки, тобто на льоту повертаються до 1НФ. Дані однакові в усіх трьох вкладках: три вакансії з містами ["kyiv", "lviv"], ["kyiv"] і ["odesa", "kyiv"].
+----+-------------------+ | id | title | +----+-------------------+ | 1 | Laravel Developer | +----+-------------------+ +-------+---+ | city | n | +-------+---+ | kyiv | 3 | | lviv | 1 | | odesa | 1 | +-------+---+
Другий аргумент JSON_CONTAINS теж JSON, тому рядок береться в подвійні лапки всередині одинарних. JSON_TABLE зʼявилась у MySQL 8.0.4 і розгортає масив у справжню таблицю з колонками.
id | title ----+------------------- 1 | Laravel Developer (1 row) city | n -------+--- kyiv | 3 lviv | 1 odesa | 1 (3 rows)
@> означає «містить»: масив зліва містить усі елементи масиву справа. Саме цей оператор підхоплює GIN-індекс. jsonb_array_elements_text повертає кожен елемент окремим рядком, і далі це звичайний GROUP BY.
+----+-------------------+ | id | title | +----+-------------------+ | 1 | Laravel Developer | +----+-------------------+ +-------+---+ | city | n | +-------+---+ | kyiv | 3 | | lviv | 1 | | odesa | 1 | +-------+---+
Окремого типу JSON у SQLite немає, масив лежить у TEXT. json_each робить з нього таблицю з колонками key і value, а індексу всередину масиву SQLite не має: пошук завжди перебирає рядки.
Звичайний індекс у масив не зазирне
B-tree на колонці cities індексував би масив цілком, і пошук «є lviv серед міст» його не використав би. Потрібен індекс, у якому кожен елемент масиву має свій запис: GIN у PostgreSQL, multi-valued index у MySQL з версії 8.0.17. Нижче пошук вакансій у Вінниці на таблиці зі 100 000 вакансій, з яких 1 000 мають це місто.
SELECT id FROM vacancies WHERE JSON_CONTAINS(cities, '"vinnytsia"');| type | key | rows | Extra |
|---|---|---|---|
| ALL | NULL | 99699 | Using where |
SELECT id FROM vacancies WHERE JSON_CONTAINS(cities, '"vinnytsia"');| type | key | rows | Extra |
|---|---|---|---|
| range | vacancies_cities_index | 1000 | Using where |
SELECT id FROM vacancies WHERE 'vinnytsia' MEMBER OF (cities);| type | key | rows | Extra |
|---|---|---|---|
| ref | vacancies_cities_index | 1000 | Using where |
MySQL 8.0.46, InnoDB, 100 000 рядків. Вивід знято зі справжньої бази, частину колонок прибрано.
SELECT id FROM vacancies WHERE cities @> '["vinnytsia"]';Seq Scan on vacancies (cost=0.00..2151.00 rows=1007 width=4) (actual time=0.029..31.081 rows=1000 loops=1) Filter: (cities @> '["vinnytsia"]'::jsonb) Rows Removed by Filter: 99000 Execution Time: 31.577 ms
SELECT id FROM vacancies WHERE cities @> '["vinnytsia"]';Bitmap Heap Scan on vacancies (cost=38.55..994.14 rows=1007 width=4) (actual time=0.223..1.434 rows=1000 loops=1)
Recheck Cond: (cities @> '["vinnytsia"]'::jsonb)
Heap Blocks: exact=901
-> Bitmap Index Scan on vacancies_cities_gin (cost=0.00..38.30 rows=1007 width=0) (actual time=0.128..0.128 rows=1000 loops=1)
Index Cond: (cities @> '["vinnytsia"]'::jsonb)
Execution Time: 1.493 ms PostgreSQL 16.15, 100 000 рядків, індекс CREATE INDEX vacancies_cities_gin ON vacancies USING gin (cities), як у міграції цього сайту. Рядки Planning Time прибрано.
- type
- ALL це перебір усієї таблиці. З індексом range або ref: база йде по записах індексу для значення vinnytsia.
- key
- Індекс створено виразом: ALTER TABLE vacancies ADD INDEX vacancies_cities_index ((CAST(cities AS CHAR(20) ARRAY))). Він працює для MEMBER OF, JSON_CONTAINS і JSON_OVERLAPS.
- rows
- Оцінка прочитаних рядків: майже вся таблиця проти тисячі потрібних.
- Seq Scan
- Без індексу база розбирає JSON кожного рядка і відкидає 99 000 з них.
- Bitmap Index Scan
- GIN знаходить усі рядки, де в масиві є vinnytsia, і збирає їхні адреси. Далі Bitmap Heap Scan читає ці сторінки таблиці.
- Recheck Cond
- Умова, яку база перевіряє ще раз на самих рядках, коли бітова карта стала надто великою і памʼятає лише сторінки. Тут Heap Blocks: exact, тобто карта точна і перевіряти не довелося.
Звʼязки замість копій
Нормалізована схема в Eloquent це звʼязки: модель тримає ключ, а дані іншої таблиці підтягуються окремим запитом або JOIN. JSON-колонка і знімок теж мають своє місце, і в Laravel для них є готові інструменти. Усе нижче звірено з vendor/laravel/framework 13.30.
Компанії, вакансії й відгуки
Схема сайту здебільшого в 3НФ: вакансія тримає company_id, а назва й сайт компанії лежать у companies в одному рядку. Відгук посилається на вакансію через vacancy_id. Відступів від чистої схеми три, і всі свідомі. applications.company_id дублює vacancies.company_id: компанію відгуку можна знайти через вакансію, але кабінет роботодавця фільтрує відгуки за company_id напряму, через індекс (company_id, status), без JOIN. Синхронізації між ними немає: ApplyToJob копіює company_id з вакансії один раз, у момент подачі, і лише для власних вакансій сайту з відомою компанією. Компанію в коді змінюють тільки вакансіям з інших дошок, а на них відгуки через сайт не приймаються, тож розійтися значенням нема звідки. job_snapshot це знімок вакансії на день відгуку. Міста й теги вакансії лежать JSON-масивами з GIN-індексами.
src/Hiring/Application/Employer/ApplyToJob.phphandle() бере company_id з вакансії і збирає job_snapshot: назву, компанію, зарплату, формат і рівень на момент відгуку. Зарплату в знімок не кладуть, якщо роботодавець її приховав.database/migrations/2026_09_10_000002_add_history_to_applications.phpКолонка job_snapshot (JSON) і таблиця application_events: історія відгуку окремими рядками з ключем application_id, а не списком у колонці.database/migrations/2026_09_02_000002_create_vacancies_table.phpcities і tags це jsonb з GIN-індексами vacancies_cities_gin і vacancies_tags_gin. Індекси й колонку search tsvector міграція створює лише на PostgreSQL.src/Hiring/Infrastructure/Catalog/EloquentJobCatalog.phpФільтр за містом це whereJsonContains('cities', …). Лічильники міст розгортають масив: jsonb_array_elements_text на PostgreSQL і json_each на SQLite, де бігають тести.database/migrations/2026_09_04_000008_create_market_snapshots.phpmarket_snapshots зберігає пораховані за день медіани й кількість вакансій. Їх можна було б порахувати з vacancies, але за минулі дні вакансій уже немає в тому стані: це теж знімок.Де схема починає собі суперечити
Список через кому в текстовій колонці
skills = 'PHP, Laravel'. Пошук через LIKE знаходить зайве, перейменування навички стає текстовою заміною, а порахувати розробників з кожною навичкою можна лише в PHP.
Натомість: Окрема таблиця звʼязку (developer_skill). Якщо список справді лише цілком читають і пишуть, JSON-масив з індексом GIN чи multi-valued.
Копія назви «щоб не робити JOIN»
vacancies.company_name поруч з company_id. Через пів року частина вакансій показує стару назву компанії, бо одне місце оновлення забули.
Натомість: Зберігайте ключ і робіть JOIN або eager loading (with('company')). Якщо копія справді потрібна, назвіть її знімком і вирішіть, хто і коли її оновлює.
Плутати знімок з дублем
Відгук бере назву вакансії через JOIN. Роботодавець переписав вакансію, і кандидат бачить у своїй історії відгук на те, на що він не відгукувався.
Натомість: Те, що має лишитися як було, копіюйте в момент події: адресу доставки, ціну, вакансію на день відгуку.
Денормалізувати без механізму синхронізації
Лічильник відгуків у vacancies збільшують у контролері, а видалення відгуку його не зменшує. Через місяць цифра на сторінці не збігається з COUNT(*).
Натомість: Оновлюйте копію в тій самій транзакції, що й оригінал, або перераховуйте її задачею. І тримайте запит, який знаходить розбіжності.
Нормалізувати те, що для вас атомарне
Адресу розбили на таблиці країн, міст, вулиць і будинків, хоча сайт лише показує її одним рядком. Кожна сторінка тепер робить пʼять JOIN.
Натомість: Атомарність визначає ваша предметна область. Якщо за частиною значення не шукають і не групують, воно може лишитися одним рядком.
- Аномалія
- Ситуація, коли звичайна операція псує дані через дублювання. Оновлення: змінили копію в одному рядку, а в інших лишилась стара. Вставка: факт не можна записати без іншого, непотрібного факту. Видалення: разом з рядком зникає факт, який був потрібен.
- Функціональна залежність
- A → B означає: однакові значення A завжди дають однакове B. vacancy_id → title, бо в однієї вакансії одна назва. Залежність це правило предметної області, а не збіг у поточних даних: те, що зараз в усіх Олен однакова адреса, ще не залежність.
- Нормальна форма
- Набір вимог до таблиці, кожна наступна суворіша. Таблиця в 3НФ автоматично в 2НФ і 1НФ. Форма описує одну таблицю, а «схема в 3НФ» означає, що в 3НФ кожна її таблиця.
- BCNF
- Нормальна форма Бойса й Кодда: ліва частина кожної нетривіальної залежності має бути потенційним ключем. Відрізняється від 3НФ лише тоді, коли в таблиці кілька складених потенційних ключів, що перекриваються.
Питання зі співбесід
Той самий факт записано в кількох рядках, і UPDATE змінив лише частину копій. Наприклад, назва компанії лежить у кожній її вакансії, а перейменування зачепило не всі. Береться вона з того, що таблиця зберігає факти про дві різні сутності. Нормалізація виносить факт туди, де він залежить лише від свого ключа.
2НФ: vacancy_title залежить лише від vacancy_id, тобто від частини складеного ключа. Назва повторюється в кожному відгуку на вакансію. Колонку прибирають, а назву беруть з vacancies через vacancy_id. Якщо ж потрібна назва на день відгуку, це вже знімок, і його так і називають.
Спершу подивитися EXPLAIN: найчастіше бракує індексу на колонках зʼєднання, особливо на зовнішніх ключах у PostgreSQL, де їх не створюють самі. Денормалізувати варто, коли читань набагато більше, ніж записів, план уже хороший, а виміряна ціна JOIN завелика. І тоді треба одразу вирішити, хто оновлює копію: та сама транзакція, тригер чи фонова задача.
3НФ дозволяє залежність, у якій права частина є частиною потенційного ключа. BCNF вимагає, щоб ліва частина кожної залежності була потенційним ключем. Приклад: mentoring (developer, skill, mentor), де кожен ментор веде одну навичку (mentor → skill), а розробник з кожної навички має одного ментора. Ключі (developer, skill) і (developer, mentor). skill входить у ключ, тож 3НФ дотримано, але mentor не ключ, і факт «ментор веде навичку» повторюється для кожного його учня. Розклад на (mentor, skill) і (developer, mentor) дає BCNF, але правило «один ментор на навичку» тоді вже не перевірити унікальним ключем однієї таблиці.