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

Питання на співбесіді: Пам'ять і продуктивність

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

Генератор - функція, яка віддає значення по одному через 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

Fatal error: Allowed memory size of 134217728 bytes exhausted означає, що скрипт спробував використати більше пам'яті, ніж дозволяє налаштування memory_limit (тут 128 МБ). PHP зупиняє виконання, щоб один запит не з'їв пам'ять усього сервера.

Типові причини:

  • Усе завантажено одразу: User::all() на мільйоні рядків, file() для великого файлу, json_decode великої відповіді API.
  • Накопичення в циклі: масив, у який дописують результати обробки сотень тисяч записів.
  • Нескінченна рекурсія чи цикл, що безкінечно додає елементи.
  • Витік у довгому процесі: воркер черги, що накопичує дані між завданнями.

Як виправляти - не піднімати ліміт, а змінити підхід:

// Замість User::all()
User::query()->chunkById(1000, function ($users) {
    foreach ($users as $user) { /* ... */ }
});

// Або ліниво, по одному
foreach (User::query()->lazy() as $user) { /* ... */ }

// Великий файл - рядок за рядком
$file = new SplFileObject('huge.csv');
foreach ($file as $line) { /* ... */ }

Коли збільшити ліміт доречно: справді важка, але обмежена задача - генерація великого звіту в команді, обробка зображення. Тоді точково: ini_set('memory_limit', '512M') у конкретній команді, а не глобально для веб-запитів.

Поруч - max_execution_time: ліміт часу виконання (у CLI за замовчуванням вимкнений, для веб - зазвичай 30 с). Помилка Maximum execution time exceeded - сигнал перенести роботу в чергу.

Як знайти винуватця: memory_get_peak_usage(true) між етапами або профайлер (Xdebug, Blackfire, SPX).

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

Посилання - друге ім'я для тієї самої змінної. Зміна через одне ім'я видна через інше.

$a = 1;
$b = &$a;
$b = 2;
echo $a;   // 2

Де трапляються посилання:

  • Передача в функцію за посиланням - функція змінює змінну викликача:
function addTax(array &$order): void
{
    $order['total'] *= 1.2;
}
  • foreach за посиланням - зміна елементів масиву на місці:
foreach ($prices as &$price) {
    $price = round($price, 2);
}
unset($price);   // обов'язково!

Пастка з foreach: після циклу $price лишається посиланням на останній елемент. Наступний foreach ($prices as $price) (вже без &) запише в нього кожне значення по черзі - і останній елемент масиву стане копією передостаннього. Тому після циклу за посиланням - unset().

Чого посилання НЕ дають - економії пам'яті. Масиви й рядки в PHP копіюються ліниво (copy-on-write): передача у функцію за значенням не копіює дані, поки їх не змінять. «Передати за посиланням, щоб було швидше» нічого не прискорює, а інколи навпаки змушує PHP розділити масив.

Об'єкти й так передаються «за ідентифікатором»: функція, що отримала об'єкт, змінює той самий об'єкт. & для об'єктів потрібен лише щоб замінити саму змінну на інший об'єкт.

Порада: у сучасному коді посилання - рідкість. Зрозуміліше повернути нове значення ($order = addTax($order)) чи використати array_map, ніж змінювати аргументи непомітно для читача.

Докладніше в документації: Посилання

Перший крок - виміряти. 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

Масив у PHP - це впорядкована хеш-таблиця: універсальна, але не найекономніша. SPL пропонує спеціалізовані структури для конкретних задач:

  • SplFixedArray - масив фіксованої довжини лише з цілими індексами. Займає помітно менше пам'яті за звичайний масив на мільйонах елементів, бо не зберігає хеш-таблицю.
  • SplObjectStorage - відображення «об'єкт → дані». Ключем може бути сам об'єкт, без вигадування рядкових ідентифікаторів. З PHP 8 для цього є й WeakMap, який не тримає об'єкти в пам'яті.
  • SplQueue / SplStack - черга й стек із чіткою семантикою. array_shift() на великому масиві повільний, бо перенумеровує ключі; SplQueue::dequeue() - ні.
  • SplPriorityQueue, SplMinHeap - коли потрібно щоразу брати найменший чи найважливіший елемент, без повного сортування.
$queue = new SplPriorityQueue();
$queue->insert('звичайний лист', 1);
$queue->insert('скидання пароля', 10);
$queue->extract(); // 'скидання пароля'

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

Докладніше в документації: Стандартна бібліотека PHP (SPL)

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

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

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

PHP-FPM (FastCGI Process Manager) - менеджер процесів, що виконують PHP. Веб-сервер (Nginx, Caddy) передає йому запити по FastCGI, а FPM роздає їх пулу воркерів. Один воркер обробляє один запит за раз.

Режими менеджера процесів (pm):

  • static - фіксована кількість воркерів (pm.max_children). Передбачувано, найкраще для виділених серверів.
  • dynamic - кількість змінюється між pm.min_spare_servers і pm.max_children, стартує з pm.start_servers. Типовий вибір.
  • ondemand - воркери створюються лише під запити й завершуються після простою. Економить пам'ять на малонавантажених сайтах, але перший запит повільніший.

Як порахувати pm.max_children: обмеження - пам'ять.

max_children ≈ (пам'ять для PHP) / (середня пам'ять одного воркера)

Сервер з 4 ГБ, з яких 1 ГБ потрібно базі й системі, і воркери по ~60 МБ: (3072 / 60) ≈ 50. Реальне споживання воркера дивляться в ps чи статусній сторінці FPM під навантаженням.

Що буде при помилці:

  • Замало воркерів: запити стають у чергу, час відповіді росте, у лозі FPM - server reached pm.max_children setting.
  • Забагато: під навантаженням пам'ять закінчується, сервер іде в swap чи ядро вбиває процеси - гірше, ніж черга.

Корисні налаштування:

  • pm.max_requests - перезапускати воркер після N запитів, щоб обмежити наслідки витоків пам'яті.
  • request_terminate_timeout - вбивати завислий запит.
  • pm.status_path - сторінка статусу: активні й вільні воркери, довжина черги.
  • slowlog з request_slowlog_timeout - стек-трейси повільних запитів.

Альтернатива - довгоживучі сервери (FrankenPHP, RoadRunner, Swoole через Laravel Octane): фреймворк завантажується один раз, але з'являються проблеми стану між запитами.

Докладніше в документації: Налаштування PHP-FPM

JIT (PHP 8.0) компілює гарячий байт-код у машинний код процесора. Звичайний PHP виконує опкоди віртуальною машиною; з JIT частина коду виконується напряму, без інтерпретації.

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

Чому веб-застосунку - ні: типовий запит більшу частину часу чекає: на базу даних, Redis, зовнішній API, файлову систему. Сам PHP-код - це складання масивів і виклики функцій, які й без JIT виконуються швидко. Прискорити 10% часу запиту вдвічі - означає виграти 5%.

Що реально пришвидшує веб:

  • OPcache з достатнім розміром (це основа, без неї JIT і не працює).
  • Менше запитів до БД, індекси, кеш.
  • Кешування конфігурації, маршрутів і подань (php artisan optimize).
  • Довгоживучий процес (Octane, FrankenPHP), щоб не завантажувати фреймворк на кожному запиті.

Мінуси JIT: більше пам'яті, складніше налагоджувати, і в окремих версіях траплялися баги саме в JIT. Тому вмикають його після вимірювань на своєму навантаженні, а не «про всяк випадок».

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

Preloading (PHP 7.4) завантажує вказані файли в пам'ять один раз - під час старту PHP-FPM. Класи, функції й константи з них стають доступні всім запитам так, ніби вони вбудовані в PHP: без автозавантаження, без перевірки файлів, без зв'язування класів на кожному запиті.

opcache.preload=/var/www/preload.php
opcache.preload_user=www-data
// preload.php
require __DIR__ . '/vendor/autoload.php';

foreach ($classmap as $file) {
    opcache_compile_file($file);
}

Обмеження:

  • Зміна коду - лише через перезапуск PHP-FPM. Перевірка файлів для попередньо завантаженого коду не працює взагалі.
  • Один застосунок на пул. Завантажені класи глобальні для всього процесу, тож кілька застосунків з різними версіями однієї бібліотеки в одному пулі конфліктуватимуть.
  • Пам'ять витрачається на все завантажене, навіть на класи, які рідко потрібні. Завантажувати весь vendor - погана ідея; краще найчастіше використовувані класи.
  • Виграш скромний: зазвичай кілька відсотків, бо OPcache і так усуває компіляцію. Найбільше він помітний на фреймворках з тисячами класів.

Довгоживучі сервери (Octane, FrankenPHP worker mode) дають те саме й більше - фреймворк завантажується один раз на процес, тож з ними preloading майже нічого не додає.

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

Fiber (PHP 8.1) - функція з власним стеком, яку можна призупинити посередині й пізніше відновити з того самого місця. Це кооперативна багатозадачність: файбер сам вирішує, коли віддати керування.

$fiber = new Fiber(function (string $first): string {
    $second = Fiber::suspend("отримав: {$first}");   // пауза, повертаємо значення
    return "завершено з {$second}";
});

$value = $fiber->start('A');     // 'отримав: A'
$fiber->resume('B');             // продовжує після suspend
echo $fiber->getReturn();        // 'завершено з B'

Відмінність від потоків:

  • Потоки виконуються паралельно (на різних ядрах чи з перемиканням ОС у будь-який момент). Потрібна синхронізація доступу до спільних даних.
  • Файбери виконуються по черзі в одному потоці. Лише один файбер працює в кожен момент, перемикання відбувається тільки в suspend()/resume(). Гонок даних у класичному розумінні немає.

Тобто файбери не роблять обчислення швидшими й не використовують кілька ядер.

Навіщо вони: для асинхронного вводу-виводу. Поки один файбер чекає відповідь мережі, цикл подій (event loop) запускає інший. Головне - файбер дозволяє писати асинхронний код як звичайний синхронний, без колбеків і промісів: функція глибоко в стеку може призупинитися, не змінюючи сигнатур викликачів.

Хто використовує: Revolt (event loop), AMPHP v3, ReactPHP-адаптери. Прикладному коду рідко потрібно створювати файбери напряму - це будівельний блок для бібліотек.

Застереження: звичайні блокуючі функції (PDO, file_get_contents, sleep) блокують увесь процес разом з усіма файберами. Асинхронність працює лише з неблокуючими драйверами з екосистеми event loop.

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

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

WeakReference (PHP 7.4) - посилання, яке не заважає збирачу сміття:

$user = new User();
$ref = WeakReference::create($user);

$ref->get();   // об'єкт
unset($user);
$ref->get();   // null - об'єкт уже звільнено

WeakMap (PHP 8.0) - словник, де ключі - об'єкти, утримувані слабко. Коли ключ-об'єкт звільнено, запис зникає з мапи сам:

final class PriceCalculator
{
    private WeakMap $cache;

    public function __construct()
    {
        $this->cache = new WeakMap();
    }

    public function totalFor(Order $order): int
    {
        return $this->cache[$order] ??= $this->expensiveCalculation($order);
    }
}

Кеш живе рівно стільки, скільки живе кожне замовлення, і не росте безмежно.

Де застосовують:

  • Мемоізація за об'єктом-аргументом - як у прикладі.
  • Метадані до чужих об'єктів, які не можна змінити (сутності ORM, об'єкти бібліотек).
  • Реєстри й ідентичні мапи в ORM і контейнерах, що не мають утримувати сутності довше за запит.

Обмеження: ключі WeakMap - лише об'єкти; значення утримуються сильно (якщо значення посилається на ключ, запис не звільниться). Момент звільнення залежить від лічильника посилань і збирача циклів, тож логіку коректності на ньому не будують - лише оптимізації.

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