0% прочитано

Похідні лічильники можуть брехати: чому для них потрібен шлях відновлення

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

20 жовтня 2026 р. 10 хв читанняЛаравель

Похідний лічильник виглядає дуже зручно. Замість складного COUNT() у кожному запиті ми зберігаємо готове число, наприклад usage_count, і читаємо його за O(1).

Проблема починається тоді, коли це число поступово починають сприймати як джерело істини.

У моєму випадку лічильник використання медіа можна було повністю відновити з канонічних usage-зв’язків. Це означає, що сам usage_count є лише обчисленим показником, який з часом може перестати відповідати фактичним даним.

Якщо значення можна однозначно перерахувати з інших даних, я вважаю ці інші дані канонічними, а counter - кешованим результатом, який повинен мати шлях відновлення.

Перший крок - визначити справжнє джерело істини

Зв’язки первинні, counter похідний
Канонічні дані media_usages  media_id  usage_context  usable_type  usable_id  Похідні дані media.usage_count

Якщо видалити usage_count, систему можна відновити, порахувавши записи в media_usages.

Якщо ж видалити самі usage-зв’язки й залишити тільки число, відновити, хто саме використовує файл, уже неможливо.

Ця асиметрія добре показує, де знаходиться джерело істини.

Похідний counter все одно може бути корисним

Те, що counter не є джерелом істини, не означає, що його треба прибрати.

Він може бути корисним для списків, фільтрів, швидкої перевірки "цей файл використовується чи ні", сортування або адміністративної статистики.

Але його роль повинна бути чесною: це оптимізоване представлення канонічних даних.

Розбіжності в даних виникають не лише через помилки в коді

Похідне значення може розійтися з реальністю з багатьох причин:

  • подія або listener не спрацювали;

  • старий код змінив relation напряму й не оновив counter;

  • міграція або одноразовий скрипт вставив канонічні рядки в обхід звичайного флоу;

  • частина транзакції завершилась, а фонове оновлення лічильника завершилося з помилкою;

  • ручне виправлення в базі торкнулося тільки однієї сторони;

  • нова логіка з’явилась пізніше, ніж старі дані.

Події добре підтримують counter, але не роблять його безпомилковим

Звичайний runtime flow
Зміни канонічних зв’язків        |        vevent dispatched        |        vlistener recalculates counter        |        vmedia.usage_count updated

Такий флоу хороший для підтримки counter майже в реальному часі.

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

Безумовне збільшення та зменшення лічильника поступово накопичують помилки

Швидко, але потребує безпомилкової історії подій
$media->increment('usage_count');

Increment/decrement працює тільки тоді, коли кожна зміна основного зв’язку між записами гарантовано супроводжується рівно однією відповідною зміною лічильника.

Одне пропущене збільшення лічильника призводить до розбіжності з фактичними даними. Повторна обробка тієї самої події може спричинити протилежну проблему - завищення значення лічильника.

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

Операція перерахунку одного запису повинна бути простою й ідемпотентною

Результат залежить від поточного канонічного стану
final class RecalculateMediaUsageCount{    public function handle(        Media $media,    ): void {        $count = DB::table('media_usages')            ->where(                'media_id',                $media->id,            )            ->count();         $media->update([            'usage_count' => $count,        ]);    }

Такий перерахунок можна виконати один раз, двічі або десять разів, і якщо основні дані не змінилися, результат завжди буде однаковим.

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

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

Команда відновлення правильних значень послідовно обробляє всі записи, не завантажуючи їх одночасно в пам’ять
final class RepairMediaUsageCounters extends Command{    protected $signature =        'media:repair-usage-counters';     public function handle(        RecalculateMediaUsageCount $recalculate,    ): int {        Media::query()            ->select([                'id',                'usage_count',            ])            ->chunkById(                500,                function ($mediaItems) use (

Спочатку виміряти розбіжності, потім виправляти

Перед виправленням можна оцінити масштаб розбіжностей
$actual = DB::table('media_usages')    ->where(        'media_id',        $media->id,    )    ->count(); if (    $media->usage_count    !== $actual) {    $this->line(        sprintf(            'media=%d stored=%d actual=%d',            $media->id,            $media->usage_count,

Мені подобається, коли інструмент відновлення даних не лише виправляє помилки, а й показує, що саме було неправильним.

Це допомагає зрозуміти причину розбіжностей: окремий застарілий запис, системна помилка в коді чи наслідки конкретної міграції.

Для production виправлення даних я додаю dry-run

Спочатку побачити наслідок, потім змінювати дані
$ php artisan media:repair-usage-counters --dry-run Scanned: 1240Correct: 1227Drifted: 13Would update: 13

Dry-run особливо корисний для першого запуску після додавання repair path.

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

Перерахунок не повинен залежати від старого значення лічильника

Відновлення правильного стану на основі вихідних даних
// Wrong for repair:$media->usage_count += $delta; // Better for repair:$media->usage_count =    $canonicalUsageCount;

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

Перерахунок повинен відновлювати правильне значення на основі фактичних даних, а не просто виправляти накопичену похибку.

Повний перерахунок не потребує однієї великої транзакції

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

Якщо значення кожного лічильника можна незалежно відновити на основі фактичних зв’язків між записами, я можу обробляти дані невеликими порціями.

Можливість безпечного повторного виконання дозволяє повторно запустити команду, навіть якщо її робота перервалася посеред процесу.

Перерахунок повинен враховувати одночасні зміни даних

Якщо production активно створює й видаляє usage-зв’язки під час перерахунку, між COUNT() і UPDATE стан може змінитися.

Для некритичного cached counter це може бути прийнятно, особливо якщо звичайний механізм роботи системи автоматично виправить значення під час наступної зміни.

Якщо потрібна сильніша гарантія, треба окремо продумати locking, maintenance window або SQL-перерахунок, який мінімізує ризик конфліктів між одночасними операціями.

Міграція нового counter повинна одразу передбачати заповнення відсутніх даних для вже наявних записів

Ще один практичний висновок: недостатньо додати колонку usage_count зі значенням 0 і підключити нові events.

Новий код почне правильно підтримувати майбутні зміни, але старі записи залишаться неправильними.

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

Перерахунок як перевірка узгодженості даних

Коли механізм перерахунку вже існує, його легко використати не тільки після інциденту.

Наприклад, scheduled dry-run може періодично рахувати кількість drifted records і сигналізувати, якщо вона стала ненульовою.

Тоді механізм перерахунку стає ще й способом перевірити, чи правильно система підтримує актуальність даних під час звичайної роботи.

Що я перевіряю для кожного отриманого counter

  • Я чітко знаю, які дані є канонічними.

  • Counter можна повністю перерахувати без використання його старого значення.

  • Під час звичайної роботи системи лічильник оновлюється після створення, зміни та видалення записів.

  • Для нової колонки передбачено початкове заповнення значень на основі вже наявних даних.

  • Є операція відновлення правильного значення для одного запису, яку можна безпечно виконувати повторно.

  • Є команда для перерахунку всіх записів із обробкою даних невеликими порціями.

  • Є dry-run або хоча б звіт про розбіжності між збереженими значеннями та фактичними даними.

  • Команду відновлення правильних значень можна безпечно запускати повторно.

  • Очікувана поведінка системи під час одночасних операцій чітко визначена.

Похідний лічильник без механізму відновлення - це прихований ризик

Для мене головна зміна в мисленні проста: counter не стає істинним лише тому, що лежить у колонці таблиці.

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

Тому разом із отриманим counter я хочу мати формулу відновлення, idempotent рекалькуляцію і команду, яка може перебудувати значення з канонічних даних.

Отриманий counter - це оптимізація читання, а не архів істини. Якщо його не можна безпечно перебудувати, система занадто сильно довіряє похідному стану.