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

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

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

4 питання

Проблема втраченого оновлення. Два менеджери відкрили одне замовлення. Перший змінив адресу доставки й зберіг. Другий, дивлячись на стару версію, змінив коментар і зберіг - і перезаписав адресу першого старим значенням. Ніхто не отримав помилки.

Оптимістичне блокування через умовні запити:

1. Сервер віддає версію ресурсу в ETag:

GET /api/orders/42

HTTP/1.1 200 OK
ETag: "v17"

2. Клієнт оновлює з умовою «лише якщо версія досі та сама»:

PATCH /api/orders/42
If-Match: "v17"

{"comment": "Подзвонити перед доставкою"}

3. Сервер перевіряє:

  • версія збігається - оновлює, повертає новий ETag: "v18";
  • версія змінилася - 412 Precondition Failed, нічого не змінює. Клієнт перечитує ресурс, показує користувачу зміни й пропонує злити їх.

Якщо клієнт не надіслав If-Match для ресурсу, де конфлікти критичні, сервер може вимагати його: 428 Precondition Required (RFC 6585).

Реалізація в Laravel:

public function update(UpdateOrderRequest $request, Order $order): JsonResponse
{
    $expected = trim($request->header('If-Match', ''), '"');

    $updated = Order::whereKey($order->id)
        ->where('version', (int) ltrim($expected, 'v'))
        ->update([...$request->validated(), 'version' => DB::raw('version + 1')]);

    abort_if($updated === 0, 412, 'Ресурс змінено іншим користувачем');

    return OrderResource::make($order->fresh())
        ->response()
        ->header('ETag', '"v'.$order->fresh()->version.'"');
}

Ключове - перевірка й оновлення одним атомарним UPDATE ... WHERE version = ?. Варіант «прочитати версію, порівняти в PHP, потім записати» має ту саму гонитву, від якої ми захищаємося.

Джерело версії: окрема колонка version (лічильник), updated_at з мікросекундами або хеш вмісту. Лічильник найнадійніший: updated_at може збігтися при двох змінах в одну секунду.

Чим це краще за песимістичне блокування (заблокувати запис на час редагування): не потрібно тримати блокування між HTTP-запитами, немає «завислих» блокувань, коли користувач закрив вкладку. Конфлікти рідкісні - і обробляються лише тоді, коли справді стаються.

Слабкі ETag (W/"v17") для If-Match не підходять - порівняння має бути сильним.

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

Пакетний ендпойнт виконує багато однотипних операцій одним запитом: імпорт тисячі товарів, позначення 200 повідомлень прочитаними, оновлення цін.

Навіщо, якщо є HTTP/2: мультиплексування знімає витрати на з'єднання, але кожен окремий запит - це окремі автентифікація, валідація, транзакція, запис у журнал, ліміт частоти. Для тисяч операцій пакет у рази ефективніший і для клієнта, і для сервера.

Проєктування:

POST /api/products:batchCreate
{"items": [{"sku": "A-1", "name": "..."}, {"sku": "A-2", "name": "..."}]}

Головне рішення - атомарність:

1. Усе або нічого - одна транзакція; при будь-якій помилці нічого не застосовується:

HTTP/1.1 422 Unprocessable Content
{"errors": {"items.17.sku": ["Такий SKU вже існує"]}}

Просто для клієнта, але одна погана позиція блокує всі інші.

2. Часткове виконання - кожна позиція окремо, у відповіді результат для кожної:

HTTP/1.1 200 OK
{
  "results": [
    {"index": 0, "status": 201, "id": 501},
    {"index": 1, "status": 422, "errors": {"sku": ["Такий SKU вже існує"]}}
  ]
}

Деякі API використовують 207 Multi-Status, але більшість обирає 200 з детальним тілом. Клієнт мусить перевіряти кожен результат - звичайна перевірка статусу відповіді нічого не скаже.

Яку модель обрано - має бути явно в документації і, бажано, в самому API (параметр atomic=true).

Обмеження й безпека:

  • максимальний розмір пакета (100-1000 позицій) - перевищення дає 413/422, а не тайм-аут;
  • ліміт частоти рахує позиції, а не запити - інакше пакети стають обходом ліміту;
  • авторизація кожної позиції - пакет не повинен дозволяти змінити чужі записи серед своїх;
  • ідемпотентність (Idempotency-Key) - повтор пакета після обриву з'єднання не повинен створити дублікати.

Великі пакети - асинхронно: імпорт десятків тисяч позицій приймається як 202 Accepted з ресурсом статусу, обробляється чергою частинами (у Laravel - Bus::batch() з джобами).

Ефективність на сервері: масова вставка (insert() чи upsert() по частинах) замість створення моделей по одній - але тоді не спрацюють події й спостерігачі моделей, і це треба враховувати.

Докладніше в документації: Google AIP-233: пакетне створення

Звичайна відповідь Model::all()->toJson() будує весь JSON у пам'яті перед відправкою. Для експорту на сотні тисяч записів це гігабайти пам'яті й хвилини тиші, поки клієнт чекає першого байта.

Потокова відповідь відправляє дані частинами, щойно вони готові.

1. Потоковий JSON у Laravel:

return response()->streamJson([
    'orders' => Order::query()->with('items')->lazyById(500),
]);

streamJson серіалізує й відправляє елементи по одному, а lazyById читає з бази порціями - пам'ять не росте з кількістю записів. Клієнт отримує звичайний валідний JSON.

2. NDJSON (JSON Lines) - один JSON-об'єкт на рядок:

{"id":1,"total":"150.00"}
{"id":2,"total":"80.50"}
return response()->stream(function () {
    foreach (Order::query()->lazyById(500) as $order) {
        echo json_encode(OrderResource::make($order)->resolve()), "\n";
        flush();
    }
}, 200, ['Content-Type' => 'application/x-ndjson']);

Перевага - клієнт може обробляти записи, не дочекавшись кінця: читати рядок, розбирати, обробляти. Звичайний JSON-масив неможливо розібрати стандартним JSON.parse до кінця відповіді.

3. Server-Sent Events - для подій, що з'являються з часом (прогрес, сповіщення, відповідь LLM по токенах):

return response()->eventStream(function () {
    foreach ($this->generateAnswer() as $chunk) {
        yield new StreamedEvent(event: 'chunk', data: $chunk);
    }
});

Що враховувати в продакшені:

  • буферизація по дорозі: Nginx (proxy_buffering), PHP output buffering, стиснення, CDN можуть накопичувати дані й віддати все наприкінці. Для потокових маршрутів - X-Accel-Buffering: no чи окреме налаштування;
  • помилка посередині потоку: статус 200 уже відправлено. Клієнт має розпізнати обірвану відповідь (невалідний JSON, відсутній завершальний маркер), а сервер - логувати;
  • воркер зайнятий весь час відправки - для PHP-FPM довгі потоки дорогі; тривалі експорти краще генерувати чергою у файл і віддавати посилання;
  • тайм-аути проксі мають покривати тривалість передачі;
  • Content-Length невідомий - використовується chunked-передача (HTTP/1.1) чи кадри HTTP/2.

Альтернатива для великих експортів: асинхронна генерація файлу (CSV, NDJSON у сховищі S3/R2) і тимчасове підписане посилання на завантаження - сервер API не тримає з'єднання хвилинами.

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

Keyset-пагінація (курсорна, «seek method») продовжує вибірку з місця, де закінчилась попередня сторінка, умовою за ключем сортування, а не пропуском N рядків:

-- перша сторінка
SELECT id, title, published_at FROM posts
ORDER BY published_at DESC, id DESC
LIMIT 20;

-- наступна: після останнього елемента (published_at = '2026-09-30 10:00', id = 1057)
SELECT id, title, published_at FROM posts
WHERE (published_at, id) < ('2026-09-30 10:00', 1057)
ORDER BY published_at DESC, id DESC
LIMIT 20;

Ключові деталі:

  • унікальність порядку: сортування лише за published_at неоднозначне - у кількох постів однаковий час, і на межі сторінок записи загубляться чи задвояться. До ключа додають унікальну колонку (id) як «розв'язувач»;
  • індекс під сортування: (published_at DESC, id DESC) - тоді кожна сторінка - короткий пошук в індексі незалежно від глибини;
  • порівняння кортежів (a, b) < (x, y) підтримують PostgreSQL і MySQL, але оптимізатор MySQL не завжди використовує для нього індекс - інколи надійніше розгорнута умова a < x OR (a = x AND b < y);
  • NULL у ключі сортування ламає порівняння - такі колонки або виключають, або обробляють окремо;
  • курсор непрозорий для клієнта: значення кодують (base64 JSON) і, бажано, підписують, щоб клієнт не підставляв довільні значення. Laravel cursorPaginate() робить це сам.

Чому total дорогий. COUNT(*) з тими самими фільтрами - окремий запит, що проходить усі відповідні рядки. На таблиці з мільйонами записів і складними фільтрами він може коштувати більше, ніж сама сторінка, - і виконується на кожну сторінку. У PostgreSQL через MVCC навіть COUNT(*) без умов читає всю таблицю чи індекс.

Що робити замість точного total:

  • не показувати - лише «далі» (has_more), як у стрічках і більшості великих API;
  • рахувати до межі: SELECT COUNT(*) FROM (SELECT 1 ... LIMIT 10001) і показувати «10 000+»;
  • приблизна кількість зі статистики бази (reltuples у PostgreSQL, EXPLAIN) - для оцінок «близько 2,3 млн»;
  • кешувати підрахунок на хвилини, якщо точність до запису не потрібна.

Обмеження keyset: немає переходу на довільну сторінку і зручного «сторінка 5 з 120». Для адмінок це інколи прийнятний компроміс з simplePaginate, для API з великими обсягами - стандарт.

Докладніше в документації: No Offset: пагінація без зміщення