Порушити нормальну форму і знати ціну
Нормалізація каже: кожен факт зберігається в одному місці. Назва компанії лежить у companies, а вакансія знає лише company_id. свідомо кладе копію факту ще кудись: кількість відгуків у вакансії, назву вакансії у відгук, підсумок за день в окрему таблицю.
Причин дві. Перша: читання. Сторінку зі списком вакансій відкривають тисячі разів, і COUNT(*) по відгуках чи медіана зарплат на кожному показі коштують дорожче, ніж одна колонка, оновлена при записі. Друга: зміст. вакансії у відгуку зберігає, на що людина відгукнулась того дня. Це вже інший факт, і після редагування вакансії він не має змінюватись.
Ціна в обох випадках однакова: тепер є два місця, і вони можуть розійтися. Хтось оновлює копію: у базі, код застосунку поруч із записом оригіналу, фоновий job, який перераховує все з нуля, або REFRESH . Знімок у цьому сенсі найдешевший: його не синхронізують зовсім.
Шість копій і що з ними буде
Питання не «чи це денормалізація», а «хто і коли оновлює копію, і що станеться, якщо не оновить». Нижче типові копії з сайту вакансій.
applications.job_snapshot (JSON)
знімок
Записується один раз разом з відгуком і більше не змінюється. Синхронізувати нічого: розбіжність з поточною вакансією і є змістом.
vacancies.views ← UPDATE … SET views = views + 1
лічильник
Атомарний інкремент у самій базі, без читання в PHP. Втрата одного перегляду нікому не шкодить, а COUNT(*) по таблиці подій на кожному показі коштував би дорожче.
market_snapshots ← щоденний job
звітна таблиця
Підсумки за день рахуються з нуля за розкладом і перезаписуються. Повторний запуск нешкідливий, а графік тренду читає готові рядки.
vacancies.search ← GENERATED ALWAYS AS … STORED
рахує база
Колонку для пошуку база сама перераховує при кожній зміні title чи excerpt. Записати в неї вручну неможливо, тож розійтись вона не може.
vacancies.applications_count ← тригер
тригер
Точна в тій самій транзакції, але логіка схована в базі. У MySQL каскадне видалення з іншої таблиці тригер не запускає, і лічильник тихо бреше.
vacancies.company_name ← копія в коді
копія заради JOIN
JOIN за первинним ключем коштує мікросекунди. Копію треба оновлювати при кожному перейменуванні компанії, і рано чи пізно якийсь шлях запису про це забуде.
Поведінку тригерів перевірено на MySQL 8.0.46, PostgreSQL 16 і SQLite 3.45; виводи нижче.
Лічильник, який оновлює сама база
Тригер AFTER INSERT OR DELETE на applications збільшує або зменшує vacancies.applications_count у тій самій транзакції. Він спрацює, хоч би звідки прийшов запис: з Eloquent, з консолі чи з імпорту. Тому тригер найнадійніший спосіб тримати копію точною і найменш помітний: у коді PHP його не видно.
Пастка MySQL: дії зовнішніх ключів (ON DELETE CASCADE, SET NULL) тригери не запускають. Якщо відгуки видаляються каскадом разом з користувачем, лічильник вакансії не зменшиться. PostgreSQL і SQLite запускають тригери і для каскадних змін.
applications_count | real_count
--------------------+------------
1 | 1 Каскадне видалення з users теж пройшло через тригер, і лічильник точний.
+--------------------+------------+ | applications_count | real_count | +--------------------+------------+ | 2 | 1 | +--------------------+------------+
Відгук видалено каскадом, тригер AFTER DELETE не спрацював, і лічильник каже 2. Прямий DELETE FROM applications лічильник зменшив би.
┌────────────────────┬────────────┐ │ applications_count │ real_count │ ├────────────────────┼────────────┤ │ 1 │ 1 │ └────────────────────┴────────────┘
Як у PostgreSQL: каскад запускає тригер. За умови, що зʼєднання ввімкнуло PRAGMA foreign_keys, інакше каскаду не буде взагалі.
Матеріалізоване представлення чи таблиця підсумків
Звіт «скільки вакансій і яка зарплата за фреймворком» рахує агрегат по всій таблиці. Замість рахувати його на кожному показі, результат зберігають. У PostgreSQL для цього є матеріалізоване представлення: запит, чий результат лежить як таблиця, а REFRESH перераховує його цілком. REFRESH … CONCURRENTLY не блокує читання, але вимагає унікального індексу на представленні.
У MySQL і SQLite матеріалізованих представлень немає. Їх роль грає звичайна таблиця підсумків, яку job перезаписує через INSERT … SELECT з upsert. Перевага таблиці в тому, що вона може тримати історію: рядок на кожен день замість одного останнього стану.
framework | jobs | median_usd -----------+------+------------ laravel | 2 | 3500 symfony | 1 | 3500 framework | jobs | median_usd -----------+------+------------ laravel | 2 | 3500 symfony | 2 | 4000
До REFRESH представлення показує стан на момент попереднього оновлення. Без унікального індексу CONCURRENTLY відмовить: cannot refresh materialized view concurrently.
+-----------+------+---------+ | framework | jobs | avg_usd | +-----------+------+---------+ | laravel | 2 | 3500 | | symfony | 2 | 4000 | +-----------+------+---------+
Повторний запуск того ж дня перезаписує рядок через ON DUPLICATE KEY UPDATE. Медіани в MySQL немає, тут середнє; медіану рахують у застосунку або віконною функцією.
┌───────────┬──────┬─────────┐ │ framework │ jobs │ avg_usd │ ├───────────┼──────┼─────────┤ │ laravel │ 2 │ 3500 │ │ symfony │ 2 │ 4000 │ └───────────┴──────┴─────────┘
Якщо SELECT закінчується на FROM таблиця без WHERE, SQLite читає ON як умову JOIN і падає з near "DO": syntax error. Тоді пишуть WHERE true.
Копія, яку неможливо забути оновити
Якщо копію можна обчислити з інших колонок того самого рядка, її краще доручити базі. Згенерована колонка STORED перераховується при кожній зміні джерела і зберігається, тож її можна індексувати. Записати в неї значення руками база не дасть.
Типовий приклад: середина зарплатної вилки для сортування чи колонка для повнотекстового пошуку з назви й опису. Синтаксис майже однаковий у всіх трьох базах, PostgreSQL підтримує його з версії 12.
id | salary_from | salary_to | salary_mid ----+-------------+-----------+------------ 10 | 3000 | 4000 | 3500 ERROR: column "salary_mid" can only be updated to DEFAULT DETAIL: Column "salary_mid" is a generated column.
Вираз має бути незмінним (IMMUTABLE): функції з поточним часом чи посиланням на інші таблиці не підійдуть.
-- salary_mid = 3500 ERROR 3105 (HY000): The value specified for generated column 'salary_mid' in table 'vg' is not allowed.
У MySQL є ще VIRTUAL: значення не зберігається, а обчислюється при читанні, і індекс на ній теж можна створити.
-- salary_mid = 3500 Error: cannot UPDATE generated column "salary_mid"
Laravel оголошує такі колонки через ->storedAs('…') і ->virtualAs('…') у міграції.
Тригер, код застосунку чи job
Чим точнішою має бути копія, тим ближче до запису оригіналу її оновлюють. Чим дорожче її порахувати, тим охочіше відкладають на потім.
Знімок вакансії у відгуку
Коли кандидат відгукується, ApplyToJob копіює у відгук назву вакансії, назву компанії, зарплату, формат, рівень і посилання. Це денормалізація, зроблена правильно, з трьох причин. Знімок означає інший факт, «на що людина відгукнулась того дня», тож його ніколи не синхронізують: компанія може змінити назву, вакансію можуть закрити чи переписати, а кабінет кандидата покаже те, що було. Він записується один раз, у тій самій транзакції, що й відгук (EloquentApplicationRepository::save). І оригінал не підмінено: відгук і далі тримає vacancy_id із зовнішнім ключем, тож поточну вакансію завжди можна прочитати поруч. Навіть приховану зарплату знімок не розкриває: при hideSalary він зберігає null. Відгуки, що зʼявились до знімків, мають job_snapshot NULL, і predatesMaterials() у домені прямо це називає, а сторінки кандидата тоді беруть назву з самої вакансії.
database/migrations/2026_09_10_000002_add_history_to_applications.phpКолонка job_snapshot це json nullable: старі відгуки без знімка лишились валідними, міграція нічого не вигадувала заднім числом.src/Hiring/Infrastructure/Persistence/EloquentApplicationRepository.phpsave() пише рядок відгуку разом зі знімком і записами історії в одній транзакції. company_id у відгуку теж копія: його можна було б узяти з вакансії, але кабінет роботодавця фільтрує відгуки за (company_id, status) без JOIN.src/Hiring/Infrastructure/Catalog/EloquentJobPageQuery.phprecordView() збільшує vacancies.views одним UPDATE через increment('views'): лічильник без COUNT(*) і без гонки читання й запису.src/Hiring/Application/Market/TakeMarketSnapshot.phpЗвітна таблиця: щодня рахує ринок загалом, за фреймворками й навичками і перезаписує рядок дня в market_snapshots. Команда hiring:snapshot стоїть у розкладі в routes/console.php.database/migrations/2026_09_02_000002_create_vacancies_table.phpНа PostgreSQL vacancies має згенеровану колонку search tsvector з title та excerpt і GIN-індекс на ній: копію для пошуку підтримує сама база.Де копії розходяться
Денормалізація замість індексу
Запит з JOIN повільний, і назву компанії копіюють у кожну вакансію. Насправді JOIN за первинним ключем дешевий, а повільно було через відсутній індекс на company_id чи N+1 у коді.
Натомість: Спершу EXPLAIN і індекси. Копіювати лише тоді, коли план уже добрий, а читань так багато, що кожна мілісекунда множиться.
Копія без власника
Колонку додали, оновлюють в одному сервісі, а адмінка, імпорт і консольна команда пишуть в оригінал напряму. Через місяць копія бреше в частині рядків, і ніхто не знає в яких.
Натомість: Один шлях оновлення (тригер, подія моделі, job) і команда, що перераховує копію з нуля та показує розбіжності.
$model->count++ і save()
Дві паралельні сесії читають 10, кожна пише 11. Один перегляд чи відгук зник, і так щодня.
Натомість: increment() з виразом у SQL, upsert з hits = hits + 1 або блокування рядка. Для точних лічильників тригер.
Знімок, який потім оновлюють
У знімок вакансії «для зручності» дописують зміни з оригіналу. Тепер це не знімок, а погано синхронізована копія, і історія відгуку змінюється заднім числом.
Натомість: Знімок пишеться один раз. Потрібні поточні дані, читайте оригінал за ключем, який лежить поруч зі знімком.
Матеріалізоване представлення без розкладу
MATERIALIZED VIEW створили, дані виглядають правильними, а REFRESH ніхто не запускає. Звіт тихо застигає на дні створення.
Натомість: REFRESH … CONCURRENTLY у планувальнику і дата останнього оновлення поруч зі звітом.
Чи денормалізувати
- Денормалізація
- Свідоме дублювання даних, які можна було б отримати JOIN або агрегатом. Не плутати з ненормалізованою схемою, де дублі зʼявились випадково і ніхто не знає, яка копія правильна.
- Знімок
- Копія даних на конкретний момент: ціна в рядку замовлення, назва й зарплата вакансії у відгуку, курс валют у платежі. Оригінал далі живе своїм життям, а знімок не змінюється ніколи.
- Тригер
- Процедура в базі, яку база сама викликає на INSERT, UPDATE чи DELETE рядка. Синхронізує копію в тій самій транзакції, але живе поза кодом застосунку, і її легко не помітити.
- Матеріалізоване представлення
- MATERIALIZED VIEW у PostgreSQL: результат запиту, збережений як таблиця. Оновлюється лише командою REFRESH, тож дані в ньому на момент останнього оновлення.
- Лічильник
- Колонка на кшталт applications_count чи views, яку оновлюють при кожній зміні замість COUNT(*) при кожному читанні. Rails називає це counter cache.
Питання зі співбесід
Свідома копія даних, які можна отримати JOIN або агрегатом: лічильник відгуків у вакансії, назва вакансії у відгуку, підсумки за день. Платить вона тим, що копію треба оновлювати при кожній зміні оригіналу, інакше дані розійдуться.
Бо це інший факт: не «яка зараз вакансія», а «на що людина відгукнулась того дня». Знімок записується один раз і не синхронізується, а поточна вакансія лишається доступною за vacancy_id. Якби знімок оновлювали разом з вакансією, він перестав би бути знімком.
Найімовірніше, відгуки видаляються каскадом через зовнішній ключ, наприклад разом з користувачем. У MySQL дії зовнішніх ключів тригери не запускають, тож AFTER DELETE не спрацьовує. Рішення: видаляти відгуки явно перед батьківським рядком, перенести логіку в застосунок або періодично перераховувати лічильник з нуля.
На PostgreSQL MATERIALIZED VIEW з REFRESH CONCURRENTLY і унікальним індексом простіший, якщо потрібен лише останній стан. Своя таблиця потрібна, коли треба історія за днями (тренд), коли перераховувати можна частково або коли база MySQL, де представлень немає. В обох випадках оновлення за розкладом і видима дата актуальності.