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-описі - інакше клієнти вгадують.
Генерація звіту, імпорт файлу, відео-конвертація займають хвилини. Тримати 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, якщо операція довга.
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.
Постійні з'єднання (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 + статус).
Дві протилежні проблеми 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: умовні зв'язки в ресурсах