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/. Кеш переповнюється, і частина файлів компілюється щоразу.
Перший крок - виміряти. 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, щоб вони періодично перезапускалися.
Головне правило - ніколи не тримати в пам'яті весь файл і не накопичувати результати. Читати рядок за рядком і одразу обробляти.
Читання потоком:
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).
Прочитати - ще не значить знати
20 питань, по одному на екран, ~20 хв. Після завершення - розбір кожної помилки з посиланням на питання.