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

Middle: питання на співбесіді з теми «HTTP і продуктивність»

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

5 питань

Списки в API майже завжди потребують фільтрів, сортування й обмеження полів. Головне - однакова схема для всіх ендпойнтів, щоб клієнтам не доводилося вчити кожен окремо.

Поширена схема (близька до JSON:API):

GET /api/vacancies?filter[city]=kyiv&filter[remote]=1&filter[salary_from]=2000
    &sort=-published_at,title
    &fields[vacancies]=id,title,salary
    &include=company
    &page[size]=20
  • filter[...] - умови; для діапазонів - суфікси (salary_from/salary_to) або оператори (filter[salary][gte]=2000);
  • sort - список полів через кому, - - за спаданням;
  • fields[тип] - лише потрібні поля (sparse fieldsets): мобільному списку не треба повного опису вакансії;
  • include - пов'язані ресурси в тій самій відповіді.

У Laravel розбір таких параметрів дає spatie/laravel-query-builder:

QueryBuilder::for(Vacancy::class)
    ->allowedFilters(['city', AllowedFilter::exact('remote'), AllowedFilter::scope('salary_from')])
    ->allowedSorts(['published_at', 'title'])
    ->allowedFields(['id', 'title', 'salary'])
    ->allowedIncludes(['company'])
    ->paginate();

А вбудовані JSON:API-ресурси Laravel (JsonApiResource) самі обробляють fields і include у відповіді.

Безпека й продуктивність - головне:

  • білий список полів для фільтрів, сортування й include. Сортування за довільною колонкою з параметра - це і SQL-ризики, і повільні запити за полями без індексу. Невідомий параметр - помилка 400, а не тихе ігнорування;
  • індекси під реальні фільтри: кожна дозволена комбінація фільтр + сортування - потенційний запит, і для частих комбінацій потрібні складені індекси;
  • обмеження глибини include і кількості елементів - інакше один запит витягне половину бази;
  • приховані поля: fields не повинен відкривати поля, яких немає в звичайній відповіді (password_hash, внутрішні примітки).

Пошук за текстом - окремий параметр (filter[q]=laravel чи q=), який веде в повнотекстовий пошук (Scout, Meilisearch), а не в LIKE '%...%' по кількох колонках.

Документування: кожен дозволений фільтр і сортування мають бути в OpenAPI-описі - інакше клієнти вгадують.

Докладніше в документації: JSON:API: sparse fieldsets

Генерація звіту, імпорт файлу, відео-конвертація займають хвилини. Тримати HTTP-з'єднання весь цей час не можна: спрацюють тайм-аути проксі й клієнта, а повтор запиту запустить роботу вдруге.

Шаблон «асинхронна операція»:

1. Запит створює задачу й одразу відповідає 202 Accepted:

POST /api/exports
{"type": "orders", "from": "2026-09-01"}

HTTP/1.1 202 Accepted
Location: /api/exports/7f3a
Retry-After: 5

{"data": {"id": "7f3a", "status": "queued"}}

202 означає «прийнято до обробки, але ще не виконано». Location вказує, де стежити за результатом.

2. Клієнт опитує ресурс статусу:

GET /api/exports/7f3a

{"data": {"id": "7f3a", "status": "processing", "progress": 45}}

3. Після завершення - посилання на результат:

{"data": {"id": "7f3a", "status": "completed", "result_url": "/api/exports/7f3a/download"}}

Або 303 See Other з Location на готовий ресурс. При помилці - status: "failed" з описом.

У Laravel:

public function store(StoreExportRequest $request): JsonResponse
{
    $export = Export::create([...$request->validated(), 'status' => 'queued', 'user_id' => $request->user()->id]);

    GenerateExport::dispatch($export);

    return ExportResource::make($export)
        ->response()
        ->setStatusCode(202)
        ->header('Location', route('exports.show', $export));
}

Джоба оновлює status і progress моделі.

Альтернативи опитуванню:

  • вебхук - сервер сам повідомляє клієнта про завершення (для інтеграцій сервер-сервер);
  • WebSocket/SSE (Laravel Reverb) - для інтерфейсу користувача;
  • Retry-After - підказка клієнту, як часто опитувати.

Що важливо:

  • ідемпотентність створення - повтор POST після обриву з'єднання не повинен ставити другу задачу (Idempotency-Key);
  • авторизація ресурсу статусу - лише власник бачить свою операцію;
  • термін життя результату й статусу (видаляти через N днів);
  • скасування - DELETE /api/exports/7f3a чи POST .../cancel, якщо операція довга.

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

HTTP/1.1 дозволяє на одному з'єднанні лише один запит за раз. Браузер відкриває близько 6 з'єднань на домен, і решта запитів чекає в черзі. Звідси старі прийоми оптимізації:

  • об'єднувати запити - «товсті» ендпойнти, що повертають усе для екрана одразу;
  • «шардинг» доменів - api1., api2., щоб обійти ліміт з'єднань;
  • склеювання ресурсів, спрайти.

HTTP/2 мультиплексує: багато запитів паралельно в одному з'єднанні, плюс стиснення заголовків (HPACK). HTTP/3 робить те саме поверх QUIC (UDP): втрата пакета в одному потоці не блокує інші, швидше встановлення з'єднання, краще поводження при зміні мережі (Wi-Fi → мобільна).

Що це змінює для API:

  • дрібні запити стали дешевшими. Кілька паралельних GET до різних ресурсів більше не впираються в ліміт з'єднань. Агрегувальні «все-в-одному» ендпойнти менш потрібні, а дрібні ресурси краще кешуються окремо;
  • шардинг доменів шкідливий: кожен домен - окреме з'єднання з TLS-рукостисканням, і мультиплексування втрачається;
  • заголовки дешеві - стиснення HPACK/QPACK зменшує вартість повторюваних заголовків (Authorization, cookies);
  • довгі з'єднання для стримінгу (SSE) не займають ліміт браузера: на HTTP/1.1 кілька вкладок з SSE вичерпують 6 з'єднань, на HTTP/2 - ні.

Чого HTTP/2 не скасовує:

  • затримка (latency) кожного запиту лишається: послідовні залежні запити («водоспад» - спершу користувач, потім його замовлення, потім товари) все одно повільні. Від водоспаду захищає проєктування (include, вкладені ресурси), а не протокол;
  • вартість на сервері: паралельні запити - це паралельна робота PHP-воркерів і бази;
  • Server Push з HTTP/2 практично мертвий - браузери прибрали його підтримку; замість нього - 103 Early Hints і preload.

Де вмикається: HTTP/2 і HTTP/3 налаштовуються на вебсервері чи CDN (Nginx, Caddy, Cloudflare), а не в Laravel. Для API за CDN клієнт спілкується з CDN по HTTP/3, а CDN з сервером - по HTTP/1.1 чи 2, і переваги для клієнта все одно є.

Перевірка: колонка Protocol у DevTools (h2, h3) чи curl --http2 -I.

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

Постійні з'єднання (keep-alive). Встановлення TCP-з'єднання й TLS-рукостискання коштують кількох обмінів пакетами - на віддалений сервер це десятки чи сотні мілісекунд. HTTP/1.1 за замовчуванням тримає з'єднання відкритим для наступних запитів, а HTTP/2 і HTTP/3 побудовані на одному довгоживучому з'єднанні.

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

Тайм-аути - обов'язкові. Запит без тайм-ауту до сервісу, що завис, тримає воркер PHP хвилинами, і кілька таких запитів вичерпують пул воркерів - падає весь застосунок.

Http::connectTimeout(3)      // встановлення з'єднання
    ->timeout(10)            // уся відповідь
    ->retry(3, 200, throw: false)
    ->get('https://api.partner.com/rates');

Види тайм-аутів:

  • з'єднання (connect) - короткий, 2-5 с: якщо сервер не відповідає на з'єднання, далі чекати марно;
  • відповіді (read/total) - під очікувану тривалість операції з запасом;
  • на сервері - max_execution_time, request_terminate_timeout у PHP-FPM, тайм-аути Nginx і балансувальника.

Узгодженість тайм-аутів по ланцюжку: зовнішній тайм-аут має бути більшим за внутрішні. Якщо балансувальник обриває через 30 с, а PHP працює до 60 с, клієнт отримає 504, а сервер ще пів хвилини витрачатиме ресурси на відповідь, яку ніхто не прочитає. І навпаки, тайм-аут HTTP-клієнта всередині запиту має вкладатися в загальний час обробки.

Повтори - лише для ідемпотентних запитів і тимчасових помилок (мережа, 502/503/504, 429), з експоненційною затримкою й випадковим розкидом.

Тайм-аут простою keep-alive на сервері (Nginx keepalive_timeout) має бути більшим, ніж у балансувальника перед ним, - інакше сервер закриває з'єднання, яке балансувальник вважає живим, і частина запитів падає з 502.

Для довгих операцій тайм-аути не збільшують до хвилин - такі операції роблять асинхронними (202 Accepted + статус).

Докладніше в документації: Заголовок Keep-Alive

Дві протилежні проблеми REST:

  • надлишкові дані (over-fetching) - відповідь містить усе, хоча клієнту потрібна дрібка;
  • недостатні дані (under-fetching) - для одного екрана потрібно кілька послідовних запитів.

На сервері обидві часто перетворюються на N+1: ресурс звертається до зв'язку для кожного елемента списку.

// контролер
return PostResource::collection(Post::paginate(20));

// ресурс
public function toArray($request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'author' => new UserResource($this->author),   // окремий запит на кожен пост
    ];
}

20 постів - 21 запит до бази.

Рішення 1 - жадібне завантаження + умовні зв'язки:

// контролер
return PostResource::collection(Post::with('author')->paginate(20));

// ресурс
'author' => UserResource::make($this->whenLoaded('author')),
'comments_count' => $this->whenCounted('comments'),

whenLoaded додає зв'язок у відповідь лише якщо його завантажили. Ресурс більше не робить запитів сам, а контролер явно вирішує, що завантажити.

Рішення 2 - include на запит клієнта: GET /api/posts?include=author,tags. Контролер завантажує лише дозволені зв'язки з цього списку (spatie/laravel-query-builder чи вбудовані JSON:API-ресурси Laravel, які серіалізують зв'язок лише коли клієнт його запросив).

Рішення 3 - вибір полів: fields[posts]=id,title - мобільний список не тягне тіло статті.

Захист від N+1 у розробці:

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

Ліниве завантаження зв'язку кидає виняток у розробці й тестах - N+1 видно одразу. Також Model::automaticallyEagerLoadRelationships() (Laravel 12+) підвантажує зв'язки для всієї колекції автоматично - зручно, але не замінює свідомого with().

Агрегати замість колекцій: withCount('comments'), withSum, withExists - кількість одним запитом, а не завантаження всіх коментарів заради count().

Коли REST не вистачає: якщо різні клієнти постійно потребують дуже різних наборів даних, а include/fields розростаються, - це аргумент за GraphQL або окремі ендпойнти під конкретний клієнт (Backend for Frontend).

Перевірка: Debugbar, Telescope чи тест, що рахує запити (DB::enableQueryLog() + expect(count(DB::getQueryLog()))->toBeLessThan(5)).

Докладніше в документації: Laravel: умовні зв'язки в ресурсах