Як обробити 11 мільйонів рядків за хвилини замість годин

Перекладено ШІ 0 stitcher.io 30 липня, 2026

Як прискорити обробку 11 мільйонів рядків із 30 до 45 000 подій на секунду лише кількома змінами в коді? Дізнайтеся, як відмова від ORM та оптимізація транзакцій перетворили багатогодинний процес на справу кількох хвилин.

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

Приблизно 5 років тому я вирішив повністю відмовитися від клієнтської аналітики на цьому блозі на користь серверної анонімної аналітики. На це було кілька причин:

  • Відсутність зайвого навантаження на стороні клієнта завдяки видаленню JavaScript-бібліотек.
  • Повага до приватності моєї аудиторії.
  • Більш точні метрики, оскільки близько 50% відвідувачів блокують клієнтські трекери.
  • Зрештою, це був цікавий виклик для мене.

Архітектура досить проста: на сервері працює скрипт, який моніторить access log блогу. Він відфільтровує краулерів та ботів, а реальний трафік зберігає в таблиці бази даних. Оскільки я хочу будувати графіки на основі цих даних, я обрав event sourcing. Кожен візит зберігається в базі, а ці історичні дані потім обробляються кількома projectors. Кожен projector створює унікальну інтерпретацію даних. Наприклад, є окремі projectors для кількості візитів за день, за місяць, для найпопулярніших дописів за тиждень тощо.

Найбільша перевага event sourcing полягає в тому, що я можу будь-коли додати нові projectors і «перезібрати» їх на основі тих самих історичних даних. Саме цю функцію перезбирання (rebuilding) я оптимізував цього тижня. За п'ять років у блозі накопичилося понад 11 мільйонів візитів. Проте система працювала на дуже застарілій версії Laravel. Настав час перевести її на Tempest і заодно підкоригувати старі графіки.

Після копіювання 11 мільйонів рядків у нову базу мені потрібно було з нуля перезібрати всі projectors — вперше з моменту запуску проєкту. Під час цього я зіткнувся з серйозною проблемою продуктивності: команда replay (яка «відтворює» історичні події) обробляла близько 30 подій на секунду для кожного projector. Таким чином, реплей 11 мільйонів рядків для десятка projectors зайняв би… близько 50 годин. Чекати так довго я не збирався. Ось що було далі.

Визначаємо baseline

Коли я хочу розібратися з продуктивністю, першим кроком завжди є встановлення baseline — поточного стану справ. Це дозволяє реально виміряти покращення. Мій baseline: 30 подій на секунду. Ось як виглядала команда replay, яку я використовував (я прибрав обчислення метрик та налаштування, залишивши коментарі для ясності):

final readonly class EventsReplayCommand
{
    #[ConsoleCommand]
    public function __invoke(?string $replay = null): void
    {
        // Налаштування та метрики (приховано)
        
        foreach ($projectors as $projectorClass) {
            // Запускаємо лише обрані projectors
            if (! in_array($projectorClass, $replay, strict: true)) {
                continue;
            }
            
            // Отримуємо projector з контейнера
            $projector = $this->container->get($projectorClass);

            // Очищуємо його дані для повного перезбирання
            $projector->clear();

            // Проходимо по всіх подіях,
            // від старих до нових, частинами (chunk) по 500
            StoredEvent::select()
                ->orderBy('createdAt ASC')
                ->chunk(
                    function (array $storedEvents) use ($projector): void {
                        foreach ($storedEvents as $storedEvent) {
                            // Кожна подія відтворюється в projector
                            $projector->replay($storedEvent->getEvent());
                        }
                    },
                    500,
                );
        }
    }
}

Повторюся: результат 30 подій на секунду — це жахливо, але це саме те, що ми будемо покращувати. Тепер до справи.

Відмова від сортування

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

// Проходимо по всіх подіях,
// частинами по 500
StoredEvent::select()
    ->orderBy('createdAt ASC') 
    ->chunk(

І справді, пропускна здатність підскочила з 30 до 6700 подій на секунду. Можна було б подумати, що проблему вирішено, але зрештою нам вдасться потроїти навіть цей результат.

Зміна порядку циклів

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

// Цикл по кожному projector
foreach ($projectors as $projector) {
    StoredEvent::select()
        ->chunk(
            function (array $storedEvents) use ($projector): void {
                // Цикл по кожній події
                foreach ($storedEvents as $storedEvent) {
                    // …
                }
            },
            500,
        );
    }
}

Тут є очевидна проблема: ми знову і знову дістаємо одні й ті самі події для кожного окремого projector. Оскільки подій мільйони, а projectors — лише десятки, логічніше змінити порядок:

StoredEvent::select()
    ->chunk(
        function (array $storedEvents) use ($projectors): void {
            // Цикл по кожному projector всередині циклу подій
            foreach ($projectors as $projector) {
                foreach ($storedEvents as $storedEvent) {
                    // …
                }
            }
        },
        500,
    );

Оскільки мій baseline базувався на одному projector, я не очікував великого приросту. Проте це не погіршило результат: швидкість зросла з 6.7k до 6.8k подій на секунду.

Геть ORM

Наступне покращення стало вагомим кроком уперед. Оскільки ми обробляємо величезну кількість даних, ORM стає вузьким місцем. Я спробував замінити його на «сирий» query builder. Це потребувало ручного мапінгу даних, але результат того вартий:

StoredEvent::select()
query('stored_events')
    ->select()
    ->chunk(function (array $data) use ($projectors) {
        // Ручний мапінг даних у класи подій
        $events = arr($data)
            ->map(function (array $item) {
                return $item['eventClass']::unserialize($item['payload']);
            })
            ->toArray();
        
        // Цикл по projectors та подіях
    )};

Стрибок з 6.8k до 7.8k подій на секунду! Цілком очікувано. Я не проти ORM заради зручності, але зручність завжди має ціну. Коли йдеться про такі обсяги, краще бути якомога ближче до бази даних.

Ще менше абстракцій

Побачивши ефект від відмови від ORM, я вирішив піти далі: замінити метод chunk() (що використовує closures) на звичайний цикл while.

$offset = 0;
$limit = 1500;

while ($data = query('stored_events')->select()->offset($offset)->limit($limit)->all()) {
    // Мапінг та обробка...
    $offset += $limit;
}

Це підвищило продуктивність з 7.8k до 8.4k подій на секунду. Також я експериментально збільшив ліміт вибірки з 500 до 1500, що дало найкращий результат у моєму оточенні.

Швидша серіалізація?

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

Пошук багу у фреймворку

Профілювання показало дивну річ: за одну ітерацію (1.5k подій) Tempest викликав TypeReflector аж 175 000 разів! Це обгортка над reflection API, яка активно використовується в ORM. Але ж я прибрав ORM!

З'ясувалося, що GenericDatabase використовував SerializerFactory для підготовки даних перед кожним вставленням у базу. Фабрика використовувала reflection, щоб визначити тип серіалізатора для кожного значення.

Але ми точно знаємо, що маємо справу зі скалярними даними, які не потребують серіалізації. Я вніс виправлення на рівні фреймворку, щоб пропускати reflection для рядків та чисел. Ця дрібниця дала неймовірний результат: швидкість зросла до 14k подій на секунду!

Оптимізація для тривалої роботи

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

$lastId = 0;
while ($data = query('stored_events')->select()->where('id > ?', $lastId)->limit($limit)->all()) {
    // …
    $lastId = array_last($data)['id'];
}

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

Буферизація записів (Buffered inserts)

У Discord-спільноті Tempest мені порадили буферизувати запити. Замість того, щоб надсилати запити по одному, можна накопичувати їх і відправляти пачкою. Я створив інтерфейс BufferedProjector та trait для накопичення SQL-запитів. Це дозволило підняти планку до 19k подій на секунду!

Чи задоволений я тепер?

Оптимізації вже вражали, але один коментар від розробника на ім'я Márk змінив усе. Він порадив використовувати транзакції, щоб зменшити затримку fsync.

Без транзакції кожен insert — це неявний commit. 20k вставлень = 20k коммітів = 20k викликів fsync() для забезпечення надійності (ACID). З явною транзакцією ми робимо один комміт на всю пачку, і fsync() викликається лише раз.

Я додав два рядки коду, щоб обгорнути обробку в транзакцію. Результат? Швидкість злетіла з 19k до 45k подій на секунду. 🤯

Остаточний результат

Я пройшов шлях від 30 до майже 50 000 подій на секунду. Перезбирання одного projector тепер займає пару хвилин замість годин. Весь код проєкту відкритий: ви можете знайти його в репозиторії блогу, модуль аналітики знаходиться тут, а сама панель — тут. Все працює на Tempest.

Оновлення: опубліковано другу частину серії, читайте тут.

Популярні

Інше, що варто прочитати

Використання повнотекстового пошуку в Laravel
180 Оновлено 26 червня, 2026

Використання повнотекстового пошуку в Laravel

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

16 Оновлено 26 червня, 2026

Простий пакет RabbitMQ для Laravel

Вам цікаво дізнатися, як спростити інтеграцію RabbitMQ у вашому Laravel-додатку? У нашій статті ми розглянемо пакет Simple RabbitMQ, який дозволяє легко налаштувати багатозʼєднання, публікувати повідомлення та обробляти черги за допомогою простого синтаксису. Читайте далі, щоб дізнатися більше!

38 Оновлено 26 червня, 2026

4 поширені помилки Vite у Laravel

Використання Vite для створення фронтенд-ресурсів у вашому додатку Laravel може бути захоплюючим, але іноді ви можете стикнутися з певними помилками. У цій статті ми розглянемо чотири поширені помилки, з якими ви можете зіткнутися, а також підкажемо способи їх усунення, щоб ви могли знову зосередитися на розробці вашого додатку