Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

PHP: пам'ять, генератори й продуктивність

20 питань · ~20 хв · Версія v3.0

Увійдіть, щоб продовжити

Генератори й потокова обробка, copy-on-write і збирання сміття, структури SPL, OPcache і JIT, профілювання - питання від middle до lead.

За спробу
20
У пулі
49
Проходжень
0
Середній бал
-
Пройшли на 70%+
-

Питання для підготовки

20 питань

PHP використовує підрахунок посилань + збирач циклічних посилань. У звичайному веб-запиті пам'ять звільняється наприкінці запиту, тож витоки малопомітні. Але у довготривалих процесах (черги, Octane) пам'ять накопичується.

Як уникати:

  • Не зберігати стан у статичних властивостях/синглтонах між завданнями.
  • unset() великих структур, скидати накопичувачі (логи запитів DB::flushQueryLog()).
  • Обробляти дані порціями (chunk, lazy), не тримати все в пам'яті.
  • Перезапускати воркери за лімітом: queue:work --max-jobs=1000 --max-time=3600 або при досягненні --memory.

Octane має gc_collect_cycles()-хуки; Horizon автоматично перезапускає воркери, що «розпухли».

Генератор - функція, яка віддає значення по одному через yield і «засинає» між ними. Вона повертає об'єкт Generator, який можна обходити foreach, але значення обчислюються лише тоді, коли їх просять.

function readLines(string $path): Generator
{
    $handle = fopen($path, 'r');

    try {
        while (($line = fgets($handle)) !== false) {
            yield $line;
        }
    } finally {
        fclose($handle);
    }
}

foreach (readLines('access.log') as $line) {
    // у пам'яті лише один рядок, хай файл і 10 ГБ
}

Навіщо:

  • Пам'ять. Масив з мільйона рядків займає сотні мегабайтів; генератор - стільки, скільки один елемент.
  • Ліниве обчислення. Якщо цикл зупиниться на сотому елементі, решта не обчислюватиметься взагалі.

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

У Laravel на генераторах побудовані LazyCollection і cursor() / lazy() в Eloquent.

Докладніше в документації: Генератори

Перед виконанням PHP компілює кожен файл у байт-код (опкоди). Без кешу це відбувається на кожному запиті для кожного підключеного файлу - а в Laravel-застосунку їх сотні.

OPcache зберігає скомпільований байт-код у спільній пам'яті. Наступні запити беруть готовий код, не читаючи й не розбираючи файли знову. Це дає кратне прискорення без жодних змін у коді.

Що він НЕ кешує: дані, результати запитів до БД, HTML. Для цього - кеш застосунку (Redis тощо).

Налаштування на проді, на які дивляться:

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

validate_timestamps=0 вимикає перевірку, чи змінився файл, - ще трохи швидше, але тоді після деплою потрібно скинути кеш (перезапустити PHP-FPM чи викликати opcache_reset()), інакше працюватиме старий код.

Типова помилка: max_accelerated_files менший за кількість файлів у vendor/. Кеш переповнюється, і частина файлів компілюється щоразу.

Докладніше в документації: OPcache

Перший крок - виміряти. memory_get_usage() показує поточне споживання, memory_get_peak_usage() - максимум за весь час. Розставивши їх між етапами, швидко видно, де стрибок:

$before = memory_get_usage();
$rows = $repository->all();
logger()->info('rows loaded', ['mb' => (memory_get_usage() - $before) / 1024 / 1024]);

Далі - профайлер, який покаже пам'ять у розрізі функцій: Xdebug-профайлер, Blackfire, SPX, Tideways. Вони відповідають на питання «яка функція виділила ці 300 МБ».

Звичайні винуватці:

  • Завантаження всього одразу: Model::all(), file() для великого файлу, json_decode величезної відповіді. Лікується порціями: chunkById(), lazy(), генератори, потоковий парсер.
  • Накопичення в довгому процесі: статичний масив-кеш, логер, що тримає записи, DB::enableQueryLog() у воркері.
  • Циклічні посилання між об'єктами, які звільняє лише збирач сміття, а не лічильник посилань.

У довгоживучих процесах (воркери черг, Octane) навіть невеликий витік на кожному завданні з часом вбиває процес. Тому воркерам ставлять --max-jobs чи --memory, щоб вони періодично перезапускалися.

Докладніше в документації: memory_get_peak_usage

Головне правило - ніколи не тримати в пам'яті весь файл і не накопичувати результати. Читати рядок за рядком і одразу обробляти.

Читання потоком:

function rows(string $path): Generator
{
    $file = new SplFileObject($path);
    $file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD);

    $header = null;
    foreach ($file as $row) {
        if ($header === null) {
            $header = $row;
            continue;
        }
        yield array_combine($header, $row);
    }
}

Генератор віддає по одному рядку, тож пам'ять стала незалежно від розміру файлу.

Запис у базу - порціями:

$batch = [];
foreach (rows('import.csv') as $row) {
    $batch[] = ['email' => $row['email'], 'name' => $row['name']];

    if (count($batch) === 1000) {
        DB::table('subscribers')->insert($batch);
        $batch = [];
    }
}
if ($batch !== []) {
    DB::table('subscribers')->insert($batch);
}

Вставка по одному рядку - мільйон запитів; порціями по 1000 - тисяча. Масова вставка через query builder ще й не створює моделей і не запускає подій.

Що ще враховувати:

  • Кодування й роздільник: CSV з Excel часто в Windows-1251 і з ;. Перекодування на льоту - потоковим фільтром (php://filter/read=convert.iconv.windows-1251/utf-8/resource=...).
  • Логування запитів вимкнене: DB::enableQueryLog() у довгому імпорті накопичує всі запити в пам'яті.
  • Помилки в окремих рядках не мають зупиняти весь імпорт - збирати їх у звіт.
  • Перервність: запам'ятовувати номер обробленого рядка, щоб продовжити після збою, а не починати спочатку.
  • Черга: великий імпорт з веб-інтерфейсу - завдання в черзі, а не синхронний запит.

У Laravel те саме зручно робити через LazyCollection::make(fn () => yield from rows(...))->chunk(1000).

Докладніше в документації: SplFileObject

Прочитати - ще не значить знати

20 питань, по одному на екран, ~20 хв. Після завершення - розбір кожної помилки з посиланням на питання.