Знову обробляємо 11 мільйонів рядків: тепер за лічені секунди

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

Як прискорити обробку 11 мільйонів подій у PHP та перетворити чотири дні очікування на лічені секунди? Розбираємо кейс екстремальної оптимізації коду та запитів до бази даних для досягнення швидкості 1,7 мільйона операцій на секунду.

Два тижні тому я влаштував справжній марафон оптимізації, коли мій PHP-скрипт мав обробити 11 мільйонів подій із бази даних. Початковий код обробляв лише 30 подій на секунду — це означало, що вся операція тривала б 4 дні. У минулому дописі мені вдалося підняти цей показник до 50 000 подій на секунду, скоротивши час виконання до кількох хвилин.

Проте я знав, що потенціал для покращення ще не вичерпано. Тож експерименти продовжилися.

Комбінований insert

Цього разу я стартував із цілком пристойного показника: 50к подій на секунду. Після публікації мого блогу Марк підказав, що проєктор (projector) відвідувань за рік можна значно пришвидшити. Для контексту: я відстежую кількість відвідувань блогу за рік. Кожного разу, коли фіксується подія visit, я інкрементую поле count у базі даних для відповідного року. Раніше я робив окремий insert для кожного візиту, але їх можна об’єднати в один запит. Замість цього:

INSERT INTO `visits_per_year` (`date`, `count`) VALUES ("2025-01-01", 1) ON DUPLICATE KEY UPDATE `count` = `count` + 1;
INSERT INTO `visits_per_year` (`date`, `count`) VALUES ("2025-01-01", 1) ON DUPLICATE KEY UPDATE `count` = `count` + 1;
INSERT INTO `visits_per_year` (`date`, `count`) VALUES ("2025-01-01", 1) ON DUPLICATE KEY UPDATE `count` = `count` + 1;
INSERT INTO `visits_per_year` (`date`, `count`) VALUES ("2025-01-01", 1) ON DUPLICATE KEY UPDATE `count` = `count` + 1;

Я виконав один запит:

INSERT INTO `visits_per_year` (`date`, `count`) 
    VALUES ("2025-01-01", 1), ("2025-01-01", 1), ("2025-01-01", 1), ("2025-01-01", 1) 
    ON DUPLICATE KEY UPDATE `count` = `count` + 1;

Завдяки цій зміні продуктивність знову подвоїлася: з 50к до 100к подій на секунду!

Більше логіки на стороні PHP

Ви помітили конструкцію ON DUPLICATE KEY UPDATE count = count + 1? Це спосіб MySQL інкрементувати значення на рівні бази даних, якщо ключ (у нашому випадку поле date) уже існує.

Марк зауважив, що такі обчислення на рівні бази даних далеко не оптимальні, і запропонував перенести їх у PHP. Це потребувало лише кількох дрібних змін спочатку в самому обробнику подій (event handler):

#[EventHandler]
public function onPageVisited(PageVisited $pageVisited): void
{
    $date = $pageVisited->visitedAt->format('Y') . '-01-01';

    $this->inserts[] = $date;
    $this->inserts[$date] = ($this->inserts[$date] ?? 0) + 1;
}

А потім у запиті до бази даних:

public function persist(): void
{
    if ($this->inserts === []) {
        return;
    }

    $values = [];

    foreach ($this->inserts as $date => $count) {
        $values[] = "(\"{$date}-01-01\",{$count})";
    }

    $query = new Query(sprintf(
        'INSERT INTO `visits_per_year` (`date`, `count`) VALUES %s ON DUPLICATE KEY UPDATE `count` = `count` + VALUES(`count`)',
        implode(',', $values),
    ));

    $query->execute();

    $this->inserts = [];
}

Результат вражає: замість одного гігантського insert з тисячами значень, ми виконуємо лише одну операцію оновлення для кожного року. Швидкість підскочила зі 100к до 250к подій на секунду. Але тримайтеся, ми ще навіть не пройшли половину шляху.

Тонке налаштування

Далі я вніс кілька менших правок: збільшив ліміт чанків (chunk limit) та почав вибирати з таблиці stored_events лише необхідні поля:

$limit = 1500;
$limit = 30_000;

while ($events = query('stored_events')->select('id', 'payload')->where('id > ?', $lastId)->limit($limit)->all()) {
    // …
}

Це додало ще 20к, піднявши планку до 270к подій на секунду. Колись 20к здавалися неймовірним результатом, а тепер — лише приємним бонусом. Але 20к це все одно 20к!

Хвилинка роздумів

На цьому етапі здавалося, що ми вперлися в стелю. Читання та запис розбиті на чанки, розрахунки перенесені в PHP. Що ще можна зробити?

Мені стало цікаво: яким буде теоретичний максимум для обробки 11 мільйонів рядків, якщо ігнорувати I/O? По суті, ми перевіряємо швидкість двох операцій:

  1. Обробити 11 млн рядків із датами, щоб витягнути рік.
  2. Підрахувати кількість візитів за рік у масиві.

Перевіримо це невеликим скриптом:

$start = microtime(true);

$inserts = [];

foreach (['2026-02-01 00:00:00', '2025-02-01 00:00:00', '2024-02-01 00:00:00', '2023-02-01 00:00:00', '2022-02-01 00:00:00', '2021-02-01 00:00:00'] as $date) {
    $i = 0;

    while ($i < 2_000_000) {
        $date = new DateTime($date)->format('Y') . '-01-01';
        $inserts[$date] ??= 0;
        $inserts[$date]++;
        $i++;
    }
}

$end = microtime(true);

echo $end - $start . 's';

Тут ми симулюємо 2 млн візитів на рік протягом 6 років. Скрипт виконується за 3.89 секунди. Це означає, що без урахування I/O моя машина може обробляти близько 3 млн рядків на секунду.

Але чи можна пришвидшити сам PHP? Я не знаю, що саме new DateTime($date)->format('Y') робить "під капотом", тому спробував просто витягнути рік із сирого рядка:

while ($i < 2_000_000) {
    $date = new DateTime($date)->format('Y') . '-01-01';
    $date = substr($date, 0, 4) . '-01-01';
    $inserts[$date] ??= 0;
    $inserts[$date]++;
    $i++;
}

Тепер скрипт відпрацював за 0.49 секунди — це фантастичні 25 мільйонів рядків на секунду!

Теоретичні 25 млн проти наших 270к. Очевидно, що попереду ще багато роботи. Звісно, у реальних умовах ми обмежені I/O, але експеримент вартий уваги.

Перехід на RAW

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

Замість цього:

$events = arr($data)
    ->map(function (array $item) {
        return $item['eventClass']::unserialize($item['payload']);
    })
    ->toArray();

Я зробив так:

$events = arr($data)
    ->map(function (array $item) {
        return json_decode($item['payload']);
    })
    ->toArray();

Проєктор довелося трохи змінити, адже раніше він працював лише з об'єктом PageVisited:

 public function replay(array|object $event): void
{
    if (is_array($event)) {
        $this->onPageVisitedArray($event);
    } elseif ($event instanceof PageVisited) {
        $this->onPageVisited($event);
    }
}

// Обробник для рантайму
#[EventHandler]
public function onPageVisited(PageVisited $pageVisited): void
{
    $date = $pageVisited->visitedAt->format('Y') . '-01-01';

    $this->inserts[$date] = ($this->inserts[$date] ?? 0) + 1;
}

// Метод для реплеїв із сирими даними масиву
public function onPageVisitedArray(array $event): void
{
    $date = substr($event['createdAt'], 0, 4);

    $this->inserts[$date] = ($this->inserts[$date] ?? 0) + 1;
}

Це рішення не назвеш елегантним, але воно підняло швидкість із 270к до 400к подій на секунду.

Фінальний бос

Здавалося, це межа. 400к подій на секунду — це чудовий результат, зважаючи на обмеження I/O та пам'яті.

Але я помітив ще одну можливість. Що, якщо нам взагалі не потрібно десеріалізувати дані? Зараз ми зберігаємо серіалізовані події в базі та десеріалізуємо їх під час реплею:

$events = arr($data)
    ->map(function (array $item) {
        return json_decode($item['payload']);
    })
    ->toArray();

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

Давайте на мить відмовимося від json_decode() і використаємо stored_events.createdAt напряму:

while ($events = query('stored_events')->select('id', 'createdAt')->where('id > ?', $lastId)->limit($limit)->all()) {
    $events = arr($data)
        ->map(function (array $item) {
            return json_decode($item['payload']);
        })
        ->toArray();
}

Ми починали з 30 подій на секунду. Дійшли до 400к. І ця остання зміна підняла планку до... 1.7 млн подій на секунду!

Звісно, на практиці мати окрему таблицю для кожного типу подій незручно. Ми маємо справу з вічним компромісом: зручність розробки проти "голого" перформансу. Якщо кожна секунда на рахунку — ми знаємо, як витиснути максимум, але це має свою ціну.

Замість висновків

Я радикально позбувся json_decode(), але, можливо, існують більш оптимальні способи серіалізації в PHP. Це потребує міграції всіх даних, що може бути довгим процесом, але я готовий спробувати вбудований серіалізатор PHP.

Також варто пам’ятати, що все це працює в одному потоці. Паралельне виконання могло б пришвидшити процес у 10–20 разів залежно від кількості ядер. Але в Event Sourcing порядок подій є критичним. Для "візитів за рік" це не має значення, але для "сесій за рік", де одна сесія — це кілька відвідувань одного користувача, паралелізм може все зіпсувати.

Попри все, я вражений результатом. Навіть із найзручнішим підходом ми скоротили час реплею з 4 днів до 40 секунд.

І наостанок. Це лише один приклад, але уявіть, скільки подібних можливостей для оптимізації приховано у вашому коді. Оптимізація — це не лише швидкість, а й економія грошей та ресурсів. Сьогодні, коли через ШІ якість коду часто відходить на другий план, ці питання стають особливо актуальними. Програмування — це прекрасне ремесло, і воно здатне на дива, якщо ми готові витратити час на справжнє розуміння того, що ми робимо.

Діліться своїми думками в коментарях або в Discord. До наступного разу!

Популярні

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

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

Локальні моделі та їх скоупи в Laravel за допомогою атрибута Scope

В Laravel 12 ми отримали можливість використовувати новий підхід для визначення локальних скоупів у моделях Eloquent. Дізнайтеся, як новий атрибут #[Scope] спрощує цей процес і зберігає ваші назви методів незмінними

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

Створення MCP-серверів на PHP

Модельний контекстний протокол (MCP) відкриває нові горизонти в інтеграції AI-додатків з PHP. Дізнайтеся, як легко створити сервер, що відповідає MCP, та які можливості відкриваються для вашого проєкту

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

Обробка геопросторових даних за допомогою Laravel Magellan

Ви готові відкрити нові горизонти у роботі з геопросторовими даними в Laravel? Дізнайтеся, як за допомогою PostGIS та пакету Laravel-Magellan можна легко зберігати, запитувати та маніпулювати інформацією про розташування, перетворюючи ваші проекти на вражаючі рішення у сфері картографії та геолокації!