Питання на співбесіді: Продуктивність
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
paginate() рахує сторінки через OFFSET, а cursorPaginate() продовжує від останнього показаного запису.
Post::query()->orderBy('id')->paginate(20);
// SELECT ... ORDER BY id LIMIT 20 OFFSET 19980 (сторінка 1000)
// + SELECT COUNT(*) ...
Post::query()->orderBy('id')->cursorPaginate(20);
// SELECT ... WHERE id > 19980 ORDER BY id LIMIT 21
Чому курсор швидший на далеких сторінках: OFFSET 19980 змушує базу прочитати й відкинути майже 20 тисяч рядків. Умова id > ... за індексом одразу стає на потрібне місце - тисячна сторінка відкривається так само швидко, як перша. До того ж немає запиту COUNT(*), який на великих таблицях теж дорогий.
Ще одна перевага: якщо поки користувач гортає, додаються нові записи, OFFSET зсуває сторінки - записи повторюються чи пропадають. Курсор такого не має.
Обмеження курсора:
- немає номерів сторінок і загальної кількості - лише «далі» й «назад»;
- сортування має бути за унікальною колонкою чи їх комбінацією (
created_at+id); - колонки сортування мають бути в індексі.
Коли що: нескінченна стрічка, API, експорт - курсор. Таблиця в адмінці з номерами сторінок - звичайна пагінація, simplePaginate() якщо загальна кількість не потрібна.
Крім кешу конфігурації й маршрутів, php artisan optimize створює ще два кеші, про які часто забувають.
event:cache - мапа подій і слухачів.
Laravel може сам знаходити слухачів: сканує каталог app/Listeners і за типом параметра handle(OrderShipped $event) визначає, на яку подію слухач підписаний. Зручно, але сканування файлів і Reflection на кожен запит - зайва робота.
php artisan event:cache # записати мапу в bootstrap/cache/events.php
php artisan event:clear
Після кешування Laravel бере мапу з файлу, нічого не скануючи.
Коли поводиться неочікувано:
- новий слухач не спрацьовує - кеш створено до його появи. Кеш має перебудовуватися при кожному деплої;
- локально кеш подій зазвичай не потрібен і заважає: додали слухача - і забули очистити кеш.
view:cache - скомпільовані шаблони Blade.
Blade компілює кожен шаблон у звичайний PHP-файл у storage/framework/views при першому зверненні й перекомпільовує, якщо шаблон змінився. view:cache компілює всі шаблони заздалегідь:
- перший запит після деплою не витрачає час на компіляцію;
- помилка синтаксису Blade виявляється під час деплою, а не в користувача.
Коли поводиться неочікувано:
- права: скомпільовані файли створені від
rootпід час деплою, а PHP-FPM працює відwww-data- при спробі перекомпілювати виникає помилка доступу; - кілька серверів чи контейнерів - кеш формується на кожному окремо, і він має відповідати коду саме цього сервера;
- змінені шаблони без очищення кешу в нестандартному деплої (файли змінено на місці) - Blade перевіряє час зміни файлів, але з OPcache і
validate_timestamps=0старий скомпільований PHP може лишитися в пам'яті.
Загальне правило для всіх кешів: у деплої спершу оновити код і залежності, потім php artisan optimize (конфігурація, маршрути, події, представлення), потім перезапустити процеси, що тримають код у пам'яті (PHP-FPM reload чи OPcache reset, воркери черг, Octane). А локально - php artisan optimize:clear, якщо поведінка не відповідає коду.
Що не кешується цими командами: дані застосунку (Cache::), відповіді й запити - це окремий рівень, і optimize:clear його не чіпає.
Докладніше в документації: Події: пошук слухачів на продакшені
Через defer() - замикання виконається вже після того, як відповідь пішла користувачу.
public function store(StoreOrderRequest $request): RedirectResponse
{
$order = Order::create($request->validated());
defer(fn () => Metrics::reportOrder($order));
return redirect()->route('orders.show', $order);
}
Користувач не чекає на звернення до сервісу метрик, а окремий воркер черги не потрібен.
Як це працює: Laravel відправляє відповідь і закриває з'єднання, а процес PHP продовжує виконання. Для команд Artisan і завдань черги відкладені функції виконуються в кінці.
Деталі:
- якщо відповідь з помилкою (4xx/5xx), відкладена функція за замовчуванням не виконується;
->always()змінює це; defer(fn () => ..., 'name')з іменем дозволяє скасувати:defer()->forget('name');- у тестах їх можна виконувати одразу через
$this->withoutDefer().
Коли defer(), а коли черга:
defer- коротка, некритична робота: метрики, логування, прогрів кешу. Якщо процес упаде, вона просто не виконається, повторів немає;- черга - усе, що має гарантовано виконатися, довго триває чи потребує повторів: листи, платежі, обробка файлів.
Пам'ятайте, що поки працює відкладена функція, процес PHP зайнятий: важка робота тут з'їдає воркери, які мали б обслуговувати запити.
Через фасад Concurrency - він запускає замикання одночасно й повертає результати в тому самому порядку.
use Illuminate\Support\Facades\Concurrency;
[$userCount, $orderCount, $rates] = Concurrency::run([
fn () => DB::table('users')->count(),
fn () => DB::table('orders')->count(),
fn () => Http::get('https://api.example.com/rates')->json(),
]);
Якщо кожна операція триває секунду, послідовно це три секунди, паралельно - близько однієї.
Як це працює: драйвер за замовчуванням process серіалізує кожне замикання й виконує його в окремому дочірньому процесі PHP через Artisan. Є драйвер fork (швидший, лише CLI, потрібен пакет spatie/fork) і sync для тестів.
Обмеження:
- кожен дочірній процес завантажує застосунок - на коротких операціях ці накладні витрати з'їдають виграш;
- замикання серіалізуються: не можна захоплювати те, що не серіалізується (з'єднання, ресурси,
$thisскладного об'єкта); - результат має бути серіалізованим.
Альтернативи:
- кілька HTTP-запитів -
Http::pool(), без нових процесів; - робота, результат якої не потрібен відповіді, -
Concurrency::defer()або черга.
Найчастіше це доречно для дашбордів з кількома незалежними важкими агрегаціями чи для зведення даних з кількох зовнішніх API.
Оптимізація йде кількома шарами:
База даних
- Усунення N+1 (eager loading
with()), правильні індекси, аналіз черезEXPLAIN. - Read/write репліки, кешування важких запитів.
Кешування
Cache::remember()для дорогих обчислень; повне кешування сторінок/фрагментів.php artisan optimize(config/route/view/event cache), OPcache.
Фонова робота
- Винесення повільних завдань (email, обробка медіа, виклики API) у черги.
Інфраструктура
- Laravel Octane (Swoole/FrankenPHP) тримає застосунок у пам'яті.
- CDN для статики, горизонтальне масштабування за load balancer, спільні сесії/кеш у Redis.
Перед оптимізацією - профілювання (Telescope, Clockwork, Debugbar), щоб бити по реальних вузьких місцях.
Octane запускає застосунок через high-performance сервери (Swoole, FrankenPHP, RoadRunner): фреймворк бутстрапиться один раз і тримається в пам'яті, обслуговуючи наступні запити без повторної ініціалізації.
php artisan octane:start --server=frankenphp
Це усуває оверхед завантаження на кожному запиті й дає кратний приріст RPS.
Підводні камені: оскільки процес довготривалий, треба уникати витоків стану між запитами - статичні властивості, синглтони з накопиченим станом, глобальні змінні можуть «протікати» від запиту до запиту. Octane надає хуки flush і перезапускає воркери для безпеки.
Ключове правило - не тримати весь файл у пам'яті.
- Стрімінг замість читання цілком:
return Storage::disk('s3')->response($path); // стрім на скачування Storage::writeStream($path, fopen($source, 'r')); // стрім на запис - Direct uploads на S3 - клієнт вантажить напряму в сховище за pre-signed URL, минаючи PHP-процес (не блокує воркер, обходить ліміти
upload_max_filesize). - Chunked upload - великі файли частинами (resumable).
- Фонова обробка - конвертацію відео/зображень виносити в черги.
- Враховувати
max_execution_time, таймаути nginx і ліміти пам'яті воркера.
У кластері з кількох інстансів локальний лічильник не годиться - потрібен спільний стан у Redis. Фасад RateLimiter під капотом використовує атомарні операції Redis (INCR + EXPIRE), тож підрахунок точний між усіма серверами.
RateLimiter::attempt(
key: 'send-sms:'.$user->id,
maxAttempts: 5,
callback: fn () => $this->sendSms(),
decaySeconds: 60,
);
Для складніших схем - алгоритми sliding window чи token bucket на Lua-скриптах (атомарність на стороні Redis, без гонок). Ключове: лічильник і його TTL мають змінюватися атомарно, інакше за конкурентного доступу ліміт «протікає».
Одна команда робить більшість:
php artisan optimize # config + route + view + event cache
Окремо:
php artisan config:cache # об'єднує конфіг у один файл
php artisan route:cache # компілює маршрути
php artisan view:cache # прекомпілює Blade
php artisan event:cache # кеш мапінгу подій/слухачів
composer install --no-dev --optimize-autoloader
Додатково: увімкнений OPcache (а краще з JIT), prebuilt ассети (npm run build).
Підводний камінь: після config:cache виклики env() поза config/ повертають null - усі env-значення мають читатися лише у конфіг-файлах. На деплої не забути php artisan optimize:clear перед повторним кешуванням.
PHP-FPM - «shared nothing»: кожен запит стартує з чистого стану, наприкінці все звільняється. Просто, безпечно, але є оверхед бутстрапу фреймворку на кожному запиті.
Octane тримає застосунок у пам'яті між запитами → кратно вищий throughput і нижча латентність.
| PHP-FPM | Octane | |
|---|---|---|
| Стан між запитами | чистий | зберігається |
| Throughput | нижчий | значно вищий |
| Ризик витоків стану | немає | є |
| Складність деплою | проста | вища (воркери, рестарти) |
Ціна Octane: треба остерігатися «протікання» стану (статика, синглтони, глобальні змінні), правильно скидати/перезапускати воркери, уважно з пам'яттю. FPM лишається розумним дефолтом, доки немає потреби в екстремальній продуктивності.
chunk() гортає сторінки через OFFSET. Якщо в циклі змінювати колонку, за якою фільтрується запит, записи «з'їжджають», і частину буде пропущено.
// Погано: після першої порції активних стало на 100 менше,
// а друга порція бере OFFSET 100 - і перескакує через 100 записів
User::where('active', false)->chunk(100, function ($users) {
$users->each->update(['active' => true]);
});
// Добре: курсор за id, а не номер сторінки
User::where('active', false)->chunkById(100, function ($users) {
$users->each->update(['active' => true]);
});
chunkById() бере наступну порцію за id > останній, тож зміни в уже обробленому не впливають.
Як обирати інструмент для великих обсягів:
chunkById()/lazyById()- порціями з постійною пам'яттю;lazyById()віддає LazyCollection, з якою зручніше працювати як з потоком;cursor()- один запит і по одній моделі в пам'яті, але драйвер бази часто буферизує весь результат, а жадібне завантаження зв'язків неможливе;- масовий
update()- якщо логіка вміщається в SQL, один запитUPDATE ... WHEREу сотні разів швидший за будь-який цикл (але без подій моделей).
Що ще важливо на мільйонах рядків:
- обробку виносять у чергу порціями, щоб падіння на 900-тисячному записі не починало все спочатку;
- запит між порціями має йти за індексом;
- вимкнений журнал запитів (
DB::disableQueryLog()), щоб пам'ять не росла.
Через заголовки Cache-Control і ETag - тоді браузер чи CDN не звертаються до застосунку або отримують коротку відповідь «не змінилося».
Route::middleware('cache.headers:public;max_age=3600;etag')->group(function () {
Route::get('/privacy', PrivacyController::class);
Route::get('/sitemap.xml', SitemapController::class);
});
Що означають директиви:
public- відповідь можна зберігати в спільних кешах (CDN, проксі);private- лише в браузері;max_age/s_maxage- скільки секунд вважати свіжою;s_maxage- лише для спільних кешів, зручно дати CDN довший термін, ніж браузеру;etag- Laravel рахує хеш тіла відповіді. Якщо браузер надіслав той самийIf-None-Match, відповідь буде304 Not Modifiedбез тіла.
Чого ETag не дає: застосунок усе одно виконує весь запит і рендеринг - економиться лише трафік. Справжня економія серверу - коли CDN віддає відповідь сам за max_age.
Головна небезпека - персоналізовані сторінки. Якщо сторінку з ім'ям користувача чи CSRF-токеном позначити public, CDN віддасть її наступному відвідувачу. Тому:
publicлише для сторінок, однакових для всіх;- сесійні cookie у відповіді зазвичай роблять її некешованою для CDN, і це правильно;
- для авторизованих -
private, no-store.
Статичні ассети з хешем у назві (Vite) кешують на рік з immutable - ім'я зміниться разом зі вмістом.