<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БАЗИ ДАНИХ · ПРОЄКТУВАННЯ

Коли денормалізуватиDenormalization

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

17 хв читання Middle → Senior
01 · КОПІЯ НАВМИСНО

Порушити нормальну форму і знати ціну

Нормалізація каже: кожен факт зберігається в одному місці. Назва компанії лежить у companies, а вакансія знає лише company_id. свідомо кладе копію факту ще кудись: кількість відгуків у вакансії, назву вакансії у відгук, підсумок за день в окрему таблицю.

СЛОВНИКДенормалізація. Свідоме дублювання даних, які можна було б отримати JOIN або агрегатом. Не плутати з ненормалізованою схемою, де дублі зʼявились випадково і ніхто не знає, яка копія правильна.
СЛОВНИКЛічильник. Колонка на кшталт applications_count чи views, яку оновлюють при кожній зміні замість COUNT(*) при кожному читанні. Rails називає це counter cache.

Причин дві. Перша: читання. Сторінку зі списком вакансій відкривають тисячі разів, і COUNT(*) по відгуках чи медіана зарплат на кожному показі коштують дорожче, ніж одна колонка, оновлена при записі. Друга: зміст. вакансії у відгуку зберігає, на що людина відгукнулась того дня. Це вже інший факт, і після редагування вакансії він не має змінюватись.

СЛОВНИКЗнімок. Копія даних на конкретний момент: ціна в рядку замовлення, назва й зарплата вакансії у відгуку, курс валют у платежі. Оригінал далі живе своїм життям, а знімок не змінюється ніколи.

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

СЛОВНИКТригер. Процедура в базі, яку база сама викликає на INSERT, UPDATE чи DELETE рядка. Синхронізує копію в тій самій транзакції, але живе поза кодом застосунку, і її легко не помітити.
СЛОВНИКМатеріалізоване представлення. MATERIALIZED VIEW у PostgreSQL: результат запиту, збережений як таблиця. Оновлюється лише командою REFRESH, тож дані в ньому на момент останнього оновлення.
02 · ДУБЛЮВАТИ ЧИ НІ

Шість копій і що з ними буде

Питання не «чи це денормалізація», а «хто і коли оновлює копію, і що станеться, якщо не оновить». Нижче типові копії з сайту вакансій.

копія і хто її підтримує
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; виводи нижче.

03 · ТРИГЕР

Лічильник, який оновлює сама база

Тригер AFTER INSERT OR DELETE на applications збільшує або зменшує vacancies.applications_count у тій самій транзакції. Він спрацює, хоч би звідки прийшов запис: з Eloquent, з консолі чи з імпорту. Тому тригер найнадійніший спосіб тримати копію точною і найменш помітний: у коді PHP його не видно.

Пастка MySQL: дії зовнішніх ключів (ON DELETE CASCADE, SET NULL) тригери не запускають. Якщо відгуки видаляються каскадом разом з користувачем, лічильник вакансії не зменшиться. PostgreSQL і SQLite запускають тригери і для каскадних змін.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1CREATE FUNCTION applications_count_sync() RETURNS trigger AS $$
2BEGIN
3 IF TG_OP = 'INSERT' THEN
4 UPDATE vacancies SET applications_count = applications_count + 1
5 WHERE id = NEW.vacancy_id;
6 ELSE
7 UPDATE vacancies SET applications_count = applications_count - 1
8 WHERE id = OLD.vacancy_id;
9 END IF;
10 RETURN NULL;
11END $$ LANGUAGE plpgsql;
12
13CREATE TRIGGER applications_count_sync
14 AFTER INSERT OR DELETE ON applications
15 FOR EACH ROW EXECUTE FUNCTION applications_count_sync();
16
17-- два відгуки, один користувач видалено, відгук пішов каскадом
18SELECT v.applications_count,
19 (SELECT COUNT(*) FROM applications a WHERE a.vacancy_id = v.id) AS real_count
20FROM vacancies v;
 applications_count | real_count
--------------------+------------
                  1 |          1

Каскадне видалення з users теж пройшло через тригер, і лічильник точний.

1CREATE TRIGGER applications_count_insert AFTER INSERT ON applications
2 FOR EACH ROW
3 UPDATE vacancies SET applications_count = applications_count + 1
4 WHERE id = NEW.vacancy_id;
5CREATE TRIGGER applications_count_delete AFTER DELETE ON applications
6 FOR EACH ROW
7 UPDATE vacancies SET applications_count = applications_count - 1
8 WHERE id = OLD.vacancy_id;
9
10-- applications.user_id … ON DELETE CASCADE
11DELETE FROM users WHERE id = 2;
12SELECT v.applications_count,
13 (SELECT COUNT(*) FROM applications a WHERE a.vacancy_id = v.id) AS real_count
14FROM vacancies v;
+--------------------+------------+
| applications_count | real_count |
+--------------------+------------+
|                  2 |          1 |
+--------------------+------------+

Відгук видалено каскадом, тригер AFTER DELETE не спрацював, і лічильник каже 2. Прямий DELETE FROM applications лічильник зменшив би.

1CREATE TRIGGER applications_count_delete AFTER DELETE ON applications
2BEGIN
3 UPDATE vacancies SET applications_count = applications_count - 1
4 WHERE id = OLD.vacancy_id;
5END;
6
7DELETE FROM users WHERE id = 2;
8SELECT v.applications_count,
9 (SELECT COUNT(*) FROM applications a WHERE a.vacancy_id = v.id) AS real_count
10FROM vacancies v;
┌────────────────────┬────────────┐
│ applications_count │ real_count │
├────────────────────┼────────────┤
│ 1                  │ 1          │
└────────────────────┴────────────┘

Як у PostgreSQL: каскад запускає тригер. За умови, що зʼєднання ввімкнуло PRAGMA foreign_keys, інакше каскаду не буде взагалі.

04 · ЗВІТНА ТАБЛИЦЯ

Матеріалізоване представлення чи таблиця підсумків

Звіт «скільки вакансій і яка зарплата за фреймворком» рахує агрегат по всій таблиці. Замість рахувати його на кожному показі, результат зберігають. У PostgreSQL для цього є матеріалізоване представлення: запит, чий результат лежить як таблиця, а REFRESH перераховує його цілком. REFRESH … CONCURRENTLY не блокує читання, але вимагає унікального індексу на представленні.

У MySQL і SQLite матеріалізованих представлень немає. Їх роль грає звичайна таблиця підсумків, яку job перезаписує через INSERT … SELECT з upsert. Перевага таблиці в тому, що вона може тримати історію: рядок на кожен день замість одного останнього стану.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1CREATE MATERIALIZED VIEW market_by_framework AS
2 SELECT framework, COUNT(*) AS jobs,
3 percentile_cont(0.5) WITHIN GROUP (ORDER BY salary_usd) AS median_usd
4 FROM jobs WHERE status = 'active'
5 GROUP BY framework;
6CREATE UNIQUE INDEX market_by_framework_key ON market_by_framework (framework);
7
8INSERT INTO jobs VALUES (5, 'symfony', 'active', 4500);
9SELECT * FROM market_by_framework; -- ще старі числа
10REFRESH MATERIALIZED VIEW CONCURRENTLY market_by_framework;
11SELECT * FROM market_by_framework;
 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.

1CREATE TABLE market_by_framework (
2 day DATE NOT NULL, framework VARCHAR(20) NOT NULL,
3 jobs INT NOT NULL, avg_usd INT,
4 PRIMARY KEY (day, framework)
5);
6
7INSERT INTO market_by_framework (day, framework, jobs, avg_usd)
8SELECT * FROM (
9 SELECT CURRENT_DATE AS day, framework, COUNT(*) AS jobs_now, AVG(salary_usd) AS avg_now
10 FROM jobs WHERE status = 'active' GROUP BY framework
11) AS fresh
12ON DUPLICATE KEY UPDATE jobs = fresh.jobs_now, avg_usd = fresh.avg_now;
13
14SELECT framework, jobs, avg_usd FROM market_by_framework;
+-----------+------+---------+
| framework | jobs | avg_usd |
+-----------+------+---------+
| laravel   |    2 |    3500 |
| symfony   |    2 |    4000 |
+-----------+------+---------+

Повторний запуск того ж дня перезаписує рядок через ON DUPLICATE KEY UPDATE. Медіани в MySQL немає, тут середнє; медіану рахують у застосунку або віконною функцією.

1INSERT INTO market_by_framework (day, framework, jobs, avg_usd)
2SELECT date('now'), framework, COUNT(*), AVG(salary_usd)
3FROM jobs WHERE status = 'active' GROUP BY framework
4ON CONFLICT (day, framework)
5DO UPDATE SET jobs = excluded.jobs, avg_usd = excluded.avg_usd;
┌───────────┬──────┬─────────┐
│ framework │ jobs │ avg_usd │
├───────────┼──────┼─────────┤
│ laravel   │ 2    │ 3500    │
│ symfony   │ 2    │ 4000    │
└───────────┴──────┴─────────┘

Якщо SELECT закінчується на FROM таблиця без WHERE, SQLite читає ON як умову JOIN і падає з near "DO": syntax error. Тоді пишуть WHERE true.

05 · ЗГЕНЕРОВАНА КОЛОНКА

Копія, яку неможливо забути оновити

Якщо копію можна обчислити з інших колонок того самого рядка, її краще доручити базі. Згенерована колонка STORED перераховується при кожній зміні джерела і зберігається, тож її можна індексувати. Записати в неї значення руками база не дасть.

Типовий приклад: середина зарплатної вилки для сортування чи колонка для повнотекстового пошуку з назви й опису. Синтаксис майже однаковий у всіх трьох базах, PostgreSQL підтримує його з версії 12.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1CREATE TABLE vg (
2 id BIGINT PRIMARY KEY,
3 salary_from INT, salary_to INT,
4 salary_mid INT GENERATED ALWAYS AS ((salary_from + salary_to) / 2) STORED
5);
6CREATE INDEX vg_salary_mid_index ON vg (salary_mid);
7
8INSERT INTO vg (id, salary_from, salary_to) VALUES (10, 3000, 4000);
9SELECT * FROM vg;
10UPDATE vg SET salary_mid = 1 WHERE id = 10;
 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): функції з поточним часом чи посиланням на інші таблиці не підійдуть.

1CREATE TABLE vg (
2 id BIGINT PRIMARY KEY,
3 salary_from INT, salary_to INT,
4 salary_mid INT GENERATED ALWAYS AS ((salary_from + salary_to) DIV 2) STORED,
5 INDEX vg_salary_mid_index (salary_mid)
6);
7
8INSERT INTO vg (id, salary_from, salary_to) VALUES (10, 3000, 4000);
9UPDATE vg SET salary_mid = 1 WHERE id = 10;
-- salary_mid = 3500
ERROR 3105 (HY000): The value specified for generated column 'salary_mid'
  in table 'vg' is not allowed.

У MySQL є ще VIRTUAL: значення не зберігається, а обчислюється при читанні, і індекс на ній теж можна створити.

1CREATE TABLE vg (
2 id INTEGER PRIMARY KEY,
3 salary_from INTEGER, salary_to INTEGER,
4 salary_mid INTEGER GENERATED ALWAYS AS ((salary_from + salary_to) / 2) STORED
5);
6
7INSERT INTO vg (id, salary_from, salary_to) VALUES (10, 3000, 4000);
8UPDATE vg SET salary_mid = 1;
-- salary_mid = 3500
Error: cannot UPDATE generated column "salary_mid"

Laravel оголошує такі колонки через ->storedAs('…') і ->virtualAs('…') у міграції.

06 · ХТО ОНОВЛЮЄ КОПІЮ

Тригер, код застосунку чи job

Чим точнішою має бути копія, тим ближче до запису оригіналу її оновлюють. Чим дорожче її порахувати, тим охочіше відкладають на потім.

php counters.php У тому ж запиті
1// UPDATE vacancies SET views = views + 1 WHERE id = ?
2JobModel::query()->whereKey($id)->increment('views');
3
4// Відгук і лічильник разом або ніяк.
5DB::transaction(function () use ($application) {
6 $application->save();
7 Vacancy::whereKey($application->vacancy_id)->increment('applications_count');
8});
ЩО ПЕРЕВІРЯЄ
increment() пише вираз views = views + 1 у SQL, тож дві паралельні сесії не затруть одна одну, як було б при $model->views++ і save(). Транзакція тримає запис і копію разом.
КОЛИ БРАТИ
Лічильники й прості копії, які змінюються в одному-двох місцях коду. Небезпека: новий шлях запису (імпорт, адмінка, консоль), що забуде про копію.
php counters.php Тригер у міграції
1public function up(): void
2{
3 DB::unprepared(<<<'SQL'
4 CREATE TRIGGER applications_count_sync
5 AFTER INSERT OR DELETE ON applications
6 FOR EACH ROW EXECUTE FUNCTION applications_count_sync()
7 SQL);
8}
ЩО ПЕРЕВІРЯЄ
Спрацьовує на будь-який запис, з коду чи ні. SQL тригера залежить від рушія, тож тести на SQLite потребують своєї версії або окремої гілки за драйвером.
КОЛИ БРАТИ
Коли копія має бути точною завжди, а писати в таблицю можуть кілька систем. Задокументуйте тригер поруч з моделлю: інакше його ніхто не знайде.
php counters.php Перерахунок за розкладом
1// routes/console.php
2Schedule::command('hiring:snapshot')->dailyAt('03:45');
3
4// У команді: порахувати з нуля і перезаписати рядок дня.
5MarketSnapshotModel::query()->updateOrCreate(
6 ['day' => $day, 'kind' => 'all', 'key' => 'all'],
7 ['jobs' => $census->jobs, 'median_usd' => $census->median],
8);
ЩО ПЕРЕВІРЯЄ
Копія завжди виводиться з оригіналу заново, тож помилка в одному запуску виправляється наступним. Між запусками дані застарілі, і це закладено в дизайн.
КОЛИ БРАТИ
Звіти, тренди, рейтинги: усе, що дорого рахувати і що допустимо показувати із затримкою.
php counters.php Подія і черга
1// Модель шле подію після коміту, слухач у черзі перераховує копію.
2class CompanyRenamed implements ShouldDispatchAfterCommit {}
3
4class RefreshCompanyCopies implements ShouldQueue
5{
6 public function handle(CompanyRenamed $event): void { /* ... */ }
7}
ЩО ПЕРЕВІРЯЄ
Запис оригіналу не чекає на оновлення копій. ShouldDispatchAfterCommit не дасть слухачу побачити компанію до коміту або після відкату.
КОЛИ БРАТИ
Коли копій багато або вони в іншому сховищі: пошуковий індекс, кеш, інша база. Копія тоді узгоджена «з часом», і інтерфейс має це витримувати.
07 · ЯК У PHP UKRAINE

Знімок вакансії у відгуку

Коли кандидат відгукується, ApplyToJob копіює у відгук назву вакансії, назву компанії, зарплату, формат, рівень і посилання. Це денормалізація, зроблена правильно, з трьох причин. Знімок означає інший факт, «на що людина відгукнулась того дня», тож його ніколи не синхронізують: компанія може змінити назву, вакансію можуть закрити чи переписати, а кабінет кандидата покаже те, що було. Він записується один раз, у тій самій транзакції, що й відгук (EloquentApplicationRepository::save). І оригінал не підмінено: відгук і далі тримає vacancy_id із зовнішнім ключем, тож поточну вакансію завжди можна прочитати поруч. Навіть приховану зарплату знімок не розкриває: при hideSalary він зберігає null. Відгуки, що зʼявились до знімків, мають job_snapshot NULL, і predatesMaterials() у домені прямо це називає, а сторінки кандидата тоді беруть назву з самої вакансії.

php ApplyToJob.php фрагмент
1$c = $job->classification;
2$snapshot = [
3 'title' => $job->title,
4 'company' => $this->companies->findById($job->companyId)?->name,← назва компанії на день відгуку
5 'salary' => $job->posting->hideSalary ? null : $c->salary?->format(),← прихована зарплата лишається прихованою
6 'format' => $c->format?->label(),
7 'level' => $c->level?->label(),
8 'path' => $materials->jobPath ?? '/jobs/'.$job->id,
9 'framework' => $c->framework?->value,← для підготовки до співбесіди
10 'level_key' => $c->level?->value,
11];
12
13$application = Application::submit($jobId, $job->companyId, $name, $email, $link, $message, $this->clock->now(), $userId, $materials->resumePath, $materials->resumeName, $snapshot);← знімок іде в агрегат разом з відгуком
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-індекс на ній: копію для пошуку підтримує сама база.
08 · ТИПОВІ ПОМИЛКИ

Де копії розходяться

✕ 1

Денормалізація замість індексу

Запит з JOIN повільний, і назву компанії копіюють у кожну вакансію. Насправді JOIN за первинним ключем дешевий, а повільно було через відсутній індекс на company_id чи N+1 у коді.

Натомість: Спершу EXPLAIN і індекси. Копіювати лише тоді, коли план уже добрий, а читань так багато, що кожна мілісекунда множиться.

✕ 2

Копія без власника

Колонку додали, оновлюють в одному сервісі, а адмінка, імпорт і консольна команда пишуть в оригінал напряму. Через місяць копія бреше в частині рядків, і ніхто не знає в яких.

Натомість: Один шлях оновлення (тригер, подія моделі, job) і команда, що перераховує копію з нуля та показує розбіжності.

✕ 3

$model->count++ і save()

Дві паралельні сесії читають 10, кожна пише 11. Один перегляд чи відгук зник, і так щодня.

Натомість: increment() з виразом у SQL, upsert з hits = hits + 1 або блокування рядка. Для точних лічильників тригер.

✕ 4

Знімок, який потім оновлюють

У знімок вакансії «для зручності» дописують зміни з оригіналу. Тепер це не знімок, а погано синхронізована копія, і історія відгуку змінюється заднім числом.

Натомість: Знімок пишеться один раз. Потрібні поточні дані, читайте оригінал за ключем, який лежить поруч зі знімком.

✕ 5

Матеріалізоване представлення без розкладу

MATERIALIZED VIEW створили, дані виглядають правильними, а REFRESH ніхто не запускає. Звіт тихо застигає на дні створення.

Натомість: REFRESH … CONCURRENTLY у планувальнику і дата останнього оновлення поруч зі звітом.

09 · ДЕРЕВО РІШЕНЬ

Чи денормалізувати

1
Чи повільне саме читання, і EXPLAIN уже показує добрий план?
Індекси на місці, N+1 немає, а агрегат по великій таблиці все одно дорогий.
НІ →Спершу індекс і запит. Копія не лікує поганий план.
2
Чи знаєте ви, хто і коли оновлюватиме копію?
Тригер, конкретний метод репозиторію, job за розкладом.
НІ →Без власника копія розійдеться. Рахуйте на льоту або через кеш з TTL.
3
Чи допустимо, що копія хоч на мить відстає від оригіналу?
Звіт за вчора чи лічильник переглядів: так. Залишок на рахунку: ні.
НІ →Тоді лише в тій самій транзакції: тригер або оновлення поруч із записом.
ТАК НА ВСІ →Денормалізуйте, і нехай копію оновлює job або подія після коміту. Додайте команду, що перераховує все з нуля.
10 · СЛОВНИК
Денормалізація
Свідоме дублювання даних, які можна було б отримати JOIN або агрегатом. Не плутати з ненормалізованою схемою, де дублі зʼявились випадково і ніхто не знає, яка копія правильна.
Знімок
Копія даних на конкретний момент: ціна в рядку замовлення, назва й зарплата вакансії у відгуку, курс валют у платежі. Оригінал далі живе своїм життям, а знімок не змінюється ніколи.
Тригер
Процедура в базі, яку база сама викликає на INSERT, UPDATE чи DELETE рядка. Синхронізує копію в тій самій транзакції, але живе поза кодом застосунку, і її легко не помітити.
Матеріалізоване представлення
MATERIALIZED VIEW у PostgreSQL: результат запиту, збережений як таблиця. Оновлюється лише командою REFRESH, тож дані в ньому на момент останнього оновлення.
Лічильник
Колонка на кшталт applications_count чи views, яку оновлюють при кожній зміні замість COUNT(*) при кожному читанні. Rails називає це counter cache.
11 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з SQL →

Свідома копія даних, які можна отримати JOIN або агрегатом: лічильник відгуків у вакансії, назва вакансії у відгуку, підсумки за день. Платить вона тим, що копію треба оновлювати при кожній зміні оригіналу, інакше дані розійдуться.

Бо це інший факт: не «яка зараз вакансія», а «на що людина відгукнулась того дня». Знімок записується один раз і не синхронізується, а поточна вакансія лишається доступною за vacancy_id. Якби знімок оновлювали разом з вакансією, він перестав би бути знімком.

Найімовірніше, відгуки видаляються каскадом через зовнішній ключ, наприклад разом з користувачем. У MySQL дії зовнішніх ключів тригери не запускають, тож AFTER DELETE не спрацьовує. Рішення: видаляти відгуки явно перед батьківським рядком, перенести логіку в застосунок або періодично перераховувати лічильник з нуля.

На PostgreSQL MATERIALIZED VIEW з REFRESH CONCURRENTLY і унікальним індексом простіший, якщо потрібен лише останній стан. Своя таблиця потрібна, коли треба історія за днями (тренд), коли перераховувати можна частково або коли база MySQL, де представлень немає. В обох випадках оновлення за розкладом і видима дата актуальності.