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

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

Питання з реальних співбесід з відповідями: 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 і перезапускає воркери для безпеки.

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

Ключове правило - не тримати весь файл у пам'яті.

  • Стрімінг замість читання цілком:
    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 мають змінюватися атомарно, інакше за конкурентного доступу ліміт «протікає».

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

Одна команда робить більшість:

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 лишається розумним дефолтом, доки немає потреби в екстремальній продуктивності.

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

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 - ім'я зміниться разом зі вмістом.

Докладніше в документації: Middleware Cache-Control