Питання на співбесіді: Пам'ять і продуктивність
Питання з реальних співбесід з відповідями: 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/. Кеш переповнюється, і частина файлів компілюється щоразу.
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).
Посилання - друге ім'я для тієї самої змінної. Зміна через одне ім'я видна через інше.
$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, щоб вони періодично перезапускалися.
Масив у 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-задачах, імпортах і алгоритмах, де елементів сотні тисяч і більше.
Головне правило - ніколи не тримати в пам'яті весь файл і не накопичувати результати. Читати рядок за рядком і одразу обробляти.
Читання потоком:
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).
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): фреймворк завантажується один раз, але з'являються проблеми стану між запитами.
JIT (PHP 8.0) компілює гарячий байт-код у машинний код процесора. Звичайний PHP виконує опкоди віртуальною машиною; з JIT частина коду виконується напряму, без інтерпретації.
Де це дає багато: обчислювальні задачі, які довго крутяться в самому PHP - математика, обробка зображень на чистому PHP, парсери, симуляції. Там прискорення буває кратним.
Чому веб-застосунку - ні: типовий запит більшу частину часу чекає: на базу даних, Redis, зовнішній API, файлову систему. Сам PHP-код - це складання масивів і виклики функцій, які й без JIT виконуються швидко. Прискорити 10% часу запиту вдвічі - означає виграти 5%.
Що реально пришвидшує веб:
- OPcache з достатнім розміром (це основа, без неї JIT і не працює).
- Менше запитів до БД, індекси, кеш.
- Кешування конфігурації, маршрутів і подань (
php artisan optimize). - Довгоживучий процес (Octane, FrankenPHP), щоб не завантажувати фреймворк на кожному запиті.
Мінуси JIT: більше пам'яті, складніше налагоджувати, і в окремих версіях траплялися баги саме в 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 майже нічого не додає.
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.
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 - лише об'єкти; значення утримуються сильно (якщо значення посилається на ключ, запис не звільниться). Момент звільнення залежить від лічильника посилань і збирача циклів, тож логіку коректності на ньому не будують - лише оптимізації.